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/.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..9074e3e425 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -7,3 +7,7 @@ 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. 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/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..76036a838c --- /dev/null +++ b/packages/next/src/otel.ts @@ -0,0 +1,46 @@ +import type { IPostHog } from 'posthog-node' +import { PostHogContextSpanProcessor, enrichCurrentSpan } from 'posthog-node/otel' +import type { CreatePostHogConfig } from './server/createPostHog.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. */ + createContextSpanProcessor(): Promise + /** + * 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 +} + +/** Reuse the same configuration as createPostHog; this entry point requires the Node.js runtime and OpenTelemetry. */ +export function createPostHogSpanContext(config: CreatePostHogConfig = {}): PostHogSpanContext { + let pendingProcessor: Promise | undefined + + return { + createContextSpanProcessor() { + pendingProcessor ??= getConfiguredPostHogClient(config.apiKey, config.options).then( + ({ client }) => new PostHogContextSpanProcessor(client) + ) + return pendingProcessor + }, + async withPostHogContext(work) { + const { client, contextData } = await resolvePostHogRequest( + config.apiKey, + config.options, + config.getDistinctId + ) + return client.withContext( + contextData ?? {}, + async () => { + if (contextData) { + enrichCurrentSpan(client) + } + return await work(client) + }, + { fresh: true } + ) + }, + } +} 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/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/src/server/getPostHog.ts b/packages/next/src/server/getPostHog.ts index 76aa05650a..4feedbc3a3 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' @@ -60,24 +60,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 +101,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/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) + }) +}) diff --git a/packages/next/tests/span-context.test.ts b/packages/next/tests/span-context.test.ts new file mode 100644 index 0000000000..4c5dc15703 --- /dev/null +++ b/packages/next/tests/span-context.test.ts @@ -0,0 +1,255 @@ +// @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, + 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' +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('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 }) + 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'])('keeps context for async SDK work after %s', async (outcome) => { + const { bridge, tracer } = await setup() + let requestContext: ReturnType + let release!: () => void + let work!: Promise + const result = bridge.withPostHogContext((requestClient) => { + requestContext = requestClient.getContext() + 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') + release() + await work + await provider.forceFlush() + 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() + }) + + 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.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( + ['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/packages/nuxt/README.md b/packages/nuxt/README.md index 161869d510..3d754993ff 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -8,6 +8,34 @@ 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, +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 +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 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. 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. +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..7ddbaeabeb 100644 --- a/packages/nuxt/src/runtime/nitro-plugin.ts +++ b/packages/nuxt/src/runtime/nitro-plugin.ts @@ -1,24 +1,53 @@ -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' import type { JsonType } from '@posthog/core' -type RequestContext = { path?: string; method?: string } +type RequestContext = { + path?: string + method?: string + source?: object + headers?: Headers | Record +} +type RequestScope = NonNullable> +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 +} + +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 - const posthogServerConfig = runtimeConfig.posthogServerConfig + const { requestContext, ...posthogServerConfig } = runtimeConfig.posthogServerConfig const debug = posthogCommon.debug === true const client = new PostHog(posthogCommon.publicKey, { @@ -26,6 +55,27 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose }: ...posthogServerConfig, }) + serverClient.current = client + const requestContexts = new WeakMap() + + if (requestContext) { + wrapRequest?.(async (request, handler) => { + 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) + return client.withContext( + scope, + async () => { + enrichCurrentSpan(client) + return await handler() + }, + { fresh: true } + ) + }) + } + if (debug) { client.debug(true) } @@ -49,12 +99,22 @@ export function setupPostHogNitroPlugin({ useRuntimeConfig, onError, onClose }: props.method = request.method } - client.captureException(error, uuidv7(), props) + 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( + scope, + () => { + client.captureException(error, scope.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/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 new file mode 100644 index 0000000000..79680bc7a5 --- /dev/null +++ b/packages/nuxt/tests/server-context.test.mjs @@ -0,0 +1,323 @@ +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, 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' + +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, enrichCurrentSpan }, + '@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: { before_send: () => null, 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') + 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)] }) + 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 () => { + 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()?.distinctId, id))) + 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: { before_send: () => null, 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()?.distinctId, id) + 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 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'))) { + 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 00fa6b8873..397c718402 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 @@ -883,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