Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fetch-request-instrumentation.md
Original file line number Diff line number Diff line change
@@ -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`.
68 changes: 68 additions & 0 deletions packages/node/references/posthog-node-references-latest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": "<T>(data: Partial<ContextData>, 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": "{ <T>(name: string, fn: (span: Span) => T): 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",
Expand Down
164 changes: 164 additions & 0 deletions packages/node/src/__tests__/extensions/fetch-request.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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<typeof vi.spyOn>
let count: ReturnType<typeof vi.spyOn>

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')
})
})
})
1 change: 1 addition & 0 deletions packages/node/src/exports.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
Loading
Loading