feat(node): distributed tracing spans - #4579
Draft
turnipdabeets wants to merge 10 commits into
Draft
Conversation
Contributor
posthog-node Compliance ReportDate: 2026-08-24 23:21:14 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Contributor
23 tasks
Contributor
posthog-js Compliance ReportDate: 2026-08-24 23:26:43 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
Contributor
|
Size Change: +78.1 kB (+0.39%) Total Size: 20.2 MB 📦 View Changed
ℹ️ View Unchanged
|
This was referenced Aug 20, 2026
turnipdabeets
force-pushed
the
feat/traces-node-mvp
branch
from
August 21, 2026 12:45
a9ff420 to
5c7301c
Compare
23 tasks
Logs classified an exhausted 408/429/5xx as `fatal`, so the batch was dropped instead of held for the next flush cycle. Fixes #4570.
Collapses both senders into `_sendOtlpBatch` so the retry policy is one decision, pins the classification with a per-signal test table, and retries 408 in the browser logs and metrics adapters.
Adds `startSpan` / `withSpan` / `getActiveSpan` to posthog-node behind the new `traces` client option, exporting OpenTelemetry-shaped OTLP spans with no OpenTelemetry dependency.
req.headers.traceparent is string[] when the header arrives twice, which made startSpan return an inert handle and lose the whole subtree. Ignore a parent that is not a span, and document the example with req.get() so it typechecks.
Logs, metrics and traces all encode the same wire shape; the encoder now lives next to the budgets it uses instead of inside logs. Adds a bigint branch for span attributes, built without bigint literals so the ES5 browser bundle still parses.
Drops the duplicate encoder. Two of its shapes are refused by ingestion for the whole request: an out-of-int64 intValue for -(2**63), and the empty AnyValue it used for a null array element. Also picks up surrogate sanitising, traversal budgets and per-key containment of throwing getters, so a poison attribute no longer costs its span.
Restores the bearer-auth branch on `_sendOtlpBatch` so `_sendTracesBatch` is a wrapper like the logs and metrics senders rather than a fourth copy of the retry policy.
turnipdabeets
force-pushed
the
feat/traces-node-mvp
branch
from
August 24, 2026 11:25
e36495f to
9365a6c
Compare
23 tasks
Contributor
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Developers instrumenting a Node service with PostHog have no way to record spans. They can send events, logs and metrics, but nothing that shows where time went in a request or how work fans out across services. Pointing an OpenTelemetry SDK at PostHog works, but it means adding an OTel dependency and wiring the join to PostHog identity by hand — so traces end up disconnected from the person and session they belong to.
This is the first of a stacked series adding first-class tracing to the JS SDKs (Q3 Goal 4). It is scoped to be releasable on its own.
#4623 (shared OTLP batch sender) has merged, and
mainis merged in here, so this PR stands on its own againstmain.Changes
Adds
startSpan,withSpanandgetActiveSpantoposthog-node, behind a newtracesclient option. Spans are encoded as OpenTelemetry-shaped OTLP JSON and POSTed to/i/v1/traces— without an OpenTelemetry dependency.tracesoption means no spans. Every span API still returns a working (inert) handle, so calling code never branches on whether tracing is on.posthogDistinctIdandsessionId, taken fromwithContextor the existing Express/NestJS middleware. This is what makes a trace reachable from a person or session.parentaccepts an inboundtraceparentstring to continue a remote trace;span.traceparent()gives you the header to propagate onward.tracestateis preserved opaquely.awaiton Node, viaAsyncLocalStorageinjected at the Node entrypoint. Core stays runtime-agnostic (nonode:async_hooks), so the edge build and future browser/RN hosts work off the same engine.Reviewer notes
flush()vsshutdown()— in this PRshutdown()drains ended spans andflush()does not. That gap is closed immediately by feat(node): flush() drains queued spans #4580, stacked on this branch: a serverless handler callsflush(), notshutdown(), so leaving it out would silently lose spans for the most commonposthog-nodedeployment. Review this PR first, then feat(node): flush() drains queued spans #4580.awaitthere begin a new trace. Passparentexplicitly to nest them. This is the documented browser limitation too.IPostHoggains three required members (startSpan,withSpan,getActiveSpan). Consumers using it as a type annotation are unaffected; anyone implementing it (hand-written test doubles, DI wrappers) will get a compile error. Same shape asmetricsin feat(metrics): wire posthog.metrics into posthog-node #4117, which shipped under aminorbump.sdk-specs.nullinside an array attribute is now dropped rather than sent as an emptyAnyValue, so array positions shift — matching iOScompactMapand AndroidmapNotNull. And-(2**63)now encodes exactly instead of asString(-(2**63)), which lands 192 below int64 min. A third change is a plain improvement: an attribute whose getter throws costs its own key, not its span, so the drop path in_encodeBatchis now only a backstop.flush()draining spans (feat(node): flush() drains queued spans #4580, already open),beforeSpanSend, per-span attribute/event caps and unended-span bounds, OpenTelemetry soft-detect, browser host, and log/exception correlation.Verification
End-to-end against a real project (381971): 4 spans POSTed,
200, all queryable afterwards. Confirmed trace assembly and parentage, remote continuation via an inboundtraceparent, span kinds, error status from a thrown callback, monotonic durations (25.3 ms measured around a 25 ms sleep), andposthogDistinctId/sessionIdpresent only on in-context spans.packages/core1034 tests pass,packages/node954 pass, lint clean, public API references regenerated unchanged. The browser bundle still builds: the shared encoder reaches it, so the bigint range check is built from a decimal string rather than anliteral, which ES5 output cannot parse.Release info Sub-libraries affected
Libraries affected
@posthog/coreis also bumped (minor); it has no checkbox above.Checklist
Backwards compatibility caveat: all runtime surface is additive, but
IPostHoggains three required members — a compile-time break for implementors only, matching themetricsprecedent. Bundle: the traces module is core-resident and the browser is not wired up in this PR.If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code, directed by @turnipdabeets, from the merged
tracescapability spec insdk-specs.Decisions worth flagging for review:
posthog.traces.*namespace — matches how the OTel API reads and keeps the common call short.main, so spans now go through the sametoOtlpAnyValue, moved tocore/src/utils/otlp-any-value.tsfor the three senders that use it. The sender-side duplication was fixed in fix(core): send logs and metrics through one OTLP batch sender #4623, now merged:_sendOtlpBatchcovers logs and metrics there, and a commit here folds_sendTracesBatchinto it by reintroducing the bearer-auth branch, so spans never add a third copy of the retry policy.OtlpSpanAnyValueandOtlpSpanKeyValueare aliases of the logs/metricsOtlpAnyValueandOtlpKeyValuerather than separate declarations — one encoder produces all three payloads. The span-flavoured names stay so the span types read as span types.@posthog/typesonly. They were briefly exported from@posthog/coretoo, purely so a node test could annotate its helpers — a permanently published API surface serving test convenience. Now matches how the logs and metrics wire types are already organised.Reviewed three times before opening: a self-review, a fresh-context agent review against the spec's 76 scenarios, and the author's own pass. The agent review caught a real bug — on a 413 the code halved the configured maximum rather than the batch that was rejected, so whenever the queue was shallower than that maximum (the ordinary timer-flush case) it re-POSTed an identical oversized body up to 8 times. Fixed, with a regression test covering queue depth below the maximum, which is exactly the gap the pre-existing test left.