From 5c169a6fe04ed9a64161cb387e9176d0be4f3bb6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 20 Aug 2026 10:53:13 +0200 Subject: [PATCH 1/2] feat(node): flush() drains queued spans A serverless handler calls flush() rather than shutdown(), so ended spans were left queued until the container was reused. --- .changeset/node-flush-drains-spans.md | 5 +++++ packages/node/src/__tests__/traces.spec.ts | 23 +++++++++++++++++----- packages/node/src/client.ts | 20 +++++++++++++++++-- 3 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 .changeset/node-flush-drains-spans.md diff --git a/.changeset/node-flush-drains-spans.md b/.changeset/node-flush-drains-spans.md new file mode 100644 index 0000000000..7ccf982096 --- /dev/null +++ b/.changeset/node-flush-drains-spans.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +`flush()` now drains queued tracing spans as well as events, so a serverless handler that calls `flush()` before returning no longer leaves ended spans sitting in the queue until the container is reused. Events and spans are flushed concurrently, and a failed span export leaves the spans queued for the next flush rather than rejecting. diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index 0180cde7d7..59adc6def2 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -297,15 +297,28 @@ describe('PostHog traces', () => { }) describe('flush cycle', () => { - it('flushes on its own interval, not with the events pipeline', async () => { - // Traces are a separate pipeline with their own queue and endpoint. - // `posthog.flush()` drains events only; wiring traces into it is a - // deliberate follow-up because it changes that method's contract. + it('drains queued spans', async () => { + // A serverless handler calls flush() rather than shutdown(), because the + // container is reused across invocations. posthog.startSpan('checkout').end() await posthog.flush() - expect(traceRequests()).toHaveLength(0) + expect(sentSpans()).toHaveLength(1) + }) + + it('resolves when the span export fails', async () => { + // Spans that could not be sent stay queued; flush() must not start + // rejecting for callers who already treat it as safe. + mockedFetch.mockRejectedValue(new Error('network down')) + posthog.startSpan('checkout').end() + + await expect(posthog.flush()).resolves.toBeUndefined() + }) + + it('still flushes on its own interval', async () => { + posthog.startSpan('checkout').end() await flushTraces() + expect(sentSpans()).toHaveLength(1) }) }) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 190ba4a7d9..9cbcbb442b 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -285,8 +285,24 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen this.scheduleDebouncedFlush() } + /** + * Drains the event queue and, when tracing is configured, the span queue too. + * + * Both run concurrently so a serverless handler waits for one round trip + * rather than two. A failing span export never rejects here: spans that could + * not be sent stay queued for the next flush, and surfacing them would add a + * rejection path to a method callers already treat as safe. + */ + private _flushEventsAndSpans(): Promise { + const events = this.flushWithPendingPromises() + if (!this._traces) { + return events + } + return Promise.all([events, this._traces.flush().catch(() => {})]).then(() => undefined) + } + override async flush(): Promise { - const flushPromise = this.flushWithPendingPromises() + const flushPromise = this._flushEventsAndSpans() const waitUntil = this.options.waitUntil // Only register when no debounce promise is already keeping runtime alive if (waitUntil && !this._waitUntilCycle) { @@ -359,7 +375,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen private async resolveWaitUntilFlush(): Promise { const resolve = this._consumeWaitUntilCycle() try { - await this.flushWithPendingPromises() + await this._flushEventsAndSpans() } catch { // Flush errors are already logged by flush() internals } finally { From 2e9bfa0c5555148f58841beb1e6ddba306dd4af9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 20 Aug 2026 10:57:13 +0200 Subject: [PATCH 2/2] chore(node): trim comments to the non-obvious rationale --- packages/node/src/__tests__/traces.spec.ts | 4 ---- packages/node/src/client.ts | 9 +++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index 59adc6def2..2a6c37483f 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -298,8 +298,6 @@ describe('PostHog traces', () => { describe('flush cycle', () => { it('drains queued spans', async () => { - // A serverless handler calls flush() rather than shutdown(), because the - // container is reused across invocations. posthog.startSpan('checkout').end() await posthog.flush() @@ -307,8 +305,6 @@ describe('PostHog traces', () => { }) it('resolves when the span export fails', async () => { - // Spans that could not be sent stay queued; flush() must not start - // rejecting for callers who already treat it as safe. mockedFetch.mockRejectedValue(new Error('network down')) posthog.startSpan('checkout').end() diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 9cbcbb442b..26cd4fcb90 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -286,12 +286,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } /** - * Drains the event queue and, when tracing is configured, the span queue too. - * - * Both run concurrently so a serverless handler waits for one round trip - * rather than two. A failing span export never rejects here: spans that could - * not be sent stay queued for the next flush, and surfacing them would add a - * rejection path to a method callers already treat as safe. + * Concurrent so a serverless handler waits for one round trip, not two. A + * failed span export leaves the spans queued rather than rejecting, since + * callers already treat `flush()` as safe to leave unwrapped. */ private _flushEventsAndSpans(): Promise { const events = this.flushWithPendingPromises()