From ffeeb1e444d58f3429b4af761325c1a8d2833832 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 6 Nov 2026 10:30:01 -0800 Subject: [PATCH 1/2] feat(node): add instrumentRequest and posthogHonoMiddleware for fetch-API servers Co-Authored-By: Claude Sonnet 5.5 --- .changeset/fetch-request-instrumentation.md | 5 + .../extensions/fetch-request.spec.ts | 153 ++++++++++++++ packages/node/src/exports.ts | 1 + packages/node/src/extensions/fetch-request.ts | 193 ++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 .changeset/fetch-request-instrumentation.md create mode 100644 packages/node/src/__tests__/extensions/fetch-request.spec.ts create mode 100644 packages/node/src/extensions/fetch-request.ts diff --git a/.changeset/fetch-request-instrumentation.md b/.changeset/fetch-request-instrumentation.md new file mode 100644 index 0000000000..634dc55256 --- /dev/null +++ b/.changeset/fetch-request-instrumentation.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +feat(node): add `instrumentRequest` and `posthogHonoMiddleware` for fetch-API servers (Hono, Bun, Deno, Next route handlers). They read `x-posthog-session-id`, wrap the request in `withContext`, open a server span that continues `traceparent` and is named by route, mark 5xx as errors, and record `http.server.request.duration` and `http.server.request.count`. diff --git a/packages/node/src/__tests__/extensions/fetch-request.spec.ts b/packages/node/src/__tests__/extensions/fetch-request.spec.ts new file mode 100644 index 0000000000..5d37e0a5f6 --- /dev/null +++ b/packages/node/src/__tests__/extensions/fetch-request.spec.ts @@ -0,0 +1,153 @@ +import { PostHog } from '@/entrypoints/index.node' +import { instrumentRequest, posthogHonoMiddleware } from '@/extensions/fetch-request' +import type { SpanRecord } from '@posthog/types' + +vi.spyOn(globalThis, 'fetch').mockImplementation() + +const TRACEPARENT = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' + +describe('fetch request instrumentation', () => { + let posthog: PostHog + let spans: SpanRecord[] + let histogram: ReturnType + let count: ReturnType + + beforeEach(() => { + spans = [] + posthog = new PostHog('phc_test_key', { + host: 'http://example.com', + disableCompression: true, + fetchRetryCount: 0, + traces: { + serviceName: 'api', + beforeSpanSend: (span) => { + spans.push(span) + return span + }, + }, + metrics: { serviceName: 'api' }, + }) + histogram = vi.spyOn(posthog.metrics, 'histogram') + count = vi.spyOn(posthog.metrics, 'count') + }) + + afterEach(async () => { + await posthog.shutdown() + }) + + it('runs the handler in context from the session header and continues traceparent', async () => { + let seen: any + const request = new Request('http://localhost/api/users/42', { + headers: { 'x-posthog-session-id': 'sess-1', 'x-posthog-distinct-id': 'user-1', traceparent: TRACEPARENT }, + }) + const response = await instrumentRequest( + posthog, + request, + () => { + seen = posthog.getContext() + return new Response('ok') + }, + { getRoute: () => '/api/users/:id' } + ) + + expect(response.status).toBe(200) + expect(seen).toMatchObject({ sessionId: 'sess-1', distinctId: 'user-1' }) + expect(spans).toHaveLength(1) + expect(spans[0]).toMatchObject({ + name: 'GET /api/users/:id', + kind: 'server', + traceId: '0af7651916cd43dd8448eb211c80319c', + parentSpanId: 'b7ad6b7169203331', + }) + expect(spans[0].attributes).toMatchObject({ 'http.route': '/api/users/:id', 'http.response.status_code': 200 }) + expect(spans[0].status?.code).not.toBe('error') + }) + + it('names the span after the method only when no route is known', async () => { + await instrumentRequest(posthog, new Request('http://localhost/anything/123'), () => new Response('ok')) + expect(spans[0].name).toBe('GET') + }) + + it('marks 5xx responses as errors', async () => { + await instrumentRequest(posthog, new Request('http://localhost/x', { method: 'POST' }), () => new Response('no', { status: 503 })) + expect(spans[0].status?.code).toBe('error') + expect(count).toHaveBeenCalledWith( + 'http.server.request.count', + 1, + expect.objectContaining({ attributes: expect.objectContaining({ 'http.response.status_code': 503, 'http.request.method': 'POST' }) }) + ) + }) + + it('does not mark 4xx responses as errors', async () => { + await instrumentRequest(posthog, new Request('http://localhost/x'), () => new Response('nope', { status: 404 })) + expect(spans[0].status?.code).not.toBe('error') + }) + + it('records the duration histogram in seconds and the request count', async () => { + await instrumentRequest(posthog, new Request('http://localhost/x'), () => new Response('ok'), { + getRoute: () => '/x', + }) + expect(histogram).toHaveBeenCalledTimes(1) + const [name, value, opts] = histogram.mock.calls[0] as any[] + expect(name).toBe('http.server.request.duration') + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThan(5) + expect(opts).toMatchObject({ + unit: 's', + attributes: { 'http.request.method': 'GET', 'http.route': '/x', 'http.response.status_code': 200 }, + }) + expect(count).toHaveBeenCalledWith('http.server.request.count', 1, expect.anything()) + }) + + it('records a thrown error as a 500 and rethrows it', async () => { + const boom = new Error('boom') + await expect( + instrumentRequest(posthog, new Request('http://localhost/x'), () => { + throw boom + }) + ).rejects.toBe(boom) + expect(spans[0].status?.code).toBe('error') + expect(spans[0].events.some((e) => e.name === 'exception')).toBe(true) + expect(histogram.mock.calls[0][2]).toMatchObject({ attributes: { 'http.response.status_code': 500 } }) + }) + + it('skips ignored requests', async () => { + const response = await instrumentRequest(posthog, new Request('http://localhost/health'), () => new Response('ok'), { + ignore: (r) => new URL(r.url).pathname === '/health', + }) + expect(response.status).toBe(200) + expect(spans).toHaveLength(0) + expect(histogram).not.toHaveBeenCalled() + }) + + it('survives a throwing route resolver', async () => { + const response = await instrumentRequest(posthog, new Request('http://localhost/x'), () => new Response('ok'), { + getRoute: () => { + throw new Error('bad') + }, + }) + expect(response.status).toBe(200) + expect(spans[0].name).toBe('GET') + }) + + describe('posthogHonoMiddleware', () => { + it('names the span by the route matched after next()', async () => { + const c: any = { + req: { + raw: new Request('http://localhost/api/dashboards/abc', { headers: { 'x-posthog-session-id': 's2' } }), + routePath: '/*', + }, + res: undefined, + } + let sessionId: string | undefined + await posthogHonoMiddleware(posthog)(c, async () => { + sessionId = posthog.getContext()?.sessionId + c.req.routePath = '/api/dashboards/:uid' + c.res = new Response('ok', { status: 500 }) + }) + expect(sessionId).toBe('s2') + expect(spans[0].name).toBe('GET /api/dashboards/:uid') + expect(spans[0].status?.code).toBe('error') + }) + }) +}) diff --git a/packages/node/src/exports.ts b/packages/node/src/exports.ts index 86e26ea3fc..d04c36a35f 100644 --- a/packages/node/src/exports.ts +++ b/packages/node/src/exports.ts @@ -1,5 +1,6 @@ export * from './extensions/sentry-integration' export * from './extensions/express' +export * from './extensions/fetch-request' export * from './types' export { enrichCurrentSpan } from './extensions/context/listeners' diff --git a/packages/node/src/extensions/fetch-request.ts b/packages/node/src/extensions/fetch-request.ts new file mode 100644 index 0000000000..cd66bbe173 --- /dev/null +++ b/packages/node/src/extensions/fetch-request.ts @@ -0,0 +1,193 @@ +import type { PostHogBackendClient } from '../client' +import type { ContextData } from './context/types' +import { notifyContextListeners } from './context/listeners' +import { addProperty, getPostHogCookieReadOptions, getPostHogTracingHeaderValues } from './tracing-headers' +import { normalizeRequestPath } from './url-utils' + +/** Duration histogram, in seconds, using the OpenTelemetry HTTP semantic convention name. */ +export const HTTP_SERVER_REQUEST_DURATION_METRIC = 'http.server.request.duration' +/** Counter of requests served. */ +export const HTTP_SERVER_REQUEST_COUNT_METRIC = 'http.server.request.count' + +// Seconds, the boundaries the OpenTelemetry HTTP semantic conventions recommend for this histogram. +const DURATION_BOUNDS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] + +export interface InstrumentRequestOptions { + /** + * The matched route pattern (`/users/:id`), once the handler has run. Used to name the span + * and as the `http.route` metric attribute. Without it the span is named after the method only, + * so raw paths never become high-cardinality span names or metric series. + */ + getRoute?: (request: Request, response: Response | undefined) => string | undefined + /** Skip instrumentation (health checks, for example). The handler still runs, with no span or metrics. */ + ignore?: (request: Request) => boolean +} + +type PostHogForRequests = Pick< + PostHogBackendClient, + 'withContext' | 'withSpan' | 'getContext' | 'metrics' | 'apiKey' | 'options' +> + +function headersToRecord(headers: Headers): Record { + const record: Record = {} + headers.forEach((value, key) => { + record[key.toLowerCase()] = value + }) + return record +} + +function safePathname(request: Request): string | undefined { + try { + return new URL(request.url).pathname + } catch { + return undefined + } +} + +/** + * Instruments one request on the fetch API (`Request` in, `Response` out): Hono, Bun, Deno, Next route + * handlers, Cloudflare Workers, and anything else that works that way. + * + * - reads `x-posthog-session-id` / `x-posthog-distinct-id` (or the posthog-js cookie when `readPostHogCookie` is on) + * and runs the handler inside `withContext`, so events and spans carry the person and the replay + * - opens a `server` span continuing an inbound `traceparent`, named `METHOD route` once the route is known + * - marks 5xx responses and thrown errors as span errors (the error is rethrown) + * - records `http.server.request.duration` (seconds histogram) and `http.server.request.count`, + * by method, route and status code + * + * @experimental Subject to change in a minor release. + * + * @example + * ```ts + * export default { + * fetch: (request: Request) => instrumentRequest(posthog, request, () => app.fetch(request)), + * } + * ``` + */ +export async function instrumentRequest( + posthog: PostHogForRequests, + request: Request, + handler: () => Response | Promise, + options: InstrumentRequestOptions = {} +): Promise { + if (options.ignore?.(request)) { + return handler() + } + + const headers = getPostHogTracingHeaderValues(headersToRecord(request.headers), getPostHogCookieReadOptions(posthog)) + const existing = posthog.getContext() + const disableCaptureUrlHashes = posthog.options.disable_capture_url_hashes === true + const pathname = safePathname(request) + const properties: Record = {} + addProperty(properties, '$request_method', request.method) + addProperty(properties, '$request_path', pathname ? normalizeRequestPath(pathname, disableCaptureUrlHashes) : undefined) + addProperty(properties, '$user_agent', request.headers.get('user-agent') ?? undefined) + + const sessionId = existing?.sessionId ?? headers.sessionId + const distinctId = existing?.distinctId ?? headers.distinctId + const contextData: Partial = { + ...(sessionId !== undefined ? { sessionId } : {}), + ...(distinctId !== undefined ? { distinctId } : {}), + properties, + } + + const method = request.method.toUpperCase() + const startedAt = performance.now() + + return posthog.withContext(contextData, () => { + notifyContextListeners(posthog as unknown as PostHogBackendClient, true) + return posthog.withSpan( + method, + { + kind: 'server', + parent: request.headers.get('traceparent') ?? undefined, + attributes: { 'http.request.method': method, ...(pathname ? { 'url.path': pathname } : {}) }, + }, + async (span) => { + let response: Response | undefined + let thrown: unknown + let failed = false + try { + response = await handler() + } catch (error) { + failed = true + thrown = error + } + + let route: string | undefined + try { + route = options.getRoute?.(request, response) + } catch { + // A broken route resolver must not break the request. + } + const statusCode = failed ? 500 : (response?.status ?? 500) + if (route) { + span.updateName(`${method} ${route}`) + span.setAttribute('http.route', route) + } + span.setAttribute('http.response.status_code', statusCode) + if (failed) { + span.recordException(thrown) + } else if (statusCode >= 500) { + span.setStatus('error') + } + + const attributes: Record = { + 'http.request.method': method, + 'http.response.status_code': statusCode, + ...(route ? { 'http.route': route } : {}), + } + posthog.metrics.histogram(HTTP_SERVER_REQUEST_DURATION_METRIC, (performance.now() - startedAt) / 1000, { + unit: 's', + attributes, + bounds: DURATION_BOUNDS_SECONDS, + }) + posthog.metrics.count(HTTP_SERVER_REQUEST_COUNT_METRIC, 1, { attributes }) + + if (failed) { + throw thrown + } + return response as Response + } + ) + }) +} + +/** The slice of a Hono `Context` the middleware uses, so `hono` is not a dependency. */ +interface HonoContextLike { + req: { raw: Request; routePath: string } + res: Response +} + +/** + * Hono middleware that instruments every request. See {@link instrumentRequest}. The span is named after + * the matched route (`GET /api/users/:id`), and a thrown error is recorded as a 500. + * + * @experimental Subject to change in a minor release. + * + * @example + * ```ts + * app.use('*', posthogHonoMiddleware(posthog)) + * ``` + */ +export function posthogHonoMiddleware( + posthog: PostHogForRequests, + options: Omit = {} +): (c: HonoContextLike, next: () => Promise) => Promise { + return async (c, next) => { + // Hono's `next()` resolves with the response on `c.res` (error handlers set it too), so wrap that. + await instrumentRequest( + posthog, + c.req.raw, + async () => { + await next() + return c.res + }, + { + ...options, + // routePath is the last matched route; the catch-all `/*` means nothing matched. + getRoute: () => (c.req.routePath && c.req.routePath !== '/*' ? c.req.routePath : undefined), + } + ) + } +} From 0bf94d17de5e979b39bb0cdc32068dbdffcb1bf2 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Fri, 6 Nov 2026 14:08:02 -0800 Subject: [PATCH 2/2] chore(node): regenerate public API references and format fetch-request Co-Authored-By: Claude Sonnet 5.5 --- .../posthog-node-references-latest.json | 68 +++++++++++++++++++ .../extensions/fetch-request.spec.ts | 21 ++++-- packages/node/src/extensions/fetch-request.ts | 6 +- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 8d267571d8..acdc4e0a42 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -2806,6 +2806,21 @@ ], "path": "src/types.ts" }, + { + "id": "HonoContextLike", + "name": "HonoContextLike", + "properties": [ + { + "type": "{\n raw: Request;\n routePath: string;\n }", + "name": "req" + }, + { + "type": "Response", + "name": "res" + } + ], + "path": "src/extensions/fetch-request.ts" + }, { "id": "IdentifyMessage", "name": "IdentifyMessage", @@ -2825,6 +2840,23 @@ ], "path": "src/types.ts" }, + { + "id": "InstrumentRequestOptions", + "name": "InstrumentRequestOptions", + "properties": [ + { + "description": "The matched route pattern (`/users/:id`), once the handler has run. Used to name the span and as the `http.route` metric attribute. Without it the span is named after the method only, so raw paths never become high-cardinality span names or metric series.", + "type": "(request: Request, response: Response | undefined) => string | undefined", + "name": "getRoute" + }, + { + "description": "Skip instrumentation (health checks, for example). The handler still runs, with no span or metrics.", + "type": "(request: Request) => boolean", + "name": "ignore" + } + ], + "path": "src/extensions/fetch-request.ts" + }, { "id": "IPostHog", "name": "IPostHog", @@ -3732,6 +3764,42 @@ ], "path": "../core/src/types.ts" }, + { + "id": "PostHogForRequests", + "name": "PostHogForRequests", + "properties": [ + { + "description": "Run a function with specific context that will be applied to all events captured within that context.\nIt propagates the context to all subsequent calls down the call stack.\nContext properties like tags and sessionId will be automatically attached to all events.\nBy default, nested contexts inherit from parent contexts. Use `{ fresh: true }` to start with a clean context.", + "type": "(data: Partial, fn: () => T, options?: ContextOptions) => T", + "name": "withContext" + }, + { + "description": "Runs a callback with a span active for its duration and ends the span for\nyou — at return for a sync callback, at settle for an async one. Takes an\noptional `StartSpanOptions` between the name and the callback:\n`withSpan(name, fn)` or `withSpan(name, options, fn)`.\n\nSpans started inside the callback nest under it automatically. If the\ncallback throws or rejects, the span records the exception and the original\nerror is rethrown unchanged. A callback that ends the span itself gets the\nrethrow but not the recording, since the span is already exported by then.\n\nSpans nest across `await` only on the Node runtime, which tracks the active\nspan with `AsyncLocalStorage`. The edge build restores the active span when\nthe callback returns its promise, so spans started after an `await` there\nbegin a new trace.\n\n{@label Traces}", + "type": "{ (name: string, fn: (span: Span) => T): T; (name: string, options: StartSpanOptions, fn: (span: Span) => T): T; }", + "name": "withSpan" + }, + { + "description": "Get the current context data.", + "type": "() => ContextData | undefined", + "name": "getContext" + }, + { + "description": "The `posthog.metrics` API: a statsd-style pre-aggregating metrics client — alpha.\nSamples are folded into per-series aggregates in memory and flushed\nperiodically as one OTLP data point per series per window, so recording\nfrom hot paths is cheap. Configure via the `metrics` client option.", + "type": "Metrics", + "name": "metrics" + }, + { + "description": "The project token the client was created with. Same value as `projectToken`.", + "type": "string", + "name": "apiKey" + }, + { + "type": "PostHogOptions", + "name": "options" + } + ], + "path": "src/extensions/fetch-request.ts" + }, { "id": "PostHogOptions", "name": "PostHogOptions", diff --git a/packages/node/src/__tests__/extensions/fetch-request.spec.ts b/packages/node/src/__tests__/extensions/fetch-request.spec.ts index 5d37e0a5f6..67b776caac 100644 --- a/packages/node/src/__tests__/extensions/fetch-request.spec.ts +++ b/packages/node/src/__tests__/extensions/fetch-request.spec.ts @@ -69,12 +69,18 @@ describe('fetch request instrumentation', () => { }) it('marks 5xx responses as errors', async () => { - await instrumentRequest(posthog, new Request('http://localhost/x', { method: 'POST' }), () => new Response('no', { status: 503 })) + await instrumentRequest( + posthog, + new Request('http://localhost/x', { method: 'POST' }), + () => new Response('no', { status: 503 }) + ) expect(spans[0].status?.code).toBe('error') expect(count).toHaveBeenCalledWith( 'http.server.request.count', 1, - expect.objectContaining({ attributes: expect.objectContaining({ 'http.response.status_code': 503, 'http.request.method': 'POST' }) }) + expect.objectContaining({ + attributes: expect.objectContaining({ 'http.response.status_code': 503, 'http.request.method': 'POST' }), + }) ) }) @@ -112,9 +118,14 @@ describe('fetch request instrumentation', () => { }) it('skips ignored requests', async () => { - const response = await instrumentRequest(posthog, new Request('http://localhost/health'), () => new Response('ok'), { - ignore: (r) => new URL(r.url).pathname === '/health', - }) + const response = await instrumentRequest( + posthog, + new Request('http://localhost/health'), + () => new Response('ok'), + { + ignore: (r) => new URL(r.url).pathname === '/health', + } + ) expect(response.status).toBe(200) expect(spans).toHaveLength(0) expect(histogram).not.toHaveBeenCalled() diff --git a/packages/node/src/extensions/fetch-request.ts b/packages/node/src/extensions/fetch-request.ts index cd66bbe173..26ec98890f 100644 --- a/packages/node/src/extensions/fetch-request.ts +++ b/packages/node/src/extensions/fetch-request.ts @@ -80,7 +80,11 @@ export async function instrumentRequest( const pathname = safePathname(request) const properties: Record = {} addProperty(properties, '$request_method', request.method) - addProperty(properties, '$request_path', pathname ? normalizeRequestPath(pathname, disableCaptureUrlHashes) : undefined) + addProperty( + properties, + '$request_path', + pathname ? normalizeRequestPath(pathname, disableCaptureUrlHashes) : undefined + ) addProperty(properties, '$user_agent', request.headers.get('user-agent') ?? undefined) const sessionId = existing?.sessionId ?? headers.sessionId