From e08fd053643b285ba9a6a27f941ed04cde1ccccc Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 10:19:17 +0100 Subject: [PATCH 01/10] feat(node): add OpenTelemetry request context processor Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/bright-jeans-learn.md | 5 + packages/node/README.md | 23 ++ packages/node/package.json | 13 +- .../__tests__/extensions/otel-context.spec.ts | 256 ++++++++++++++++++ packages/node/src/__tests__/traces.spec.ts | 26 ++ .../src/__tests__/types/public-interface.ts | 8 + packages/node/src/client.ts | 11 +- packages/node/src/entrypoints/otel.ts | 57 ++++ .../node/src/extensions/context/context.ts | 18 +- .../node/src/extensions/context/lifetime.ts | 21 ++ .../node/src/extensions/context/listeners.ts | 23 ++ packages/node/src/extensions/express.ts | 28 +- pnpm-lock.yaml | 3 + 13 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 .changeset/bright-jeans-learn.md create mode 100644 packages/node/src/__tests__/extensions/otel-context.spec.ts create mode 100644 packages/node/src/entrypoints/otel.ts create mode 100644 packages/node/src/extensions/context/lifetime.ts create mode 100644 packages/node/src/extensions/context/listeners.ts diff --git a/.changeset/bright-jeans-learn.md b/.changeset/bright-jeans-learn.md new file mode 100644 index 0000000000..7fcee5b4bc --- /dev/null +++ b/.changeset/bright-jeans-learn.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add an optional OpenTelemetry context processor and keep Express request IDs local to the response lifetime. diff --git a/packages/node/README.md b/packages/node/README.md index 434eddea61..a6ff3662c8 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -8,6 +8,29 @@ SDK usage examples and code snippets live in the official documentation so they - [Node.js library docs](https://posthog.com/docs/libraries/node) +## OpenTelemetry request context + +The optional `posthog-node/otel` entrypoint exports `PostHogContextSpanProcessor`. +Pass your PostHog client to its constructor and add the processor to the application's OpenTelemetry provider. +Install the optional `@opentelemetry/api` peer dependency and keep the application's provider, exporter, sampling, and other processors. +Configure OpenTelemetry before the application accepts requests, and install `setupExpressRequestContext` before Express routes. + +The processor adds available `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. +It also updates an active request span when PostHog reads the request context. +It preserves explicit span attributes and reads values once, so exported spans keep their original context. +API-only spans without public readable attributes are skipped. +For a manual request integration, call `enrichCurrentSpan(posthog)` from `posthog-node/otel` after entering the header context. +This fills missing attributes on an incoming span that already has application context. +The helper does nothing when no context processor is installed. +Express preserves an existing application identity and treats incoming headers as fallback analytics context. +The headers must not be used for authentication or authorization. + +Express context stays active through async request work until the response finishes or closes. +New spans from detached work after that point have no request IDs. +Use explicit context for a background job that needs its own IDs. +This integration does not create IDs, traces, recordings, or log exporters. +It does not change the application's OpenTelemetry setup or require OpenTelemetry for other SDK use. + ## Questions?? ### [Check out our community page.](https://posthog.com/posts) diff --git a/packages/node/package.json b/packages/node/package.json index bbb5e7d3e0..3d7c4142aa 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -49,16 +49,17 @@ "@edge-runtime/vm": "^5.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", "@posthog-tooling/tsconfig-base": "workspace:*", "@posthog/types": "workspace:^", "@rslib/core": "catalog:", "@types/express": "^5.0.6", "@types/node": "^20.0.0", "esbuild": "^0.25.0", - "rxjs": "^7.8.0", "@vitest/coverage-v8": "catalog:", - "vitest": "catalog:", - "typescript": "catalog:native" + "rxjs": "^7.8.0", + "typescript": "catalog:native", + "vitest": "catalog:" }, "peerDependencies": { "@opentelemetry/api": "^1.2.0", @@ -120,6 +121,12 @@ "import": "./dist/experimental.mjs", "require": "./dist/experimental.js", "default": "./dist/experimental.js" + }, + "./otel": { + "types": "./dist/entrypoints/otel.d.ts", + "import": "./dist/entrypoints/otel.mjs", + "require": "./dist/entrypoints/otel.js", + "default": "./dist/entrypoints/otel.js" } } } diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts new file mode 100644 index 0000000000..4696952bf2 --- /dev/null +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -0,0 +1,256 @@ +import { EventEmitter } from 'node:events' +import { context, trace, ROOT_CONTEXT, type Attributes } from '@opentelemetry/api' +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { PostHog } from '@/entrypoints/index.node' +import { PostHogContextSpanProcessor, enrichCurrentSpan } from '@/entrypoints/otel' +import { setupExpressRequestContext } from '@/extensions/express' + +describe('OpenTelemetry request context', () => { + const manager = new AsyncLocalStorageContextManager() + let client: PostHog + let exporter: InMemorySpanExporter + let provider: BasicTracerProvider + let processor: PostHogContextSpanProcessor + + beforeAll(() => context.setGlobalContextManager(manager.enable())) + afterAll(() => { + context.disable() + manager.disable() + }) + + beforeEach(() => { + vi.useRealTimers() + client = new PostHog('test-key', { disableGeoip: true }) + exporter = new InMemorySpanExporter() + processor = new PostHogContextSpanProcessor(client) + provider = new BasicTracerProvider({ spanProcessors: [processor, new SimpleSpanProcessor(exporter)] }) + }) + + afterEach(async () => { + await provider.shutdown() + await client.shutdown() + }) + + function middleware(): (...args: any[]) => void { + const app = { use: vi.fn() } + setupExpressRequestContext(client, app) + return app.use.mock.calls[0][0] + } + + it.each([ + [{}, { sessionId: 'browser-session', posthogDistinctId: 'browser-user' }], + [{ sessionId: 'explicit-session' }, { sessionId: 'explicit-session', posthogDistinctId: 'browser-user' }], + [{ posthogDistinctId: 'app-user' }, { sessionId: 'browser-session', posthogDistinctId: 'app-user' }], + ])('stamps the existing request span and snapshots children with attributes %j', async (attributes, expected) => { + const tracer = provider.getTracer('test') + const requestSpan = tracer.startSpan('request', { attributes }) + const response = new EventEmitter() + await context.with( + trace.setSpan(ROOT_CONTEXT, requestSpan), + () => + new Promise((resolve, reject) => { + middleware()( + { headers: { 'x-posthog-session-id': 'browser-session', 'x-posthog-distinct-id': 'browser-user' } }, + response, + () => { + void (async () => { + await Promise.resolve() + const child = tracer.startSpan('child', { attributes }) + client.enterContext({ distinctId: 'later-user', sessionId: 'later-session' }) + response.emit('finish') + child.end() + const detached = tracer.startSpan('detached') + detached.end() + requestSpan.end() + resolve() + })().catch(reject) + } + ) + }) + ) + await provider.forceFlush() + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'request')?.attributes).toMatchObject(expected) + expect(spans.find((span) => span.name === 'child')?.attributes).toMatchObject(expected) + expect(spans.find((span) => span.name === 'detached')?.attributes).toEqual({}) + expect(client.getContext()).toBeUndefined() + }) + + it.each(['finish', 'close'])('isolates concurrent requests and expires scope on %s', async (event) => { + const tracer = provider.getTracer('test') + const run = middleware() + const releases: (() => void)[] = [] + const spans: Attributes[] = [] + await Promise.all( + ['first', 'second'].map( + (id) => + new Promise((resolve, reject) => { + const response = new EventEmitter() + run({ headers: { 'x-posthog-session-id': id } }, response, () => { + void (async () => { + await new Promise((release) => releases.push(release)) + const span = tracer.startSpan(id) + span.end() + response.emit(event) + const late = tracer.startSpan('late') + late.end() + expect(client.getContext()).toBeUndefined() + resolve() + })().catch(reject) + }) + if (releases.length === 2) releases.reverse().forEach((release) => release()) + }) + ) + ) + await provider.forceFlush() + exporter.getFinishedSpans().forEach((span) => spans.push(span.attributes)) + expect(spans).toEqual(expect.arrayContaining([{ sessionId: 'first' }, { sessionId: 'second' }, {}])) + }) + + it('preserves application identity and omits invalid or missing header IDs', () => { + const tracer = provider.getTracer('test') + client.withContext({ distinctId: 'authenticated-user' }, () => { + middleware()( + { headers: { 'x-posthog-distinct-id': 'untrusted-user', 'x-posthog-session-id': ' \n ' } }, + new EventEmitter(), + () => { + const span = tracer.startSpan('identity') + span.end() + } + ) + }) + expect(exporter.getFinishedSpans()[0].attributes).toEqual({ posthogDistinctId: 'authenticated-user' }) + }) + + it.each(['Express', 'manual'])('adds header session after application identity with %s setup', (setup) => { + const span = provider.getTracer('test').startSpan('request') + context.with(trace.setSpan(ROOT_CONTEXT, span), () => { + client.withContext({ distinctId: 'authenticated-user' }, () => { + if (setup === 'Express') { + middleware()( + { headers: { 'x-posthog-session-id': 'browser-session', 'x-posthog-distinct-id': 'browser-user' } }, + new EventEmitter(), + () => span.end() + ) + } else { + client.withContext({ sessionId: 'browser-session' }, () => { + enrichCurrentSpan(client) + span.end() + }) + } + }) + }) + expect(exporter.getFinishedSpans()[0].attributes).toEqual({ + sessionId: 'browser-session', + posthogDistinctId: 'authenticated-user', + }) + }) + + it('does not read context on export or enrich spans after shutdown', async () => { + const tracer = provider.getTracer('test') + const span = client.withContext({ sessionId: 'snapshot' }, () => tracer.startSpan('delayed')) + await processor.shutdown() + expect(() => enrichCurrentSpan(client)).not.toThrow() + client.withContext({ sessionId: 'other' }, () => span.end()) + const later = client.withContext({ sessionId: 'other' }, () => tracer.startSpan('later')) + later.end() + expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual([{ sessionId: 'snapshot' }, {}]) + }) + + it('keeps explicit attributes when the SDK attribute limit is full', async () => { + await provider.shutdown() + exporter = new InMemorySpanExporter() + processor = new PostHogContextSpanProcessor(client) + provider = new BasicTracerProvider({ + spanLimits: { attributeCountLimit: 1 }, + spanProcessors: [processor, new SimpleSpanProcessor(exporter)], + }) + client.withContext({ sessionId: 'browser-session', distinctId: 'browser-user' }, () => { + const span = provider.getTracer('test').startSpan('full', { attributes: { posthogDistinctId: 'explicit-user' } }) + span.end() + }) + expect(exporter.getFinishedSpans()[0].attributes).toEqual({ posthogDistinctId: 'explicit-user' }) + }) + + it.each(['withContext', 'enterContext'])('keeps absent IDs stable through %s', (setup) => { + const tracer = provider.getTracer('test') + client.withContext({ distinctId: 'original-user' }, () => { + const span = tracer.startSpan('snapshot') + context.with(trace.setSpan(context.active(), span), () => { + if (setup === 'withContext') { + client.withContext({ sessionId: 'later-session' }, () => span.end()) + } else { + client.enterContext({ sessionId: 'later-session' }) + span.end() + } + }) + }) + expect(exporter.getFinishedSpans()[0].attributes).toEqual({ posthogDistinctId: 'original-user' }) + }) + + it.each([ + [{ sessionId: 'job' }, { sessionId: 'job' }], + [{ distinctId: 'job-user' }, { posthogDistinctId: 'job-user' }], + ])('clears a thrown request scope and accepts explicit context %j after expiry', async (data, expected) => { + const error = new Error('handler failed') + let detached: Promise | undefined + let release: () => void = () => undefined + expect(() => + middleware()( + { headers: { 'x-posthog-session-id': 'request', 'x-posthog-distinct-id': 'request-user' } }, + new EventEmitter(), + () => { + detached = (async () => { + await new Promise((resolve) => { + release = resolve + }) + expect(client.getContext()).toBeUndefined() + client.withContext( + data, + () => { + provider.getTracer('test').startSpan('job').end() + }, + { fresh: true } + ) + })() + throw error + } + ) + ).toThrow(error) + release() + await detached + expect(exporter.getFinishedSpans()[0].attributes).toEqual(expected) + }) + + it('keeps clients and processor shutdown separate', async () => { + const other = new PostHog('other-test-key') + const duplicate = new PostHogContextSpanProcessor(client) + const separate = new PostHogContextSpanProcessor(other) + try { + await duplicate.shutdown() + other.withContext({ sessionId: 'other' }, () => { + client.withContext({ sessionId: 'client' }, () => { + provider.getTracer('test').startSpan('own-client').end() + }) + }) + expect(exporter.getFinishedSpans()[0].attributes).toEqual({ sessionId: 'client' }) + } finally { + await separate.shutdown() + await other.shutdown() + } + }) + + it('ignores API-only spans and context lookup failures', () => { + const apiSpan = trace.wrapSpanContext({ traceId: '1'.repeat(32), spanId: '2'.repeat(16), traceFlags: 0 }) + expect(() => + context.with(trace.setSpan(ROOT_CONTEXT, apiSpan), () => + client.withContext({ sessionId: 'test' }, () => undefined) + ) + ).not.toThrow() + vi.spyOn(client, 'getContext').mockImplementation(() => { + throw new Error('lookup failed') + }) + expect(() => provider.getTracer('test').startSpan('safe').end()).not.toThrow() + }) +}) diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index b2fab35929..3c1bd925ae 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -1,4 +1,6 @@ import { platform, release } from 'node:os' +import { EventEmitter } from 'node:events' +import { setupExpressRequestContext } from '@/extensions/express' import { PostHog } from '@/entrypoints/index.node' import type { OtlpSpan, OtlpTracesPayload } from '@posthog/types' import { waitForPromises } from './utils' @@ -295,6 +297,30 @@ describe('PostHog traces', () => { expect(attributeOf(span, 'sessionId')).toEqual({ stringValue: 'session-123' }) }) + it('keeps native snapshots and omits request IDs from spans after the response', async () => { + const app = { use: vi.fn() } + setupExpressRequestContext(posthog, app) + const response = new EventEmitter() + app.use.mock.calls[0][0]( + { headers: { 'x-posthog-session-id': 'request-session', 'x-posthog-distinct-id': 'request-user' } }, + response, + () => { + posthog.withContext({ properties: { nested: true } }, () => { + const span = posthog.startSpan('request-work') + response.emit('finish') + span.end() + posthog.startSpan('detached-work').end() + }) + } + ) + await flushTraces() + const [request, detached] = sentSpans() + expect(attributeOf(request, 'sessionId')).toEqual({ stringValue: 'request-session' }) + expect(attributeOf(request, 'posthogDistinctId')).toEqual({ stringValue: 'request-user' }) + expect(attributeOf(detached, 'sessionId')).toBeUndefined() + expect(attributeOf(detached, 'posthogDistinctId')).toBeUndefined() + }) + it('omits the keys outside a request context', async () => { posthog.startSpan('background-job').end() await flushTraces() diff --git a/packages/node/src/__tests__/types/public-interface.ts b/packages/node/src/__tests__/types/public-interface.ts index a15eaa541a..6ba9e2ef36 100644 --- a/packages/node/src/__tests__/types/public-interface.ts +++ b/packages/node/src/__tests__/types/public-interface.ts @@ -6,3 +6,11 @@ export function checkPublicInterface(posthog: PostHog): Promise { client.captureException(new Error('queued exception'), 'user') return client.captureExceptionImmediate(new Error('immediate exception'), 'user') } + +export async function checkOpenTelemetryInterface(posthog: PostHog): Promise { + const { BasicTracerProvider } = await import('@opentelemetry/sdk-trace-base') + const { PostHogContextSpanProcessor, enrichCurrentSpan } = await import('../../entrypoints/otel') + const provider = new BasicTracerProvider({ spanProcessors: [new PostHogContextSpanProcessor(posthog)] }) + enrichCurrentSpan(posthog) + await provider.shutdown() +} diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 8e1fa3daba..b274d06247 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -72,6 +72,7 @@ import ErrorTracking from './extensions/error-tracking' import { getActiveTraceProperties } from './extensions/error-tracking/trace-context' import { PostHogMemoryStorage } from './storage-memory' import { ContextData, ContextOptions, IPostHogContext } from './extensions/context/types' +import { notifyContextListeners } from './extensions/context/listeners' import { type CaptureMode, resolveCaptureMode } from './capture-v1/config' import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routing' import { V1CaptureSender } from './capture-v1/sender' @@ -2843,7 +2844,14 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return fn() } - return this.context.run(data, fn, options) + return this.context.run( + data, + () => { + notifyContextListeners(this) + return fn() + }, + options + ) } /** @@ -2882,6 +2890,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen */ enterContext(data: Partial, options?: ContextOptions): void { this.context?.enter(data as ContextData, options) + notifyContextListeners(this) } /** diff --git a/packages/node/src/entrypoints/otel.ts b/packages/node/src/entrypoints/otel.ts new file mode 100644 index 0000000000..f33ae6519e --- /dev/null +++ b/packages/node/src/entrypoints/otel.ts @@ -0,0 +1,57 @@ +import { trace, type Attributes, type Span } from '@opentelemetry/api' +import type { PostHogBackendClient } from '../client' +import { addContextListener, notifyContextListeners } from '../extensions/context/listeners' + +type ReadableSpan = Span & { readonly attributes: Attributes } + +function hasReadableAttributes(span: Span | undefined): span is ReadableSpan { + return !!span && 'attributes' in span && typeof span.attributes === 'object' && span.attributes !== null +} + +export function enrichCurrentSpan(posthog: PostHogBackendClient): void { + notifyContextListeners(posthog, true) +} + +export class PostHogContextSpanProcessor { + private active = true + private readonly snapshots = new WeakSet() + private readonly unsubscribe: () => void + + constructor(private readonly posthog: PostHogBackendClient) { + this.unsubscribe = addContextListener(posthog, (requestEntry) => this.enrich(trace.getActiveSpan(), requestEntry)) + } + + private enrich(span: Span | undefined, requestEntry = false): void { + if (!this.active) return + try { + if (!hasReadableAttributes(span) || !span.isRecording() || (this.snapshots.has(span) && !requestEntry)) return + const context = this.posthog.getContext() + if (!context) return + this.snapshots.add(span) + if (context.sessionId && span.attributes.sessionId === undefined) { + span.setAttribute('sessionId', context.sessionId) + } + if (context.distinctId && span.attributes.posthogDistinctId === undefined) { + span.setAttribute('posthogDistinctId', context.distinctId) + } + } catch {} + } + + onStart(span: Span): void { + this.enrich(span) + } + + onEnd(): void {} + + forceFlush(): Promise { + return Promise.resolve() + } + + shutdown(): Promise { + if (this.active) { + this.active = false + this.unsubscribe() + } + return Promise.resolve() + } +} diff --git a/packages/node/src/extensions/context/context.ts b/packages/node/src/extensions/context/context.ts index 044dbf9260..be8990ed57 100644 --- a/packages/node/src/extensions/context/context.ts +++ b/packages/node/src/extensions/context/context.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { ContextData, ContextOptions, IPostHogContext } from './types' +import { inheritContextLifetime, isContextActive } from './lifetime' export class PostHogContext implements IPostHogContext { private storage: AsyncLocalStorage @@ -9,7 +10,8 @@ export class PostHogContext implements IPostHogContext { } get(): ContextData | undefined { - return this.storage.getStore() + const context = this.storage.getStore() + return context && isContextActive(context) ? context : undefined } run(context: ContextData, fn: () => T, options?: ContextOptions): T { @@ -21,18 +23,18 @@ export class PostHogContext implements IPostHogContext { } private resolve(context: ContextData, options?: ContextOptions): ContextData { + const current = this.get() if (options?.fresh === true) { - return context + return inheritContextLifetime(current, { ...context }) } - const current = this.get() || {} - return { - distinctId: context.distinctId ?? current.distinctId, - sessionId: context.sessionId ?? current.sessionId, + return inheritContextLifetime(current, { + distinctId: context.distinctId ?? current?.distinctId, + sessionId: context.sessionId ?? current?.sessionId, properties: { - ...(current.properties || {}), + ...(current?.properties || {}), ...(context.properties || {}), }, - } + }) } } diff --git a/packages/node/src/extensions/context/lifetime.ts b/packages/node/src/extensions/context/lifetime.ts new file mode 100644 index 0000000000..830800791f --- /dev/null +++ b/packages/node/src/extensions/context/lifetime.ts @@ -0,0 +1,21 @@ +import type { ContextData } from './types' + +export interface ContextLifetime { + active: boolean +} + +const lifetimes = new WeakMap() + +export function bindContextLifetime(context: ContextData | undefined, lifetime: ContextLifetime): void { + if (context) lifetimes.set(context, lifetime) +} + +export function inheritContextLifetime(parent: ContextData | undefined, child: ContextData): ContextData { + const lifetime = parent && lifetimes.get(parent) + if (lifetime) lifetimes.set(child, lifetime) + return child +} + +export function isContextActive(context: ContextData): boolean { + return lifetimes.get(context)?.active !== false +} diff --git a/packages/node/src/extensions/context/listeners.ts b/packages/node/src/extensions/context/listeners.ts new file mode 100644 index 0000000000..39e326439f --- /dev/null +++ b/packages/node/src/extensions/context/listeners.ts @@ -0,0 +1,23 @@ +import type { PostHogBackendClient } from '../../client' + +type ContextListener = (requestEntry: boolean) => void + +const listeners = new WeakMap>() + +export function addContextListener(client: PostHogBackendClient, listener: ContextListener): () => void { + const callbacks = listeners.get(client) ?? new Set() + listeners.set(client, callbacks) + callbacks.add(listener) + return () => { + callbacks.delete(listener) + if (!callbacks.size) listeners.delete(client) + } +} + +export function notifyContextListeners(client: PostHogBackendClient, requestEntry = false): void { + for (const listener of listeners.get(client) ?? []) { + try { + listener(requestEntry) + } catch {} + } +} diff --git a/packages/node/src/extensions/express.ts b/packages/node/src/extensions/express.ts index 694095467b..888c3922f9 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -5,6 +5,8 @@ import { addProperty, getFirstHeaderValue, getPostHogTracingHeaderValues } from import { normalizeRequestCurrentUrl, normalizeRequestPath } from './url-utils' import type { Request, Response } from 'express' import type { ContextData } from './context/types' +import { bindContextLifetime } from './context/lifetime' +import { notifyContextListeners } from './context/listeners' type ExpressMiddleware = (req: Request, res: Response, next: () => void) => void @@ -34,7 +36,10 @@ function getClientIp(req: Request): string | undefined { } function buildRequestContextData(posthog: PostHogBackendClient, req: Request): Partial { - const { sessionId, distinctId } = getPostHogTracingHeaderValues(req.headers) + const headers = getPostHogTracingHeaderValues(req.headers) + const context = posthog.getContext() + const sessionId = context?.sessionId ?? headers.sessionId + const distinctId = context?.distinctId ?? headers.distinctId const properties: Record = {} const disableCaptureUrlHashes = posthog.options.disable_capture_url_hashes === true @@ -65,8 +70,25 @@ export function setupExpressRequestContext( } function posthogRequestContext(posthog: PostHogBackendClient): ExpressMiddleware { - return (req, _res, next): void => { - posthog.withContext(buildRequestContextData(posthog, req), () => next()) + return (req, res, next): void => { + const lifetime = { active: true } + const complete = (): void => { + lifetime.active = false + res.removeListener('finish', complete) + res.removeListener('close', complete) + } + res.once('finish', complete) + res.once('close', complete) + posthog.withContext(buildRequestContextData(posthog, req), () => { + bindContextLifetime(posthog.getContext(), lifetime) + notifyContextListeners(posthog, true) + try { + next() + } catch (error) { + complete() + throw error + } + }) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 592a48769b..00fa6b8873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,6 +819,9 @@ importers: '@opentelemetry/context-async-hooks': specifier: ^2.2.0 version: 2.10.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.0) '@posthog-tooling/tsconfig-base': specifier: workspace:* version: link:../../tooling/tsconfig-base From ae92f11a80f0517e98e22fcd5ba2ba9893528383 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 11:57:25 +0100 Subject: [PATCH 02/10] feat(node): add signal-controlled context lifetimes Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/bright-jeans-learn.md | 2 +- packages/node/README.md | 3 + .../posthog-node-references-latest.json | 5 + packages/node/src/__tests__/context.spec.ts | 102 ++++++++++++++++++ .../__tests__/extensions/otel-context.spec.ts | 42 ++++++++ .../src/__tests__/types/public-interface.ts | 3 + .../node/src/extensions/context/context.ts | 20 ++-- .../node/src/extensions/context/lifetime.ts | 24 ++++- packages/node/src/extensions/context/types.ts | 5 + 9 files changed, 193 insertions(+), 13 deletions(-) diff --git a/.changeset/bright-jeans-learn.md b/.changeset/bright-jeans-learn.md index 7fcee5b4bc..9ffa301441 100644 --- a/.changeset/bright-jeans-learn.md +++ b/.changeset/bright-jeans-learn.md @@ -2,4 +2,4 @@ 'posthog-node': minor --- -Add an optional OpenTelemetry context processor and keep Express request IDs local to the response lifetime. +Add an optional OpenTelemetry context processor and request context lifetimes for backend frameworks. diff --git a/packages/node/README.md b/packages/node/README.md index a6ff3662c8..41316c6e03 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -22,6 +22,9 @@ API-only spans without public readable attributes are skipped. For a manual request integration, call `enrichCurrentSpan(posthog)` from `posthog-node/otel` after entering the header context. This fills missing attributes on an incoming span that already has application context. The helper does nothing when no context processor is installed. +For other Node.js frameworks, pass `{ signal: controller.signal }` to `withContext` or `enterContext` and abort the controller when the request ends. +This ends the context for detached work, including inherited nested scopes, without cancelling the handler. +The signal also works without OpenTelemetry. Calls without a signal keep their existing scope behavior. Express preserves an existing application identity and treats incoming headers as fallback analytics context. The headers must not be used for authentication or authorization. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index dbe0b9d64e..ac19395f75 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2098,6 +2098,11 @@ "description": "If true, replaces the current context entirely. If false, merges with the existing context (new values override existing ones). false", "type": "boolean", "name": "fresh" + }, + { + "description": "Ends this context and its inherited scopes when aborted, without cancelling the callback. An already aborted signal starts no active context. Parent lifetime limits still apply.", + "type": "AbortSignal", + "name": "signal" } ], "path": "src/extensions/context/types.ts" diff --git a/packages/node/src/__tests__/context.spec.ts b/packages/node/src/__tests__/context.spec.ts index 015d1bb874..2957031f4d 100644 --- a/packages/node/src/__tests__/context.spec.ts +++ b/packages/node/src/__tests__/context.spec.ts @@ -46,6 +46,108 @@ describe('PostHog Context', () => { await posthog.shutdown() }) + describe('context lifetime signals', () => { + it.each(['withContext', 'enterContext'] as const)('expires %s without cancelling its callback', async (method) => { + const controller = new AbortController() + const data = { distinctId: 'request-user', sessionId: 'request-session' } + const callback = async (): Promise => { + expect(posthog.getContext()).toMatchObject(data) + controller.abort() + await Promise.resolve() + expect(posthog.getContext()).toBeUndefined() + return 'handler completed' + } + const result = + method === 'withContext' + ? posthog.withContext(data, callback, { signal: controller.signal }) + : posthog.withContext({}, () => { + posthog.enterContext(data, { signal: controller.signal }) + return callback() + }) + expect(await result).toBe('handler completed') + expect(posthog.getContext()).toBeUndefined() + }) + + it.each([ + ['parent', false], + ['parent', true], + ['child', false], + ['child', true], + ] as const)('composes %s expiry with fresh=%s nested scopes', (expired, fresh) => { + const parent = new AbortController() + const child = new AbortController() + posthog.withContext( + { distinctId: 'parent-user', sessionId: 'parent-session' }, + () => { + posthog.withContext( + { distinctId: 'child-user' }, + () => { + expect(posthog.getContext()).toMatchObject({ distinctId: 'child-user' }) + const controller = expired === 'parent' ? parent : child + controller.abort() + expect(posthog.getContext()).toBeUndefined() + }, + { fresh, signal: child.signal } + ) + if (expired === 'parent') expect(posthog.getContext()).toBeUndefined() + else expect(posthog.getContext()).toMatchObject({ distinctId: 'parent-user', sessionId: 'parent-session' }) + }, + { signal: parent.signal } + ) + }) + + it.each([{ distinctId: 'new-user' }, { sessionId: 'new-session' }])( + 'starts explicit scopes after expiry with %j', + (data) => { + const controller = new AbortController() + posthog.withContext( + { distinctId: 'ended-user', sessionId: 'ended-session' }, + () => { + controller.abort() + posthog.withContext(data, () => expect(posthog.getContext()).toEqual(data), { fresh: true }) + posthog.withContext(data, () => { + expect(posthog.getContext()?.distinctId).toBe(data.distinctId) + expect(posthog.getContext()?.sessionId).toBe(data.sessionId) + }) + }, + { signal: controller.signal } + ) + } + ) + + it('runs a callback with no active context when its signal is already aborted', () => { + const controller = new AbortController() + controller.abort() + const result = posthog.withContext( + { distinctId: 'ended-user' }, + () => { + expect(posthog.getContext()).toBeUndefined() + return 'handler completed' + }, + { signal: controller.signal } + ) + expect(result).toBe('handler completed') + }) + + it('isolates signal expiry across concurrent async contexts', async () => { + const controllers = [new AbortController(), new AbortController()] + const releases: (() => void)[] = [] + const results = controllers.map((controller, index) => + posthog.withContext( + { sessionId: `session-${index}` }, + async () => { + await new Promise((resolve) => releases.push(resolve)) + return posthog.getContext()?.sessionId + }, + { signal: controller.signal } + ) + ) + controllers[0].abort() + releases.forEach((release) => release()) + expect(await Promise.all(results)).toEqual([undefined, 'session-1']) + }) + }) + it('should attach context tags to events', async () => { posthog.withContext({ properties: { plan: 'premium', region: 'us-east' } }, () => { posthog.capture({ distinctId: 'user-1', event: 'test_event' }) diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index 4696952bf2..cf38f8d321 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -38,6 +38,48 @@ describe('OpenTelemetry request context', () => { return app.use.mock.calls[0][0] } + it.each(['signal', 'request', 'outer-signal'] as const)( + 'expires nested span context on %s completion', + async (expired) => { + const response = new EventEmitter() + const controller = new AbortController() + const outer = new AbortController() + const tracer = provider.getTracer('signal-test') + client.withContext( + {}, + () => { + middleware()( + { headers: { 'x-posthog-session-id': 'request-session', 'x-posthog-distinct-id': 'request-user' } }, + response, + () => { + client.withContext( + { properties: { scope: 'nested' } }, + () => { + const before = tracer.startSpan('before') + if (expired === 'signal') controller.abort() + else if (expired === 'outer-signal') outer.abort() + else response.emit('finish') + expect(client.getContext()).toBeUndefined() + tracer.startSpan('after').end() + before.end() + }, + { signal: controller.signal } + ) + response.emit('finish') + } + ) + }, + { signal: outer.signal } + ) + await provider.forceFlush() + expect(exporter.getFinishedSpans().find((span) => span.name === 'before')?.attributes).toEqual({ + sessionId: 'request-session', + posthogDistinctId: 'request-user', + }) + expect(exporter.getFinishedSpans().find((span) => span.name === 'after')?.attributes).toEqual({}) + } + ) + it.each([ [{}, { sessionId: 'browser-session', posthogDistinctId: 'browser-user' }], [{ sessionId: 'explicit-session' }, { sessionId: 'explicit-session', posthogDistinctId: 'browser-user' }], diff --git a/packages/node/src/__tests__/types/public-interface.ts b/packages/node/src/__tests__/types/public-interface.ts index 6ba9e2ef36..e845025ac0 100644 --- a/packages/node/src/__tests__/types/public-interface.ts +++ b/packages/node/src/__tests__/types/public-interface.ts @@ -2,6 +2,9 @@ import type { IPostHog, PostHog } from '../../entrypoints/index.node' export function checkPublicInterface(posthog: PostHog): Promise { const client: IPostHog = posthog + const signal = new AbortController().signal + client.withContext({ sessionId: 'request-session' }, () => client.getContext(), { fresh: true, signal }) + client.enterContext({ distinctId: 'request-user' }, { signal }) client.metrics.count('jobs.processed', 1) client.captureException(new Error('queued exception'), 'user') return client.captureExceptionImmediate(new Error('immediate exception'), 'user') diff --git a/packages/node/src/extensions/context/context.ts b/packages/node/src/extensions/context/context.ts index be8990ed57..739da7281e 100644 --- a/packages/node/src/extensions/context/context.ts +++ b/packages/node/src/extensions/context/context.ts @@ -25,16 +25,20 @@ export class PostHogContext implements IPostHogContext { private resolve(context: ContextData, options?: ContextOptions): ContextData { const current = this.get() if (options?.fresh === true) { - return inheritContextLifetime(current, { ...context }) + return inheritContextLifetime(current, { ...context }, options.signal) } - return inheritContextLifetime(current, { - distinctId: context.distinctId ?? current?.distinctId, - sessionId: context.sessionId ?? current?.sessionId, - properties: { - ...(current?.properties || {}), - ...(context.properties || {}), + return inheritContextLifetime( + current, + { + distinctId: context.distinctId ?? current?.distinctId, + sessionId: context.sessionId ?? current?.sessionId, + properties: { + ...(current?.properties || {}), + ...(context.properties || {}), + }, }, - }) + options?.signal + ) } } diff --git a/packages/node/src/extensions/context/lifetime.ts b/packages/node/src/extensions/context/lifetime.ts index 830800791f..ce28c79cf1 100644 --- a/packages/node/src/extensions/context/lifetime.ts +++ b/packages/node/src/extensions/context/lifetime.ts @@ -2,20 +2,36 @@ import type { ContextData } from './types' export interface ContextLifetime { active: boolean + signal?: AbortSignal + parent?: ContextLifetime } const lifetimes = new WeakMap() export function bindContextLifetime(context: ContextData | undefined, lifetime: ContextLifetime): void { - if (context) lifetimes.set(context, lifetime) + if (context) { + const parent = lifetimes.get(context) + if (parent !== lifetime) lifetime.parent = parent + lifetimes.set(context, lifetime) + } } -export function inheritContextLifetime(parent: ContextData | undefined, child: ContextData): ContextData { +export function inheritContextLifetime( + parent: ContextData | undefined, + child: ContextData, + signal?: AbortSignal +): ContextData { const lifetime = parent && lifetimes.get(parent) - if (lifetime) lifetimes.set(child, lifetime) + if (signal) lifetimes.set(child, { active: true, signal, parent: lifetime }) + else if (lifetime) lifetimes.set(child, lifetime) return child } export function isContextActive(context: ContextData): boolean { - return lifetimes.get(context)?.active !== false + let lifetime = lifetimes.get(context) + while (lifetime) { + if (!lifetime.active || lifetime.signal?.aborted) return false + lifetime = lifetime.parent + } + return true } diff --git a/packages/node/src/extensions/context/types.ts b/packages/node/src/extensions/context/types.ts index f74d9da48c..2ae257ab16 100644 --- a/packages/node/src/extensions/context/types.ts +++ b/packages/node/src/extensions/context/types.ts @@ -11,6 +11,11 @@ export interface ContextOptions { * @default false */ fresh?: boolean + /** + * Ends this context and its inherited scopes when aborted, without cancelling the callback. + * An already aborted signal starts no active context. Parent lifetime limits still apply. + */ + signal?: AbortSignal } export interface IPostHogContext { From 2de44506938b35ede7d79f777026e481cfc66d7c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 13:06:48 +0100 Subject: [PATCH 03/10] fix(node): preserve exception snapshots and fresh scopes Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/node/README.md | 5 ++ .../posthog-node-references-latest.json | 2 +- packages/node/src/__tests__/context.spec.ts | 52 ++++++++++++++++++- .../__tests__/extensions/otel-context.spec.ts | 39 ++++++++++++++ packages/node/src/client.ts | 9 ++-- .../node/src/extensions/context/context.ts | 2 +- .../src/extensions/context/event-context.ts | 14 +++++ packages/node/src/extensions/context/types.ts | 2 +- .../src/extensions/error-tracking/index.ts | 15 ++++-- packages/node/src/extensions/express.ts | 3 +- 10 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 packages/node/src/extensions/context/event-context.ts diff --git a/packages/node/README.md b/packages/node/README.md index 41316c6e03..9655340fdd 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -18,6 +18,7 @@ Configure OpenTelemetry before the application accepts requests, and install `se The processor adds available `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. It also updates an active request span when PostHog reads the request context. It preserves explicit span attributes and reads values once, so exported spans keep their original context. +It recognizes `sessionId` and `posthogDistinctId`; `user.id` alone does not override header-derived analytics identity. API-only spans without public readable attributes are skipped. For a manual request integration, call `enrichCurrentSpan(posthog)` from `posthog-node/otel` after entering the header context. This fills missing attributes on an incoming span that already has application context. @@ -25,11 +26,15 @@ The helper does nothing when no context processor is installed. For other Node.js frameworks, pass `{ signal: controller.signal }` to `withContext` or `enterContext` and abort the controller when the request ends. This ends the context for detached work, including inherited nested scopes, without cancelling the handler. The signal also works without OpenTelemetry. Calls without a signal keep their existing scope behavior. +`fresh: true` starts an independent context with only the supplied values and its own optional signal. +A background job can use it with explicit IDs before or after the request ends. +Without `fresh: true`, nested scopes inherit the parent lifetime as well as their own signal. Express preserves an existing application identity and treats incoming headers as fallback analytics context. The headers must not be used for authentication or authorization. Express context stays active through async request work until the response finishes or closes. New spans from detached work after that point have no request IDs. +Exceptions captured before completion keep their original context while asynchronous error processing finishes. Use explicit context for a background job that needs its own IDs. This integration does not create IDs, traces, recordings, or log exporters. It does not change the application's OpenTelemetry setup or require OpenTelemetry for other SDK use. diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index ac19395f75..2370cbee3a 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2100,7 +2100,7 @@ "name": "fresh" }, { - "description": "Ends this context and its inherited scopes when aborted, without cancelling the callback. An already aborted signal starts no active context. Parent lifetime limits still apply.", + "description": "Ends this context and its inherited scopes when aborted, without cancelling the callback. An already aborted signal starts no active context. A fresh context has an independent lifetime.", "type": "AbortSignal", "name": "signal" } diff --git a/packages/node/src/__tests__/context.spec.ts b/packages/node/src/__tests__/context.spec.ts index 2957031f4d..a18508d6c1 100644 --- a/packages/node/src/__tests__/context.spec.ts +++ b/packages/node/src/__tests__/context.spec.ts @@ -1,3 +1,5 @@ +import { EventEmitter } from 'node:events' +import { setupExpressRequestContext } from '@/extensions/express' import { PostHog } from '@/entrypoints/index.node' import { apiImplementation } from './utils' import { waitForPromises } from './utils' @@ -85,7 +87,8 @@ describe('PostHog Context', () => { expect(posthog.getContext()).toMatchObject({ distinctId: 'child-user' }) const controller = expired === 'parent' ? parent : child controller.abort() - expect(posthog.getContext()).toBeUndefined() + if (expired === 'parent' && fresh) expect(posthog.getContext()).toEqual({ distinctId: 'child-user' }) + else expect(posthog.getContext()).toBeUndefined() }, { fresh, signal: child.signal } ) @@ -576,6 +579,53 @@ describe('PostHog Context', () => { }) }) + it.each( + (['buffered', 'immediate'] as const).flatMap((mode) => + (['signal', 'response'] as const).flatMap((lifetime) => + (['context', 'explicit', 'personless'] as const).map((identity) => ({ mode, lifetime, identity })) + ) + ) + )( + 'keeps $mode exception context after $lifetime expiry with $identity identity', + async ({ mode, lifetime, identity }) => { + const controller = new AbortController() + const response = new EventEmitter() + const explicit = identity === 'explicit' + const contextId = identity === 'personless' ? undefined : 'request-user' + const data = { distinctId: contextId, sessionId: 'request-session', properties: { route: '/work' } } + let pending: Promise | undefined + const capture = (): void => { + const error = new Error('operation failed') + const properties = explicit ? { $session_id: 'explicit-session', route: '/explicit' } : undefined + const distinctId = explicit ? 'explicit-user' : undefined + if (mode === 'immediate') pending = posthog.captureExceptionImmediate(error, distinctId, properties) + else posthog.captureException(error, distinctId, properties) + if (lifetime === 'signal') controller.abort() + else response.emit('finish') + expect(posthog.getContext()).toBeUndefined() + } + + if (lifetime === 'signal') posthog.withContext(data, capture, { signal: controller.signal }) + else { + const app = { use: vi.fn() } + setupExpressRequestContext(posthog, app) + posthog.withContext(data, () => app.use.mock.calls[0][0]({ headers: {} }, response, capture)) + } + await pending + await posthog.flush() + + const events = getLastBatchEvents() + expect(events).toHaveLength(1) + const event = events?.[0] + expect(event.event).toBe('$exception') + expect(event.properties.$session_id).toBe(explicit ? 'explicit-session' : 'request-session') + expect(event.properties.route).toBe(explicit ? '/explicit' : '/work') + expect(event.properties.$process_person_profile).toBe(identity === 'personless' ? false : undefined) + if (identity === 'personless') expect(event.distinct_id).toEqual(expect.any(String)) + else expect(event.distinct_id).toBe(explicit ? 'explicit-user' : 'request-user') + } + ) + it.each([ { label: 'no distinctId and no context → personless', diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index cf38f8d321..512e13a72f 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -80,6 +80,45 @@ describe('OpenTelemetry request context', () => { } ) + it.each([false, true])('keeps a fresh job independent of the response with own signal=%s', async (withSignal) => { + const response = new EventEmitter() + const controller = new AbortController() + const tracer = provider.getTracer('job-test') + const data = { distinctId: 'job-user', sessionId: 'job-session' } + let job: Promise | undefined + middleware()( + { headers: { 'x-posthog-session-id': 'request-session', 'x-posthog-distinct-id': 'request-user' } }, + response, + () => { + job = client.withContext( + data, + async () => { + await Promise.resolve() + expect(client.getContext()).toEqual(data) + tracer.startSpan('job').end() + if (withSignal) { + controller.abort() + expect(client.getContext()).toBeUndefined() + tracer.startSpan('ended-job').end() + } + }, + { fresh: true, signal: withSignal ? controller.signal : undefined } + ) + response.emit('finish') + expect(client.getContext()).toBeUndefined() + tracer.startSpan('ended-request').end() + } + ) + await job + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'job')?.attributes).toEqual({ + sessionId: 'job-session', + posthogDistinctId: 'job-user', + }) + expect(spans.find((span) => span.name === 'ended-request')?.attributes).toEqual({}) + if (withSignal) expect(spans.find((span) => span.name === 'ended-job')?.attributes).toEqual({}) + }) + it.each([ [{}, { sessionId: 'browser-session', posthogDistinctId: 'browser-user' }], [{ sessionId: 'explicit-session' }, { sessionId: 'explicit-session', posthogDistinctId: 'browser-user' }], diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index b274d06247..71cea6216b 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -73,6 +73,7 @@ import { getActiveTraceProperties } from './extensions/error-tracking/trace-cont import { PostHogMemoryStorage } from './storage-memory' import { ContextData, ContextOptions, IPostHogContext } from './extensions/context/types' import { notifyContextListeners } from './extensions/context/listeners' +import { getEventContext } from './extensions/context/event-context' import { type CaptureMode, resolveCaptureMode } from './capture-v1/config' import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routing' import { V1CaptureSender } from './capture-v1/sender' @@ -3185,7 +3186,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen error, { syntheticException }, distinctId, - additionalProperties + additionalProperties, + this.getContext() ).then((msg) => this._capturePreparedEvent({ ...msg, uuid, flags }, false)) ) } @@ -3241,7 +3243,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen error, { syntheticException }, distinctId, - additionalProperties + additionalProperties, + this.getContext() ).then((msg) => this.captureImmediate({ ...msg, flags })) ) } @@ -3277,7 +3280,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen uuid, }: EventMessage = props - const contextData = this.context?.get() + const contextData = getEventContext(props, this.context?.get()) const includeContextProperties = options.includeContextProperties ?? true let mergedDistinctId = distinctId || contextData?.distinctId diff --git a/packages/node/src/extensions/context/context.ts b/packages/node/src/extensions/context/context.ts index 739da7281e..7cfbbc4635 100644 --- a/packages/node/src/extensions/context/context.ts +++ b/packages/node/src/extensions/context/context.ts @@ -25,7 +25,7 @@ export class PostHogContext implements IPostHogContext { private resolve(context: ContextData, options?: ContextOptions): ContextData { const current = this.get() if (options?.fresh === true) { - return inheritContextLifetime(current, { ...context }, options.signal) + return inheritContextLifetime(undefined, { ...context }, options.signal) } return inheritContextLifetime( diff --git a/packages/node/src/extensions/context/event-context.ts b/packages/node/src/extensions/context/event-context.ts new file mode 100644 index 0000000000..d86ca51aec --- /dev/null +++ b/packages/node/src/extensions/context/event-context.ts @@ -0,0 +1,14 @@ +import type { EventMessage } from '../../types' +import type { ContextData } from './types' + +const capturedContext = Symbol('capturedContext') +type ContextEvent = EventMessage & { [capturedContext]?: ContextData | null } + +export function snapshotEventContext(context: ContextData | undefined): (event: EventMessage) => EventMessage { + const snapshot = context ? { ...context, properties: { ...context.properties } } : null + return (event) => Object.assign(event, { [capturedContext]: snapshot }) +} + +export function getEventContext(event: EventMessage, fallback: ContextData | undefined): ContextData | undefined { + return capturedContext in event ? ((event as ContextEvent)[capturedContext] ?? undefined) : fallback +} diff --git a/packages/node/src/extensions/context/types.ts b/packages/node/src/extensions/context/types.ts index 2ae257ab16..4dd5405c3f 100644 --- a/packages/node/src/extensions/context/types.ts +++ b/packages/node/src/extensions/context/types.ts @@ -13,7 +13,7 @@ export interface ContextOptions { fresh?: boolean /** * Ends this context and its inherited scopes when aborted, without cancelling the callback. - * An already aborted signal starts no active context. Parent lifetime limits still apply. + * An already aborted signal starts no active context. A fresh context has an independent lifetime. */ signal?: AbortSignal } diff --git a/packages/node/src/extensions/error-tracking/index.ts b/packages/node/src/extensions/error-tracking/index.ts index 0573c10da9..4ff558f725 100644 --- a/packages/node/src/extensions/error-tracking/index.ts +++ b/packages/node/src/extensions/error-tracking/index.ts @@ -1,5 +1,7 @@ import { addUncaughtExceptionListener, addUnhandledRejectionListener } from './autocapture' import { PostHogBackendClient } from '@/client' +import type { ContextData } from '../context/types' +import { snapshotEventContext } from '../context/event-context' import { isObject } from '@posthog/core' import { EventMessage, PostHogOptions } from '@/types' import type { Logger } from '@posthog/core' @@ -47,8 +49,10 @@ export default class ErrorTracking { error: unknown, hint: CoreErrorTracking.EventHint, distinctId?: string, - additionalProperties?: Record + additionalProperties?: Record, + context?: ContextData ): Promise { + const applyContextSnapshot = snapshotEventContext(context) const properties: EventMessage['properties'] = { ...additionalProperties } const exceptionProperties = builder.buildFromUnknown(error, hint) @@ -59,7 +63,7 @@ export default class ErrorTracking { properties.$release_id = injectedReleaseId } - return { + return applyContextSnapshot({ event: '$exception', // Leave distinctId resolution to prepareEventMessage which checks request context // and falls back to a random UUID with $process_person_profile = false @@ -69,7 +73,7 @@ export default class ErrorTracking { ...properties, }, _originatedFromCaptureException: true, - } + }) } private startAutocaptureIfEnabled(): void { @@ -86,7 +90,10 @@ export default class ErrorTracking { const eventMessage = await ErrorTracking.buildEventMessage( this.client.getErrorPropertiesBuilder(), exception, - hint + hint, + undefined, + undefined, + this.client.getContext() ) const exceptionProperties = eventMessage.properties const exceptionType = exceptionProperties?.$exception_list[0]?.type ?? 'Exception' diff --git a/packages/node/src/extensions/express.ts b/packages/node/src/extensions/express.ts index 888c3922f9..3f91ccfc85 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -144,7 +144,8 @@ function posthogErrorHandler(posthog: PostHogBackendClient): ExpressErrorMiddlew error, hint, contextData.distinctId, - additionalProperties + additionalProperties, + posthog.getContext() ), // Downstream error handlers can choose a different status, even asynchronously. getResponseStatusCode(res), From 6f1e87ffa623b68891f800b8d53cedcc8bd41ad4 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 13:09:36 +0100 Subject: [PATCH 04/10] fix(node): scope NestJS span context to the response Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/node/README.md | 3 + .../extensions/nestjs-span-context.spec.ts | 145 ++++++++++++++++++ .../src/__tests__/extensions/nestjs.spec.ts | 5 +- packages/node/src/extensions/nestjs.ts | 39 ++++- 4 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts diff --git a/packages/node/README.md b/packages/node/README.md index 9655340fdd..86df456385 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -36,6 +36,9 @@ Express context stays active through async request work until the response finis New spans from detached work after that point have no request IDs. Exceptions captured before completion keep their original context while asynchronous error processing finishes. Use explicit context for a background job that needs its own IDs. +The Node.js `PostHogInterceptor` from `posthog-node/nestjs` also enriches request and child spans. +It keeps its existing fresh header context and ends it when the Express or Fastify response finishes or closes. +Explicit `sessionId` and `posthogDistinctId` span attributes keep their values. This integration does not create IDs, traces, recordings, or log exporters. It does not change the application's OpenTelemetry setup or require OpenTelemetry for other SDK use. diff --git a/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts new file mode 100644 index 0000000000..99b987765b --- /dev/null +++ b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from 'node:events' +import { Observable, defer, lastValueFrom } from 'rxjs' +import { context, trace, ROOT_CONTEXT } from '@opentelemetry/api' +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { PostHog } from '@/entrypoints/index.node' +import { PostHogInterceptor } from '@/extensions/nestjs' +import { PostHogContextSpanProcessor } from '@/entrypoints/otel' + +describe('NestJS span context', () => { + const manager = new AsyncLocalStorageContextManager() + let client: PostHog + let provider: BasicTracerProvider + let exporter: InMemorySpanExporter + + beforeAll(() => context.setGlobalContextManager(manager.enable())) + afterAll(() => { + context.disable() + manager.disable() + }) + beforeEach(() => { + vi.useRealTimers() + client = new PostHog('phc_test') + exporter = new InMemorySpanExporter() + provider = new BasicTracerProvider({ + spanProcessors: [new PostHogContextSpanProcessor(client), new SimpleSpanProcessor(exporter)], + }) + }) + afterEach(async () => { + await provider.shutdown() + await client.shutdown() + }) + + function requestContext(response: object): any { + return { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { 'x-posthog-session-id': 'browser-session', 'x-posthog-distinct-id': 'header-user' }, + }), + getResponse: () => response, + }), + } + } + + it.each([ + ['express', 'success', 'finish'], + ['express', 'error', 'close'], + ['fastify', 'success', 'finish'], + ['fastify', 'error', 'close'], + ])('keeps %s %s context until response %s', async (adapter, outcome, event) => { + const response = new EventEmitter() + const tracer = provider.getTracer('nestjs-test') + const incoming = tracer.startSpan('incoming', { attributes: { posthogDistinctId: 'application-user' } }) + let releasePending!: () => void + let releaseLate!: () => void + let pending!: Promise + let late!: Promise + await context.with(trace.setSpan(ROOT_CONTEXT, incoming), () => + client.withContext({ properties: { application: true } }, async () => { + const interceptor = new PostHogInterceptor(client) + const result = lastValueFrom( + interceptor.intercept(requestContext(adapter === 'fastify' ? { raw: response } : response), { + handle: () => + defer(async () => { + const child = tracer.startSpan('child') + pending = (async () => { + await new Promise((resolve) => { + releasePending = resolve + }) + tracer.startSpan('pending-response').end() + })() + late = (async () => { + await new Promise((resolve) => { + releaseLate = resolve + }) + tracer.startSpan('late').end() + expect(client.getContext()).toBeUndefined() + child.end() + })() + if (outcome === 'error') throw new Error('request failure') + return 'ok' + }), + }) + ) + if (outcome === 'error') await expect(result).rejects.toThrow('request failure') + else expect(await result).toBe('ok') + releasePending() + await pending + response.emit(event) + releaseLate() + await late + }) + ) + incoming.end() + await provider.forceFlush() + const spans = exporter.getFinishedSpans() + for (const name of ['child', 'pending-response']) { + expect(spans.find((span) => span.name === name)?.attributes).toEqual({ + sessionId: 'browser-session', + posthogDistinctId: 'header-user', + }) + } + expect(spans.find((span) => span.name === 'incoming')?.attributes).toEqual({ + sessionId: 'browser-session', + posthogDistinctId: 'application-user', + }) + expect(spans.find((span) => span.name === 'late')?.attributes).toEqual({}) + expect(response.listenerCount('finish')).toBe(0) + expect(response.listenerCount('close')).toBe(0) + }) + + it.each(['throw', 'error', 'unsubscribe'])('expires unsupported-response scope on %s', async (outcome) => { + const tracer = provider.getTracer('nestjs-test') + let release!: () => void + let late!: Promise + const begin = (): void => { + late = (async () => { + await new Promise((resolve) => { + release = resolve + }) + tracer.startSpan('late').end() + expect(client.getContext()).toBeUndefined() + })() + } + const interceptor = new PostHogInterceptor(client) + const stream = interceptor.intercept(requestContext({}), { + handle: () => { + if (outcome === 'throw') { + begin() + throw new Error('request failure') + } + return new Observable((subscriber) => { + begin() + if (outcome === 'error') subscriber.error(new Error('request failure')) + }) + }, + }) + if (outcome === 'unsubscribe') stream.subscribe().unsubscribe() + else await expect(lastValueFrom(stream)).rejects.toThrow('request failure') + release() + await late + await provider.forceFlush() + expect(exporter.getFinishedSpans().find((span) => span.name === 'late')?.attributes).toEqual({}) + }) +}) diff --git a/packages/node/src/__tests__/extensions/nestjs.spec.ts b/packages/node/src/__tests__/extensions/nestjs.spec.ts index ae836a6dfe..fbd3fdc3c5 100644 --- a/packages/node/src/__tests__/extensions/nestjs.spec.ts +++ b/packages/node/src/__tests__/extensions/nestjs.spec.ts @@ -1,4 +1,5 @@ import type { IncomingHttpHeaders } from 'node:http' +import { EventEmitter } from 'node:events' import { defer, of, throwError, lastValueFrom } from 'rxjs' import { PostHog } from '@/entrypoints/index.node' @@ -44,9 +45,9 @@ const createMockContext = (overrides?: { headers: overrides?.headers ?? {}, socket: { remoteAddress: overrides?.remoteAddress ?? '127.0.0.1' }, } - const response = { + const response = Object.assign(new EventEmitter(), { statusCode: overrides?.statusCode ?? 500, - } + }) return { switchToHttp: () => ({ getRequest: () => request, diff --git a/packages/node/src/extensions/nestjs.ts b/packages/node/src/extensions/nestjs.ts index cc6d601042..61f29cfdfe 100644 --- a/packages/node/src/extensions/nestjs.ts +++ b/packages/node/src/extensions/nestjs.ts @@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators' import ErrorTracking from './error-tracking' import { addProperty, getFirstHeaderValue, getPostHogTracingHeaderValues } from './tracing-headers' import { normalizeRequestCurrentUrl, normalizeRequestPath } from './url-utils' +import { notifyContextListeners } from './context/listeners' import { PostHogBackendClient } from '../client' // Local interfaces to avoid runtime dependency on @nestjs/common @@ -95,11 +96,35 @@ export class PostHogInterceptor implements NestInterceptor { properties, } - return new Observable((subscriber) => - this.posthog.withContext( + return new Observable((subscriber) => { + const controller = new AbortController() + const rawResponse = response?.raw ?? response + const hasResponseEvents = + typeof rawResponse?.once === 'function' && typeof rawResponse?.removeListener === 'function' + const complete = (): void => { + controller.abort() + if (hasResponseEvents) { + rawResponse.removeListener('finish', complete) + rawResponse.removeListener('close', complete) + } + } + if (hasResponseEvents) { + rawResponse.once('finish', complete) + rawResponse.once('close', complete) + if (rawResponse.writableFinished || rawResponse.destroyed) complete() + } + + return this.posthog.withContext( contextData, () => { - let source = next.handle() + notifyContextListeners(this.posthog, true) + let source: Observable + try { + source = next.handle() + } catch (error) { + complete() + throw error + } if (this.captureExceptions) { source = source.pipe( @@ -120,10 +145,12 @@ export class PostHogInterceptor implements NestInterceptor { ) } - return source.subscribe(subscriber) + const subscription = source.subscribe(subscriber) + if (!hasResponseEvents) subscription.add(complete) + return subscription }, - { fresh: true } + { fresh: true, signal: controller.signal } ) - ) + }) } } From 45f150e449d37795e9ef17fa28c9a92a0a563303 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 16:21:20 +0100 Subject: [PATCH 05/10] refactor(node): share abort signals for request lifetime Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .../node/src/extensions/context/lifetime.ts | 15 ++-------- packages/node/src/extensions/express.ts | 28 ++++++++++--------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/node/src/extensions/context/lifetime.ts b/packages/node/src/extensions/context/lifetime.ts index ce28c79cf1..c7f1d8a5d5 100644 --- a/packages/node/src/extensions/context/lifetime.ts +++ b/packages/node/src/extensions/context/lifetime.ts @@ -1,28 +1,19 @@ import type { ContextData } from './types' export interface ContextLifetime { - active: boolean - signal?: AbortSignal + signal: AbortSignal parent?: ContextLifetime } const lifetimes = new WeakMap() -export function bindContextLifetime(context: ContextData | undefined, lifetime: ContextLifetime): void { - if (context) { - const parent = lifetimes.get(context) - if (parent !== lifetime) lifetime.parent = parent - lifetimes.set(context, lifetime) - } -} - export function inheritContextLifetime( parent: ContextData | undefined, child: ContextData, signal?: AbortSignal ): ContextData { const lifetime = parent && lifetimes.get(parent) - if (signal) lifetimes.set(child, { active: true, signal, parent: lifetime }) + if (signal) lifetimes.set(child, { signal, parent: lifetime }) else if (lifetime) lifetimes.set(child, lifetime) return child } @@ -30,7 +21,7 @@ export function inheritContextLifetime( export function isContextActive(context: ContextData): boolean { let lifetime = lifetimes.get(context) while (lifetime) { - if (!lifetime.active || lifetime.signal?.aborted) return false + if (lifetime.signal.aborted) return false lifetime = lifetime.parent } return true diff --git a/packages/node/src/extensions/express.ts b/packages/node/src/extensions/express.ts index 3f91ccfc85..8c5a1d0723 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -5,7 +5,6 @@ import { addProperty, getFirstHeaderValue, getPostHogTracingHeaderValues } from import { normalizeRequestCurrentUrl, normalizeRequestPath } from './url-utils' import type { Request, Response } from 'express' import type { ContextData } from './context/types' -import { bindContextLifetime } from './context/lifetime' import { notifyContextListeners } from './context/listeners' type ExpressMiddleware = (req: Request, res: Response, next: () => void) => void @@ -71,24 +70,27 @@ export function setupExpressRequestContext( function posthogRequestContext(posthog: PostHogBackendClient): ExpressMiddleware { return (req, res, next): void => { - const lifetime = { active: true } + const controller = new AbortController() const complete = (): void => { - lifetime.active = false + controller.abort() res.removeListener('finish', complete) res.removeListener('close', complete) } res.once('finish', complete) res.once('close', complete) - posthog.withContext(buildRequestContextData(posthog, req), () => { - bindContextLifetime(posthog.getContext(), lifetime) - notifyContextListeners(posthog, true) - try { - next() - } catch (error) { - complete() - throw error - } - }) + posthog.withContext( + buildRequestContextData(posthog, req), + () => { + notifyContextListeners(posthog, true) + try { + next() + } catch (error) { + complete() + throw error + } + }, + { signal: controller.signal } + ) } } From d1723e0f9beaf25a0e5e4d07c26fd78971675918 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 21:24:39 +0100 Subject: [PATCH 06/10] fix(node): enrich verified request server ancestors Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/node/README.md | 3 + .../__tests__/extensions/otel-context.spec.ts | 139 +++++++++++++++++- packages/node/src/entrypoints/otel.ts | 25 +++- 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 86df456385..03868a85cb 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -17,6 +17,9 @@ Configure OpenTelemetry before the application accepts requests, and install `se The processor adds available `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. It also updates an active request span when PostHog reads the request context. +Explicit request entry also fills missing IDs on the nearest recording server ancestor, following parent span objects observed by the processor. +If the sampler drops an intermediate span, that parent chain cannot be verified and the earlier server span stays unchanged. +Ordinary context entry keeps its current-span behavior and does not walk to server ancestors. It preserves explicit span attributes and reads values once, so exported spans keep their original context. It recognizes `sessionId` and `posthogDistinctId`; `user.id` alone does not override header-derived analytics identity. API-only spans without public readable attributes are skipped. diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index 512e13a72f..eb07e0d69e 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -1,7 +1,12 @@ import { EventEmitter } from 'node:events' -import { context, trace, ROOT_CONTEXT, type Attributes } from '@opentelemetry/api' +import { context, trace, ROOT_CONTEXT, SpanKind, type Attributes } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + SamplingDecision, +} from '@opentelemetry/sdk-trace-base' import { PostHog } from '@/entrypoints/index.node' import { PostHogContextSpanProcessor, enrichCurrentSpan } from '@/entrypoints/otel' import { setupExpressRequestContext } from '@/extensions/express' @@ -228,6 +233,136 @@ describe('OpenTelemetry request context', () => { }) }) + it.each([ + [{}, { sessionId: 'browser-session', posthogDistinctId: 'browser-user' }], + [{ sessionId: 'explicit-session' }, { sessionId: 'explicit-session', posthogDistinctId: 'browser-user' }], + [{ posthogDistinctId: false }, { sessionId: 'browser-session', posthogDistinctId: false }], + ])('enriches only the nearest verified server ancestor, preserving %j', async (attributes, expected) => { + const tracer = provider.getTracer('server-chain') + await tracer.startActiveSpan('outer-server', { kind: SpanKind.SERVER }, async (outer) => { + await tracer.startActiveSpan('server', { kind: SpanKind.SERVER, attributes }, async (server) => { + await tracer.startActiveSpan('express-layer', async (layer) => { + await tracer.startActiveSpan('active-middleware', async (active) => { + await client.withContext({ distinctId: 'browser-user', sessionId: 'browser-session' }, async () => { + enrichCurrentSpan(client) + tracer.startSpan('child').end() + client.enterContext({ distinctId: 'later-user', sessionId: 'later-session' }) + }) + active.end() + }) + layer.end() + }) + server.end() + }) + outer.end() + }) + await provider.forceFlush() + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'server')?.attributes).toEqual(expected) + for (const name of ['outer-server', 'express-layer']) { + expect(spans.find((span) => span.name === name)?.attributes).toEqual({}) + } + for (const name of ['active-middleware', 'child']) { + expect(spans.find((span) => span.name === name)?.attributes).toEqual({ + sessionId: 'browser-session', + posthogDistinctId: 'browser-user', + }) + } + }) + + it('does not walk to server ancestors on ordinary context entry', async () => { + const tracer = provider.getTracer('ordinary-scope') + await tracer.startActiveSpan('server', { kind: SpanKind.SERVER }, async (server) => { + await tracer.startActiveSpan('caller', async (caller) => { + client.withContext({ distinctId: 'job-user', sessionId: 'job-session' }, () => { + tracer.startSpan('job-child').end() + }) + caller.end() + }) + server.end() + }) + expect(exporter.getFinishedSpans().find((span) => span.name === 'server')?.attributes).toEqual({}) + expect(exporter.getFinishedSpans().find((span) => span.name === 'job-child')?.attributes).toEqual({ + sessionId: 'job-session', + posthogDistinctId: 'job-user', + }) + }) + + it('does not guess server ancestry across a sampler-dropped span', async () => { + await provider.shutdown() + exporter = new InMemorySpanExporter() + processor = new PostHogContextSpanProcessor(client) + provider = new BasicTracerProvider({ + sampler: { + shouldSample: (_parent, _traceId, name) => ({ + decision: name === 'dropped' ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED, + }), + toString: () => 'drop-intermediate', + }, + spanProcessors: [processor, new SimpleSpanProcessor(exporter)], + }) + const tracer = provider.getTracer('sampled-gap') + await tracer.startActiveSpan('server', { kind: SpanKind.SERVER }, async (server) => { + await tracer.startActiveSpan('dropped', async (dropped) => { + expect(dropped.isRecording()).toBe(false) + await tracer.startActiveSpan('inner', async (inner) => { + client.withContext({ sessionId: 'browser-session' }, () => { + enrichCurrentSpan(client) + tracer.startSpan('child').end() + }) + inner.end() + }) + dropped.end() + }) + server.end() + }) + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'server')?.attributes).toEqual({}) + expect(spans.some((span) => span.name === 'dropped')).toBe(false) + for (const name of ['inner', 'child']) { + expect(spans.find((span) => span.name === name)?.attributes).toEqual({ sessionId: 'browser-session' }) + } + }) + + it('keeps concurrent server ancestor chains in separate traces', async () => { + const tracer = provider.getTracer('concurrent-server-chains') + const releases: (() => void)[] = [] + await Promise.all( + ['first', 'second'].map((id) => + context.with(ROOT_CONTEXT, () => + tracer.startActiveSpan(id, { kind: SpanKind.SERVER }, async (server) => { + await tracer.startActiveSpan(`middleware-${id}`, async (active) => { + await client.withContext({ sessionId: id }, async () => { + const ready = new Promise((resolve) => releases.push(resolve)) + if (releases.length === 2) releases.reverse().forEach((release) => release()) + await ready + enrichCurrentSpan(client) + }) + active.end() + }) + server.end() + }) + ) + ) + ) + const servers = exporter.getFinishedSpans().filter((span) => span.kind === SpanKind.SERVER) + expect(new Set(servers.map((span) => span.spanContext().traceId)).size).toBe(2) + for (const span of servers) expect(span.attributes).toEqual({ sessionId: span.name }) + }) + + it('does not enrich recorded server ancestors after shutdown', async () => { + const tracer = provider.getTracer('stopped-chain') + await tracer.startActiveSpan('server', { kind: SpanKind.SERVER }, async (server) => { + await tracer.startActiveSpan('middleware', async (active) => { + await processor.shutdown() + client.withContext({ sessionId: 'ignored' }, () => enrichCurrentSpan(client)) + active.end() + }) + server.end() + }) + expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual([{}, {}]) + }) + it('does not read context on export or enrich spans after shutdown', async () => { const tracer = provider.getTracer('test') const span = client.withContext({ sessionId: 'snapshot' }, () => tracer.startSpan('delayed')) diff --git a/packages/node/src/entrypoints/otel.ts b/packages/node/src/entrypoints/otel.ts index f33ae6519e..6d151b360a 100644 --- a/packages/node/src/entrypoints/otel.ts +++ b/packages/node/src/entrypoints/otel.ts @@ -1,4 +1,4 @@ -import { trace, type Attributes, type Span } from '@opentelemetry/api' +import { trace, SpanKind, type Attributes, type Context, type Span } from '@opentelemetry/api' import type { PostHogBackendClient } from '../client' import { addContextListener, notifyContextListeners } from '../extensions/context/listeners' @@ -15,10 +15,15 @@ export function enrichCurrentSpan(posthog: PostHogBackendClient): void { export class PostHogContextSpanProcessor { private active = true private readonly snapshots = new WeakSet() + private readonly parents = new WeakMap() private readonly unsubscribe: () => void constructor(private readonly posthog: PostHogBackendClient) { - this.unsubscribe = addContextListener(posthog, (requestEntry) => this.enrich(trace.getActiveSpan(), requestEntry)) + this.unsubscribe = addContextListener(posthog, (requestEntry) => { + const span = trace.getActiveSpan() + this.enrich(span, requestEntry) + if (requestEntry) this.enrichNearestServerSpan(span) + }) } private enrich(span: Span | undefined, requestEntry = false): void { @@ -37,7 +42,21 @@ export class PostHogContextSpanProcessor { } catch {} } - onStart(span: Span): void { + private enrichNearestServerSpan(current: Span | undefined): void { + let span = current + while (span) { + if ('kind' in span && span.kind === SpanKind.SERVER && span.isRecording()) { + if (span !== current) this.enrich(span, true) + return + } + span = this.parents.get(span) + } + } + + onStart(span: Span, parentContext?: Context): void { + if (!this.active) return + const parent = parentContext && trace.getSpan(parentContext) + if (parent && parent !== span) this.parents.set(span, parent) this.enrich(span) } From 86bddb2124d223a61d8082b33e75e695b570c204 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 22:49:11 +0100 Subject: [PATCH 07/10] fix(node): expose context enrichment without tracing imports Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/node/README.md | 1 + .../node/src/__tests__/extensions/otel-context.spec.ts | 4 ++-- packages/node/src/entrypoints/otel.ts | 8 +++----- packages/node/src/exports.ts | 1 + packages/node/src/extensions/context/listeners.ts | 8 ++++++++ 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index 03868a85cb..f9d34362b6 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -17,6 +17,7 @@ Configure OpenTelemetry before the application accepts requests, and install `se The processor adds available `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. It also updates an active request span when PostHog reads the request context. +`enrichCurrentSpan` is also available from the main `posthog-node` entry without loading OpenTelemetry. It is a no-op until a processor is registered. Explicit request entry also fills missing IDs on the nearest recording server ancestor, following parent span objects observed by the processor. If the sampler drops an intermediate span, that parent chain cannot be verified and the earlier server span stays unchanged. Ordinary context entry keeps its current-span behavior and does not walk to server ancestors. diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index eb07e0d69e..52eb272f9e 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -7,8 +7,8 @@ import { SimpleSpanProcessor, SamplingDecision, } from '@opentelemetry/sdk-trace-base' -import { PostHog } from '@/entrypoints/index.node' -import { PostHogContextSpanProcessor, enrichCurrentSpan } from '@/entrypoints/otel' +import { PostHog, enrichCurrentSpan } from '@/entrypoints/index.node' +import { PostHogContextSpanProcessor } from '@/entrypoints/otel' import { setupExpressRequestContext } from '@/extensions/express' describe('OpenTelemetry request context', () => { diff --git a/packages/node/src/entrypoints/otel.ts b/packages/node/src/entrypoints/otel.ts index 6d151b360a..c6e3ca5fa0 100644 --- a/packages/node/src/entrypoints/otel.ts +++ b/packages/node/src/entrypoints/otel.ts @@ -1,6 +1,8 @@ import { trace, SpanKind, type Attributes, type Context, type Span } from '@opentelemetry/api' import type { PostHogBackendClient } from '../client' -import { addContextListener, notifyContextListeners } from '../extensions/context/listeners' +import { addContextListener } from '../extensions/context/listeners' + +export { enrichCurrentSpan } from '../extensions/context/listeners' type ReadableSpan = Span & { readonly attributes: Attributes } @@ -8,10 +10,6 @@ function hasReadableAttributes(span: Span | undefined): span is ReadableSpan { return !!span && 'attributes' in span && typeof span.attributes === 'object' && span.attributes !== null } -export function enrichCurrentSpan(posthog: PostHogBackendClient): void { - notifyContextListeners(posthog, true) -} - export class PostHogContextSpanProcessor { private active = true private readonly snapshots = new WeakSet() diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index b30dd1de21..9762b40dba 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -1,6 +1,7 @@ export * from './extensions/sentry-integration' export * from './extensions/express' export * from './types' +export { enrichCurrentSpan } from './extensions/context/listeners' export { FeatureFlagEvaluations } from './feature-flag-evaluations' export type { diff --git a/packages/node/src/extensions/context/listeners.ts b/packages/node/src/extensions/context/listeners.ts index 39e326439f..4a49ec53cf 100644 --- a/packages/node/src/extensions/context/listeners.ts +++ b/packages/node/src/extensions/context/listeners.ts @@ -21,3 +21,11 @@ export function notifyContextListeners(client: PostHogBackendClient, requestEntr } catch {} } } + +/** + * Notify installed span processors after request IDs become available. + * This entry point does not load OpenTelemetry; without a processor it does nothing. + */ +export function enrichCurrentSpan(client: PostHogBackendClient): void { + notifyContextListeners(client, true) +} From a251a6b5d82cdfa427c1511f83a3910d6c348ab0 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 23:37:33 +0100 Subject: [PATCH 08/10] fix: respect node span privacy and request lifetime Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/node/README.md | 5 ++- .../src/__tests__/extensions/express.spec.ts | 21 +++++++++++ .../__tests__/extensions/otel-context.spec.ts | 35 +++++++++++++++++++ packages/node/src/entrypoints/otel.ts | 7 ++-- packages/node/src/extensions/express.ts | 1 + 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/node/README.md b/packages/node/README.md index f9d34362b6..4a25a4a3e7 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -16,10 +16,12 @@ Install the optional `@opentelemetry/api` peer dependency and keep the applicati Configure OpenTelemetry before the application accepts requests, and install `setupExpressRequestContext` before Express routes. The processor adds available `sessionId` and `posthogDistinctId` attributes to recording OpenTelemetry SDK spans. +Disabled or opted-out clients add no PostHog attributes. Existing span attributes keep their values. It also updates an active request span when PostHog reads the request context. `enrichCurrentSpan` is also available from the main `posthog-node` entry without loading OpenTelemetry. It is a no-op until a processor is registered. Explicit request entry also fills missing IDs on the nearest recording server ancestor, following parent span objects observed by the processor. -If the sampler drops an intermediate span, that parent chain cannot be verified and the earlier server span stays unchanged. +Parent links use weak references, so a retained child does not keep ended ancestors alive. +If the sampler drops an intermediate span or garbage collection removes it, that parent chain cannot be verified and the earlier server span stays unchanged. Ordinary context entry keeps its current-span behavior and does not walk to server ancestors. It preserves explicit span attributes and reads values once, so exported spans keep their original context. It recognizes `sessionId` and `posthogDistinctId`; `user.id` alone does not override header-derived analytics identity. @@ -37,6 +39,7 @@ Express preserves an existing application identity and treats incoming headers a The headers must not be used for authentication or authorization. Express context stays active through async request work until the response finishes or closes. +If the response is already finished or destroyed when the middleware runs, its context starts expired. New spans from detached work after that point have no request IDs. Exceptions captured before completion keep their original context while asynchronous error processing finishes. Use explicit context for a background job that needs its own IDs. diff --git a/packages/node/src/__tests__/extensions/express.spec.ts b/packages/node/src/__tests__/extensions/express.spec.ts index e0eed1b1ca..25236eec7d 100644 --- a/packages/node/src/__tests__/extensions/express.spec.ts +++ b/packages/node/src/__tests__/extensions/express.spec.ts @@ -100,6 +100,27 @@ describe('Express extension', () => { expect(app.use).toHaveBeenCalledWith(expect.any(Function)) }) + it.each(['destroyed', 'writableFinished'] as const)( + 'does not start request context after the response is %s', + async (state) => { + const res = Object.assign(createMockResponse(), { [state]: true }) + let continuation: Promise | undefined + const next = vi.fn(() => { + expect(posthog.getContext()).toBeUndefined() + continuation = Promise.resolve().then(() => expect(posthog.getContext()).toBeUndefined()) + }) + createRequestContextMiddleware(posthog)( + createMockRequest({ headers: { 'x-posthog-session-id': 'expired-session' } }), + res, + next + ) + await continuation + expect(next).toHaveBeenCalledOnce() + expect(res.listenerCount('finish')).toBe(0) + expect(res.listenerCount('close')).toBe(0) + } + ) + it('should set request context for normal captures', async () => { const middleware = createRequestContextMiddleware(posthog) const req = createMockRequest({ diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index 52eb272f9e..629429fe86 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -37,6 +37,41 @@ describe('OpenTelemetry request context', () => { await client.shutdown() }) + it.each(['disabled', 'default-opt-out', 'runtime-opt-out'] as const)( + 'keeps request IDs off spans when the client is %s', + async (privacy) => { + await provider.shutdown() + await client.shutdown() + client = new PostHog('test-key', { + disabled: privacy === 'disabled', + defaultOptIn: privacy !== 'default-opt-out', + }) + exporter = new InMemorySpanExporter() + processor = new PostHogContextSpanProcessor(client) + provider = new BasicTracerProvider({ spanProcessors: [processor, new SimpleSpanProcessor(exporter)] }) + const tracer = provider.getTracer('privacy') + await tracer.startActiveSpan('server', { kind: SpanKind.SERVER }, async (server) => { + await tracer.startActiveSpan('active', { attributes: { sessionId: 'application-session' } }, async (active) => { + if (privacy === 'runtime-opt-out') await client.optOut() + middleware()( + { headers: { 'x-posthog-session-id': 'browser-session', 'x-posthog-distinct-id': 'browser-user' } }, + new EventEmitter(), + () => { + enrichCurrentSpan(client) + tracer.startSpan('child').end() + } + ) + active.end() + }) + server.end() + }) + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'server')?.attributes).toEqual({}) + expect(spans.find((span) => span.name === 'active')?.attributes).toEqual({ sessionId: 'application-session' }) + expect(spans.find((span) => span.name === 'child')?.attributes).toEqual({}) + } + ) + function middleware(): (...args: any[]) => void { const app = { use: vi.fn() } setupExpressRequestContext(client, app) diff --git a/packages/node/src/entrypoints/otel.ts b/packages/node/src/entrypoints/otel.ts index c6e3ca5fa0..d80337cdba 100644 --- a/packages/node/src/entrypoints/otel.ts +++ b/packages/node/src/entrypoints/otel.ts @@ -13,7 +13,7 @@ function hasReadableAttributes(span: Span | undefined): span is ReadableSpan { export class PostHogContextSpanProcessor { private active = true private readonly snapshots = new WeakSet() - private readonly parents = new WeakMap() + private readonly parents = new WeakMap>() private readonly unsubscribe: () => void constructor(private readonly posthog: PostHogBackendClient) { @@ -27,6 +27,7 @@ export class PostHogContextSpanProcessor { private enrich(span: Span | undefined, requestEntry = false): void { if (!this.active) return try { + if (this.posthog.isDisabled || this.posthog.optedOut) return if (!hasReadableAttributes(span) || !span.isRecording() || (this.snapshots.has(span) && !requestEntry)) return const context = this.posthog.getContext() if (!context) return @@ -47,14 +48,14 @@ export class PostHogContextSpanProcessor { if (span !== current) this.enrich(span, true) return } - span = this.parents.get(span) + span = this.parents.get(span)?.deref() } } onStart(span: Span, parentContext?: Context): void { if (!this.active) return const parent = parentContext && trace.getSpan(parentContext) - if (parent && parent !== span) this.parents.set(span, parent) + if (parent && parent !== span) this.parents.set(span, new WeakRef(parent)) this.enrich(span) } diff --git a/packages/node/src/extensions/express.ts b/packages/node/src/extensions/express.ts index 8c5a1d0723..189b5a391d 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -78,6 +78,7 @@ function posthogRequestContext(posthog: PostHogBackendClient): ExpressMiddleware } res.once('finish', complete) res.once('close', complete) + if (res.destroyed || res.writableFinished) complete() posthog.withContext( buildRequestContextData(posthog, req), () => { From dc50e8018f4a30008844dc85be543b3af4a54b59 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 26 Oct 2026 12:09:19 +0000 Subject: [PATCH 09/10] fix(node): share context listeners across CommonJS and ESM builds An app that uses require('posthog-node') and imports posthog-node/otel loads two module copies. Each copy had its own listener registry, so the incoming request span was not enriched. Keep one registry on globalThis. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- .../extensions/context-listeners.spec.ts | 29 +++++++++++++++++++ .../node/src/extensions/context/listeners.ts | 7 ++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/node/src/__tests__/extensions/context-listeners.spec.ts diff --git a/packages/node/src/__tests__/extensions/context-listeners.spec.ts b/packages/node/src/__tests__/extensions/context-listeners.spec.ts new file mode 100644 index 0000000000..446d0fffaa --- /dev/null +++ b/packages/node/src/__tests__/extensions/context-listeners.spec.ts @@ -0,0 +1,29 @@ +import type { PostHogBackendClient } from '@/client' + +async function loadTwoModuleCopies() { + vi.resetModules() + const first = await import('@/extensions/context/listeners') + vi.resetModules() + const second = await import('@/extensions/context/listeners') + return { first, second } +} + +describe('context listeners', () => { + it.each([ + ['the listener', 'second', 'first'], + ['the notifier', 'first', 'second'], + ] as const)('share one registry when %s comes from another module copy', async (_, adder, notifier) => { + const copies = await loadTwoModuleCopies() + expect(copies.second).not.toBe(copies.first) + const client = {} as PostHogBackendClient + const listener = vi.fn() + + const unsubscribe = copies[adder].addContextListener(client, listener) + copies[notifier].enrichCurrentSpan(client) + unsubscribe() + copies[notifier].enrichCurrentSpan(client) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(true) + }) +}) diff --git a/packages/node/src/extensions/context/listeners.ts b/packages/node/src/extensions/context/listeners.ts index 4a49ec53cf..47d6beedd3 100644 --- a/packages/node/src/extensions/context/listeners.ts +++ b/packages/node/src/extensions/context/listeners.ts @@ -2,7 +2,12 @@ import type { PostHogBackendClient } from '../../client' type ContextListener = (requestEntry: boolean) => void -const listeners = new WeakMap>() +type ContextListenerRegistry = WeakMap> + +// The CommonJS and ESM builds are separate module copies, so they share one registry through a global. +const REGISTRY_KEY = Symbol.for('posthog-node.context-listeners') +const registryHolder = globalThis as typeof globalThis & { [REGISTRY_KEY]?: ContextListenerRegistry } +const listeners = (registryHolder[REGISTRY_KEY] ??= new WeakMap()) export function addContextListener(client: PostHogBackendClient, listener: ContextListener): () => void { const callbacks = listeners.get(client) ?? new Set() From d1abe3af29f72b97df3168bfe5522c720c768af1 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 26 Oct 2026 14:19:09 +0000 Subject: [PATCH 10/10] fix(node): keep request context after the response ends Express and NestJS no longer end the request context when the response finishes or closes. A promise or timer that the handler starts can still belong to the same user action, so getContext(), captured events, and new spans keep the request IDs as before. Apps that want a hard end can still pass ContextOptions.signal. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- .changeset/bright-jeans-learn.md | 2 +- packages/node/README.md | 8 +- packages/node/src/__tests__/context.spec.ts | 72 +++++------- .../src/__tests__/extensions/express.spec.ts | 21 ---- .../extensions/nestjs-span-context.spec.ts | 53 ++------- .../src/__tests__/extensions/nestjs.spec.ts | 5 +- .../__tests__/extensions/otel-context.spec.ts | 109 ++++++++---------- packages/node/src/__tests__/traces.spec.ts | 12 +- packages/node/src/extensions/express.ts | 28 +---- packages/node/src/extensions/nestjs.ts | 37 +----- 10 files changed, 109 insertions(+), 238 deletions(-) diff --git a/.changeset/bright-jeans-learn.md b/.changeset/bright-jeans-learn.md index 9ffa301441..aa96740e29 100644 --- a/.changeset/bright-jeans-learn.md +++ b/.changeset/bright-jeans-learn.md @@ -2,4 +2,4 @@ 'posthog-node': minor --- -Add an optional OpenTelemetry context processor and request context lifetimes for backend frameworks. +Add an optional OpenTelemetry context processor, and an optional `signal` that ends a context's lifetime. diff --git a/packages/node/README.md b/packages/node/README.md index 4a25a4a3e7..10e1a95ae9 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -38,13 +38,11 @@ Without `fresh: true`, nested scopes inherit the parent lifetime as well as thei Express preserves an existing application identity and treats incoming headers as fallback analytics context. The headers must not be used for authentication or authorization. -Express context stays active through async request work until the response finishes or closes. -If the response is already finished or destroyed when the middleware runs, its context starts expired. -New spans from detached work after that point have no request IDs. -Exceptions captured before completion keep their original context while asynchronous error processing finishes. +Express context stays active through async request work, including promises and timers that continue after the response ends. +Exceptions keep the context they were captured in while asynchronous error processing finishes. Use explicit context for a background job that needs its own IDs. The Node.js `PostHogInterceptor` from `posthog-node/nestjs` also enriches request and child spans. -It keeps its existing fresh header context and ends it when the Express or Fastify response finishes or closes. +It keeps its existing fresh header context, with the same lifetime as Express. Explicit `sessionId` and `posthogDistinctId` span attributes keep their values. This integration does not create IDs, traces, recordings, or log exporters. It does not change the application's OpenTelemetry setup or require OpenTelemetry for other SDK use. diff --git a/packages/node/src/__tests__/context.spec.ts b/packages/node/src/__tests__/context.spec.ts index a18508d6c1..39b7e06a7a 100644 --- a/packages/node/src/__tests__/context.spec.ts +++ b/packages/node/src/__tests__/context.spec.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'node:events' -import { setupExpressRequestContext } from '@/extensions/express' import { PostHog } from '@/entrypoints/index.node' import { apiImplementation } from './utils' import { waitForPromises } from './utils' @@ -581,50 +579,38 @@ describe('PostHog Context', () => { it.each( (['buffered', 'immediate'] as const).flatMap((mode) => - (['signal', 'response'] as const).flatMap((lifetime) => - (['context', 'explicit', 'personless'] as const).map((identity) => ({ mode, lifetime, identity })) - ) + (['context', 'explicit', 'personless'] as const).map((identity) => ({ mode, identity })) ) - )( - 'keeps $mode exception context after $lifetime expiry with $identity identity', - async ({ mode, lifetime, identity }) => { - const controller = new AbortController() - const response = new EventEmitter() - const explicit = identity === 'explicit' - const contextId = identity === 'personless' ? undefined : 'request-user' - const data = { distinctId: contextId, sessionId: 'request-session', properties: { route: '/work' } } - let pending: Promise | undefined - const capture = (): void => { - const error = new Error('operation failed') - const properties = explicit ? { $session_id: 'explicit-session', route: '/explicit' } : undefined - const distinctId = explicit ? 'explicit-user' : undefined - if (mode === 'immediate') pending = posthog.captureExceptionImmediate(error, distinctId, properties) - else posthog.captureException(error, distinctId, properties) - if (lifetime === 'signal') controller.abort() - else response.emit('finish') - expect(posthog.getContext()).toBeUndefined() - } + )('keeps $mode exception context after signal expiry with $identity identity', async ({ mode, identity }) => { + const controller = new AbortController() + const explicit = identity === 'explicit' + const contextId = identity === 'personless' ? undefined : 'request-user' + const data = { distinctId: contextId, sessionId: 'request-session', properties: { route: '/work' } } + let pending: Promise | undefined + const capture = (): void => { + const error = new Error('operation failed') + const properties = explicit ? { $session_id: 'explicit-session', route: '/explicit' } : undefined + const distinctId = explicit ? 'explicit-user' : undefined + if (mode === 'immediate') pending = posthog.captureExceptionImmediate(error, distinctId, properties) + else posthog.captureException(error, distinctId, properties) + controller.abort() + expect(posthog.getContext()).toBeUndefined() + } - if (lifetime === 'signal') posthog.withContext(data, capture, { signal: controller.signal }) - else { - const app = { use: vi.fn() } - setupExpressRequestContext(posthog, app) - posthog.withContext(data, () => app.use.mock.calls[0][0]({ headers: {} }, response, capture)) - } - await pending - await posthog.flush() + posthog.withContext(data, capture, { signal: controller.signal }) + await pending + await posthog.flush() - const events = getLastBatchEvents() - expect(events).toHaveLength(1) - const event = events?.[0] - expect(event.event).toBe('$exception') - expect(event.properties.$session_id).toBe(explicit ? 'explicit-session' : 'request-session') - expect(event.properties.route).toBe(explicit ? '/explicit' : '/work') - expect(event.properties.$process_person_profile).toBe(identity === 'personless' ? false : undefined) - if (identity === 'personless') expect(event.distinct_id).toEqual(expect.any(String)) - else expect(event.distinct_id).toBe(explicit ? 'explicit-user' : 'request-user') - } - ) + const events = getLastBatchEvents() + expect(events).toHaveLength(1) + const event = events?.[0] + expect(event.event).toBe('$exception') + expect(event.properties.$session_id).toBe(explicit ? 'explicit-session' : 'request-session') + expect(event.properties.route).toBe(explicit ? '/explicit' : '/work') + expect(event.properties.$process_person_profile).toBe(identity === 'personless' ? false : undefined) + if (identity === 'personless') expect(event.distinct_id).toEqual(expect.any(String)) + else expect(event.distinct_id).toBe(explicit ? 'explicit-user' : 'request-user') + }) it.each([ { diff --git a/packages/node/src/__tests__/extensions/express.spec.ts b/packages/node/src/__tests__/extensions/express.spec.ts index 25236eec7d..e0eed1b1ca 100644 --- a/packages/node/src/__tests__/extensions/express.spec.ts +++ b/packages/node/src/__tests__/extensions/express.spec.ts @@ -100,27 +100,6 @@ describe('Express extension', () => { expect(app.use).toHaveBeenCalledWith(expect.any(Function)) }) - it.each(['destroyed', 'writableFinished'] as const)( - 'does not start request context after the response is %s', - async (state) => { - const res = Object.assign(createMockResponse(), { [state]: true }) - let continuation: Promise | undefined - const next = vi.fn(() => { - expect(posthog.getContext()).toBeUndefined() - continuation = Promise.resolve().then(() => expect(posthog.getContext()).toBeUndefined()) - }) - createRequestContextMiddleware(posthog)( - createMockRequest({ headers: { 'x-posthog-session-id': 'expired-session' } }), - res, - next - ) - await continuation - expect(next).toHaveBeenCalledOnce() - expect(res.listenerCount('finish')).toBe(0) - expect(res.listenerCount('close')).toBe(0) - } - ) - it('should set request context for normal captures', async () => { const middleware = createRequestContextMiddleware(posthog) const req = createMockRequest({ diff --git a/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts index 99b987765b..447575254c 100644 --- a/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts +++ b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { Observable, defer, lastValueFrom } from 'rxjs' +import { defer, lastValueFrom } from 'rxjs' import { context, trace, ROOT_CONTEXT } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' @@ -43,11 +43,9 @@ describe('NestJS span context', () => { } it.each([ - ['express', 'success', 'finish'], - ['express', 'error', 'close'], - ['fastify', 'success', 'finish'], - ['fastify', 'error', 'close'], - ])('keeps %s %s context until response %s', async (adapter, outcome, event) => { + ['success', 'finish'], + ['error', 'close'], + ])('keeps %s context after response %s', async (outcome, event) => { const response = new EventEmitter() const tracer = provider.getTracer('nestjs-test') const incoming = tracer.startSpan('incoming', { attributes: { posthogDistinctId: 'application-user' } }) @@ -59,7 +57,7 @@ describe('NestJS span context', () => { client.withContext({ properties: { application: true } }, async () => { const interceptor = new PostHogInterceptor(client) const result = lastValueFrom( - interceptor.intercept(requestContext(adapter === 'fastify' ? { raw: response } : response), { + interceptor.intercept(requestContext(response), { handle: () => defer(async () => { const child = tracer.startSpan('child') @@ -74,7 +72,7 @@ describe('NestJS span context', () => { releaseLate = resolve }) tracer.startSpan('late').end() - expect(client.getContext()).toBeUndefined() + expect(client.getContext()?.sessionId).toBe('browser-session') child.end() })() if (outcome === 'error') throw new Error('request failure') @@ -94,7 +92,7 @@ describe('NestJS span context', () => { incoming.end() await provider.forceFlush() const spans = exporter.getFinishedSpans() - for (const name of ['child', 'pending-response']) { + for (const name of ['child', 'pending-response', 'late']) { expect(spans.find((span) => span.name === name)?.attributes).toEqual({ sessionId: 'browser-session', posthogDistinctId: 'header-user', @@ -104,42 +102,5 @@ describe('NestJS span context', () => { sessionId: 'browser-session', posthogDistinctId: 'application-user', }) - expect(spans.find((span) => span.name === 'late')?.attributes).toEqual({}) - expect(response.listenerCount('finish')).toBe(0) - expect(response.listenerCount('close')).toBe(0) - }) - - it.each(['throw', 'error', 'unsubscribe'])('expires unsupported-response scope on %s', async (outcome) => { - const tracer = provider.getTracer('nestjs-test') - let release!: () => void - let late!: Promise - const begin = (): void => { - late = (async () => { - await new Promise((resolve) => { - release = resolve - }) - tracer.startSpan('late').end() - expect(client.getContext()).toBeUndefined() - })() - } - const interceptor = new PostHogInterceptor(client) - const stream = interceptor.intercept(requestContext({}), { - handle: () => { - if (outcome === 'throw') { - begin() - throw new Error('request failure') - } - return new Observable((subscriber) => { - begin() - if (outcome === 'error') subscriber.error(new Error('request failure')) - }) - }, - }) - if (outcome === 'unsubscribe') stream.subscribe().unsubscribe() - else await expect(lastValueFrom(stream)).rejects.toThrow('request failure') - release() - await late - await provider.forceFlush() - expect(exporter.getFinishedSpans().find((span) => span.name === 'late')?.attributes).toEqual({}) }) }) diff --git a/packages/node/src/__tests__/extensions/nestjs.spec.ts b/packages/node/src/__tests__/extensions/nestjs.spec.ts index fbd3fdc3c5..ae836a6dfe 100644 --- a/packages/node/src/__tests__/extensions/nestjs.spec.ts +++ b/packages/node/src/__tests__/extensions/nestjs.spec.ts @@ -1,5 +1,4 @@ import type { IncomingHttpHeaders } from 'node:http' -import { EventEmitter } from 'node:events' import { defer, of, throwError, lastValueFrom } from 'rxjs' import { PostHog } from '@/entrypoints/index.node' @@ -45,9 +44,9 @@ const createMockContext = (overrides?: { headers: overrides?.headers ?? {}, socket: { remoteAddress: overrides?.remoteAddress ?? '127.0.0.1' }, } - const response = Object.assign(new EventEmitter(), { + const response = { statusCode: overrides?.statusCode ?? 500, - }) + } return { switchToHttp: () => ({ getRequest: () => request, diff --git a/packages/node/src/__tests__/extensions/otel-context.spec.ts b/packages/node/src/__tests__/extensions/otel-context.spec.ts index 629429fe86..b3bd0dc2cc 100644 --- a/packages/node/src/__tests__/extensions/otel-context.spec.ts +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { context, trace, ROOT_CONTEXT, SpanKind, type Attributes } from '@opentelemetry/api' +import { context, trace, ROOT_CONTEXT, SpanKind } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' import { BasicTracerProvider, @@ -78,49 +78,45 @@ describe('OpenTelemetry request context', () => { return app.use.mock.calls[0][0] } - it.each(['signal', 'request', 'outer-signal'] as const)( - 'expires nested span context on %s completion', - async (expired) => { - const response = new EventEmitter() - const controller = new AbortController() - const outer = new AbortController() - const tracer = provider.getTracer('signal-test') - client.withContext( - {}, - () => { - middleware()( - { headers: { 'x-posthog-session-id': 'request-session', 'x-posthog-distinct-id': 'request-user' } }, - response, - () => { - client.withContext( - { properties: { scope: 'nested' } }, - () => { - const before = tracer.startSpan('before') - if (expired === 'signal') controller.abort() - else if (expired === 'outer-signal') outer.abort() - else response.emit('finish') - expect(client.getContext()).toBeUndefined() - tracer.startSpan('after').end() - before.end() - }, - { signal: controller.signal } - ) - response.emit('finish') - } - ) - }, - { signal: outer.signal } - ) - await provider.forceFlush() - expect(exporter.getFinishedSpans().find((span) => span.name === 'before')?.attributes).toEqual({ - sessionId: 'request-session', - posthogDistinctId: 'request-user', - }) - expect(exporter.getFinishedSpans().find((span) => span.name === 'after')?.attributes).toEqual({}) - } - ) + it.each(['signal', 'outer-signal'] as const)('expires nested span context when the %s aborts', async (expired) => { + const response = new EventEmitter() + const controller = new AbortController() + const outer = new AbortController() + const tracer = provider.getTracer('signal-test') + client.withContext( + {}, + () => { + middleware()( + { headers: { 'x-posthog-session-id': 'request-session', 'x-posthog-distinct-id': 'request-user' } }, + response, + () => { + client.withContext( + { properties: { scope: 'nested' } }, + () => { + const before = tracer.startSpan('before') + if (expired === 'signal') controller.abort() + else outer.abort() + expect(client.getContext()).toBeUndefined() + tracer.startSpan('after').end() + before.end() + }, + { signal: controller.signal } + ) + response.emit('finish') + } + ) + }, + { signal: outer.signal } + ) + await provider.forceFlush() + expect(exporter.getFinishedSpans().find((span) => span.name === 'before')?.attributes).toEqual({ + sessionId: 'request-session', + posthogDistinctId: 'request-user', + }) + expect(exporter.getFinishedSpans().find((span) => span.name === 'after')?.attributes).toEqual({}) + }) - it.each([false, true])('keeps a fresh job independent of the response with own signal=%s', async (withSignal) => { + it.each([false, true])('keeps a fresh job separate from the request with own signal=%s', async (withSignal) => { const response = new EventEmitter() const controller = new AbortController() const tracer = provider.getTracer('job-test') @@ -144,9 +140,6 @@ describe('OpenTelemetry request context', () => { }, { fresh: true, signal: withSignal ? controller.signal : undefined } ) - response.emit('finish') - expect(client.getContext()).toBeUndefined() - tracer.startSpan('ended-request').end() } ) await job @@ -155,7 +148,6 @@ describe('OpenTelemetry request context', () => { sessionId: 'job-session', posthogDistinctId: 'job-user', }) - expect(spans.find((span) => span.name === 'ended-request')?.attributes).toEqual({}) if (withSignal) expect(spans.find((span) => span.name === 'ended-job')?.attributes).toEqual({}) }) @@ -179,10 +171,7 @@ describe('OpenTelemetry request context', () => { await Promise.resolve() const child = tracer.startSpan('child', { attributes }) client.enterContext({ distinctId: 'later-user', sessionId: 'later-session' }) - response.emit('finish') child.end() - const detached = tracer.startSpan('detached') - detached.end() requestSpan.end() resolve() })().catch(reject) @@ -194,15 +183,13 @@ describe('OpenTelemetry request context', () => { const spans = exporter.getFinishedSpans() expect(spans.find((span) => span.name === 'request')?.attributes).toMatchObject(expected) expect(spans.find((span) => span.name === 'child')?.attributes).toMatchObject(expected) - expect(spans.find((span) => span.name === 'detached')?.attributes).toEqual({}) expect(client.getContext()).toBeUndefined() }) - it.each(['finish', 'close'])('isolates concurrent requests and expires scope on %s', async (event) => { + it.each(['finish', 'close'])('isolates concurrent requests and keeps their scope after %s', async (event) => { const tracer = provider.getTracer('test') const run = middleware() const releases: (() => void)[] = [] - const spans: Attributes[] = [] await Promise.all( ['first', 'second'].map( (id) => @@ -214,9 +201,9 @@ describe('OpenTelemetry request context', () => { const span = tracer.startSpan(id) span.end() response.emit(event) - const late = tracer.startSpan('late') + const late = tracer.startSpan(`${id}-late`) late.end() - expect(client.getContext()).toBeUndefined() + expect(client.getContext()?.sessionId).toBe(id) resolve() })().catch(reject) }) @@ -225,8 +212,12 @@ describe('OpenTelemetry request context', () => { ) ) await provider.forceFlush() - exporter.getFinishedSpans().forEach((span) => spans.push(span.attributes)) - expect(spans).toEqual(expect.arrayContaining([{ sessionId: 'first' }, { sessionId: 'second' }, {}])) + expect(Object.fromEntries(exporter.getFinishedSpans().map((span) => [span.name, span.attributes]))).toEqual({ + first: { sessionId: 'first' }, + 'first-late': { sessionId: 'first' }, + second: { sessionId: 'second' }, + 'second-late': { sessionId: 'second' }, + }) }) it('preserves application identity and omits invalid or missing header IDs', () => { @@ -443,7 +434,7 @@ describe('OpenTelemetry request context', () => { it.each([ [{ sessionId: 'job' }, { sessionId: 'job' }], [{ distinctId: 'job-user' }, { posthogDistinctId: 'job-user' }], - ])('clears a thrown request scope and accepts explicit context %j after expiry', async (data, expected) => { + ])('lets detached work after a thrown request start a fresh context %j', async (data, expected) => { const error = new Error('handler failed') let detached: Promise | undefined let release: () => void = () => undefined @@ -456,7 +447,7 @@ describe('OpenTelemetry request context', () => { await new Promise((resolve) => { release = resolve }) - expect(client.getContext()).toBeUndefined() + expect(client.getContext()?.sessionId).toBe('request') client.withContext( data, () => { diff --git a/packages/node/src/__tests__/traces.spec.ts b/packages/node/src/__tests__/traces.spec.ts index 3c1bd925ae..39a201ba51 100644 --- a/packages/node/src/__tests__/traces.spec.ts +++ b/packages/node/src/__tests__/traces.spec.ts @@ -297,7 +297,7 @@ describe('PostHog traces', () => { expect(attributeOf(span, 'sessionId')).toEqual({ stringValue: 'session-123' }) }) - it('keeps native snapshots and omits request IDs from spans after the response', async () => { + it('keeps request IDs on spans that start after the response', async () => { const app = { use: vi.fn() } setupExpressRequestContext(posthog, app) const response = new EventEmitter() @@ -314,11 +314,11 @@ describe('PostHog traces', () => { } ) await flushTraces() - const [request, detached] = sentSpans() - expect(attributeOf(request, 'sessionId')).toEqual({ stringValue: 'request-session' }) - expect(attributeOf(request, 'posthogDistinctId')).toEqual({ stringValue: 'request-user' }) - expect(attributeOf(detached, 'sessionId')).toBeUndefined() - expect(attributeOf(detached, 'posthogDistinctId')).toBeUndefined() + for (const span of sentSpans()) { + expect(attributeOf(span, 'sessionId')).toEqual({ stringValue: 'request-session' }) + expect(attributeOf(span, 'posthogDistinctId')).toEqual({ stringValue: 'request-user' }) + } + expect(sentSpans()).toHaveLength(2) }) it('omits the keys outside a request context', async () => { diff --git a/packages/node/src/extensions/express.ts b/packages/node/src/extensions/express.ts index 189b5a391d..b067ea20ca 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -69,29 +69,11 @@ export function setupExpressRequestContext( } function posthogRequestContext(posthog: PostHogBackendClient): ExpressMiddleware { - return (req, res, next): void => { - const controller = new AbortController() - const complete = (): void => { - controller.abort() - res.removeListener('finish', complete) - res.removeListener('close', complete) - } - res.once('finish', complete) - res.once('close', complete) - if (res.destroyed || res.writableFinished) complete() - posthog.withContext( - buildRequestContextData(posthog, req), - () => { - notifyContextListeners(posthog, true) - try { - next() - } catch (error) { - complete() - throw error - } - }, - { signal: controller.signal } - ) + return (req, _res, next): void => { + posthog.withContext(buildRequestContextData(posthog, req), () => { + notifyContextListeners(posthog, true) + next() + }) } } diff --git a/packages/node/src/extensions/nestjs.ts b/packages/node/src/extensions/nestjs.ts index 61f29cfdfe..640c06aebc 100644 --- a/packages/node/src/extensions/nestjs.ts +++ b/packages/node/src/extensions/nestjs.ts @@ -96,35 +96,12 @@ export class PostHogInterceptor implements NestInterceptor { properties, } - return new Observable((subscriber) => { - const controller = new AbortController() - const rawResponse = response?.raw ?? response - const hasResponseEvents = - typeof rawResponse?.once === 'function' && typeof rawResponse?.removeListener === 'function' - const complete = (): void => { - controller.abort() - if (hasResponseEvents) { - rawResponse.removeListener('finish', complete) - rawResponse.removeListener('close', complete) - } - } - if (hasResponseEvents) { - rawResponse.once('finish', complete) - rawResponse.once('close', complete) - if (rawResponse.writableFinished || rawResponse.destroyed) complete() - } - - return this.posthog.withContext( + return new Observable((subscriber) => + this.posthog.withContext( contextData, () => { notifyContextListeners(this.posthog, true) - let source: Observable - try { - source = next.handle() - } catch (error) { - complete() - throw error - } + let source = next.handle() if (this.captureExceptions) { source = source.pipe( @@ -145,12 +122,10 @@ export class PostHogInterceptor implements NestInterceptor { ) } - const subscription = source.subscribe(subscriber) - if (!hasResponseEvents) subscription.add(complete) - return subscription + return source.subscribe(subscriber) }, - { fresh: true, signal: controller.signal } + { fresh: true } ) - }) + ) } }