Skip to content

feat(node): add request context to OpenTelemetry spans - #5314

Merged
pauldambra merged 10 commits into
mainfrom
posthog/node-framework-span-context
Oct 28, 2026
Merged

pauldambra merged 10 commits into
mainfrom
posthog/node-framework-span-context

Conversation

@pauldambra

@pauldambra pauldambra commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

[Robot] Prepared with PostHog Desktop on behalf of @pauldambra.

Problem

Developers need to find backend work from a browser session. OpenTelemetry spans currently omit the existing PostHog request IDs.

Part of sdk-specs#128, with the proposed contract in sdk-specs#129.

Changes

  • Add the optional posthog-node/otel entrypoint. Its public API exports PostHogContextSpanProcessor(posthog) and enrichCurrentSpan(posthog).
  • Expose the same enrichCurrentSpan(posthog) helper from the main entry without loading OpenTelemetry. It does nothing until a processor is registered.
  • Register the processor with the application's provider to add available session and person IDs to recording SDK spans. Explicit attributes keep their values.
  • Enrich the active Express request span after header extraction, including when application identity was set first. Explicit request entry also enriches the nearest recording server ancestor through parent objects observed by the processor. Child spans keep their context snapshot.
  • Keep the existing Express and NestJS context lifetime. Work that the handler starts, such as a promise or a timer, keeps the request IDs after the response ends.
  • Add optional ContextOptions.signal for apps that want a hard end to a context. Aborting it ends the context without cancelling the handler. fresh: true keeps an explicit job scope independent from its parent.
  • Snapshot exception context before asynchronous stack processing so errors captured during a request keep their IDs after the response ends.
  • Add setup notes, a minor changeset, and tests for real OpenTelemetry exports, concurrent requests, explicit attributes, capacity limits, cleanup, and optional integration behavior.

Note

Detached work after the Express response must start a new context with explicit IDs. API-only spans without public readable attributes are skipped.
The manual helper enriches spans; it does not add a request lifecycle to a framework. A sampler-dropped intermediate hides earlier server ancestors, which remain unchanged.

Validation: all 1,187 tests in 42 Node SDK files passed after the combined changes. Source and public type checks, build, lint, formatting, and API reference generation passed. Real Fastify, Hono, and NestJS tests cover concurrent requests, errors, incoming and child spans, and detached work. The NestJS tests cover both Express and Fastify adapters. Official HTTP and Express instrumentation verifies the earlier HTTP server span. The main helper also runs with all OpenTelemetry imports blocked.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

GPT-6 (Codex) wrote the integrations with PostHog Desktop. Independent SDK review by gpt-6-astra found and fixed exception snapshots and fresh-scope behavior. Used Git, pnpm, Vitest, TypeScript, Oxlint, Oxfmt, API Extractor, and the GitHub CLI. A human must review this change.

The processor uses public readable SDK attributes. OpenTelemetry stays optional, and the application retains its provider, exporter, sampling, and other processors. No live PostHog project or browser replay test was run.


Created with PostHog Desktop

@pauldambra pauldambra self-assigned this Oct 17, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 78bf4b0: 1 Must fix, 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Coherence

0 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 7 bullets (5 invariants, 2 requirements), 0 problems

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Node SDK compliance v2

Commit: d1abe3a
Workflow run and artifacts

Capture v0 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:

Harness exit: 1; report validation exit: 1.

Capture v1 — acceptance

✅ 28 passed / ❌ 3 non-passing / ⚠️ 853 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

❌ server_snapshot_payload_false — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_zero — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:
❌ server_snapshot_payload_null — failed_assertion

Expected native data result

Code: snapshot_result

Failed step: acceptance/public/evaluate-flags.feature:868

the public snapshot outcomes should have these semantics:

Harness exit: 1; report validation exit: 1.

Capture v0 — migration

✅ 55 passed / ❌ 1 non-passing / ⚠️ 101 not selected

Drill-down: run artifacts → node-v2-v0 → reports/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1 — migration

✅ 120 passed / ❌ 1 non-passing / ⚠️ 36 not selected

Drill-down: run artifacts → node-v2-v1 → reports/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

@pauldambra pauldambra changed the title feat(node): add OpenTelemetry request context processor feat(node): add request context to OpenTelemetry spans Oct 17, 2026
@pauldambra
pauldambra marked this pull request as ready for review October 17, 2026 14:44
@pauldambra
pauldambra requested a review from a team as a code owner October 17, 2026 14:44
@pauldambra
pauldambra added this pull request to stack #5316 October 17, 2026 15:03

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 0eb7e8b: 1 Must fix, 2 Should fix.

Comment thread packages/node/src/entrypoints/otel.ts Resolved
Comment thread packages/node/src/extensions/express.ts Resolved
Comment thread packages/node/src/entrypoints/otel.ts Resolved
@pauldambra

Copy link
Copy Markdown
Member Author

Note

🤖 Automated comment by QA Swarm — not written by a human

Verdict: ✅ APPROVE (round 1 @ 9331ef4)

