Line data Source code
1 : // Copyright The OpenTelemetry Authors
2 : // SPDX-License-Identifier: Apache-2.0
3 :
4 : import 'dart:convert' show utf8;
5 :
6 : import 'package:dartastic_opentelemetry_api/dartastic_opentelemetry_api.dart';
7 :
8 : import '../../otel.dart';
9 :
10 : /// Implementation of the W3C Baggage specification for context propagation.
11 : ///
12 : /// This propagator handles the extraction and injection of baggage information
13 : /// following the W3C Baggage specification as defined at:
14 : /// https://www.w3.org/TR/baggage/
15 : ///
16 : /// Baggage allows for propagating key-value pairs alongside the trace context
17 : /// across service boundaries. This enables the correlation of related telemetry
18 : /// using application-specific or domain-specific properties.
19 : class W3CBaggagePropagator
20 : implements TextMapPropagator<Map<String, String>, String> {
21 : /// The standard header name for W3C baggage as defined in the specification
22 : static const _baggageHeader = 'baggage';
23 :
24 : /// Extracts baggage information from the carrier and updates the context.
25 : ///
26 : /// This method parses the W3C baggage header and creates a new baggage
27 : /// context to return as part of the updated Context.
28 : ///
29 : /// @param context The current context
30 : /// @param carrier The carrier containing the baggage header
31 : /// @param getter The getter used to extract values from the carrier
32 : /// @return A new Context with the extracted baggage
33 3 : @override
34 : Context extract(
35 : Context context,
36 : Map<String, String> carrier,
37 : TextMapGetter<String> getter,
38 : ) {
39 3 : final value = getter.get(_baggageHeader);
40 3 : if (OTelLog.isDebug()) {
41 6 : OTelLog.debug('Extracting baggage: $value');
42 : }
43 3 : if (value == null || value.isEmpty) {
44 : // Propagators API spec: extract returns the passed context, updated
45 : // with extracted values — and unchanged when there is nothing to
46 : // extract. Returning a fresh context here would discard whatever an
47 : // earlier propagator in a composite (e.g. tracecontext) extracted.
48 : return context;
49 : }
50 :
51 3 : final entries = <String, BaggageEntry>{};
52 3 : final pairs = value.split(',');
53 6 : for (final pair in pairs) {
54 3 : final trimmedPair = pair.trim();
55 3 : if (trimmedPair.isEmpty) continue;
56 :
57 : // Split on the first '=' only — the W3C Baggage spec allows '='
58 : // inside values (e.g. base64 padding like "token=abc123==").
59 3 : final eqIndex = trimmedPair.indexOf('=');
60 3 : if (eqIndex <= 0) continue;
61 :
62 : // Keys are tokens per RFC 7230 — they are NOT percent-encoded or
63 : // decoded on the wire. Apply the same token rule inject applies so a
64 : // pass-through hop cannot lose entries (extract→inject symmetry).
65 6 : final key = trimmedPair.substring(0, eqIndex).trim();
66 3 : if (!_isValidToken(key)) continue;
67 :
68 9 : final valueAndMetadata = trimmedPair.substring(eqIndex + 1).split(';');
69 :
70 : // W3C Baggage: an unparsable list member is ignored, not fatal — one
71 : // malformed entry must neither break the rest of the header nor, in
72 : // composite propagation, prevent traceparent from being parsed.
73 : String value;
74 : try {
75 9 : value = _decodeValue(valueAndMetadata[0].trim());
76 : }
77 : // ignore: avoid_catching_errors
78 1 : on ArgumentError {
79 : // Uri.decodeComponent signals truncated/invalid escapes as
80 : // ArgumentError; W3C says skip the list member.
81 : continue;
82 1 : } on FormatException {
83 : continue;
84 : }
85 :
86 : String? metadata;
87 6 : if (valueAndMetadata.length > 1) {
88 12 : metadata = _safeDecode(valueAndMetadata.sublist(1).join(';').trim());
89 : }
90 :
91 6 : entries[key] = OTel.baggageEntry(value, metadata);
92 : }
93 :
94 : // Propagators API spec: "If a value can not be parsed from the carrier
95 : // for a cross-cutting concern, the implementation MUST NOT store a new
96 : // value in the Context, in order to preserve any previously existing
97 : // valid value." An empty entry map means nothing was parsed, so we
98 : // return the original context untouched.
99 3 : if (entries.isEmpty) return context;
100 3 : final baggage = OTel.baggage(entries);
101 3 : return context.withBaggage(baggage);
102 : }
103 :
104 : /// Injects baggage from the context into the carrier.
105 : ///
106 : /// This method serializes the baggage from the context into the
107 : /// W3C baggage header format and adds it to the carrier.
108 : ///
109 : /// @param context The context containing baggage to be injected
110 : /// @param carrier The carrier to inject the baggage header into
111 : /// @param setter The setter used to add values to the carrier
112 2 : @override
113 : void inject(
114 : Context context,
115 : Map<String, String> carrier,
116 : TextMapSetter<String> setter,
117 : ) {
118 2 : if (OTelLog.isDebug()) {
119 4 : OTelLog.debug('Injecting baggage. Context: $context');
120 : }
121 2 : final contextBaggage = context.baggage;
122 : if (contextBaggage != null) {
123 2 : if (OTelLog.isDebug()) {
124 2 : OTelLog.debug(
125 4 : 'Context baggage: $contextBaggage (${contextBaggage.runtimeType})',
126 : );
127 : }
128 :
129 : final baggage = contextBaggage;
130 2 : final entries = baggage.getAllEntries();
131 6 : if (OTelLog.isDebug()) OTelLog.debug('Baggage entries: $entries');
132 :
133 2 : if (entries.isEmpty) {
134 2 : if (OTelLog.isDebug()) OTelLog.debug('Empty baggage entries');
135 : return;
136 : }
137 :
138 6 : final serializedEntries = entries.entries.where((entry) {
139 4 : if (!_isValidToken(entry.key)) {
140 1 : if (OTelLog.isDebug()) {
141 1 : OTelLog.debug(
142 2 : 'Dropping baggage entry with invalid key: ${entry.key}',
143 : );
144 : }
145 : return false;
146 : }
147 : return true;
148 4 : }).map((entry) {
149 2 : final key = entry.key;
150 6 : final value = _encodeValue(entry.value.value);
151 4 : final metadata = entry.value.metadata;
152 2 : if (OTelLog.isDebug()) {
153 2 : OTelLog.debug(
154 2 : 'Processing entry - Key: $key, Value: $value, Metadata: $metadata',
155 : );
156 : }
157 : // Metadata is `property = token "=" *baggage-octet` on the wire and
158 : // free text in the API, so it must go through the same encoding as
159 : // values or a raw `,`/`;`/CRLF in it would forge extra list members.
160 2 : if (metadata != null && metadata.isNotEmpty) {
161 4 : return '$key=$value;${_encodeValue(metadata)}';
162 : }
163 2 : return '$key=$value';
164 2 : }).join(',');
165 :
166 2 : if (OTelLog.isDebug()) {
167 4 : OTelLog.debug('Setting baggage header to: $serializedEntries');
168 : }
169 2 : if (serializedEntries.isNotEmpty) {
170 2 : setter.set(_baggageHeader, serializedEntries);
171 : }
172 : }
173 : }
174 :
175 : /// Returns the list of propagation fields used by this propagator.
176 : ///
177 : /// @return A list containing the baggage header name
178 0 : @override
179 : List<String> fields() => const [_baggageHeader];
180 :
181 : /// Matches an RFC 7230 `token`, the grammar W3C Baggage requires for
182 : /// keys (and property names): `!`, `#`, `$`, `%`, `&`, `'`, `*`, `+`,
183 : /// `-`, `.`, `^`, `_`, `` ` ``, `|`, `~`, DIGIT, ALPHA.
184 9 : static final RegExp _token = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$");
185 :
186 : /// Checks whether [key] is a valid W3C Baggage key.
187 9 : bool _isValidToken(String key) => _token.hasMatch(key);
188 :
189 : /// Whether [octet] is in the W3C Baggage `baggage-octet` set
190 : /// (`%x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E`) — i.e. printable
191 : /// ASCII excluding space, `"`, `,`, `;`, `\`, controls, and non-ASCII.
192 2 : static bool _isBaggageOctet(int octet) =>
193 2 : octet >= 0x21 &&
194 2 : octet <= 0x7E &&
195 2 : octet != 0x22 && // "
196 2 : octet != 0x2C && // ,
197 2 : octet != 0x3B && // ;
198 2 : octet != 0x5C; // \
199 :
200 : /// Encodes a baggage value per the W3C Baggage specification.
201 : ///
202 : /// Allowlist encoding: every UTF-8 byte outside `baggage-octet`
203 : /// (non-ASCII included) is percent-encoded with uppercase hex. `%` itself
204 : /// is always emitted as `%25` even though it is a valid octet — otherwise
205 : /// the decoder could misread pre-existing escape sequences and the codec
206 : /// would not be injective (e.g. `a%2Cb` must not decode to `a,b`).
207 2 : String _encodeValue(String value) {
208 2 : final buffer = StringBuffer();
209 4 : for (final octet in utf8.encode(value)) {
210 2 : if (octet == 0x25) {
211 1 : buffer.write('%25');
212 2 : } else if (_isBaggageOctet(octet)) {
213 2 : buffer.writeCharCode(octet);
214 : } else {
215 2 : buffer.write(
216 8 : '%${octet.toRadixString(16).toUpperCase().padLeft(2, '0')}',
217 : );
218 : }
219 : }
220 2 : return buffer.toString();
221 : }
222 :
223 : /// Decodes a baggage value (plain percent-decoding, no form-style `+`).
224 : ///
225 : /// Throws [ArgumentError] or [FormatException] on malformed input;
226 : /// callers guard per entry per the W3C "ignore unparsable member" rule.
227 3 : String _decodeValue(String value) {
228 3 : return Uri.decodeComponent(value);
229 : }
230 :
231 : /// Best-effort decode used for metadata: returns the input unchanged when
232 : /// it is not valid percent-encoding, since metadata is auxiliary and must
233 : /// never be able to fail the surrounding entry.
234 3 : static String _safeDecode(String value) {
235 : try {
236 3 : return Uri.decodeComponent(value);
237 : }
238 : // ignore: avoid_catching_errors
239 0 : on ArgumentError {
240 : return value;
241 0 : } on FormatException {
242 : return value;
243 : }
244 : }
245 : }
|