From f621296b21358abac865ad0bb8f41e03fb00f697 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 15:47:54 +0100 Subject: [PATCH 01/10] feat(next): add optional server span context bridge Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/tidy-cups-trace.md | 5 + packages/next/README.md | 2 + packages/next/package.json | 18 ++- packages/next/src/otel.ts | 82 ++++++++++ packages/next/src/otel.unsupported.ts | 3 + packages/next/src/server/getPostHog.ts | 45 ++++-- packages/next/tests/span-context.test.ts | 195 +++++++++++++++++++++++ packages/next/vitest.config.ts | 4 + pnpm-lock.yaml | 9 ++ 9 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 .changeset/tidy-cups-trace.md create mode 100644 packages/next/src/otel.ts create mode 100644 packages/next/src/otel.unsupported.ts create mode 100644 packages/next/tests/span-context.test.ts diff --git a/.changeset/tidy-cups-trace.md b/.changeset/tidy-cups-trace.md new file mode 100644 index 0000000000..fc9c614275 --- /dev/null +++ b/.changeset/tidy-cups-trace.md @@ -0,0 +1,5 @@ +--- +'@posthog/next': minor +--- + +Add an optional Node.js App Router span context bridge that reuses the configured PostHog client and request identity. diff --git a/packages/next/README.md b/packages/next/README.md index 05bb6c0d5f..a6b793d4ac 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -7,3 +7,5 @@ SDK usage examples and code snippets live in the official documentation so they ## Documentation - [Next.js library docs](https://posthog.com/docs/libraries/next-js) + +The optional `@posthog/next/otel` entry point links Node.js App Router work to existing OpenTelemetry spans. Reuse your `createPostHog` configuration and add its processor to your existing tracer provider. Request context ends when `withPostHogContext` returns or throws. It does not cover later component rendering, streamed response bodies, or the Edge runtime. diff --git a/packages/next/package.json b/packages/next/package.json index 31ad5b1582..7303680ac1 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -43,6 +43,15 @@ "react-server": "./dist/pages.js", "node": "./dist/pages.js", "default": "./dist/pages.client.js" + }, + "./otel": { + "types": "./dist/otel.d.ts", + "edge-light": "./dist/otel.unsupported.js", + "edge": "./dist/otel.unsupported.js", + "worker": "./dist/otel.unsupported.js", + "browser": "./dist/otel.unsupported.js", + "node": "./dist/otel.js", + "default": "./dist/otel.unsupported.js" } }, "main": "./dist/index.client.js", @@ -74,6 +83,9 @@ "server-only": "^0.0.1" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.10.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", "@posthog-tooling/tsconfig-base": "workspace:*", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -91,11 +103,15 @@ "@vercel/functions": ">=1.0.0", "next": ">=13.0.0", "react": ">=18.0.0", - "react-dom": ">=18.0.0" + "react-dom": ">=18.0.0", + "@opentelemetry/api": "^1.9.0" }, "peerDependenciesMeta": { "@vercel/functions": { "optional": true + }, + "@opentelemetry/api": { + "optional": true } }, "packageManager": "pnpm@10.23.0" diff --git a/packages/next/src/otel.ts b/packages/next/src/otel.ts new file mode 100644 index 0000000000..228c120b79 --- /dev/null +++ b/packages/next/src/otel.ts @@ -0,0 +1,82 @@ +import { context, trace, SpanKind, type Context, type Span } from '@opentelemetry/api' +import type { IPostHog, PostHog } from 'posthog-node' +import { PostHogContextSpanProcessor, enrichCurrentSpan } from 'posthog-node/otel' +import type { CreatePostHogConfig } from './server/createPostHog.js' +import { getConfiguredPostHogClient, resolvePostHogRequest, withRequestContext } from './server/getPostHog.js' + +export interface PostHogSpanContext { + /** Add this processor to the application's existing Node.js tracer provider before it handles requests. */ + createContextSpanProcessor(): Promise + /** Scope Node.js App Router work. Context ends when the callback settles, including on error. */ + withPostHogContext(work: (posthog: IPostHog) => T | Promise): Promise +} + +class NextPostHogContextSpanProcessor extends PostHogContextSpanProcessor { + private readonly _parents = new WeakMap() + + constructor(private readonly _client: PostHog) { + super(_client) + } + + override onStart(span: Span, parentContext?: Context): void { + const parent = parentContext && trace.getSpan(parentContext) + if (parent && parent !== span) { + this._parents.set(span, parent) + } + super.onStart(span) + } + + enrichNearestServerSpan(): void { + let span = trace.getActiveSpan() + while (span) { + if ('kind' in span && span.kind === SpanKind.SERVER && span.isRecording()) { + context.with(trace.setSpan(context.active(), span), () => enrichCurrentSpan(this._client)) + return + } + span = this._parents.get(span) + } + } +} + +/** Reuse the same configuration as createPostHog; this entry point requires the Node.js runtime and OpenTelemetry. */ +export function createPostHogSpanContext(config: CreatePostHogConfig = {}): PostHogSpanContext { + let processor: NextPostHogContextSpanProcessor | undefined + let pendingProcessor: Promise | undefined + + return { + createContextSpanProcessor() { + pendingProcessor ??= getConfiguredPostHogClient(config.apiKey, config.options).then(({ client }) => { + processor = new NextPostHogContextSpanProcessor(client) + return processor + }) + return pendingProcessor + }, + async withPostHogContext(work) { + const { client, contextData } = await resolvePostHogRequest( + config.apiKey, + config.options, + config.getDistinctId + ) + const controller = new AbortController() + return client.withContext( + contextData ?? {}, + async () => { + try { + if (contextData) { + enrichCurrentSpan(client) + processor?.enrichNearestServerSpan() + } + return await work( + contextData + ? withRequestContext(client, contextData, { signal: controller.signal }) + : client + ) + } finally { + controller.abort() + } + }, + { fresh: true, signal: controller.signal } + ) + }, + } +} diff --git a/packages/next/src/otel.unsupported.ts b/packages/next/src/otel.unsupported.ts new file mode 100644 index 0000000000..1d5c56124c --- /dev/null +++ b/packages/next/src/otel.unsupported.ts @@ -0,0 +1,3 @@ +export function createPostHogSpanContext(): never { + throw new Error('PostHog span context requires the Next.js Node.js server runtime.') +} diff --git a/packages/next/src/server/getPostHog.ts b/packages/next/src/server/getPostHog.ts index 76aa05650a..1e8bfee5ab 100644 --- a/packages/next/src/server/getPostHog.ts +++ b/packages/next/src/server/getPostHog.ts @@ -1,5 +1,5 @@ import { isFunction } from '@posthog/core' -import type { PostHogOptions, IPostHog } from 'posthog-node' +import type { PostHog, PostHogOptions, IPostHog } from 'posthog-node' import { cookies, headers } from 'next/headers.js' import { getOrCreateNodeClient } from './clientCache.node.js' import { readPostHogCookie, isOptedOut } from '../shared/cookie.js' @@ -13,7 +13,11 @@ import { resolveServerDistinctId, type PostHogDistinctIdResolver } from '../shar * AsyncLocalStorage.enterWith() doesn't propagate back to the caller * across the await boundary of this async function. */ -export function withRequestContext(client: IPostHog, contextData: Parameters[0]): IPostHog { +export function withRequestContext( + client: IPostHog, + contextData: Parameters[0], + options?: Parameters[2] +): IPostHog { return new Proxy(client, { get(target, prop, receiver) { if (prop === 'withContext') { @@ -21,7 +25,10 @@ export function withRequestContext(client: IPostHog, contextData: Parameters target.withContext(contextData, () => value.apply(target, args)) + return (...args: unknown[]) => + options + ? target.withContext(contextData, () => value.apply(target, args), options) + : target.withContext(contextData, () => value.apply(target, args)) } return value }, @@ -60,24 +67,33 @@ function resolveServerDistinctIdOncePerRequest( return result } -export async function getRequestScopedPostHog( +export async function getConfiguredPostHogClient( apiKey?: string, - options?: Partial, - getDistinctId?: PostHogDistinctIdResolver -): Promise { + options?: Partial +): Promise<{ client: PostHog; resolvedApiKey?: string }> { const resolvedApiKey = resolveApiKey(apiKey) const host = resolveHostOrDefault(options?.host) const resolvedOptions = { ...options, host } const client = await getOrCreateNodeClient(resolvedApiKey ?? '', resolvedOptions) + return { client, resolvedApiKey } +} + +export async function resolvePostHogRequest( + apiKey?: string, + options?: Partial, + getDistinctId?: PostHogDistinctIdResolver +): Promise<{ client: PostHog; contextData?: Parameters[0] }> { + const { client, resolvedApiKey } = await getConfiguredPostHogClient(apiKey, options) + if (!resolvedApiKey) { - return client + return { client } } const cookieStore = await cookies() if (isOptedOut(cookieStore, resolvedApiKey)) { - return client + return { client } } const state = readPostHogCookie(cookieStore, resolvedApiKey) @@ -92,5 +108,14 @@ export async function getRequestScopedPostHog( } } - return withRequestContext(client, contextData) + return { client, contextData } +} + +export async function getRequestScopedPostHog( + apiKey?: string, + options?: Partial, + getDistinctId?: PostHogDistinctIdResolver +): Promise { + const { client, contextData } = await resolvePostHogRequest(apiKey, options, getDistinctId) + return contextData ? withRequestContext(client, contextData) : client } diff --git a/packages/next/tests/span-context.test.ts b/packages/next/tests/span-context.test.ts new file mode 100644 index 0000000000..ce0801dace --- /dev/null +++ b/packages/next/tests/span-context.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +import { AsyncLocalStorage } from 'node:async_hooks' +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 { PostHog, type IPostHog } from 'posthog-node' +import { cookies, headers } from 'next/headers.js' +import { createPostHog } from '../src/server/createPostHog.js' +import { createPostHogSpanContext } from '../src/otel.js' + +const { cachedClient } = vi.hoisted(() => ({ cachedClient: vi.fn() })) +vi.mock('../src/server/clientCache.node.js', () => ({ getOrCreateNodeClient: cachedClient })) +vi.mock('next/headers.js', () => ({ cookies: vi.fn(), headers: vi.fn() })) + +describe('Next.js optional span context', () => { + const manager = new AsyncLocalStorageContextManager() + const requests = new AsyncLocalStorage<{ headers: Headers; consent?: string }>() + let client: PostHog + let provider: BasicTracerProvider + let exporter: InMemorySpanExporter + const config = { apiKey: 'phc_test', options: { host: 'https://example.com' } } + + beforeAll(() => context.setGlobalContextManager(manager.enable())) + afterAll(() => { + context.disable() + manager.disable() + }) + beforeEach(() => { + client = new PostHog('phc_test', { + disableCompression: true, + fetch: async () => ({ status: 200, text: async () => '{}', json: async () => ({}) }), + }) + cachedClient.mockResolvedValue(client) + const defaultHeaders = new Headers({ + 'x-posthog-session-id': 'header-session', + 'x-posthog-distinct-id': 'header-person', + }) + vi.mocked(headers).mockImplementation(async () => (requests.getStore()?.headers ?? defaultHeaders) as any) + vi.mocked(cookies).mockImplementation( + async () => + ({ + get: (name: string) => + name === '__ph_opt_in_out_phc_test' && requests.getStore()?.consent + ? { value: requests.getStore()?.consent } + : undefined, + }) as any + ) + exporter = new InMemorySpanExporter() + }) + afterEach(async () => { + await provider?.shutdown() + await client.shutdown() + }) + + async function setup(overrides = {}) { + const bridge = createPostHogSpanContext({ ...config, ...overrides }) + provider = new BasicTracerProvider({ + spanProcessors: [await bridge.createContextSpanProcessor(), new SimpleSpanProcessor(exporter)], + }) + return { bridge, tracer: provider.getTracer('next-test') } + } + + it.each([{}, { sessionId: 'explicit-session', posthogDistinctId: 'explicit-person' }])( + 'enriches the nearest server ancestor and preserves %j', + async (attributes: Attributes) => { + const { bridge, tracer } = await setup() + const outer = tracer.startSpan('outer', { kind: SpanKind.SERVER }) + await context.with(trace.setSpan(ROOT_CONTEXT, outer), async () => { + await tracer.startActiveSpan('nearest', { kind: SpanKind.SERVER, attributes }, async (server) => { + await tracer.startActiveSpan('inner', async (inner) => { + await bridge.withPostHogContext(async () => { + await Promise.resolve() + tracer.startSpan('child').end() + }) + inner.end() + }) + server.end() + }) + }) + outer.end() + await provider.forceFlush() + const spans = exporter.getFinishedSpans() + expect(spans.find((span) => span.name === 'nearest')?.attributes).toEqual({ + sessionId: 'header-session', + posthogDistinctId: 'header-person', + ...attributes, + }) + expect(spans.find((span) => span.name === 'outer')?.attributes).toEqual({}) + expect(spans.find((span) => span.name === 'child')?.attributes).toEqual({ + sessionId: 'header-session', + posthogDistinctId: 'header-person', + }) + } + ) + + it('uses the configured cached client at startup without reading a request', async () => { + const getDistinctId = vi.fn(() => 'trusted-person') + const { bridge } = await setup({ getDistinctId }) + expect(cookies).not.toHaveBeenCalled() + expect(headers).not.toHaveBeenCalled() + expect(getDistinctId).not.toHaveBeenCalled() + const scoped = await createPostHog({ ...config, getDistinctId }).getPostHog() + expect(scoped.getContext()?.distinctId).toBe('trusted-person') + await bridge.withPostHogContext((requestClient) => + expect(requestClient.getContext()?.distinctId).toBe('trusted-person') + ) + expect(getDistinctId).toHaveBeenCalledTimes(1) + expect(cachedClient).toHaveBeenLastCalledWith(config.apiKey, config.options) + }) + + it.each(['success', 'failure'])('expires captured client calls and async SDK work after %s', async (outcome) => { + const { bridge, tracer } = await setup() + let retained!: IPostHog + let release!: () => void + let work!: Promise + const result = bridge.withPostHogContext((requestClient) => { + retained = requestClient + work = requestClient.withSpan('job', async () => { + await new Promise((resolve) => { + release = resolve + }) + tracer.startSpan('late-work').end() + }) + if (outcome === 'failure') throw new Error('request failure') + return 'ok' + }) + if (outcome === 'failure') await expect(result).rejects.toThrow('request failure') + else expect(await result).toBe('ok') + expect(retained.getContext()).toBeUndefined() + retained.withSpan('late-client', () => tracer.startSpan('late-client-work').end()) + release() + await work + await provider.forceFlush() + expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual([{}, {}]) + expect(client.getContext()).toBeUndefined() + }) + + it.each(['0', '1'])('keeps consent=%s identity and resolver behavior', async (consent) => { + const getDistinctId = vi.fn(() => 'trusted-person') + const { bridge, tracer } = await setup({ getDistinctId }) + await requests.run( + { + headers: new Headers({ + 'x-posthog-session-id': 'request-session', + 'x-posthog-distinct-id': 'request-person', + }), + consent, + }, + () => + bridge.withPostHogContext((requestClient) => { + expect(requestClient.getContext()?.distinctId).toBe(consent === '0' ? undefined : 'trusted-person') + tracer.startSpan('child').end() + }) + ) + await provider.forceFlush() + expect(exporter.getFinishedSpans()[0].attributes).toEqual( + consent === '0' ? {} : { sessionId: 'request-session', posthogDistinctId: 'trusted-person' } + ) + expect(getDistinctId).toHaveBeenCalledTimes(consent === '0' ? 0 : 1) + }) + + it.each(['ordinary', 'redirect'])('preserves resolver %s errors', async (kind) => { + const error = Object.assign( + new Error('resolver failure'), + kind === 'redirect' ? { digest: 'NEXT_REDIRECT;replace;/example;307;' } : {} + ) + const { bridge } = await setup({ + getDistinctId: () => { + throw error + }, + }) + const run = bridge.withPostHogContext((requestClient) => requestClient.getContext()?.distinctId) + if (kind === 'redirect') await expect(run).rejects.toBe(error) + else expect(await run).toBe('header-person') + expect(client.getContext()).toBeUndefined() + }) + + it('isolates concurrent requests through awaited work', async () => { + const { bridge, tracer } = await setup() + await Promise.all( + ['first', 'second'].map((id) => + requests.run({ headers: new Headers({ 'x-posthog-session-id': id }) }, () => + bridge.withPostHogContext(async () => { + await Promise.resolve() + tracer.startSpan(id).end() + }) + ) + ) + ) + await provider.forceFlush() + expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual( + expect.arrayContaining([{ sessionId: 'first' }, { sessionId: 'second' }]) + ) + }) +}) diff --git a/packages/next/vitest.config.ts b/packages/next/vitest.config.ts index 22f0e0d868..78788b8fdd 100644 --- a/packages/next/vitest.config.ts +++ b/packages/next/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ new URL('../browser/src/entrypoints/module.no-external.es.ts', import.meta.url) ), }, + { + find: /^posthog-node\/otel$/, + replacement: fileURLToPath(new URL('../node/src/entrypoints/otel.ts', import.meta.url)), + }, { find: /^posthog-node\/edge$/, replacement: fileURLToPath(new URL('../node/src/entrypoints/index.edge.ts', import.meta.url)), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00fa6b8873..aa6b8afd48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,15 @@ importers: specifier: ^0.0.1 version: 0.0.1 devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/context-async-hooks': + specifier: ^2.10.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 b28c852506437cb159233ea0f3f295398f0e7830 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 20:19:36 +0100 Subject: [PATCH 02/10] test(next): define sampler gap context boundary Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/next/README.md | 2 ++ packages/next/src/otel.ts | 6 +++- packages/next/tests/span-context.test.ts | 42 +++++++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/next/README.md b/packages/next/README.md index a6b793d4ac..bcf7043fff 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -9,3 +9,5 @@ SDK usage examples and code snippets live in the official documentation so they - [Next.js library docs](https://posthog.com/docs/libraries/next-js) The optional `@posthog/next/otel` entry point links Node.js App Router work to existing OpenTelemetry spans. Reuse your `createPostHog` configuration and add its processor to your existing tracer provider. Request context ends when `withPostHogContext` returns or throws. It does not cover later component rendering, streamed response bodies, or the Edge runtime. + +Server span enrichment follows parent spans observed by the processor. If the application sampler drops an intermediate span, the helper cannot prove the earlier server ancestor and leaves it unchanged. Recording spans created inside the callback still receive context. The integration does not change the sampler or guess ancestry from trace IDs. diff --git a/packages/next/src/otel.ts b/packages/next/src/otel.ts index 228c120b79..cc31d5becc 100644 --- a/packages/next/src/otel.ts +++ b/packages/next/src/otel.ts @@ -7,7 +7,11 @@ import { getConfiguredPostHogClient, resolvePostHogRequest, withRequestContext } export interface PostHogSpanContext { /** Add this processor to the application's existing Node.js tracer provider before it handles requests. */ createContextSpanProcessor(): Promise - /** Scope Node.js App Router work. Context ends when the callback settles, including on error. */ + /** + * Scope Node.js App Router work. Context ends when the callback settles, including on error. + * Earlier server spans are enriched only through parent links observed by the processor; + * a sampler-dropped intermediate span breaks that chain. New recording child spans still receive context. + */ withPostHogContext(work: (posthog: IPostHog) => T | Promise): Promise } diff --git a/packages/next/tests/span-context.test.ts b/packages/next/tests/span-context.test.ts index ce0801dace..a93576039f 100644 --- a/packages/next/tests/span-context.test.ts +++ b/packages/next/tests/span-context.test.ts @@ -2,7 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks' 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, type IPostHog } from 'posthog-node' import { cookies, headers } from 'next/headers.js' import { createPostHog } from '../src/server/createPostHog.js' @@ -93,6 +98,41 @@ describe('Next.js optional span context', () => { } ) + it('does not guess server ancestry across a sampler-dropped span', async () => { + const bridge = createPostHogSpanContext(config) + provider = new BasicTracerProvider({ + sampler: { + shouldSample: (_parent, _traceId, name) => ({ + decision: name === 'dropped' ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED, + }), + toString: () => 'drop-intermediate', + }, + spanProcessors: [await bridge.createContextSpanProcessor(), 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) => { + await bridge.withPostHogContext(() => tracer.startSpan('child').end()) + inner.end() + }) + dropped.end() + }) + server.end() + }) + await provider.forceFlush() + 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: 'header-session', + posthogDistinctId: 'header-person', + }) + } + }) + it('uses the configured cached client at startup without reading a request', async () => { const getDistinctId = vi.fn(() => 'trusted-person') const { bridge } = await setup({ getDistinctId }) From 41e238371c538b4370f734f4a1439afbe6d78689 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 15:57:18 +0100 Subject: [PATCH 03/10] feat(nuxt): add optional server request span context Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/calm-otters-listen.md | 5 + packages/nuxt/README.md | 27 ++ packages/nuxt/package.json | 11 +- packages/nuxt/src/module.ts | 2 +- packages/nuxt/src/runtime/nitro-plugin-v2.ts | 15 +- packages/nuxt/src/runtime/nitro-plugin-v3.ts | 14 + packages/nuxt/src/runtime/nitro-plugin.ts | 63 ++++- packages/nuxt/src/runtime/server-client.ts | 3 + packages/nuxt/src/runtime/server.ts | 6 + packages/nuxt/tests/nitro-plugin.test.mjs | 44 +-- packages/nuxt/tests/server-context.test.mjs | 266 +++++++++++++++++++ pnpm-lock.yaml | 9 + 12 files changed, 439 insertions(+), 26 deletions(-) create mode 100644 .changeset/calm-otters-listen.md create mode 100644 packages/nuxt/src/runtime/server-client.ts create mode 100644 packages/nuxt/src/runtime/server.ts create mode 100644 packages/nuxt/tests/server-context.test.mjs diff --git a/.changeset/calm-otters-listen.md b/.changeset/calm-otters-listen.md new file mode 100644 index 0000000000..6e32edb340 --- /dev/null +++ b/.changeset/calm-otters-listen.md @@ -0,0 +1,5 @@ +--- +'@posthog/nuxt': minor +--- + +Add optional server request context and access to the existing server client for OpenTelemetry span enrichment. diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 161869d510..969bd026ef 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -8,6 +8,33 @@ SDK usage examples and code snippets live in the official documentation so they - [Nuxt library docs](https://posthog.com/docs/libraries/nuxt-js) +## Server request context + +Set `posthogConfig.serverConfig.requestContext` to `true` to add incoming +`X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` values to the server request +context. The default is `false`. These headers are untrusted analytics context. +Never use them for authentication or authorization. Existing application context +takes precedence. Blank or absent IDs are omitted. + +For application-owned OpenTelemetry spans, use the optional +`PostHogContextSpanProcessor` from `posthog-node/otel` with the existing server +client returned by `getPostHogServerClient()` from `@posthog/nuxt/server`. +The accessor returns `undefined` before the module's Nitro plugin starts and +after that client closes. Initialize the application's tracing setup after the +PostHog Nitro plugin and before requests start. Keep the application's existing +provider, exporters, processors, and sampler. This module does not create spans +or install OpenTelemetry. The Node SDK must include the shared context span +bridge and abort-signal context support before this integration is released. + +The wrapper covers Nitro 2's H3 request handler and Nitro 3's fetch handler. +It adds context to an existing recording request span and to child spans started +inside the handler when the shared processor is installed. Context ends when the +handler returns or throws. Nitro 3 can return a streaming response before the +body finishes; later stream work and detached background tasks omit request IDs. +An error reported through Nitro's error hook uses the saved request context. +Background errors without a request keep the existing anonymous identity. +The server accessor is available only through its Node export. + ## Questions? ### [Check out our community page.](https://posthog.com/posts) diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index ab49e5ef29..af52fbb26f 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -19,6 +19,10 @@ ".": { "types": "./dist/types.d.mts", "import": "./dist/module.mjs" + }, + "./server": { + "types": "./dist/runtime/server.d.ts", + "node": "./dist/runtime/server.js" } }, "main": "./dist/module.mjs", @@ -31,7 +35,7 @@ "build": "nuxt-module-build build", "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz", "lint": "oxlint --report-unused-disable-directives-severity error .", - "test:unit": "node tests/vue-plugin.test.mjs && node tests/nitro-plugin.test.mjs && node tests/sourcemaps-ssr.test.mjs && node tests/wait-for-packages.test.mjs && node tests/nuxt5-consumer.test.mjs" + "test:unit": "node tests/vue-plugin.test.mjs && node tests/nitro-plugin.test.mjs && node tests/server-context.test.mjs && node tests/sourcemaps-ssr.test.mjs && node tests/wait-for-packages.test.mjs && node tests/nuxt5-consumer.test.mjs" }, "dependencies": { "@posthog/cli": "catalog:", @@ -50,6 +54,9 @@ "eslint-plugin-regexp": "2.10.0", "typescript": "catalog:", "nitro": "3.0.260610-beta", - "nitropack": "2.13.4" + "nitropack": "2.13.4", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.10.0" } } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index b487a79d1e..44c39d3fb1 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -76,7 +76,7 @@ export interface PostHogCommon { debug?: boolean } -export type PostHogServerConfig = PostHogOptions +export type PostHogServerConfig = PostHogOptions & { requestContext?: boolean } export type PostHogClientConfig = Partial export default defineNuxtModule({ diff --git a/packages/nuxt/src/runtime/nitro-plugin-v2.ts b/packages/nuxt/src/runtime/nitro-plugin-v2.ts index d665b38058..617904fe58 100644 --- a/packages/nuxt/src/runtime/nitro-plugin-v2.ts +++ b/packages/nuxt/src/runtime/nitro-plugin-v2.ts @@ -4,9 +4,22 @@ import { setupPostHogNitroPlugin } from './nitro-plugin' export default defineNitroPlugin((nitroApp) => { setupPostHogNitroPlugin({ useRuntimeConfig, + wrapRequest: (wrapper) => { + const handler = nitroApp.h3App.handler + nitroApp.h3App.handler = (event) => + wrapper( + { + source: event, + path: event.path, + method: event.method, + headers: event.node.req.headers, + }, + () => handler(event) + ) + }, onError: (handler) => nitroApp.hooks.hook('error', (error, { event }) => - handler(error, event ? { path: event.path, method: event.method } : undefined) + handler(error, event ? { source: event, path: event.path, method: event.method } : undefined) ), onClose: (handler) => nitroApp.hooks.hook('close', handler), }) diff --git a/packages/nuxt/src/runtime/nitro-plugin-v3.ts b/packages/nuxt/src/runtime/nitro-plugin-v3.ts index ccafbf20d3..2fd9ea8dc2 100644 --- a/packages/nuxt/src/runtime/nitro-plugin-v3.ts +++ b/packages/nuxt/src/runtime/nitro-plugin-v3.ts @@ -5,12 +5,26 @@ import { setupPostHogNitroPlugin } from './nitro-plugin' export default definePlugin((nitroApp) => { setupPostHogNitroPlugin({ useRuntimeConfig, + wrapRequest: (wrapper) => { + const handler = nitroApp.fetch + nitroApp.fetch = (request) => + wrapper( + { + source: request, + path: request.url, + method: request.method, + headers: request.headers, + }, + () => handler(request) + ) + }, onError: (handler) => nitroApp.hooks.hook('error', (error, { event }) => handler( error, event ? { + source: event.req, path: event.req.url, method: event.req.method, } diff --git a/packages/nuxt/src/runtime/nitro-plugin.ts b/packages/nuxt/src/runtime/nitro-plugin.ts index 16bcbff281..c003b2bf59 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -1,24 +1,44 @@ import { PostHog } from 'posthog-node' +import { serverClient } from './server-client' import { uuidv7 } from '@posthog/core/vendor/uuidv7' import type { PostHogCommon, PostHogServerConfig } from '../module' import type { JsonType } from '@posthog/core' -type RequestContext = { path?: string; method?: string } +type RequestContext = { + path?: string + method?: string + source?: object + headers?: Headers | Record +} +type RequestIdentity = { distinctId?: string; sessionId?: string } +type RequestWrapper = (request: RequestContext, handler: () => T | Promise) => Promise type ErrorHandler = (error: unknown, request?: RequestContext) => Promise type NitroBindings = { useRuntimeConfig: () => unknown onError: (handler: ErrorHandler) => void onClose: (handler: () => Promise) => void + wrapRequest?: (wrapper: RequestWrapper) => void } type RuntimeConfig = { public: { posthog: PostHogCommon } posthogServerConfig: PostHogServerConfig } -export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose }: NitroBindings): void { +function readHeader(headers: RequestContext['headers'], name: string): string | undefined { + const value = headers instanceof Headers ? headers.get(name) : headers?.[name] + if (Array.isArray(value)) { + return value.map((item) => readHeader({ [name]: item }, name)).find((item) => item !== undefined) + } + if (typeof value !== 'string') return undefined + // oxlint-disable-next-line no-control-regex + const sanitized = value.replace(/[\x00-\x1F\x7F-\x9F]/g, '') + return sanitized.trim().slice(0, 1000) || undefined +} + +export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wrapRequest }: NitroBindings): void { const runtimeConfig = useRuntimeConfig() as RuntimeConfig const posthogCommon = runtimeConfig.public.posthog - const posthogServerConfig = runtimeConfig.posthogServerConfig + const { requestContext, ...posthogServerConfig } = runtimeConfig.posthogServerConfig const debug = posthogCommon.debug === true const client = new PostHog(posthogCommon.publicKey, { @@ -26,6 +46,32 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose }: ...posthogServerConfig, }) + serverClient.current = client + const requestContexts = new WeakMap() + + if (requestContext) { + wrapRequest?.(async (request, handler) => { + const existing = client.getContext() + const identity = { + distinctId: existing?.distinctId ?? readHeader(request.headers, 'x-posthog-distinct-id'), + sessionId: existing?.sessionId ?? readHeader(request.headers, 'x-posthog-session-id'), + } + if (request.source) requestContexts.set(request.source, identity) + const lifetime = new AbortController() + return client.withContext( + identity, + async () => { + try { + return await handler() + } finally { + lifetime.abort() + } + }, + { fresh: true, signal: lifetime.signal } + ) + }) + } + if (debug) { client.debug(true) } @@ -49,12 +95,21 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose }: props.method = request.method } - client.captureException(error, uuidv7(), props) + const identity = request?.source ? requestContexts.get(request.source) : undefined + if (identity?.sessionId) props.$session_id = identity.sessionId + client.withContext( + identity ?? {}, + () => { + client.captureException(error, identity?.distinctId ?? uuidv7(), props) + }, + { fresh: true } + ) return client.flush().catch(() => {}) }) } onClose(async () => { await client.shutdown() + if (serverClient.current === client) serverClient.current = undefined }) } diff --git a/packages/nuxt/src/runtime/server-client.ts b/packages/nuxt/src/runtime/server-client.ts new file mode 100644 index 0000000000..db538dd82b --- /dev/null +++ b/packages/nuxt/src/runtime/server-client.ts @@ -0,0 +1,3 @@ +import type { PostHog } from 'posthog-node' + +export const serverClient: { current?: PostHog } = {} diff --git a/packages/nuxt/src/runtime/server.ts b/packages/nuxt/src/runtime/server.ts new file mode 100644 index 0000000000..f198ae0144 --- /dev/null +++ b/packages/nuxt/src/runtime/server.ts @@ -0,0 +1,6 @@ +import type { PostHog } from 'posthog-node' +import { serverClient } from './server-client' + +export function getPostHogServerClient(): PostHog | undefined { + return serverClient.current +} diff --git a/packages/nuxt/tests/nitro-plugin.test.mjs b/packages/nuxt/tests/nitro-plugin.test.mjs index 79b32d08b1..160b74778b 100644 --- a/packages/nuxt/tests/nitro-plugin.test.mjs +++ b/packages/nuxt/tests/nitro-plugin.test.mjs @@ -1,19 +1,22 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import ts from 'typescript' const source = readFileSync(new URL('../src/runtime/nitro-plugin.ts', import.meta.url), 'utf8') assert.doesNotMatch(source, /from ['"]nitro/) -const executableSource = source - .replace(/^import .*$/gm, '') - .replace(/^type [\s\S]+?^export function /m, 'return function ') - .replace(': NitroBindings', '') - .replace('): void {', ') {') - .replace(' as RuntimeConfig', '') - .replace(': JsonType', '') - function loadSetup({ PostHog, uuidv7 }) { - return new Function('PostHog', 'uuidv7', executableSource)(PostHog, uuidv7) + const exports = {} + const dependencies = { + 'posthog-node': { PostHog }, + '@posthog/core/vendor/uuidv7': { uuidv7 }, + './server-client': { serverClient: {} }, + } + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText + new Function('exports', 'require', compiled)(exports, (name) => dependencies[name]) + return exports.setupPostHogNitroPlugin } const calls = [] @@ -30,6 +33,10 @@ class PostHog { calls.push(['captureException', ...args]) } + withContext(_identity, handler) { + return handler() + } + async flush() { calls.push(['flush']) } @@ -121,8 +128,9 @@ nitro2.bindings.onError((_error, request) => { nitro2Request = request return nitro2Promise }) -assert.equal(nitro2.adapterHandlers.error(error, { event: { path: '/v2', method: 'GET' } }), nitro2Promise) -assert.deepEqual(nitro2Request, { path: '/v2', method: 'GET' }) +const nitro2Event = { path: '/v2', method: 'GET' } +assert.equal(nitro2.adapterHandlers.error(error, { event: nitro2Event }), nitro2Promise) +assert.deepEqual(nitro2Request, { source: nitro2Event, path: '/v2', method: 'GET' }) const nitro3 = loadAdapter('nitro-plugin-v3.ts', 'definePlugin') assert.match(nitro3.adapterSource, /from 'nitro'/) @@ -134,14 +142,14 @@ nitro3.bindings.onError((_error, request) => { nitro3Request = request return nitro3Promise }) -assert.equal( - nitro3.adapterHandlers.error(error, { - event: { req: { url: 'https://example.com/v3?query=ignored', method: 'POST', waitUntil } }, - }), - nitro3Promise -) +const nitro3Event = { req: { url: 'https://example.com/v3?query=ignored', method: 'POST', waitUntil } } +assert.equal(nitro3.adapterHandlers.error(error, { event: nitro3Event }), nitro3Promise) -assert.deepEqual(nitro3Request, { path: 'https://example.com/v3?query=ignored', method: 'POST' }) +assert.deepEqual(nitro3Request, { + source: nitro3Event.req, + path: 'https://example.com/v3?query=ignored', + method: 'POST', +}) for (const [adapter, promise, getRequest] of [ [nitro2, nitro2Promise, () => nitro2Request], diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs new file mode 100644 index 0000000000..385e3f7999 --- /dev/null +++ b/packages/nuxt/tests/server-context.test.mjs @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { createServer } from 'node:http' +import ts from 'typescript' +import { PostHog } from 'posthog-node' +import { PostHogContextSpanProcessor } from 'posthog-node/otel' +import { context } from '@opentelemetry/api' +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' + +const require = createRequire(import.meta.url) +const errors = [] +class CapturingPostHog extends PostHog { + captureException(error, distinctId, properties) { + errors.push({ error, distinctId, properties, context: this.getContext() }) + } +} + +function loadRuntime(name, dependencies) { + const source = readFileSync(new URL(`../src/runtime/${name}.ts`, import.meta.url), 'utf8') + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText + const exports = {} + new Function('exports', 'require', compiled)(exports, (name) => dependencies[name] ?? require(name)) + return exports +} + +const clientState = loadRuntime('server-client', {}) +const server = loadRuntime('server', { './server-client': clientState }) +assert.equal(server.getPostHogServerClient(), undefined) +const { setupPostHogNitroPlugin } = loadRuntime('nitro-plugin', { + 'posthog-node': { PostHog: CapturingPostHog }, + '@posthog/core/vendor/uuidv7': { uuidv7: () => 'anonymous-id' }, + './server-client': clientState, +}) + +for (const requestContext of [undefined, false, true]) { + let wrapper + let onError + let close + setupPostHogNitroPlugin({ + useRuntimeConfig: () => ({ + public: { posthog: { publicKey: 'phc_test', host: 'https://example.com' } }, + posthogServerConfig: { disabled: true, enableExceptionAutocapture: true, requestContext }, + }), + wrapRequest: (handler) => { + wrapper = handler + }, + onError: (handler) => { + onError = handler + }, + onClose: (handler) => { + close = handler + }, + }) + const client = server.getPostHogServerClient() + assert.ok(client instanceof PostHog) + assert.equal(client.options.requestContext, undefined) + assert.equal(typeof wrapper, requestContext ? 'function' : 'undefined') + await onError(new Error('background')) + assert.equal(errors.at(-1).distinctId, 'anonymous-id') + if (wrapper) { + for (const headers of [undefined, {}, { 'x-posthog-distinct-id': ' ', 'x-posthog-session-id': '\t' }]) { + const source = {} + await wrapper({ source, headers }, async () => { + assert.equal(client.getContext()?.distinctId, undefined) + assert.equal(client.getContext()?.sessionId, undefined) + }) + await onError(new Error('unlinked'), { source }) + assert.equal(errors.at(-1).distinctId, 'anonymous-id') + assert.equal(errors.at(-1).properties.$session_id, undefined) + } + for (const [value, expected] of [ + [[' ', 'person'], 'person'], + [' \u0000person\u007F ', 'person'], + ['p'.repeat(1001), 'p'.repeat(1000)], + ]) { + await wrapper({ source: {}, headers: { 'x-posthog-distinct-id': value } }, async () => { + assert.equal(client.getContext()?.distinctId, expected) + }) + } + await client.withContext({ distinctId: 'authenticated' }, () => + wrapper( + { + source: {}, + headers: { 'x-posthog-distinct-id': 'browser-person' }, + }, + async () => { + assert.equal(client.getContext()?.distinctId, 'authenticated') + } + ) + ) + let release + const after = new Promise((resolve) => { + release = resolve + }) + const detached = [] + await Promise.all( + Array.from({ length: 12 }, async (_, index) => { + const source = {} + const id = 'person-' + index + const headers = { 'x-posthog-distinct-id': id, 'x-posthog-session-id': id + '-session' } + const error = new Error('request failed') + try { + await wrapper({ source, headers }, async () => { + await Promise.resolve() + assert.equal(client.getContext()?.distinctId, id) + assert.equal(client.getContext()?.sessionId, id + '-session') + detached.push(after.then(() => assert.equal(client.getContext(), undefined))) + throw error + }) + } catch (caught) { + assert.equal(caught, error) + } + await onError(error, { source }) + const captured = errors.find((value) => value.error === error) + assert.equal(captured.distinctId, id) + assert.equal(captured.properties.$session_id, id + '-session') + assert.equal(captured.context.distinctId, id) + }) + ) + release() + await Promise.all(detached) + } + await close() + assert.equal(server.getPostHogServerClient(), undefined) +} + +console.log( + 'Nuxt server context: default/disabled, blank headers, 12 concurrent errors, snapshots, expiry, and client reuse passed' +) + +for (const version of [2, 3]) { + const config = { + public: { posthog: { publicKey: 'phc_test', host: 'https://example.com' } }, + posthogServerConfig: { disabled: true, enableExceptionAutocapture: true, requestContext: true }, + } + const hooks = {} + const h3 = createRequire(import.meta.resolve(version === 2 ? 'nitropack/runtime' : 'nitro'))('h3') + let nitro + let request + let listener + let httpServer + const onError = async (error, event) => { + await hooks.error(error, { event }) + if (version === 3) return Response.json({ error: 'request failed' }, { status: 500 }) + } + const route = async (event) => { + const id = + version === 2 ? event.node.req.headers['x-posthog-distinct-id'] : event.req.headers.get('x-posthog-distinct-id') + await Promise.resolve() + tracer.startSpan('child:' + id).end() + const observed = client.getContext() + detached.push( + afterResponse.then(() => { + assert.equal(client.getContext(), undefined) + tracer.startSpan('after:' + id).end() + }) + ) + assert.equal(observed?.distinctId, id) + if (id.endsWith('1')) throw h3.createError({ statusCode: 500, message: 'request failed' }) + return { person: observed.distinctId, session: observed.sessionId } + } + const app = version === 2 ? h3.createApp({ onError }) : new h3.H3({ onError }) + if (version === 2) { + app.use(h3.eventHandler(route)) + nitro = { h3App: app } + listener = h3.toNodeListener(app) + } else { + app.get('/', route) + nitro = { fetch: (request) => app.fetch(request) } + } + nitro.hooks = { + hook: (name, handler) => { + hooks[name] = handler + }, + } + const adapter = loadRuntime('nitro-plugin-v' + version, { + [version === 2 ? 'nitropack/runtime' : 'nitro']: { + defineNitroPlugin: (plugin) => plugin, + definePlugin: (plugin) => plugin, + useRuntimeConfig: () => config, + }, + 'nitro/runtime-config': { useRuntimeConfig: () => config }, + './nitro-plugin': { setupPostHogNitroPlugin }, + }).default + adapter(nitro) + const client = server.getPostHogServerClient() + const exporter = new InMemorySpanExporter() + const provider = new BasicTracerProvider({ + spanProcessors: [new PostHogContextSpanProcessor(client), new SimpleSpanProcessor(exporter)], + }) + const tracer = provider.getTracer('nuxt-server-context') + const manager = new AsyncLocalStorageContextManager().enable() + context.setGlobalContextManager(manager) + const detached = [] + let release + const afterResponse = new Promise((resolve) => { + release = resolve + }) + if (version === 2) { + httpServer = createServer((req, res) => + tracer.startActiveSpan('request:' + req.headers['x-posthog-distinct-id'], async (span) => { + try { + await listener(req, res) + } finally { + span.end() + } + }) + ) + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + request = async (id) => { + const response = await fetch('http://127.0.0.1:' + httpServer.address().port, { + headers: { 'x-posthog-distinct-id': id, 'x-posthog-session-id': id + '-session' }, + }) + return { status: response.status, body: await response.json() } + } + } else { + request = (id) => + tracer.startActiveSpan('request:' + id, async (span) => { + try { + const response = await nitro.fetch( + new Request('http://example.com/', { + headers: { 'x-posthog-distinct-id': id, 'x-posthog-session-id': id + '-session' }, + }) + ) + return { status: response.status, body: await response.json() } + } finally { + span.end() + } + }) + } + try { + const ids = Array.from({ length: 12 }, (_, index) => 'request-' + index) + const values = await Promise.all(ids.map((id) => request(id))) + values.forEach((value, index) => { + assert.equal(value.status, ids[index].endsWith('1') ? 500 : 200) + if (value.status === 200) assert.deepEqual(value.body, { person: ids[index], session: ids[index] + '-session' }) + }) + release() + await Promise.all(detached) + for (const span of exporter.getFinishedSpans()) { + const [kind, id] = span.name.split(':') + assert.equal(span.attributes.posthogDistinctId, kind === 'after' ? undefined : id) + assert.equal(span.attributes.sessionId, kind === 'after' ? undefined : id + '-session') + } + assert.equal(exporter.getFinishedSpans().length, 36) + for (const id of ids.filter((id) => id.endsWith('1'))) { + assert.ok(errors.some((error) => error.distinctId === id && error.properties.$session_id === id + '-session')) + } + console.log( + 'Nuxt Nitro' + version + ': real H3 handlers, 12 concurrent requests, 36 spans, errors and expiry passed' + ) + } finally { + if (httpServer) { + httpServer.closeAllConnections() + await new Promise((resolve) => httpServer.close(resolve)) + } + await hooks.close() + await provider.shutdown() + manager.disable() + context.disable() + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa6b8afd48..397c718402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -892,6 +892,15 @@ importers: '@nuxt/schema': specifier: ^4.1.2 version: 4.1.3 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@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) '@types/node': specifier: ^20.0.0 version: 20.19.9 From 7f85d0b966978b5611ef323453d3939f14703bf2 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 16:33:56 +0100 Subject: [PATCH 04/10] fix(nuxt): enrich incoming spans at request entry Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/nuxt/src/runtime/nitro-plugin.ts | 3 +++ packages/nuxt/tests/server-context.test.mjs | 28 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/nitro-plugin.ts b/packages/nuxt/src/runtime/nitro-plugin.ts index c003b2bf59..d9f9b5e9f1 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -50,6 +50,7 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr const requestContexts = new WeakMap() if (requestContext) { + const spanContext = import('posthog-node/otel').catch(() => undefined) wrapRequest?.(async (request, handler) => { const existing = client.getContext() const identity = { @@ -62,6 +63,8 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr identity, async () => { try { + const tracing = await spanContext + tracing?.enrichCurrentSpan?.(client) return await handler() } finally { lifetime.abort() diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs index 385e3f7999..b25bf6de78 100644 --- a/packages/nuxt/tests/server-context.test.mjs +++ b/packages/nuxt/tests/server-context.test.mjs @@ -4,7 +4,7 @@ import { createRequire } from 'node:module' import { createServer } from 'node:http' import ts from 'typescript' import { PostHog } from 'posthog-node' -import { PostHogContextSpanProcessor } from 'posthog-node/otel' +import { PostHogContextSpanProcessor, enrichCurrentSpan } from 'posthog-node/otel' import { context } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' @@ -32,6 +32,7 @@ const server = loadRuntime('server', { './server-client': clientState }) assert.equal(server.getPostHogServerClient(), undefined) const { setupPostHogNitroPlugin } = loadRuntime('nitro-plugin', { 'posthog-node': { PostHog: CapturingPostHog }, + 'posthog-node/otel': { enrichCurrentSpan }, '@posthog/core/vendor/uuidv7': { uuidv7: () => 'anonymous-id' }, './server-client': clientState, }) @@ -62,6 +63,31 @@ for (const requestContext of [undefined, false, true]) { await onError(new Error('background')) assert.equal(errors.at(-1).distinctId, 'anonymous-id') if (wrapper) { + const manager = new AsyncLocalStorageContextManager().enable() + context.setGlobalContextManager(manager) + const provider = new BasicTracerProvider({ spanProcessors: [new PostHogContextSpanProcessor(client)] }) + try { + for (const explicit of [{}, { posthogDistinctId: 'explicit-person' }]) { + await client.withContext({ distinctId: 'application-person' }, () => + provider.getTracer('incoming-context').startActiveSpan('incoming', { attributes: explicit }, async (span) => { + try { + await wrapper({ source: {}, headers: { 'x-posthog-session-id': 'request-session' } }, () => { + assert.equal(client.getContext().distinctId, 'application-person') + }) + assert.equal(span.attributes.posthogDistinctId, explicit.posthogDistinctId ?? 'application-person') + assert.equal(span.attributes.sessionId, 'request-session') + } finally { + span.end() + } + }) + ) + } + } finally { + await provider.shutdown() + context.disable() + manager.disable() + } + for (const headers of [undefined, {}, { 'x-posthog-distinct-id': ' ', 'x-posthog-session-id': '\t' }]) { const source = {} await wrapper({ source, headers }, async () => { From ff990d6822cb08a4e6b87c1e5b0ab93bac3cd479 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 23:00:49 +0100 Subject: [PATCH 05/10] fix: share framework span enrichment without optional imports Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- packages/next/src/otel.ts | 41 ++------------- packages/nuxt/src/runtime/nitro-plugin.ts | 6 +-- packages/nuxt/tests/nuxt5-consumer.test.mjs | 58 ++++++++++----------- packages/nuxt/tests/server-context.test.mjs | 3 +- 4 files changed, 37 insertions(+), 71 deletions(-) diff --git a/packages/next/src/otel.ts b/packages/next/src/otel.ts index cc31d5becc..c262d7147f 100644 --- a/packages/next/src/otel.ts +++ b/packages/next/src/otel.ts @@ -1,5 +1,4 @@ -import { context, trace, SpanKind, type Context, type Span } from '@opentelemetry/api' -import type { IPostHog, PostHog } from 'posthog-node' +import type { IPostHog } from 'posthog-node' import { PostHogContextSpanProcessor, enrichCurrentSpan } from 'posthog-node/otel' import type { CreatePostHogConfig } from './server/createPostHog.js' import { getConfiguredPostHogClient, resolvePostHogRequest, withRequestContext } from './server/getPostHog.js' @@ -15,44 +14,15 @@ export interface PostHogSpanContext { withPostHogContext(work: (posthog: IPostHog) => T | Promise): Promise } -class NextPostHogContextSpanProcessor extends PostHogContextSpanProcessor { - private readonly _parents = new WeakMap() - - constructor(private readonly _client: PostHog) { - super(_client) - } - - override onStart(span: Span, parentContext?: Context): void { - const parent = parentContext && trace.getSpan(parentContext) - if (parent && parent !== span) { - this._parents.set(span, parent) - } - super.onStart(span) - } - - enrichNearestServerSpan(): void { - let span = trace.getActiveSpan() - while (span) { - if ('kind' in span && span.kind === SpanKind.SERVER && span.isRecording()) { - context.with(trace.setSpan(context.active(), span), () => enrichCurrentSpan(this._client)) - return - } - span = this._parents.get(span) - } - } -} - /** Reuse the same configuration as createPostHog; this entry point requires the Node.js runtime and OpenTelemetry. */ export function createPostHogSpanContext(config: CreatePostHogConfig = {}): PostHogSpanContext { - let processor: NextPostHogContextSpanProcessor | undefined - let pendingProcessor: Promise | undefined + let pendingProcessor: Promise | undefined return { createContextSpanProcessor() { - pendingProcessor ??= getConfiguredPostHogClient(config.apiKey, config.options).then(({ client }) => { - processor = new NextPostHogContextSpanProcessor(client) - return processor - }) + pendingProcessor ??= getConfiguredPostHogClient(config.apiKey, config.options).then( + ({ client }) => new PostHogContextSpanProcessor(client) + ) return pendingProcessor }, async withPostHogContext(work) { @@ -68,7 +38,6 @@ export function createPostHogSpanContext(config: CreatePostHogConfig = {}): Post try { if (contextData) { enrichCurrentSpan(client) - processor?.enrichNearestServerSpan() } return await work( contextData diff --git a/packages/nuxt/src/runtime/nitro-plugin.ts b/packages/nuxt/src/runtime/nitro-plugin.ts index d9f9b5e9f1..55abcc2ee1 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -1,4 +1,4 @@ -import { PostHog } from 'posthog-node' +import { PostHog, enrichCurrentSpan } from 'posthog-node' import { serverClient } from './server-client' import { uuidv7 } from '@posthog/core/vendor/uuidv7' import type { PostHogCommon, PostHogServerConfig } from '../module' @@ -50,7 +50,6 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr const requestContexts = new WeakMap() if (requestContext) { - const spanContext = import('posthog-node/otel').catch(() => undefined) wrapRequest?.(async (request, handler) => { const existing = client.getContext() const identity = { @@ -63,8 +62,7 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr identity, async () => { try { - const tracing = await spanContext - tracing?.enrichCurrentSpan?.(client) + enrichCurrentSpan(client) return await handler() } finally { lifetime.abort() diff --git a/packages/nuxt/tests/nuxt5-consumer.test.mjs b/packages/nuxt/tests/nuxt5-consumer.test.mjs index b24b1ad571..158b23ad3b 100644 --- a/packages/nuxt/tests/nuxt5-consumer.test.mjs +++ b/packages/nuxt/tests/nuxt5-consumer.test.mjs @@ -12,7 +12,6 @@ import { waitForPackages } from './wait-for-packages.mjs' const packageDir = dirname(fileURLToPath(import.meta.url)) const fixtureDir = mkdtempSync(join(tmpdir(), 'posthog-nuxt5-consumer-')) const packageRoot = join(packageDir, '..') -const packageStageDir = join(fixtureDir, 'package') let captureServer let nuxtServer @@ -66,36 +65,37 @@ if (posthogJsVersion === unpublishedPosthogJs) { } try { - const packageManifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) const packagesToWaitFor = new Map() - packageManifest.dependencies = Object.fromEntries( - Object.entries(packageManifest.dependencies).map(([name, version]) => { - if (version !== 'catalog:' && !version.startsWith('workspace:')) { - return [name, version] - } - - const dependencyManifest = JSON.parse( - readFileSync(join(packageRoot, 'node_modules', ...name.split('/'), 'package.json'), 'utf8') - ) - const range = version === 'workspace:^' ? '^' : version === 'workspace:~' ? '~' : '' - packagesToWaitFor.set(name, dependencyManifest.version) - return [name, `${range}${dependencyManifest.version}`] - }) - ) - delete packageManifest.scripts - delete packageManifest.devDependencies - cpSync(join(packageRoot, 'dist'), join(packageStageDir, 'dist'), { recursive: true }) - writeFileSync(join(packageStageDir, 'package.json'), JSON.stringify(packageManifest, null, 2)) - - execFileSync('pnpm', ['pack', '--pack-destination', fixtureDir], { - cwd: packageStageDir, - stdio: 'inherit', + function packBuiltPackage(root, name, localDependencies = {}) { + const stage = join(fixtureDir, name) + const packageManifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + packageManifest.dependencies = Object.fromEntries( + Object.entries(packageManifest.dependencies).map(([dependency, version]) => { + if (localDependencies[dependency]) return [dependency, localDependencies[dependency]] + if (version !== 'catalog:' && !version.startsWith('workspace:')) return [dependency, version] + const manifest = JSON.parse( + readFileSync(join(root, 'node_modules', ...dependency.split('/'), 'package.json'), 'utf8') + ) + const range = version === 'workspace:^' ? '^' : version === 'workspace:~' ? '~' : '' + packagesToWaitFor.set(dependency, manifest.version) + return [dependency, `${range}${manifest.version}`] + }) + ) + delete packageManifest.scripts + delete packageManifest.devDependencies + mkdirSync(stage, { recursive: true }) + cpSync(join(root, 'dist'), join(stage, 'dist'), { recursive: true }) + writeFileSync(join(stage, 'package.json'), JSON.stringify(packageManifest, null, 2)) + execFileSync('pnpm', ['pack', '--pack-destination', stage], { cwd: stage, stdio: 'inherit' }) + return join( + stage, + readdirSync(stage).find((filename) => filename.endsWith('.tgz')) + ) + } + const nodeTarball = packBuiltPackage(join(packageRoot, '..', 'node'), 'node-package') + const packageTarball = packBuiltPackage(packageRoot, 'nuxt-package', { + 'posthog-node': `file:${nodeTarball}`, }) - - const packageTarball = join( - fixtureDir, - readdirSync(fixtureDir).find((filename) => filename.endsWith('.tgz')) - ) let resolveCapture const capture = new Promise((resolve) => { resolveCapture = resolve diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs index b25bf6de78..1e4fd303bf 100644 --- a/packages/nuxt/tests/server-context.test.mjs +++ b/packages/nuxt/tests/server-context.test.mjs @@ -31,8 +31,7 @@ const clientState = loadRuntime('server-client', {}) const server = loadRuntime('server', { './server-client': clientState }) assert.equal(server.getPostHogServerClient(), undefined) const { setupPostHogNitroPlugin } = loadRuntime('nitro-plugin', { - 'posthog-node': { PostHog: CapturingPostHog }, - 'posthog-node/otel': { enrichCurrentSpan }, + 'posthog-node': { PostHog: CapturingPostHog, enrichCurrentSpan }, '@posthog/core/vendor/uuidv7': { uuidv7: () => 'anonymous-id' }, './server-client': clientState, }) From 645e43c848d4db71d9e791adf108bd486095a7a9 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 18 Oct 2026 11:12:19 +0100 Subject: [PATCH 06/10] chore(nuxt): use enabled clients in span context tests Generated-By: PostHog Desktop Task-Id: bfd18848-e498-4352-a58a-b9ba85de4198 --- packages/nuxt/tests/server-context.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs index 1e4fd303bf..a0b9402ffc 100644 --- a/packages/nuxt/tests/server-context.test.mjs +++ b/packages/nuxt/tests/server-context.test.mjs @@ -43,7 +43,7 @@ for (const requestContext of [undefined, false, true]) { setupPostHogNitroPlugin({ useRuntimeConfig: () => ({ public: { posthog: { publicKey: 'phc_test', host: 'https://example.com' } }, - posthogServerConfig: { disabled: true, enableExceptionAutocapture: true, requestContext }, + posthogServerConfig: { before_send: () => null, enableExceptionAutocapture: true, requestContext }, }), wrapRequest: (handler) => { wrapper = handler @@ -160,7 +160,7 @@ console.log( for (const version of [2, 3]) { const config = { public: { posthog: { publicKey: 'phc_test', host: 'https://example.com' } }, - posthogServerConfig: { disabled: true, enableExceptionAutocapture: true, requestContext: true }, + posthogServerConfig: { before_send: () => null, enableExceptionAutocapture: true, requestContext: true }, } const hooks = {} const h3 = createRequire(import.meta.resolve(version === 2 ? 'nitropack/runtime' : 'nitro'))('h3') From 5dd10493273bf8fab57cca0bd2e1a63b11d2eacd Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 26 Oct 2026 12:11:26 +0000 Subject: [PATCH 07/10] fix(next): keep nested context in span context callbacks withPostHogContext passed a proxy that put the request identity back on every call, so posthog.withContext inside the callback was ignored. The callback already runs inside the request scope, so pass the client. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- packages/next/src/otel.ts | 8 ++------ packages/next/src/server/getPostHog.ts | 11 ++--------- packages/next/tests/span-context.test.ts | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/next/src/otel.ts b/packages/next/src/otel.ts index c262d7147f..e36eae9993 100644 --- a/packages/next/src/otel.ts +++ b/packages/next/src/otel.ts @@ -1,7 +1,7 @@ import type { IPostHog } from 'posthog-node' import { PostHogContextSpanProcessor, enrichCurrentSpan } from 'posthog-node/otel' import type { CreatePostHogConfig } from './server/createPostHog.js' -import { getConfiguredPostHogClient, resolvePostHogRequest, withRequestContext } from './server/getPostHog.js' +import { getConfiguredPostHogClient, resolvePostHogRequest } from './server/getPostHog.js' export interface PostHogSpanContext { /** Add this processor to the application's existing Node.js tracer provider before it handles requests. */ @@ -39,11 +39,7 @@ export function createPostHogSpanContext(config: CreatePostHogConfig = {}): Post if (contextData) { enrichCurrentSpan(client) } - return await work( - contextData - ? withRequestContext(client, contextData, { signal: controller.signal }) - : client - ) + return await work(client) } finally { controller.abort() } diff --git a/packages/next/src/server/getPostHog.ts b/packages/next/src/server/getPostHog.ts index 1e8bfee5ab..4feedbc3a3 100644 --- a/packages/next/src/server/getPostHog.ts +++ b/packages/next/src/server/getPostHog.ts @@ -13,11 +13,7 @@ import { resolveServerDistinctId, type PostHogDistinctIdResolver } from '../shar * AsyncLocalStorage.enterWith() doesn't propagate back to the caller * across the await boundary of this async function. */ -export function withRequestContext( - client: IPostHog, - contextData: Parameters[0], - options?: Parameters[2] -): IPostHog { +export function withRequestContext(client: IPostHog, contextData: Parameters[0]): IPostHog { return new Proxy(client, { get(target, prop, receiver) { if (prop === 'withContext') { @@ -25,10 +21,7 @@ export function withRequestContext( } const value = Reflect.get(target, prop, receiver) if (isFunction(value)) { - return (...args: unknown[]) => - options - ? target.withContext(contextData, () => value.apply(target, args), options) - : target.withContext(contextData, () => value.apply(target, args)) + return (...args: unknown[]) => target.withContext(contextData, () => value.apply(target, args)) } return value }, diff --git a/packages/next/tests/span-context.test.ts b/packages/next/tests/span-context.test.ts index a93576039f..1972a5111c 100644 --- a/packages/next/tests/span-context.test.ts +++ b/packages/next/tests/span-context.test.ts @@ -215,6 +215,26 @@ describe('Next.js optional span context', () => { expect(client.getContext()).toBeUndefined() }) + it.each([ + ['context lookups', (requestClient: IPostHog) => requestClient.getContext()?.distinctId], + [ + 'captures', + (requestClient: IPostHog) => { + const captured = new Promise((resolve) => + client.on('capture', (message) => resolve(message.distinct_id)) + ) + requestClient.capture({ event: 'nested' }) + return captured + }, + ], + ])('keeps nested application identity for %s', async (_, readDistinctId) => { + const { bridge } = await setup() + const distinctId = await bridge.withPostHogContext((requestClient) => + requestClient.withContext({ distinctId: 'authenticated-person' }, () => readDistinctId(requestClient)) + ) + expect(distinctId).toBe('authenticated-person') + }) + it('isolates concurrent requests through awaited work', async () => { const { bridge, tracer } = await setup() await Promise.all( From bfa0ee983c7d2ef8ca0ea0cfd31199fd2b0c1289 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 26 Oct 2026 12:13:18 +0000 Subject: [PATCH 08/10] fix(nuxt): keep application context in request scopes and errors With requestContext on, the request scope now keeps properties from an outer application context. The error hook now prefers the active application identity over the saved header identity. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- packages/nuxt/README.md | 9 +++--- packages/nuxt/src/runtime/nitro-plugin.ts | 35 +++++++++++++-------- packages/nuxt/tests/server-context.test.mjs | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 969bd026ef..28c2baf6d9 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -13,8 +13,8 @@ SDK usage examples and code snippets live in the official documentation so they Set `posthogConfig.serverConfig.requestContext` to `true` to add incoming `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` values to the server request context. The default is `false`. These headers are untrusted analytics context. -Never use them for authentication or authorization. Existing application context -takes precedence. Blank or absent IDs are omitted. +Never use them for authentication or authorization. Existing application context, +including its properties, takes precedence. Blank or absent IDs are omitted. For application-owned OpenTelemetry spans, use the optional `PostHogContextSpanProcessor` from `posthog-node/otel` with the existing server @@ -31,8 +31,9 @@ It adds context to an existing recording request span and to child spans started inside the handler when the shared processor is installed. Context ends when the handler returns or throws. Nitro 3 can return a streaming response before the body finishes; later stream work and detached background tasks omit request IDs. -An error reported through Nitro's error hook uses the saved request context. -Background errors without a request keep the existing anonymous identity. +An error reported through Nitro's error hook uses the active application context +first, then the saved request context. Errors without either keep the existing +anonymous identity. The server accessor is available only through its Node export. ## Questions? diff --git a/packages/nuxt/src/runtime/nitro-plugin.ts b/packages/nuxt/src/runtime/nitro-plugin.ts index 55abcc2ee1..87526b24b3 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -10,7 +10,7 @@ type RequestContext = { source?: object headers?: Headers | Record } -type RequestIdentity = { distinctId?: string; sessionId?: string } +type RequestScope = NonNullable> type RequestWrapper = (request: RequestContext, handler: () => T | Promise) => Promise type ErrorHandler = (error: unknown, request?: RequestContext) => Promise type NitroBindings = { @@ -35,6 +35,15 @@ function readHeader(headers: RequestContext['headers'], name: string): string | return sanitized.trim().slice(0, 1000) || undefined } +function preferActiveContext(active: RequestScope | undefined, fallback: RequestScope | undefined): RequestScope { + return { + ...fallback, + ...active, + distinctId: active?.distinctId ?? fallback?.distinctId, + sessionId: active?.sessionId ?? fallback?.sessionId, + } +} + export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wrapRequest }: NitroBindings): void { const runtimeConfig = useRuntimeConfig() as RuntimeConfig const posthogCommon = runtimeConfig.public.posthog @@ -47,19 +56,18 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr }) serverClient.current = client - const requestContexts = new WeakMap() + const requestContexts = new WeakMap() if (requestContext) { wrapRequest?.(async (request, handler) => { - const existing = client.getContext() - const identity = { - distinctId: existing?.distinctId ?? readHeader(request.headers, 'x-posthog-distinct-id'), - sessionId: existing?.sessionId ?? readHeader(request.headers, 'x-posthog-session-id'), - } - if (request.source) requestContexts.set(request.source, identity) + const scope = preferActiveContext(client.getContext(), { + distinctId: readHeader(request.headers, 'x-posthog-distinct-id'), + sessionId: readHeader(request.headers, 'x-posthog-session-id'), + }) + if (request.source) requestContexts.set(request.source, scope) const lifetime = new AbortController() return client.withContext( - identity, + scope, async () => { try { enrichCurrentSpan(client) @@ -96,12 +104,13 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr props.method = request.method } - const identity = request?.source ? requestContexts.get(request.source) : undefined - if (identity?.sessionId) props.$session_id = identity.sessionId + const saved = request?.source ? requestContexts.get(request.source) : undefined + const scope = requestContext ? preferActiveContext(client.getContext(), saved) : {} + if (scope.sessionId) props.$session_id = scope.sessionId client.withContext( - identity ?? {}, + scope, () => { - client.captureException(error, identity?.distinctId ?? uuidv7(), props) + client.captureException(error, scope.distinctId ?? uuidv7(), props) }, { fresh: true } ) diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs index a0b9402ffc..9a9352c1ce 100644 --- a/packages/nuxt/tests/server-context.test.mjs +++ b/packages/nuxt/tests/server-context.test.mjs @@ -61,7 +61,39 @@ for (const requestContext of [undefined, false, true]) { assert.equal(typeof wrapper, requestContext ? 'function' : 'undefined') await onError(new Error('background')) assert.equal(errors.at(-1).distinctId, 'anonymous-id') + await client.withContext({ distinctId: 'authenticated-person' }, () => onError(new Error('application background'))) + assert.equal(errors.at(-1).distinctId, requestContext ? 'authenticated-person' : 'anonymous-id') if (wrapper) { + const browserHeaders = { 'x-posthog-distinct-id': 'browser-person', 'x-posthog-session-id': 'browser-session' } + for (const outer of [ + { properties: { tenant: 'acme' } }, + { distinctId: 'authenticated', properties: { plan: 'pro' } }, + ]) { + await client.withContext(outer, () => + wrapper({ source: {}, headers: browserHeaders }, async () => { + assert.deepEqual(client.getContext()?.properties, outer.properties) + assert.equal(client.getContext()?.distinctId, outer.distinctId ?? 'browser-person') + assert.equal(client.getContext()?.sessionId, 'browser-session') + }) + ) + } + for (const [label, report, expected] of [ + [ + 'inside application context', + (error, source) => client.withContext({ distinctId: 'authenticated-person' }, () => onError(error, { source })), + 'authenticated-person', + ], + ['inside request scope', (error, source) => onError(error, { source }), 'browser-person'], + ]) { + const source = {} + const error = new Error(label) + await wrapper({ source, headers: browserHeaders }, () => report(error, source)) + const captured = errors.find((value) => value.error === error) + assert.equal(captured.distinctId, expected, label) + assert.equal(captured.context.distinctId, expected, label) + assert.equal(captured.properties.$session_id, 'browser-session', label) + } + const manager = new AsyncLocalStorageContextManager().enable() context.setGlobalContextManager(manager) const provider = new BasicTracerProvider({ spanProcessors: [new PostHogContextSpanProcessor(client)] }) From 916660edad73ff6b889c8795bf24e01787e7a52d Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 26 Oct 2026 14:22:30 +0000 Subject: [PATCH 09/10] fix(next,nuxt): keep request context for work after the handler Match posthog-node: withPostHogContext and the Nitro request wrapper no longer end the context when the handler returns or throws. Work the handler starts keeps the request IDs. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- packages/next/README.md | 2 +- packages/next/src/otel.ts | 13 ++++--------- packages/next/tests/span-context.test.ts | 12 ++++++------ packages/nuxt/README.md | 8 ++++---- packages/nuxt/src/runtime/nitro-plugin.ts | 11 +++-------- packages/nuxt/tests/server-context.test.mjs | 10 +++++----- 6 files changed, 23 insertions(+), 33 deletions(-) diff --git a/packages/next/README.md b/packages/next/README.md index bcf7043fff..9074e3e425 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -8,6 +8,6 @@ SDK usage examples and code snippets live in the official documentation so they - [Next.js library docs](https://posthog.com/docs/libraries/next-js) -The optional `@posthog/next/otel` entry point links Node.js App Router work to existing OpenTelemetry spans. Reuse your `createPostHog` configuration and add its processor to your existing tracer provider. Request context ends when `withPostHogContext` returns or throws. It does not cover later component rendering, streamed response bodies, or the Edge runtime. +The optional `@posthog/next/otel` entry point links Node.js App Router work to existing OpenTelemetry spans. Reuse your `createPostHog` configuration and add its processor to your existing tracer provider. Work that the callback starts keeps the request context after `withPostHogContext` returns or throws. It does not cover later component rendering, streamed response bodies, or the Edge runtime. Server span enrichment follows parent spans observed by the processor. If the application sampler drops an intermediate span, the helper cannot prove the earlier server ancestor and leaves it unchanged. Recording spans created inside the callback still receive context. The integration does not change the sampler or guess ancestry from trace IDs. diff --git a/packages/next/src/otel.ts b/packages/next/src/otel.ts index e36eae9993..76036a838c 100644 --- a/packages/next/src/otel.ts +++ b/packages/next/src/otel.ts @@ -31,20 +31,15 @@ export function createPostHogSpanContext(config: CreatePostHogConfig = {}): Post config.options, config.getDistinctId ) - const controller = new AbortController() return client.withContext( contextData ?? {}, async () => { - try { - if (contextData) { - enrichCurrentSpan(client) - } - return await work(client) - } finally { - controller.abort() + if (contextData) { + enrichCurrentSpan(client) } + return await work(client) }, - { fresh: true, signal: controller.signal } + { fresh: true } ) }, } diff --git a/packages/next/tests/span-context.test.ts b/packages/next/tests/span-context.test.ts index 1972a5111c..4c5dc15703 100644 --- a/packages/next/tests/span-context.test.ts +++ b/packages/next/tests/span-context.test.ts @@ -148,13 +148,13 @@ describe('Next.js optional span context', () => { expect(cachedClient).toHaveBeenLastCalledWith(config.apiKey, config.options) }) - it.each(['success', 'failure'])('expires captured client calls and async SDK work after %s', async (outcome) => { + it.each(['success', 'failure'])('keeps context for async SDK work after %s', async (outcome) => { const { bridge, tracer } = await setup() - let retained!: IPostHog + let requestContext: ReturnType let release!: () => void let work!: Promise const result = bridge.withPostHogContext((requestClient) => { - retained = requestClient + requestContext = requestClient.getContext() work = requestClient.withSpan('job', async () => { await new Promise((resolve) => { release = resolve @@ -166,12 +166,12 @@ describe('Next.js optional span context', () => { }) if (outcome === 'failure') await expect(result).rejects.toThrow('request failure') else expect(await result).toBe('ok') - expect(retained.getContext()).toBeUndefined() - retained.withSpan('late-client', () => tracer.startSpan('late-client-work').end()) release() await work await provider.forceFlush() - expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual([{}, {}]) + const expected = { sessionId: requestContext?.sessionId, posthogDistinctId: requestContext?.distinctId } + expect(expected.sessionId).toEqual(expect.any(String)) + expect(exporter.getFinishedSpans().map((span) => span.attributes)).toEqual([expected]) expect(client.getContext()).toBeUndefined() }) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 28c2baf6d9..3d754993ff 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -24,13 +24,13 @@ after that client closes. Initialize the application's tracing setup after the PostHog Nitro plugin and before requests start. Keep the application's existing provider, exporters, processors, and sampler. This module does not create spans or install OpenTelemetry. The Node SDK must include the shared context span -bridge and abort-signal context support before this integration is released. +bridge before this integration is released. The wrapper covers Nitro 2's H3 request handler and Nitro 3's fetch handler. It adds context to an existing recording request span and to child spans started -inside the handler when the shared processor is installed. Context ends when the -handler returns or throws. Nitro 3 can return a streaming response before the -body finishes; later stream work and detached background tasks omit request IDs. +inside the handler when the shared processor is installed. Work that the handler +starts, including detached background tasks, keeps the request context after the +handler returns or throws. An error reported through Nitro's error hook uses the active application context first, then the saved request context. Errors without either keep the existing anonymous identity. diff --git a/packages/nuxt/src/runtime/nitro-plugin.ts b/packages/nuxt/src/runtime/nitro-plugin.ts index 87526b24b3..7ddbaeabeb 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -65,18 +65,13 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose, wr sessionId: readHeader(request.headers, 'x-posthog-session-id'), }) if (request.source) requestContexts.set(request.source, scope) - const lifetime = new AbortController() return client.withContext( scope, async () => { - try { - enrichCurrentSpan(client) - return await handler() - } finally { - lifetime.abort() - } + enrichCurrentSpan(client) + return await handler() }, - { fresh: true, signal: lifetime.signal } + { fresh: true } ) }) } diff --git a/packages/nuxt/tests/server-context.test.mjs b/packages/nuxt/tests/server-context.test.mjs index 9a9352c1ce..79680bc7a5 100644 --- a/packages/nuxt/tests/server-context.test.mjs +++ b/packages/nuxt/tests/server-context.test.mjs @@ -165,7 +165,7 @@ for (const requestContext of [undefined, false, true]) { await Promise.resolve() assert.equal(client.getContext()?.distinctId, id) assert.equal(client.getContext()?.sessionId, id + '-session') - detached.push(after.then(() => assert.equal(client.getContext(), undefined))) + detached.push(after.then(() => assert.equal(client.getContext()?.distinctId, id))) throw error }) } catch (caught) { @@ -212,7 +212,7 @@ for (const version of [2, 3]) { const observed = client.getContext() detached.push( afterResponse.then(() => { - assert.equal(client.getContext(), undefined) + assert.equal(client.getContext()?.distinctId, id) tracer.startSpan('after:' + id).end() }) ) @@ -299,9 +299,9 @@ for (const version of [2, 3]) { release() await Promise.all(detached) for (const span of exporter.getFinishedSpans()) { - const [kind, id] = span.name.split(':') - assert.equal(span.attributes.posthogDistinctId, kind === 'after' ? undefined : id) - assert.equal(span.attributes.sessionId, kind === 'after' ? undefined : id + '-session') + const id = span.name.split(':')[1] + assert.equal(span.attributes.posthogDistinctId, id) + assert.equal(span.attributes.sessionId, id + '-session') } assert.equal(exporter.getFinishedSpans().length, 36) for (const id of ids.filter((id) => id.endsWith('1'))) { From 7dc2d4219e5a4c671eef5a40788bd09df4bdaa06 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 28 Oct 2026 11:59:46 +0000 Subject: [PATCH 10/10] fix(next): share the Node.js client cache across webpack layers Next.js 15 bundles instrumentation and App Router handlers in separate webpack layers, each loading its own copy of clientCache.node.ts. That gave each layer its own client for the same key, so the OTel span processor (reading one client's context) and withPostHogContext (writing another's) could disagree about the current request. Cache clients on globalThis, keyed by Symbol.for, so every layer shares one. Generated-By: PostHog Desktop Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9 --- packages/next/src/server/clientCache.node.ts | 9 +++- packages/next/tests/clientCache.node.test.ts | 43 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 packages/next/tests/clientCache.node.test.ts diff --git a/packages/next/src/server/clientCache.node.ts b/packages/next/src/server/clientCache.node.ts index 5c679abe6b..2342bc00c9 100644 --- a/packages/next/src/server/clientCache.node.ts +++ b/packages/next/src/server/clientCache.node.ts @@ -2,7 +2,14 @@ import { PostHog } from 'posthog-node' import type { PostHogOptions } from 'posthog-node' import { getOrCreatePostHogClient } from './clientCache.js' -const cache = new Map() +// With default Next.js bundling, instrumentation and App Router request handlers +// run in separate webpack layers, each loading its own copy of this module. Share +// the cache through globalThis so every layer reuses the same client — otherwise +// the OTel span processor and withPostHogContext can read and write different +// clients' context for the same request. +const CACHE_KEY = Symbol.for('posthog-nextjs.node-client-cache') +const cacheHolder = globalThis as typeof globalThis & { [CACHE_KEY]?: Map } +const cache = (cacheHolder[CACHE_KEY] ??= new Map()) export async function getOrCreateNodeClient(apiKey: string, options?: Partial): Promise { return getOrCreatePostHogClient(cache, PostHog, apiKey, options) diff --git a/packages/next/tests/clientCache.node.test.ts b/packages/next/tests/clientCache.node.test.ts new file mode 100644 index 0000000000..4439efe096 --- /dev/null +++ b/packages/next/tests/clientCache.node.test.ts @@ -0,0 +1,43 @@ +vi.mock('server-only', () => ({})) + +const { mockPostHogConstructor } = vi.hoisted(() => ({ + mockPostHogConstructor: vi.fn(() => ({})), +})) + +vi.mock('posthog-node', () => ({ + PostHog: mockPostHogConstructor, +})) + +async function loadTwoModuleCopies() { + vi.resetModules() + const first = await import('../src/server/clientCache.node') + vi.resetModules() + const second = await import('../src/server/clientCache.node') + return { first, second } +} + +describe('clientCache.node cache sharing', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + // With default Next.js bundling, instrumentation and App Router handlers load + // their own copy of this module (separate webpack layers). Without a shared + // cache, each copy creates its own PostHog client for the same key, so the + // OTel span processor (reading one client's context) and withPostHogContext + // (writing another's) can disagree about the current request context. + it.each([ + ['the first copy created the client', 'first', 'second', 'phc_test_a'], + ['the second copy created the client', 'second', 'first', 'phc_test_b'], + ] as const)('reuses the client across module copies when %s', async (_, creator, reader, apiKey) => { + const copies = await loadTwoModuleCopies() + expect(copies.second).not.toBe(copies.first) + + const created = await copies[creator].getOrCreateNodeClient(apiKey, { host: 'https://test.com' }) + const read = await copies[reader].getOrCreateNodeClient(apiKey, { host: 'https://test.com' }) + + expect(read).toBe(created) + expect(mockPostHogConstructor).toHaveBeenCalledTimes(1) + }) +})