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
60 changes: 60 additions & 0 deletions tests/test_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
58 changes: 56 additions & 2 deletions traceroot_observability/spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,53 @@
"""

import json
import os
from datetime import datetime, timezone

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down