diff --git a/.changeset/bright-jeans-learn.md b/.changeset/bright-jeans-learn.md new file mode 100644 index 0000000000..aa96740e29 --- /dev/null +++ b/.changeset/bright-jeans-learn.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +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 434eddea61..10e1a95ae9 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -8,6 +8,45 @@ 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. +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. +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. +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. +`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, 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, 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. + ## 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/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index dbe0b9d64e..2370cbee3a 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. A fresh context has an independent lifetime.", + "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..39b7e06a7a 100644 --- a/packages/node/src/__tests__/context.spec.ts +++ b/packages/node/src/__tests__/context.spec.ts @@ -46,6 +46,109 @@ 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() + if (expired === 'parent' && fresh) expect(posthog.getContext()).toEqual({ distinctId: 'child-user' }) + else 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' }) @@ -474,6 +577,41 @@ describe('PostHog Context', () => { }) }) + it.each( + (['buffered', 'immediate'] as const).flatMap((mode) => + (['context', 'explicit', 'personless'] as const).map((identity) => ({ mode, identity })) + ) + )('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() + } + + 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') + }) + it.each([ { label: 'no distinctId and no context → personless', 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/__tests__/extensions/nestjs-span-context.spec.ts b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts new file mode 100644 index 0000000000..447575254c --- /dev/null +++ b/packages/node/src/__tests__/extensions/nestjs-span-context.spec.ts @@ -0,0 +1,106 @@ +import { EventEmitter } from 'node:events' +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' +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([ + ['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' } }) + 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(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()?.sessionId).toBe('browser-session') + 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', 'late']) { + 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', + }) + }) +}) 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..b3bd0dc2cc --- /dev/null +++ b/packages/node/src/__tests__/extensions/otel-context.spec.ts @@ -0,0 +1,498 @@ +import { EventEmitter } from 'node:events' +import { context, trace, ROOT_CONTEXT, SpanKind } from '@opentelemetry/api' +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + SamplingDecision, +} from '@opentelemetry/sdk-trace-base' +import { PostHog, enrichCurrentSpan } from '@/entrypoints/index.node' +import { PostHogContextSpanProcessor } 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() + }) + + 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) + return app.use.mock.calls[0][0] + } + + 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 separate from the request 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 } + ) + } + ) + await job + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'job')?.attributes).toEqual({ + sessionId: 'job-session', + posthogDistinctId: 'job-user', + }) + 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' }], + [{ 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' }) + child.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(client.getContext()).toBeUndefined() + }) + + 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)[] = [] + 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(`${id}-late`) + late.end() + expect(client.getContext()?.sessionId).toBe(id) + resolve() + })().catch(reject) + }) + if (releases.length === 2) releases.reverse().forEach((release) => release()) + }) + ) + ) + await provider.forceFlush() + 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', () => { + 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.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')) + 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' }], + ])('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 + 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()?.sessionId).toBe('request') + 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..39a201ba51 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 request IDs on spans that start 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() + 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 () => { 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..e845025ac0 100644 --- a/packages/node/src/__tests__/types/public-interface.ts +++ b/packages/node/src/__tests__/types/public-interface.ts @@ -2,7 +2,18 @@ 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') } + +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..71cea6216b 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -72,6 +72,8 @@ 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 { 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' @@ -2843,7 +2845,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 +2891,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen */ enterContext(data: Partial, options?: ContextOptions): void { this.context?.enter(data as ContextData, options) + notifyContextListeners(this) } /** @@ -3176,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)) ) } @@ -3232,7 +3243,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen error, { syntheticException }, distinctId, - additionalProperties + additionalProperties, + this.getContext() ).then((msg) => this.captureImmediate({ ...msg, flags })) ) } @@ -3268,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/entrypoints/otel.ts b/packages/node/src/entrypoints/otel.ts new file mode 100644 index 0000000000..d80337cdba --- /dev/null +++ b/packages/node/src/entrypoints/otel.ts @@ -0,0 +1,75 @@ +import { trace, SpanKind, type Attributes, type Context, type Span } from '@opentelemetry/api' +import type { PostHogBackendClient } from '../client' +import { addContextListener } from '../extensions/context/listeners' + +export { enrichCurrentSpan } 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 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) => { + const span = trace.getActiveSpan() + this.enrich(span, requestEntry) + if (requestEntry) this.enrichNearestServerSpan(span) + }) + } + + 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 + 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 {} + } + + 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)?.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, new WeakRef(parent)) + 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/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/context.ts b/packages/node/src/extensions/context/context.ts index 044dbf9260..7cfbbc4635 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,22 @@ export class PostHogContext implements IPostHogContext { } private resolve(context: ContextData, options?: ContextOptions): ContextData { + const current = this.get() if (options?.fresh === true) { - return context + return inheritContextLifetime(undefined, { ...context }, options.signal) } - const current = this.get() || {} - return { - 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/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/lifetime.ts b/packages/node/src/extensions/context/lifetime.ts new file mode 100644 index 0000000000..c7f1d8a5d5 --- /dev/null +++ b/packages/node/src/extensions/context/lifetime.ts @@ -0,0 +1,28 @@ +import type { ContextData } from './types' + +export interface ContextLifetime { + signal: AbortSignal + parent?: ContextLifetime +} + +const lifetimes = new WeakMap() + +export function inheritContextLifetime( + parent: ContextData | undefined, + child: ContextData, + signal?: AbortSignal +): ContextData { + const lifetime = parent && lifetimes.get(parent) + if (signal) lifetimes.set(child, { signal, parent: lifetime }) + else if (lifetime) lifetimes.set(child, lifetime) + return child +} + +export function isContextActive(context: ContextData): boolean { + let lifetime = lifetimes.get(context) + while (lifetime) { + if (lifetime.signal.aborted) return false + lifetime = lifetime.parent + } + return true +} diff --git a/packages/node/src/extensions/context/listeners.ts b/packages/node/src/extensions/context/listeners.ts new file mode 100644 index 0000000000..47d6beedd3 --- /dev/null +++ b/packages/node/src/extensions/context/listeners.ts @@ -0,0 +1,36 @@ +import type { PostHogBackendClient } from '../../client' + +type ContextListener = (requestEntry: boolean) => void + +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() + 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 {} + } +} + +/** + * 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) +} diff --git a/packages/node/src/extensions/context/types.ts b/packages/node/src/extensions/context/types.ts index f74d9da48c..4dd5405c3f 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. A fresh context has an independent lifetime. + */ + signal?: AbortSignal } export interface IPostHogContext { 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 694095467b..b067ea20ca 100644 --- a/packages/node/src/extensions/express.ts +++ b/packages/node/src/extensions/express.ts @@ -5,6 +5,7 @@ import { addProperty, getFirstHeaderValue, getPostHogTracingHeaderValues } from import { normalizeRequestCurrentUrl, normalizeRequestPath } from './url-utils' import type { Request, Response } from 'express' import type { ContextData } from './context/types' +import { notifyContextListeners } from './context/listeners' type ExpressMiddleware = (req: Request, res: Response, next: () => void) => void @@ -34,7 +35,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 @@ -66,7 +70,10 @@ export function setupExpressRequestContext( function posthogRequestContext(posthog: PostHogBackendClient): ExpressMiddleware { return (req, _res, next): void => { - posthog.withContext(buildRequestContextData(posthog, req), () => next()) + posthog.withContext(buildRequestContextData(posthog, req), () => { + notifyContextListeners(posthog, true) + next() + }) } } @@ -122,7 +129,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), diff --git a/packages/node/src/extensions/nestjs.ts b/packages/node/src/extensions/nestjs.ts index cc6d601042..640c06aebc 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 @@ -99,6 +100,7 @@ export class PostHogInterceptor implements NestInterceptor { this.posthog.withContext( contextData, () => { + notifyContextListeners(this.posthog, true) let source = next.handle() if (this.captureExceptions) { 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