Skip to content

docs(traces): refine context propagation and attribute limits - #56

Merged
turnipdabeets merged 2 commits into
mainfrom
feat/traces-review-driven-refinements
Sep 1, 2026
Merged

docs(traces): refine context propagation and attribute limits#56
turnipdabeets merged 2 commits into
mainfrom
feat/traces-review-driven-refinements

Conversation

@turnipdabeets

Copy link
Copy Markdown
Collaborator

Why

The traces capability was specified before any SDK implemented it. Reviewing the first port — posthog-node, PostHog/posthog-js#4579 and the PRs stacked on it — surfaced three places where the v1 text assumes something the implementation found to be wrong or incomplete. All three came out of @jonmcwest's review. Each is a cross-SDK contract, so each belongs here rather than in one port.

posthog-node implements all three today. This makes the canonical text match, rather than leaving the reference implementation silently divergent.

What changes

openspec/changes/refine-traces-propagation-and-limits/ — six MODIFIED requirements in the traces capability.

1. Pass-through inert handles (No-op span handles)

The spec says a no-op handle SHALL never be activated and SHALL never produce a well-formed traceparent. Right for a handle with nothing behind it — but it also silences a service in the middle of a traced chain that simply hasn't enabled traces yet: it forwards no header, so every downstream hop starts a fresh trace and the chain breaks at the least-instrumented service.

OTel makes this the one exception to no-op behavior: with no SDK installed "the API MUST return a non-recording Span with the SpanContext in the parent Context". Echoing an inbound header invents no ids — the span id forwarded is the upstream caller's, and upstream recorded it.

So "inert" splits in two. A no-op handle (no inbound context) keeps its current contract exactly. A pass-through handle echoes the inbound header verbatim — including its version byte, since it re-emits a header it didn't construct — and is activated by scoped helpers so getActiveSpan()?.traceparent() works inside the callback.

2. Inbound sampled-flag propagation (Trace context interop, Span data model)

The spec pins traceparent at -01 and the wire flags low byte at 1. Legal under W3C, and truthful while the SDK records everything — but it breaks head-sampled fleets. Service A samples a trace out (00), a PostHog-traced service B propagates 01, and a downstream OTel service C with the default ParentBased sampler records a trace its own head sampler already rejected.

Recording and propagating are separable; OTel already names this combination RECORD_ONLY. The wire flags now carries the same byte the span propagates, so the record and the header can't disagree, and the has-is-remote / is-remote bits get set from parent remoteness — which the SDK always knows, and which can never be backfilled.

Also adds the W3C rule that flag bits the emitted version doesn't define are zeroed rather than forwarded.

Confirmed before proposing: ingestion stores flags verbatim and reads nothing from it, so a cleared sampled bit changes no server behavior today.

3. maxAttributeValueLength and exception.stacktrace (Span limits, Configuration knobs, Scoped span helpers and exception recording)

The count caps bound how many entries a span carries, not how big one is. A single multi-megabyte value pushes the span past the ingestion body limit; the too-large path then halves the batch down to that span, finds it still too large, and drops it whole. The user loses the span rather than the excess.

The bound has to reach inside arrays and maps — setAttribute("payload", { body: res.body }) is the ordinary way an oversized value arrives. Traversal is depth-limited so a self-referencing value terminates, and a value the SDK can't walk safely is left alone rather than taken as a reason to drop the span.

exception.stacktrace was deferred by the add-traces design, which invited exactly this follow-up: "exception.type/exception.message are the v1 contract, and ports may propose the stacktrace attribute … as a follow-up." The reason to defer was that no scrub hook and no size bound existed yet. Both now do. Ordering is normative: attached before beforeSpanSend so a hook can drop it, bounded after so a hook can't reintroduce an unbounded one.

Notes for review

  • The three are independent and can be accepted separately. They're bundled because they came from one review pass. Happy to split into three changes if that's easier to land.
  • Open question in tasks.md: should maxAttributeValueLength carry one cross-SDK default, or stay per-platform like every other knob in this capability? posthog-node chose 8192.
  • One behavior change for existing users: a service with tracing off that is handed a parent now forwards the inbound header where it previously forwarded nothing. No span is recorded either way.
  • No wire-format break: flags gains values the server already stores without interpreting, and exception.stacktrace is an additional event attribute.

Checks

  • openspec validate refine-traces-propagation-and-limits --strict — passes
  • openspec validate --specs --strict — 62 passed, 0 failed
  • git diff --check — clean
  • Each restated requirement diffed against the canonical text to confirm only intended lines move

🤖 Generated with Claude Code

https://claude.ai/code/session_015Lz2epJDdgSt12vUMNcfDZ

Records three corrections the first traces port surfaced: pass-through
inert handles, inbound sampled-flag propagation, and a bound on
attribute value length with exception.stacktrace.
@turnipdabeets
turnipdabeets marked this pull request as ready for review September 1, 2026 19:29
@turnipdabeets
turnipdabeets requested a review from a team as a code owner September 1, 2026 19:29

@dustinbyrne dustinbyrne 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.

i don't see a spec.md under openspec/specs, is this missing a final step?

Merges the six modified requirements into the canonical traces spec and
moves the change into the archive, matching how every other change in
this repo lands.
@turnipdabeets

turnipdabeets commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch — it was. Fixed in d33c924.

@turnipdabeets
turnipdabeets merged commit 4f662f7 into main Sep 1, 2026
10 checks passed
@turnipdabeets
turnipdabeets deleted the feat/traces-review-driven-refinements branch September 1, 2026 21:41
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.

2 participants