Skip to content

Commit 4340352

Browse files
committed
feat: forward W3C baggage
Forward the inbound W3C baggage header transparently, mirroring the existing tracestate design: the raw value is stored on the server span and injected verbatim on outgoing http and undici/fetch requests made while handling it. It is never parsed or modified, and child spans inherit their parent's baggage the same way they inherit tracestate, with an explicit inbound value winning over the parent's. Baggage is a propagated header, not a span attribute: it is deliberately absent from the preallocated attribute-key set and never appears in the OTLP payload. Outgoing injection follows the tracestate override semantics: a caller-supplied baggage header is replaced rather than appended to, and a stale caller-supplied value is removed when the span has none to inherit. Per the W3C Baggage specification, baggage propagates independently of traceparent, so an inbound baggage header is read unconditionally: a request with baggage but no (or rejected) traceparent still starts a fresh trace whose outgoing requests carry the baggage. This differs from tracestate, which is discarded when the traceparent is absent or rejected per the W3C Trace Context spec. Signed-off-by: Bryan English <bryan@bryanenglish.com>
1 parent 53e87a3 commit 4340352

5 files changed

Lines changed: 207 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ It seeks to enable:
1010

1111
* Instrumentation of inbound and outbound HTTP transactions, following [OTel Semconv](https://opentelemetry.io/docs/specs/semconv/general/trace/).
1212
* Tracing data egress via OTLP/JSON. (JSON-only to avoid bringing in a gRPC dependency.)
13-
* Distributed tracing via W3C Traceparent.
13+
* Distributed tracing via W3C Traceparent, including forwarding baggage (but not modifying it).
1414

1515
Anything else, including the following, should be considered initally out of scope:
1616

@@ -127,8 +127,9 @@ and span end timing are the same as for the HTTP client spans above. Requests
127127
that fail before they are dispatched, such as a refused connection, produce
128128
no span: the undici channels only fire once the request is underway. The
129129
injected `traceparent` replaces any caller-supplied value rather than
130-
appending to it, and a stale `tracestate` is removed when the span has none
131-
to inherit.
130+
appending to it, a stale `tracestate` is removed when the span has none to
131+
inherit, and the inherited `baggage` replaces any caller-supplied value the
132+
same way.
132133

133134
Attributes set on undici/fetch client spans:
134135

@@ -153,10 +154,15 @@ requests, and child spans created during request processing inherit the trace
153154
ID. `traceparent` values using a version other than `00` are rejected, and a
154155
fresh trace is started instead. The `tracestate` header, when present, is
155156
attached to the span and forwarded verbatim to outgoing requests. It is never
156-
parsed or modified.
157+
parsed or modified. The `baggage` header is likewise attached and forwarded
158+
verbatim, never parsed or modified; per the W3C Baggage specification it
159+
propagates independently of `traceparent`, so it is carried forward even when
160+
the `traceparent` is absent or rejected.
157161
* **Outgoing requests**: The `traceparent` header is injected into outgoing
158-
HTTP and undici/fetch requests, together with the stored `tracestate` when
159-
one is present, enabling distributed tracing across services.
162+
HTTP and undici/fetch requests, together with the stored `tracestate` and
163+
`baggage` when present, enabling distributed tracing across services.
164+
Baggage is a propagated header, not a span attribute, so it never appears in
165+
the exported span data.
160166

161167
## Benchmarks
162168

@@ -178,9 +184,10 @@ The tracing subsystem is independent of the OpenTelemetry JavaScript packages. I
178184
is not interoperable with `@opentelemetry/api`, and spans created by one are not
179185
visible to the other. Running both in the same process produces duplicate trace
180186
and span IDs. The built-in instrumentation also overwrites the `traceparent`
181-
(and, when present, `tracestate`) header on outgoing HTTP requests, discarding
182-
whatever a userland propagator may have set. Users should run either this
183-
package or a userland OpenTelemetry SDK, not both.
187+
(and, when present, `tracestate`) header on outgoing HTTP requests, and
188+
replaces the `baggage` header when the span carries one, discarding whatever a
189+
userland propagator may have set. Users should run either this package or a
190+
userland OpenTelemetry SDK, not both.
184191

185192
`init()` activates tracing in the thread that calls it. Worker threads
186193
(`node:worker_threads`) are not traced unless they call `init()` themselves.

‎lib/instrumentations.js‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,22 @@ function onHttpServerRequestStart({ request, response, socket }) {
4949
const exportUrl = getExportUrl();
5050
if (
5151
request.url === exportUrl.pathname &&
52-
request.headers?.host === getCollectorHost()
52+
request.headers.host === getCollectorHost()
5353
) {
5454
return;
5555
}
5656

57-
const traceparent = request.headers?.traceparent;
57+
const traceparent = request.headers.traceparent;
5858
const parent =
5959
traceparent != null ? Span.fromTraceparent(traceparent) : undefined;
6060
// Per the W3C Trace Context spec, tracestate is only meaningful
6161
// alongside a valid traceparent. It is discarded when the traceparent
6262
// is absent or rejected.
63-
const tracestate = parent != null ? request.headers?.tracestate : undefined;
63+
const tracestate = parent != null ? request.headers.tracestate : undefined;
64+
// Unlike tracestate, baggage propagates independently of traceparent
65+
// per the W3C Baggage spec, so it is read unconditionally. Like
66+
// tracestate it is forwarded verbatim; it is never parsed.
67+
const baggage = request.headers.baggage;
6468

6569
const method = request.method || 'GET';
6670
const rawUrl = request.url || '/';
@@ -71,8 +75,10 @@ function onHttpServerRequestStart({ request, response, socket }) {
7175
// in an attribute.
7276
const span = new Span(method, SPAN_KIND_SERVER, {
7377
parent,
74-
// The tracestate is forwarded verbatim; it is never parsed.
78+
// The tracestate and baggage are forwarded verbatim; neither is
79+
// ever parsed.
7580
tracestate,
81+
baggage,
7682
});
7783

7884
span.setAttribute('http.request.method', method);
@@ -82,7 +88,7 @@ function onHttpServerRequestStart({ request, response, socket }) {
8288
span.setAttribute('url.scheme', socket?.encrypted ? 'https' : 'http');
8389
span.setAttribute('network.protocol.version', request.httpVersion || '1.1');
8490

85-
const host = request.headers?.host;
91+
const host = request.headers.host;
8692
if (host) span.setAttribute('server.address', splitAuthority(host)[0]);
8793

8894
// The span is stashed on the request so the response handlers do not
@@ -184,10 +190,13 @@ function onHttpClientRequestCreated({ request }) {
184190
if (request.headersSent) return;
185191

186192
request.setHeader('traceparent', span.toTraceparent());
187-
// A stale tracestate from an unrelated trace must not travel with the
188-
// new trace context.
193+
// A caller-supplied tracestate or baggage is replaced rather than
194+
// appended, and a stale value is removed when the span has none to
195+
// inherit, so it does not travel with the new trace context.
189196
if (span.tracestate) request.setHeader('tracestate', span.tracestate);
190197
else request.removeHeader('tracestate');
198+
if (span.baggage) request.setHeader('baggage', span.baggage);
199+
else request.removeHeader('baggage');
191200
}
192201

193202
function onHttpClientResponseFinish({ request, response }) {
@@ -260,6 +269,8 @@ function onUndiciRequestCreate({ request }) {
260269
if (span.tracestate)
261270
setUndiciHeader(request, 'tracestate', span.tracestate);
262271
else removeUndiciHeader(request, 'tracestate');
272+
if (span.baggage) setUndiciHeader(request, 'baggage', span.baggage);
273+
else removeUndiciHeader(request, 'baggage');
263274
}
264275
}
265276

‎lib/span.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class Span {
3232
// Raw W3C tracestate header value, forwarded verbatim to child
3333
// requests. Undefined when no tracestate was received.
3434
tracestate;
35+
// Raw W3C baggage header value, forwarded verbatim like tracestate:
36+
// never parsed, never modified, and not a span attribute, so it never
37+
// appears in the OTLP payload. Undefined when no baggage was received.
38+
baggage;
3539
// Timestamps are epoch milliseconds. The start comes from the wall
3640
// clock; the end is derived as start + a monotonic duration, so spans
3741
// survive a backward clock step while keeping human-readable absolute
@@ -86,6 +90,9 @@ class Span {
8690
}
8791
// An explicit tracestate wins; children inherit their parent's.
8892
this.tracestate = options?.tracestate ?? parent?.tracestate;
93+
// Baggage follows the same inheritance: an explicit inbound value
94+
// wins, children inherit their parent's.
95+
this.baggage = options?.baggage ?? parent?.baggage;
8996
}
9097

9198
get traceFlags() {

‎test/test-context-propagation.js‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const {
1414
} = require('./common/otel');
1515

1616
// W3C trace context propagation: child spans share the incoming trace ID,
17-
// and outgoing requests carry the traceparent.
17+
// and outgoing requests carry the traceparent. The tracestate and
18+
// baggage headers are forwarded verbatim.
1819

1920
describe('otel context propagation', () => {
2021
let collector;
@@ -96,6 +97,112 @@ describe('otel context propagation', () => {
9697
assert.strictEqual(outgoingTracestate, incomingTracestate);
9798
});
9899

100+
it('forwards baggage verbatim across HTTP hops', async () => {
101+
const incomingTraceId = 'abcdef0123456789abcdef0123456789';
102+
const incomingTraceparent = `00-${incomingTraceId}-0123456789abcdef-01`;
103+
// A value with arbitrary list members and extra spaces: baggage is
104+
// forwarded verbatim, so nothing may be parsed, trimmed, or reordered.
105+
const incomingBaggage = 'key1=value1,key2=value2, unpacked = value3';
106+
107+
let outgoingBaggage = 'sentinel';
108+
109+
const backend = await startHTTPServer((req, res) => {
110+
outgoingBaggage = req.headers.baggage;
111+
res.writeHead(200);
112+
res.end('backend-ok');
113+
});
114+
115+
// The frontend makes an outgoing call to the backend, carrying a
116+
// stale baggage header that the span's inherited baggage must
117+
// override.
118+
const frontend = await startHTTPServer((req, res) => {
119+
http.get(
120+
`http://127.0.0.1:${backend.port}/backend`,
121+
{
122+
headers: { baggage: 'stale=will-be-overridden' },
123+
},
124+
(backendRes) => {
125+
backendRes.resume();
126+
backendRes.on('end', () => {
127+
res.writeHead(200);
128+
res.end('frontend-ok');
129+
});
130+
},
131+
);
132+
});
133+
134+
await rawRequest(frontend.port, '/frontend', {
135+
traceparent: incomingTraceparent,
136+
baggage: incomingBaggage,
137+
});
138+
139+
const spans = await flushAndCollect(collector);
140+
141+
await closeAll(frontend, backend);
142+
143+
assert.strictEqual(
144+
outgoingBaggage,
145+
incomingBaggage,
146+
'The baggage must be forwarded verbatim to child requests',
147+
);
148+
149+
// Baggage is a propagation header, not a span attribute: it must
150+
// not be emitted into the OTLP payload.
151+
const serverSpan = getServerSpan(spans, '/frontend');
152+
assert.ok(serverSpan, 'Expected a frontend server span');
153+
const attributeKeys = (serverSpan.attributes ?? []).map((a) => a.key);
154+
assert.ok(
155+
!attributeKeys.includes('baggage'),
156+
'baggage must not be emitted as a span attribute',
157+
);
158+
});
159+
160+
it('forwards baggage without a traceparent', async () => {
161+
// Per the W3C Baggage spec, baggage propagates independently of
162+
// traceparent: a request with baggage but no traceparent starts a
163+
// fresh trace that still carries the baggage forward.
164+
const incomingBaggage = 'vendor=state,key=value';
165+
166+
let outgoingTraceparent = null;
167+
let outgoingBaggage = 'sentinel';
168+
169+
const backend = await startHTTPServer((req, res) => {
170+
outgoingTraceparent = req.headers.traceparent || null;
171+
outgoingBaggage = req.headers.baggage;
172+
res.writeHead(200);
173+
res.end('backend-ok');
174+
});
175+
176+
const frontend = await startHTTPServer((req, res) => {
177+
http.get(`http://127.0.0.1:${backend.port}/backend`, (backendRes) => {
178+
backendRes.resume();
179+
backendRes.on('end', () => {
180+
res.writeHead(200);
181+
res.end('frontend-ok');
182+
});
183+
});
184+
});
185+
186+
await rawRequest(frontend.port, '/frontend', {
187+
baggage: incomingBaggage,
188+
});
189+
190+
await flushAndCollect(collector);
191+
192+
await closeAll(frontend, backend);
193+
194+
// A fresh trace was started, and the baggage still travels with it.
195+
assert.ok(
196+
outgoingTraceparent,
197+
'Expected a traceparent on the outgoing request',
198+
);
199+
assert.strictEqual(
200+
outgoingBaggage,
201+
incomingBaggage,
202+
'baggage must be forwarded even without a traceparent',
203+
);
204+
});
205+
99206
it('discards tracestate when the traceparent is invalid', async () => {
100207
const rejectedTraceId = 'abcdef0123456789abcdef0123456789';
101208

‎test/test-undici.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,64 @@ describe('otel undici spans', () => {
157157
);
158158
});
159159

160+
it('overrides a stale baggage with the inherited one', async () => {
161+
const incomingTraceId = 'b'.repeat(32);
162+
const incomingBaggage = 'vendor=inherited,key=value';
163+
164+
let seen;
165+
// The fetch target records the trace context headers it receives.
166+
const fetchTarget = await startHTTPServer((req, res) => {
167+
seen = {
168+
traceparent: req.headers.traceparent,
169+
baggage: req.headers.baggage,
170+
};
171+
res.writeHead(200);
172+
res.end('ok');
173+
});
174+
175+
// A fetch made while handling a traced request inherits the incoming
176+
// baggage and must override the caller-supplied stale one.
177+
const frontend = await startHTTPServer((req, res) => {
178+
fetch(`http://127.0.0.1:${fetchTarget.port}/down`, {
179+
headers: { baggage: 'stale=caller-value' },
180+
}).then((fr) => {
181+
fr.text().then(() => {
182+
res.writeHead(200);
183+
res.end('ok');
184+
});
185+
});
186+
});
187+
188+
await rawRequest(frontend.port, '/up', {
189+
traceparent: `00-${incomingTraceId}-0123456789abcdef-01`,
190+
baggage: incomingBaggage,
191+
});
192+
193+
const spans = await flushAndCollect(collector);
194+
await closeAll(fetchTarget, frontend);
195+
196+
assert.ok(seen?.traceparent, 'Expected a traceparent on the fetch');
197+
assert.ok(
198+
seen.traceparent.includes(incomingTraceId),
199+
'The fetch must carry the incoming trace ID',
200+
);
201+
assert.strictEqual(
202+
seen.baggage,
203+
incomingBaggage,
204+
'The inherited baggage must win over the stale one',
205+
);
206+
207+
// Baggage is a propagation header, not a span attribute: it must
208+
// not be emitted into the OTLP payload.
209+
const clientSpan = getClientSpan(spans, '/down');
210+
assert.ok(clientSpan, 'Expected a client span for the fetch');
211+
const attributeKeys = (clientSpan.attributes ?? []).map((a) => a.key);
212+
assert.ok(
213+
!attributeKeys.includes('baggage'),
214+
'baggage must not be emitted as a span attribute',
215+
);
216+
});
217+
160218
it('removes a stale tracestate when the span has none', async () => {
161219
let receivedTracestate = 'sentinel';
162220
const target = await startHTTPServer((req, res) => {

0 commit comments

Comments
 (0)