Coverage for dataexcept/trace_context.py: 92%
91 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
1"""W3C Trace Context parsing without a tracing runtime dependency.
3DataExcept only needs enough trace context to keep failures correlated across
4execution boundaries. It does not start spans or generate identifiers. The
5raw carrier values are preserved for forwarding, while parsed identifiers are
6available to logging, error-tracker and protocol adapters.
7"""
9from __future__ import annotations
11import re
12from collections.abc import Mapping
13from dataclasses import dataclass, replace
15from .observability import OperationContext
17__all__ = [
18 "W3CTraceContext",
19 "parse_traceparent",
20 "trace_context_from_mapping",
21]
23_LOWER_HEX = re.compile(r"^[0-9a-f]+$")
24_ZERO_TRACE_ID = "0" * 32
25_ZERO_PARENT_ID = "0" * 16
28def _is_lower_hex(value: str, length: int) -> bool:
29 return len(value) == length and _LOWER_HEX.fullmatch(value) is not None
32def _safe_optional_header(value: object) -> str | None:
33 """Return a propagation value only when it is a single safe text field."""
34 if not isinstance(value, str):
35 return None
36 if "\r" in value or "\n" in value:
37 return None
38 return value
41def _has_valid_separators(value: str) -> bool:
42 """Return whether the mandatory traceparent separators are present."""
43 return value[2] == "-" and value[35] == "-" and value[52] == "-"
46def _mandatory_fields_are_valid(
47 version: str,
48 trace_id: str,
49 parent_id: str,
50 trace_flags: str,
51) -> bool:
52 """Validate the fixed-width fields shared by all traceparent versions."""
53 if not _is_lower_hex(version, 2) or version == "ff":
54 return False
55 if not _is_lower_hex(trace_id, 32) or trace_id == _ZERO_TRACE_ID:
56 return False
57 if not _is_lower_hex(parent_id, 16) or parent_id == _ZERO_PARENT_ID:
58 return False
59 return _is_lower_hex(trace_flags, 2)
62def _version_shape_is_valid(value: str, version: str) -> bool:
63 """Validate version-specific length and extension framing rules."""
64 if version == "00":
65 return len(value) == 55
66 return len(value) == 55 or value[55] == "-"
69@dataclass(frozen=True, slots=True)
70class W3CTraceContext:
71 """Parsed W3C ``traceparent`` plus optional propagation companions.
73 ``parent_id`` is the caller's span identifier from the incoming carrier.
74 It is deliberately not treated as the local ``OperationContext.span_id``.
75 ``traceparent`` is retained verbatim so pass-through code can forward an
76 unknown future version without reconstructing fields it does not understand.
77 """
79 traceparent: str
80 version: str
81 trace_id: str
82 parent_id: str
83 trace_flags: str
84 tracestate: str | None = None
85 baggage: str | None = None
87 @property
88 def sampled(self) -> bool:
89 """Whether the W3C sampled bit is set in ``trace_flags``."""
90 return bool(int(self.trace_flags, 16) & 0x01)
92 def to_carrier(self) -> dict[str, str]:
93 """Return propagation fields without changing their received values."""
94 carrier = {"traceparent": self.traceparent}
95 if self.tracestate is not None:
96 carrier["tracestate"] = self.tracestate
97 if self.baggage is not None:
98 carrier["baggage"] = self.baggage
99 return carrier
101 def to_operation_context(
102 self,
103 context: OperationContext | None = None,
104 ) -> OperationContext:
105 """Return *context* correlated with this trace without inventing a span.
107 Existing operation metadata is preserved. A conflicting trace ID is
108 rejected because silently replacing provenance would make correlation
109 less trustworthy than omitting it.
110 """
111 if context is None: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 return OperationContext(trace_id=self.trace_id)
113 if not isinstance(context, OperationContext): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 raise TypeError("context must be an OperationContext or None")
115 if context.trace_id is not None and context.trace_id != self.trace_id:
116 raise ValueError("context trace_id conflicts with traceparent")
117 if context.trace_id == self.trace_id: 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true
118 return context
119 return replace(context, trace_id=self.trace_id)
122def parse_traceparent(
123 value: str,
124 *,
125 tracestate: str | None = None,
126 baggage: str | None = None,
127) -> W3CTraceContext | None:
128 """Parse a W3C ``traceparent`` value, returning ``None`` when invalid.
130 Version ``00`` uses the exact 55-character format. Higher versions are
131 accepted when their mandatory prefix is parseable; any extension remains
132 opaque and is preserved in ``traceparent`` for forwarding. No identifiers
133 are generated when parsing fails.
134 """
135 if not isinstance(value, str):
136 raise TypeError("traceparent must be a string")
137 if value != value.strip() or len(value) < 55: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 return None
139 if not _has_valid_separators(value): 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 return None
142 version = value[:2]
143 trace_id = value[3:35]
144 parent_id = value[36:52]
145 trace_flags = value[53:55]
147 if not _mandatory_fields_are_valid(version, trace_id, parent_id, trace_flags):
148 return None
149 if not _version_shape_is_valid(value, version):
150 return None
152 return W3CTraceContext(
153 traceparent=value,
154 version=version,
155 trace_id=trace_id,
156 parent_id=parent_id,
157 trace_flags=trace_flags,
158 tracestate=_safe_optional_header(tracestate),
159 baggage=_safe_optional_header(baggage),
160 )
163def trace_context_from_mapping(
164 carrier: Mapping[str, object],
165) -> W3CTraceContext | None:
166 """Extract W3C trace context from a case-insensitive mapping-like carrier.
168 The mapping can represent HTTP headers, RPC metadata, broker properties or
169 protocol metadata such as MCP ``_meta``. Invalid ``tracestate`` or
170 ``baggage`` values are dropped without invalidating a valid ``traceparent``.
171 """
172 if not isinstance(carrier, Mapping):
173 raise TypeError("carrier must be a mapping")
175 values: dict[str, object] = {}
176 for key, value in carrier.items():
177 if isinstance(key, str): 177 ↛ 176line 177 didn't jump to line 176 because the condition on line 177 was always true
178 normalized = key.lower()
179 if normalized in {"traceparent", "tracestate", "baggage"}:
180 values[normalized] = value
182 traceparent = values.get("traceparent")
183 if traceparent is None:
184 return None
185 if not isinstance(traceparent, str):
186 return None
188 return parse_traceparent(
189 traceparent,
190 tracestate=_safe_optional_header(values.get("tracestate")),
191 baggage=_safe_optional_header(values.get("baggage")),
192 )