From 651d0fdf7cd476f847caed87d76d23dbcdeccb74 Mon Sep 17 00:00:00 2001 From: XinweiHe Date: Wed, 24 Jun 2026 09:41:34 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20distributed=20tracing=20=E2=80=94=20nes?= =?UTF-8?q?t=20Claude=20Code=20under=20an=20inbound=20TRACEPARENT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read W3C trace context from the environment (TRACEPARENT, and BAGGAGE) and parent the ask-root spans under it, so a backend that launches Claude Code (e.g. backend -> sandbox -> claude) gets ONE connected trace with the Claude Code session nested under the backend span. Standard OTEL propagation (OTEP-0258 env carriers). Fail-open: no/malformed TRACEPARENT -> fresh root trace, exactly as before. Baggage entries are stamped as traceroot.baggage.* attributes. Scoped per-process (each claude invocation reads its own env), so reused or parallel-flow sandboxes stay correctly separated. +4 tests (87 total). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_spans.py | 60 ++++++++++++++++++++++++++++++++ traceroot_observability/spans.py | 58 ++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/tests/test_spans.py b/tests/test_spans.py index 2c59f9d..0a6810d 100644 --- a/tests/test_spans.py +++ b/tests/test_spans.py @@ -763,3 +763,63 @@ def test_successful_tool_result_is_not_error(): assert tool_spans[0].status.status_code != StatusCode.ERROR, ( "A successful tool result must not produce an ERROR span" ) + + +# --------------------------------------------------------------------------- +# Distributed tracing — inbound TRACEPARENT propagation +# --------------------------------------------------------------------------- + +def _ask_turn(text="hi", ts="2026-06-22T04:00:00Z"): + return Turn( + user_msg={"message": {"role": "user", "content": text}, "timestamp": ts}, + assistant_msgs=[{"type": "assistant", "requestId": "rp", "timestamp": "2026-06-22T04:00:01Z", + "message": {"id": "rp", "model": "claude-opus-4-8", + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 1, "output_tokens": 1}}}], + tool_results={}) + + +def test_emit_ask_nests_under_inbound_traceparent(monkeypatch): + """With TRACEPARENT set, the root must adopt the inbound trace_id and parent span id.""" + TRACE_ID = "0af7651916cd43dd8448eb211c80319c" + PARENT_SPAN = "b7ad6b7169203331" + monkeypatch.setenv("TRACEPARENT", f"00-{TRACE_ID}-{PARENT_SPAN}-01") + tracer, exp = _tracer() + spans.build_ask_spans(tracer, [_ask_turn()], session_id="sess-tp", git=("", ""), tags=[], max_chars=20000) + roots = [s for s in exp.get_finished_spans() if s.attributes.get(spans.OI["KIND"]) == "AGENT"] + assert len(roots) == 1 + r = roots[0] + assert format(r.context.trace_id, "032x") == TRACE_ID, "root must share the inbound trace_id" + assert r.parent is not None and format(r.parent.span_id, "016x") == PARENT_SPAN, ( + "root must be parented to the inbound span id" + ) + + +def test_emit_ask_new_trace_without_traceparent(monkeypatch): + """Without TRACEPARENT, the root is a fresh trace (no parent) — unchanged behavior.""" + monkeypatch.delenv("TRACEPARENT", raising=False) + monkeypatch.delenv("BAGGAGE", raising=False) + tracer, exp = _tracer() + spans.build_ask_spans(tracer, [_ask_turn()], session_id="sess-np", git=("", ""), tags=[], max_chars=20000) + r = [s for s in exp.get_finished_spans() if s.attributes.get(spans.OI["KIND"]) == "AGENT"][0] + assert r.parent is None, "no TRACEPARENT → root must have no parent (fresh trace)" + + +def test_emit_ask_malformed_traceparent_falls_open(monkeypatch): + """A malformed TRACEPARENT must not crash and must fall back to a fresh trace.""" + monkeypatch.setenv("TRACEPARENT", "not-a-valid-traceparent") + tracer, exp = _tracer() + spans.build_ask_spans(tracer, [_ask_turn()], session_id="sess-bad", git=("", ""), tags=[], max_chars=20000) + r = [s for s in exp.get_finished_spans() if s.attributes.get(spans.OI["KIND"]) == "AGENT"][0] + assert r.parent is None, "malformed TRACEPARENT → fail-open to fresh trace" + + +def test_emit_ask_stamps_baggage_as_attributes(monkeypatch): + """Baggage entries are stamped as traceroot.baggage.* attributes for correlation.""" + monkeypatch.setenv("TRACEPARENT", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + monkeypatch.setenv("BAGGAGE", "request_id=req-123,customer=acme") + tracer, exp = _tracer() + spans.build_ask_spans(tracer, [_ask_turn()], session_id="sess-bag", git=("", ""), tags=[], max_chars=20000) + r = [s for s in exp.get_finished_spans() if s.attributes.get(spans.OI["KIND"]) == "AGENT"][0] + assert r.attributes.get("traceroot.baggage.request_id") == "req-123" + assert r.attributes.get("traceroot.baggage.customer") == "acme" diff --git a/traceroot_observability/spans.py b/traceroot_observability/spans.py index 375a809..c363536 100644 --- a/traceroot_observability/spans.py +++ b/traceroot_observability/spans.py @@ -13,6 +13,7 @@ """ import json +import os from datetime import datetime, timezone from opentelemetry import trace @@ -20,6 +21,45 @@ from .tokens import llm_calls + +def _inbound_parent_context(): + """ + Build an OTEL parent Context from inbound W3C trace context, for distributed + tracing: a backend (e.g. traceroot-py) that launches Claude Code can inject + ``TRACEPARENT`` (and optionally ``BAGGAGE``) into the environment of the + ``claude`` process, and these spans then nest under the backend's span in the + SAME trace. + + Scope is per-process: the env is read from THIS hook process (which Claude + Code spawned as a child of the launching ``claude`` invocation), so parallel + flows that each launch ``claude`` with their own TRACEPARENT stay separate. + + Returns ``(context_or_None, baggage_dict)``. Fail-open: a missing or malformed + TRACEPARENT yields ``(None, {})`` → the caller starts a fresh root trace, exactly + as before. Baggage entries are returned so the caller can stamp them as attributes. + """ + tp = os.environ.get("TRACEPARENT") + bg = os.environ.get("BAGGAGE") + if not tp and not bg: + return None, {} + try: + from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + from opentelemetry.baggage.propagation import W3CBaggagePropagator + from opentelemetry import baggage as _bag + + carrier = {} + if tp: + carrier["traceparent"] = tp + if bg: + carrier["baggage"] = bg + ctx = TraceContextTextMapPropagator().extract(carrier) + ctx = W3CBaggagePropagator().extract(carrier, context=ctx) + valid = trace.get_current_span(ctx).get_span_context().is_valid + bag_attrs = {str(k): str(v) for k, v in _bag.get_all(ctx).items()} if bg else {} + return (ctx if valid else None), bag_attrs + except Exception: + return None, {} + # --------------------------------------------------------------------------- # SDK identity constants # Keep SDK_VERSION in sync with .claude-plugin/plugin.json "version" field. @@ -414,7 +454,13 @@ def build_turn_spans( for tag in tags: root_attrs[f"tag.{tag}"] = True - root = tracer.start_span("Claude Code Turn", start_time=start_ns, attributes=root_attrs) + parent_ctx, baggage_attrs = _inbound_parent_context() + for k, v in baggage_attrs.items(): + root_attrs[f"traceroot.baggage.{k}"] = v + + root = tracer.start_span( + "Claude Code Turn", start_time=start_ns, attributes=root_attrs, context=parent_ctx + ) _stamp_sdk(root) root_ctx = trace.set_span_in_context(root) @@ -481,7 +527,15 @@ def build_ask_spans( for tag in tags: root_attrs[f"tag.{tag}"] = True - root = tracer.start_span("Claude Code Turn", start_time=start_ns, attributes=root_attrs) + # Distributed tracing: if a launching backend injected TRACEPARENT/BAGGAGE, + # parent this ask under the backend's span (same trace_id); else new root. + parent_ctx, baggage_attrs = _inbound_parent_context() + for k, v in baggage_attrs.items(): + root_attrs[f"traceroot.baggage.{k}"] = v + + root = tracer.start_span( + "Claude Code Turn", start_time=start_ns, attributes=root_attrs, context=parent_ctx + ) _stamp_sdk(root) root_ctx = trace.set_span_in_context(root)