Line data Source code
1 : // Licensed under the Apache License, Version 2.0
2 : // Copyright 2025, Michael Bushe, All rights reserved.
3 :
4 : import 'package:dartastic_opentelemetry/src/otel.dart';
5 : import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart';
6 :
7 : /// Implementation of the W3C Trace Context specification for context propagation.
8 : ///
9 : /// This propagator handles the extraction and injection of trace context information
10 : /// following the W3C Trace Context specification as defined at:
11 : /// https://www.w3.org/TR/trace-context/
12 : ///
13 : /// The traceparent header contains:
14 : /// - version (2 hex digits)
15 : /// - trace-id (32 hex digits)
16 : /// - parent-id/span-id (16 hex digits)
17 : /// - trace-flags (2 hex digits)
18 : ///
19 : /// Example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
20 : ///
21 : /// The tracestate header is optional and contains vendor-specific trace information
22 : /// as a comma-separated list of key=value pairs.
23 : class W3CTraceContextPropagator
24 : implements TextMapPropagator<Map<String, String>, String> {
25 : /// The standard header name for W3C trace parent
26 : static const _traceparentHeader = 'traceparent';
27 :
28 : /// The standard header name for W3C trace state
29 : static const _tracestateHeader = 'tracestate';
30 :
31 : /// The current version of the W3C Trace Context specification
32 : static const _version = '00';
33 :
34 : /// The length of a valid traceparent header value
35 : static const _traceparentLength = 55; // 00-{32}-{16}-{2}
36 :
37 1 : @override
38 : Context extract(Context context, Map<String, String> carrier,
39 : TextMapGetter<String> getter) {
40 1 : final traceparent = getter.get(_traceparentHeader);
41 :
42 1 : if (OTelLog.isDebug()) {
43 2 : OTelLog.debug('Extracting traceparent: $traceparent');
44 : }
45 :
46 1 : if (traceparent == null || traceparent.isEmpty) {
47 : return context;
48 : }
49 :
50 : // Parse the traceparent header
51 1 : final spanContext = _parseTraceparent(traceparent);
52 : if (spanContext == null) {
53 1 : if (OTelLog.isDebug()) {
54 1 : OTelLog.debug('Invalid traceparent format, skipping extraction');
55 : }
56 : return context;
57 : }
58 :
59 : // Extract tracestate if present
60 1 : final tracestate = getter.get(_tracestateHeader);
61 : SpanContext finalSpanContext = spanContext;
62 :
63 1 : if (tracestate != null && tracestate.isNotEmpty) {
64 1 : final tracestateMap = _parseTracestate(tracestate);
65 1 : if (tracestateMap.isNotEmpty) {
66 : finalSpanContext =
67 2 : spanContext.withTraceState(OTel.traceState(tracestateMap));
68 : }
69 : }
70 :
71 1 : if (OTelLog.isDebug()) {
72 2 : OTelLog.debug('Extracted span context: $finalSpanContext');
73 : }
74 :
75 1 : return context.withSpanContext(finalSpanContext);
76 : }
77 :
78 1 : @override
79 : void inject(Context context, Map<String, String> carrier,
80 : TextMapSetter<String> setter) {
81 1 : final spanContext = context.spanContext;
82 :
83 1 : if (OTelLog.isDebug()) {
84 2 : OTelLog.debug('Injecting span context: $spanContext');
85 : }
86 :
87 1 : if (spanContext == null || !spanContext.isValid) {
88 1 : if (OTelLog.isDebug()) {
89 1 : OTelLog.debug('No valid span context to inject');
90 : }
91 : return;
92 : }
93 :
94 : // Build traceparent header: version-traceId-spanId-traceFlags
95 1 : final traceparent = '$_version-'
96 2 : '${spanContext.traceId.hexString}-'
97 2 : '${spanContext.spanId.hexString}-'
98 1 : '${spanContext.traceFlags}';
99 :
100 1 : setter.set(_traceparentHeader, traceparent);
101 :
102 1 : if (OTelLog.isDebug()) {
103 2 : OTelLog.debug('Injected traceparent: $traceparent');
104 : }
105 :
106 : // Inject tracestate if present
107 1 : final traceState = spanContext.traceState;
108 2 : if (traceState != null && traceState.entries.isNotEmpty) {
109 1 : final tracestateValue = _serializeTracestate(traceState);
110 1 : setter.set(_tracestateHeader, tracestateValue);
111 :
112 1 : if (OTelLog.isDebug()) {
113 2 : OTelLog.debug('Injected tracestate: $tracestateValue');
114 : }
115 : }
116 : }
117 :
118 1 : @override
119 : List<String> fields() => const [_traceparentHeader, _tracestateHeader];
120 :
121 : /// Parses a traceparent header value into a SpanContext.
122 : ///
123 : /// The traceparent format is: version-traceId-spanId-traceFlags
124 : /// Example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
125 : ///
126 : /// Returns null if the format is invalid.
127 1 : SpanContext? _parseTraceparent(String traceparent) {
128 : // Basic validation
129 2 : if (traceparent.length != _traceparentLength) {
130 1 : if (OTelLog.isDebug()) {
131 1 : OTelLog.debug(
132 2 : 'Invalid traceparent length: ${traceparent.length}, expected $_traceparentLength');
133 : }
134 : return null;
135 : }
136 :
137 1 : final parts = traceparent.split('-');
138 2 : if (parts.length != 4) {
139 0 : if (OTelLog.isDebug()) {
140 0 : OTelLog.debug(
141 0 : 'Invalid traceparent format: expected 4 parts, got ${parts.length}');
142 : }
143 : return null;
144 : }
145 :
146 1 : final version = parts[0];
147 1 : final traceIdHex = parts[1];
148 1 : final spanIdHex = parts[2];
149 1 : final traceFlagsHex = parts[3];
150 :
151 : // Validate version (currently only 00 is supported)
152 1 : if (version != _version) {
153 1 : if (OTelLog.isDebug()) {
154 2 : OTelLog.debug('Unsupported traceparent version: $version');
155 : }
156 : // Per spec, we should still try to parse if version is unknown
157 : // but for now we'll reject it
158 : return null;
159 : }
160 :
161 : // Validate trace ID length (32 hex chars = 16 bytes)
162 2 : if (traceIdHex.length != 32) {
163 0 : if (OTelLog.isDebug()) {
164 0 : OTelLog.debug(
165 0 : 'Invalid trace ID length: ${traceIdHex.length}, expected 32');
166 : }
167 : return null;
168 : }
169 :
170 : // Validate span ID length (16 hex chars = 8 bytes)
171 2 : if (spanIdHex.length != 16) {
172 0 : if (OTelLog.isDebug()) {
173 0 : OTelLog.debug(
174 0 : 'Invalid span ID length: ${spanIdHex.length}, expected 16');
175 : }
176 : return null;
177 : }
178 :
179 : // Validate trace flags length (2 hex chars = 1 byte)
180 2 : if (traceFlagsHex.length != 2) {
181 0 : if (OTelLog.isDebug()) {
182 0 : OTelLog.debug(
183 0 : 'Invalid trace flags length: ${traceFlagsHex.length}, expected 2');
184 : }
185 : return null;
186 : }
187 :
188 : try {
189 : // Parse the components
190 1 : final traceId = OTel.traceIdFrom(traceIdHex);
191 1 : final spanId = OTel.spanIdFrom(spanIdHex);
192 1 : final traceFlags = TraceFlags.fromString(traceFlagsHex);
193 :
194 : // Validate that trace ID and span ID are not all zeros
195 1 : if (!traceId.isValid) {
196 1 : if (OTelLog.isDebug()) {
197 1 : OTelLog.debug('Invalid trace ID: all zeros');
198 : }
199 : return null;
200 : }
201 :
202 1 : if (!spanId.isValid) {
203 1 : if (OTelLog.isDebug()) {
204 1 : OTelLog.debug('Invalid span ID: all zeros');
205 : }
206 : return null;
207 : }
208 :
209 : // Create the span context with isRemote=true since it came from a carrier
210 1 : return OTel.spanContext(
211 : traceId: traceId,
212 : spanId: spanId,
213 : traceFlags: traceFlags,
214 : isRemote: true,
215 : );
216 : } catch (e) {
217 0 : if (OTelLog.isDebug()) {
218 0 : OTelLog.debug('Error parsing traceparent: $e');
219 : }
220 : return null;
221 : }
222 : }
223 :
224 : /// Parses a tracestate header value into a map.
225 : ///
226 : /// The tracestate format is: key1=value1,key2=value2,...
227 : /// Example: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
228 1 : Map<String, String> _parseTracestate(String tracestate) {
229 1 : final result = <String, String>{};
230 :
231 1 : if (tracestate.isEmpty) {
232 : return result;
233 : }
234 :
235 : // Split by comma and process each entry
236 1 : final entries = tracestate.split(',');
237 2 : for (final entry in entries) {
238 1 : final trimmedEntry = entry.trim();
239 1 : if (trimmedEntry.isEmpty) continue;
240 :
241 1 : final separatorIndex = trimmedEntry.indexOf('=');
242 4 : if (separatorIndex <= 0 || separatorIndex >= trimmedEntry.length - 1) {
243 : // Invalid format, skip this entry
244 1 : if (OTelLog.isDebug()) {
245 2 : OTelLog.debug('Invalid tracestate entry format: $trimmedEntry');
246 : }
247 : continue;
248 : }
249 :
250 2 : final key = trimmedEntry.substring(0, separatorIndex).trim();
251 3 : final value = trimmedEntry.substring(separatorIndex + 1).trim();
252 :
253 2 : if (key.isNotEmpty && value.isNotEmpty) {
254 1 : result[key] = value;
255 : }
256 : }
257 :
258 : return result;
259 : }
260 :
261 : /// Serializes a TraceState into a tracestate header value.
262 : ///
263 : /// The format is: key1=value1,key2=value2,...
264 1 : String _serializeTracestate(TraceState traceState) {
265 1 : final entries = traceState.entries;
266 1 : if (entries.isEmpty) {
267 : return '';
268 : }
269 :
270 7 : return entries.entries.map((e) => '${e.key}=${e.value}').join(',');
271 : }
272 : }
|