Three review findings are fixed and their threads are resolved:

  • Span enrichment stops when the client is disabled or opted out.
  • Express context expires at once when the response is already finished or destroyed.
  • Parent links are weak, so a retained child span does not keep ended ancestors alive.

Checks

All Node tests (1,192), types, build, and lint pass. A GC regression test and a real HTTP/Express run pass. An independent review of the fix found no remaining issue.


Automated by QA Swarm. Human review is required. This comment does not approve a merge or release.

@hpouillot

Copy link
Copy Markdown
Contributor

🤖 Comment written by an AI assistant.

I would keep the existing Node SDK context lifetime for this PR rather than aborting Express and NestJS context on response finish/close. A promise or timer started by the handler can still be part of the same user action after the response ends, so spans it starts should continue to inherit the session ID. The HTTP server span should still end with the response.

For independent background jobs, callers should start a fresh context and trace, passing the session ID explicitly if that association is wanted. Queue workers likewise need explicit propagation. The new ContextOptions.signal can remain available for applications that want a hard lifetime, without changing the default framework behavior.

Would you consider removing the automatic response-abort behavior from this PR? It changes existing getContext() and event-capture behavior as well as the new OpenTelemetry enrichment.

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated advisory code review.

@marandaneto
marandaneto requested a review from a team October 21, 2026 12:53
@pauldambra
pauldambra force-pushed the posthog/node-framework-span-context branch from 9331ef4 to 92308a2 Compare October 26, 2026 12:14

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed c19aa99: 1 Should fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread packages/node/src/extensions/express.ts Resolved
@pauldambra

Copy link
Copy Markdown
Member Author

🤖 Automated reply from Paul's agent:

Agreed, thank you. In c19aa99 the Express middleware and the NestJS interceptor no longer end the context on response finish or close. A promise or timer that the handler starts now keeps the request IDs, as before this PR. This applies to getContext(), captured events, and new spans. ContextOptions.signal is still there for apps that want a hard end.

I made the same change to the Next.js and Nuxt wrappers in #5315 (8bb9fe4), so all the JS server integrations behave the same way.

@pauldambra

Copy link
Copy Markdown
Member Author

🤖 Automated comment from Paul's agent:

@hpouillot, a question across all SDKs, following your point above: when should backend request context end?

Each backend SDK chooses this for itself today, so they do not agree:

  • posthog-node, Next.js, and Nuxt (this PR and feat: add next.js and nuxt server span context #5315) keep the context for all work that the request starts, including work that runs after the response ends. ContextOptions.signal lets an app end the context earlier.
  • Go (posthog-go#375), Python, .NET, Elixir, and PHP end the context when the handler returns or the request process exits. New spans from detached work then get no IDs.

Your argument for the JS choice: a promise or timer that the handler starts can still be part of the same user action. An independent background job should start a fresh context and pass IDs on purpose.

Proposal: add one rule to the sdk-specs traces spec, "Auto-captured context attributes". Work that starts inside a request keeps that request's IDs. A detached job that needs its own IDs starts a fresh context. SDKs can offer an opt-in way to end a context early. Then line up the other SDKs with this rule, one PR per SDK.

Does that rule match what you had in mind?

@hpouillot

hpouillot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

@pauldambra I agree, we should really enforce the behaviour on the sdk-spec repo. By the way, now that I think about it, if we forward the session id to background context, we could artificially inflate session duration metrics if an event is sent, right ? Maybe, by default, we could forward the distinct_id and not the session_id (after requests ends) ?

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving to unblock, codewise is ok, my finding was fixed, but theres an open discussion with @hpouillot

Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
Generated-By: PostHog Desktop
Task-Id: 5681be44-4986-48db-b140-1bda88f75f96
An app that uses require('posthog-node') and imports posthog-node/otel
loads two module copies. Each copy had its own listener registry, so the
incoming request span was not enriched. Keep one registry on globalThis.

Generated-By: PostHog Desktop
Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9
Express and NestJS no longer end the request context when the response
finishes or closes. A promise or timer that the handler starts can
still belong to the same user action, so getContext(), captured events,
and new spans keep the request IDs as before. Apps that want a hard end
can still pass ContextOptions.signal.

Generated-By: PostHog Desktop
Task-Id: e73c8b25-9501-4f04-9fd8-e82415e1f9f9
@pauldambra
pauldambra force-pushed the posthog/node-framework-span-context branch from c19aa99 to d1abe3a Compare October 28, 2026 13:26
@github-actions

Copy link
Copy Markdown
Contributor

Error tracking smoke

Commit: d1abe3af29f72b97df3168bfe5522c720c768af1 · CI run and artifacts

⚠️ No valid smoke summary (job: failure)

ENOENT: no such file or directory, stat '/home/runner/work/_temp/smoke-report/summary.json'. Inspect the CI run.

@pauldambra
pauldambra added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 096131d Oct 28, 2026
76 of 77 checks passed
@pauldambra
pauldambra deleted the posthog/node-framework-span-context branch October 28, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants