Skip to content

Commit 541d241

Browse files
authored
fix(posthog): flush on SIGTERM and log async capture errors (#217)
1 parent 680f8b0 commit 541d241

2 files changed

Lines changed: 61 additions & 11 deletions

File tree

src/api/v2/agent-templates/services/posthog.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */
1616

1717
import { POSTHOG_HOST, POSTHOG_PROJECT_TOKEN } from "@/config";
18+
import logger from "@/utils/logger";
1819
import type { GenerationMetrics } from "./templateGen";
1920

2021
// ---------------------------------------------------------------------------
@@ -73,6 +74,16 @@ function getPostHogClient(): any {
7374
PostHog: new (apiKey: string, opts: { host: string }) => any;
7475
};
7576
_posthogClient = new PostHog(POSTHOG_PROJECT_TOKEN, { host: POSTHOG_HOST });
77+
// Surface async capture failures (auth 401s on a wrong token, wrong host,
78+
// network errors). capture() is fire-and-forget so these are otherwise
79+
// invisible — the request that triggered it has already returned. Subscribe
80+
// once at client creation; client is cached for the lifetime of the process.
81+
_posthogClient.on("error", (err: unknown) => {
82+
logger.error(
83+
{ err: err instanceof Error ? err.message : err },
84+
"[posthog] async capture error",
85+
);
86+
});
7687
return _posthogClient;
7788
}
7889

@@ -129,9 +140,36 @@ export function capturePostHog(properties: PostHogCaptureProperties): void {
129140
properties,
130141
});
131142
} catch (err) {
132-
console.error(
133-
"[posthog] capture failed:",
134-
err instanceof Error ? err.message : err,
143+
logger.error(
144+
{ err: err instanceof Error ? err.message : err },
145+
"[posthog] capture failed",
146+
);
147+
}
148+
}
149+
150+
// ---------------------------------------------------------------------------
151+
// Graceful shutdown
152+
// ---------------------------------------------------------------------------
153+
154+
/**
155+
* Flush any buffered events and tear down the PostHog client. Call once
156+
* during SIGTERM so events captured in the last `flushInterval` window
157+
* (default 10s in posthog-node v5) aren't lost when the process exits.
158+
*
159+
* No-op when the client was never created (env vars unset, or test
160+
* override installed). Errors are caught and logged — never propagate
161+
* to the caller so shutdown can continue.
162+
*/
163+
export async function shutdownPostHog(timeoutMs = 5000): Promise<void> {
164+
const client = _posthogClient;
165+
if (!client) return;
166+
_posthogClient = null;
167+
try {
168+
await client.shutdown(timeoutMs);
169+
} catch (err) {
170+
logger.error(
171+
{ err: err instanceof Error ? err.message : err },
172+
"[posthog] shutdown failed",
135173
);
136174
}
137175
}

src/index.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import cookieParser from "cookie-parser";
33
import cors from "cors";
44
import express from "express";
55
import helmet from "helmet";
6+
import { shutdownPostHog } from "@/api/v2/agent-templates/services/posthog";
67
import {
78
startTtlSweep as startGenerationTtlSweep,
89
stopTtlSweep as stopGenerationTtlSweep,
@@ -89,15 +90,26 @@ validateJWTKeys()
8990
}
9091
});
9192

93+
// Wrap the async drain steps in a void-IIFE so the SIGTERM listener
94+
// itself returns void (Node ignores the listener's return value, and
95+
// an `async` listener would trip @typescript-eslint/no-misused-promises).
96+
// `shutdownPostHog()` catches its own errors, so the IIFE never rejects.
9297
process.on("SIGTERM", () => {
93-
logger.info("SIGTERM signal received: closing Convos API service");
94-
// Stop the generation TTL sweep so its setInterval doesn't keep
95-
// dispatching DB queries against a closing pool during the drain
96-
// window. No-op if the sweep was never started (production gate).
97-
stopGenerationTtlSweep();
98-
server.close(() => {
99-
logger.info("Convos API service closed");
100-
});
98+
void (async () => {
99+
logger.info("SIGTERM signal received: closing Convos API service");
100+
// Stop the generation TTL sweep so its setInterval doesn't keep
101+
// dispatching DB queries against a closing pool during the drain
102+
// window. No-op if the sweep was never started (production gate).
103+
stopGenerationTtlSweep();
104+
// Flush buffered PostHog events before the process exits. The SDK
105+
// buffers up to flushAt (default 20) or flushInterval (default 10s)
106+
// — without an explicit shutdown, low-volume captures get dropped
107+
// on every redeploy. Catches its own errors; never throws.
108+
await shutdownPostHog();
109+
server.close(() => {
110+
logger.info("Convos API service closed");
111+
});
112+
})();
101113
});
102114
})
103115
.catch((error: unknown) => {

0 commit comments

Comments
 (0)