Skip to content

Commit de8d8cf

Browse files
committed
ENG-2398: Add private baggage propagation and explicit errors
1 parent 75507e0 commit de8d8cf

3 files changed

Lines changed: 548 additions & 54 deletions

File tree

README.md

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A DX-focused OpenTelemetry wrapper for **Bun** and **Node.js**.
55
Vanilla OTel works, but the setup is verbose, the logger plumbing is awkward, and PII scrubbing is on you. `@photon-ai/otel` wraps the OTLP/HTTP stack into a few well-named functions:
66

77
- **`setupOtel()`** — idempotent one-call bootstrap for traces + logs + metrics. Honors standard `OTEL_EXPORTER_OTLP_*` env vars.
8-
- **`createIsolatedOtel()`** — creates an isolated trace + log runtime with its own Resource and configurable propagation header, without replacing global providers or context.
8+
- **`createIsolatedOtel()`** — creates an isolated trace + log runtime with its own Resource, configurable trace carrier, and optional private Baggage carrier, without replacing global providers or context.
99
- **`otel.getMeter(name)`** — creates standard OpenTelemetry instruments from this setup's meter provider, with identical behavior in global and scoped mode.
1010
- **`createLogger(module)`** — structured logger that writes to both the OTel logger provider and `console`, with automatic trace correlation and exception capture. Every level (`debug`/`info`/`warn`/`error`) accepts `attrs` **and** an `error`, and shares one configurable level gate.
1111
- **`withSpan(name, attrs?, fn)`** — wrap any sync or async function in a span; errors are recorded and PII in the error message is scrubbed before being attached to span status.
@@ -95,7 +95,7 @@ attribute guidance, and scoped mode.
9595
| Function | Description |
9696
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
9797
| `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs + metrics. The handle exposes `getMeter()`, providers, and `shutdown()`. Pass `register: false` for scoped mode. |
98-
| `createIsolatedOtel(options): IsolatedOtelHandle` | Creates an isolated, non-global trace + log runtime with its own endpoint, Resource, and propagation header. Returns a new runtime on every call. |
98+
| `createIsolatedOtel(options): IsolatedOtelHandle` | Creates an isolated, non-global trace + log runtime with its own endpoint, Resource, private trace carrier, and optional private Baggage carrier. Returns a new runtime on every call. |
9999
| `isOtelActive(): boolean` | Returns `true` if `setupOtel` has already run in this process. |
100100
| `instrumentFetch(options?): FetchInstrumentation` | Low-level wrap of `globalThis.fetch` for CLIENT spans + W3C propagation. Returns `{ unpatch() }`. `setupOtel` calls this on Bun; on Node it prefers native undici. |
101101
| `createInstrumentedFetch(baseFetch?, options?): typeof fetch` | Returns a NEW instrumented fetch (CLIENT spans + W3C propagation) wrapping `baseFetch` (default `globalThis.fetch`) without touching the global. For SDKs that take a `fetch` option. |
@@ -116,19 +116,31 @@ recorded trace/log stream to a different OTLP backend without taking over the
116116
main OTel runtime:
117117

118118
```ts
119+
import { propagation } from "@opentelemetry/api";
119120
import { createIsolatedOtel } from "@photon-ai/otel";
120121

121122
const auditOtel = createIsolatedOtel({
122123
endpoint: "https://audit-collector.example.com",
123124
serviceName: "audit-service",
124125
serviceVersion: "1.2.3",
126+
baggageHeader: "x-audit-baggage",
125127
traceparentHeader: "x-audit-traceparent",
126128
});
127129
const auditLogger = auditOtel.createLogger("example.audit");
128130

129-
await auditOtel.withSpan("audit.write", async () => {
130-
auditLogger.emit({ body: "writing audit entry" });
131+
const baggage = propagation.createBaggage({
132+
"audit.tenant.id": { value: "tenant-123" },
131133
});
134+
const baggageContext = propagation.setBaggage(
135+
auditOtel.propagation.capture(),
136+
baggage
137+
);
138+
139+
await auditOtel.propagation.run(baggageContext, () =>
140+
auditOtel.withSpan("audit.write", async () => {
141+
auditLogger.emit({ body: "writing audit entry" });
142+
})
143+
);
132144

133145
await auditOtel.shutdown();
134146
```
@@ -143,8 +155,21 @@ The isolated runtime has its own providers, processors, exporters, Resource, and
143155
read the main `OTEL_EXPORTER_OTLP_*` variables, add `deployment.environment`, or
144156
require `setupOtel()`.
145157

146-
Its propagation helper carries only its isolated trace context through the
147-
configured `traceparentHeader`; it never changes the standard `traceparent`.
158+
Its propagation helper carries isolated trace context through the configured
159+
`traceparentHeader`. When `baggageHeader` is present, `inject()` and `extract()`
160+
also serialize standard OTel Baggage through that private carrier. They never
161+
change the standard `traceparent` or `baggage` headers used by main OTel.
162+
163+
The runtime does not add a second Baggage API. Use
164+
`@opentelemetry/api`'s `createBaggage()` / `setBaggage()` on the Context returned
165+
by `propagation.capture()`, then activate the returned immutable Context with
166+
the isolated `propagation.run()` as shown above.
167+
168+
If an isolated Span callback throws, the runtime marks the Span `ERROR` and
169+
rethrows the same value without automatically recording its type, message, or
170+
stack. Call `auditOtel.recordError(error)` inside the active callback when those
171+
exception details should be exported. Calling it outside a local recording Span
172+
is a diagnostic no-op.
148173

149174
### Logger signatures
150175

src/isolated-runtime.ts

Lines changed: 154 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
defaultTextMapSetter,
77
diag,
88
INVALID_SPAN_CONTEXT,
9+
propagation as otelPropagation,
910
ROOT_CONTEXT,
1011
type Span,
1112
type SpanOptions,
@@ -14,7 +15,10 @@ import {
1415
} from "@opentelemetry/api";
1516
import type { Logger, LogRecord } from "@opentelemetry/api-logs";
1617
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
17-
import { W3CTraceContextPropagator } from "@opentelemetry/core";
18+
import {
19+
W3CBaggagePropagator,
20+
W3CTraceContextPropagator,
21+
} from "@opentelemetry/core";
1822
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
1923
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
2024
import {
@@ -38,6 +42,7 @@ import {
3842
} from "./service-resource";
3943

4044
const INSTRUMENTATION_SCOPE = "@photon-ai/otel";
45+
const BAGGAGE_KEY = "baggage";
4146
const TRACEPARENT_KEY = "traceparent";
4247

4348
// biome-ignore assist/source/useSortedInterfaceMembers: required options precede optional configuration.
@@ -47,16 +52,18 @@ export interface IsolatedOtelOptions extends ServiceResourceOptions {
4752
* runtime. Standard main OTel environment variables do not override it.
4853
*/
4954
endpoint: string;
50-
/** Private carrier header; the standard `traceparent` name is rejected. */
55+
/** Private trace carrier; standard `traceparent` and `baggage` are rejected. */
5156
traceparentHeader: string;
57+
/** Optional private W3C Baggage carrier; standard `baggage` is untouched. */
58+
baggageHeader?: string;
5259
/** Optional OTLP transport headers, typically used for Collector auth. */
5360
headers?: Record<string, string>;
5461
}
5562

5663
/** Transport-only options; the Resource is supplied separately. */
5764
type IsolatedOtelTransport = Pick<
5865
IsolatedOtelOptions,
59-
"endpoint" | "headers" | "traceparentHeader"
66+
"baggageHeader" | "endpoint" | "headers" | "traceparentHeader"
6067
>;
6168

6269
export interface IsolatedOtelHandle {
@@ -78,6 +85,8 @@ export interface IsolatedOtelHandle {
7885
/** Run a callback in this runtime's isolated async context. */
7986
run: <T>(captured: Context, fn: () => T) => T;
8087
};
88+
/** Explicitly record exception details on the current local recording Span. */
89+
recordError(error: unknown): void;
8190
shutdown(): Promise<void>;
8291
/** Run a callback with a Span active only in this runtime's private Context. */
8392
withActiveSpan<T>(
@@ -127,21 +136,51 @@ export const createIsolatedOtelRuntime = (
127136
if (!(endpointProtocol === "http:" || endpointProtocol === "https:")) {
128137
throw new TypeError("createIsolatedOtel: endpoint must use http or https");
129138
}
130-
const traceparentHeader = options.traceparentHeader;
131-
try {
132-
if (!traceparentHeader) {
133-
throw new TypeError("traceparentHeader is empty");
139+
const validateHeaderName = (
140+
headerName: string,
141+
optionName: "baggageHeader" | "traceparentHeader"
142+
): void => {
143+
try {
144+
if (!headerName) {
145+
throw new TypeError(`${optionName} is empty`);
146+
}
147+
new Headers().set(headerName, "validate");
148+
} catch {
149+
throw new TypeError(
150+
`createIsolatedOtel: ${optionName} must be a valid HTTP header name`
151+
);
134152
}
135-
new Headers().set(traceparentHeader, "validate");
136-
} catch {
153+
};
154+
155+
const traceparentHeader = options.traceparentHeader;
156+
validateHeaderName(traceparentHeader, "traceparentHeader");
157+
const normalizedTraceparentHeader = traceparentHeader.toLowerCase();
158+
if (
159+
normalizedTraceparentHeader === TRACEPARENT_KEY ||
160+
normalizedTraceparentHeader === BAGGAGE_KEY
161+
) {
137162
throw new TypeError(
138-
"createIsolatedOtel: traceparentHeader must be a valid HTTP header name"
163+
"createIsolatedOtel: traceparentHeader must not be traceparent or baggage"
139164
);
140165
}
141-
if (traceparentHeader.toLowerCase() === TRACEPARENT_KEY) {
142-
throw new TypeError(
143-
"createIsolatedOtel: traceparentHeader must not be traceparent"
144-
);
166+
167+
const baggageHeader = options.baggageHeader;
168+
if (baggageHeader !== undefined) {
169+
validateHeaderName(baggageHeader, "baggageHeader");
170+
const normalizedBaggageHeader = baggageHeader.toLowerCase();
171+
if (
172+
normalizedBaggageHeader === TRACEPARENT_KEY ||
173+
normalizedBaggageHeader === BAGGAGE_KEY
174+
) {
175+
throw new TypeError(
176+
"createIsolatedOtel: baggageHeader must not be traceparent or baggage"
177+
);
178+
}
179+
if (normalizedBaggageHeader === normalizedTraceparentHeader) {
180+
throw new TypeError(
181+
"createIsolatedOtel: baggageHeader must differ from traceparentHeader"
182+
);
183+
}
145184
}
146185
const headers = options.headers ? { ...options.headers } : undefined;
147186
const traceEndpoint = resolveOtlpEndpoint("traces", endpoint, {});
@@ -172,50 +211,88 @@ export const createIsolatedOtelRuntime = (
172211
});
173212
const contextManager = new AsyncLocalStorageContextManager().enable();
174213
const localSpanKey = createContextKey("@photon-ai/isolated-otel.local-span");
214+
const baggagePropagator = new W3CBaggagePropagator();
175215
const traceContextPropagator = new W3CTraceContextPropagator();
176216
const tracer = tracerProvider.getTracer(INSTRUMENTATION_SCOPE);
177217
let shutdownPromise: Promise<void> | undefined;
178218

179219
const propagation: IsolatedOtelHandle["propagation"] = {
180220
capture: () => contextManager.active(),
181221
extract: (headersObject) => {
182-
const value = headersObject.get(traceparentHeader);
183-
if (!value) {
184-
return;
222+
let extracted = ROOT_CONTEXT;
223+
let foundContext = false;
224+
225+
const traceValue = headersObject.get(traceparentHeader);
226+
if (traceValue) {
227+
try {
228+
const traceExtracted = traceContextPropagator.extract(
229+
extracted,
230+
{ [TRACEPARENT_KEY]: traceValue },
231+
defaultTextMapGetter
232+
);
233+
const spanContext = trace.getSpanContext(traceExtracted);
234+
if (spanContext && trace.isSpanContextValid(spanContext)) {
235+
extracted = traceExtracted;
236+
foundContext = true;
237+
} else {
238+
reportDiagnostic("ignored invalid isolated trace header");
239+
}
240+
} catch (error) {
241+
reportDiagnostic("ignored invalid isolated trace header", error);
242+
}
185243
}
186-
try {
187-
const extracted = traceContextPropagator.extract(
188-
ROOT_CONTEXT,
189-
{ [TRACEPARENT_KEY]: value },
190-
defaultTextMapGetter
191-
);
192-
const spanContext = trace.getSpanContext(extracted);
193-
if (spanContext && trace.isSpanContextValid(spanContext)) {
194-
return extracted;
244+
245+
const baggageValue = baggageHeader
246+
? headersObject.get(baggageHeader)
247+
: undefined;
248+
if (baggageValue) {
249+
try {
250+
const baggageExtracted = baggagePropagator.extract(
251+
extracted,
252+
{ [BAGGAGE_KEY]: baggageValue },
253+
defaultTextMapGetter
254+
);
255+
const baggage = otelPropagation.getBaggage(baggageExtracted);
256+
if (baggage && baggage.getAllEntries().length > 0) {
257+
extracted = baggageExtracted;
258+
foundContext = true;
259+
} else {
260+
reportDiagnostic("ignored invalid isolated baggage header");
261+
}
262+
} catch (error) {
263+
reportDiagnostic("ignored invalid isolated baggage header", error);
195264
}
196-
reportDiagnostic("ignored invalid isolated trace header");
197-
return;
198-
} catch (error) {
199-
reportDiagnostic("ignored invalid isolated trace header", error);
200-
return;
201265
}
266+
267+
return foundContext ? extracted : undefined;
202268
},
203269
inject: (headersObject, captured) => {
270+
const active = captured ?? contextManager.active();
204271
try {
205272
headersObject.delete(traceparentHeader);
206273
const carrier: Record<string, string> = {};
207-
traceContextPropagator.inject(
208-
captured ?? contextManager.active(),
209-
carrier,
210-
defaultTextMapSetter
211-
);
274+
traceContextPropagator.inject(active, carrier, defaultTextMapSetter);
212275
const value = carrier[TRACEPARENT_KEY];
213276
if (value) {
214277
headersObject.set(traceparentHeader, value);
215278
}
216279
} catch (error) {
217280
reportDiagnostic("failed to inject isolated trace header", error);
218281
}
282+
283+
if (baggageHeader) {
284+
try {
285+
headersObject.delete(baggageHeader);
286+
const carrier: Record<string, string> = {};
287+
baggagePropagator.inject(active, carrier, defaultTextMapSetter);
288+
const value = carrier[BAGGAGE_KEY];
289+
if (value) {
290+
headersObject.set(baggageHeader, value);
291+
}
292+
} catch (error) {
293+
reportDiagnostic("failed to inject isolated baggage header", error);
294+
}
295+
}
219296
},
220297
run: (captured, fn) => contextManager.with(captured, fn),
221298
};
@@ -244,14 +321,9 @@ export const createIsolatedOtelRuntime = (
244321
return await fn(span);
245322
} catch (error) {
246323
try {
247-
span.recordException(error instanceof Error ? error : String(error));
248-
span.setAttribute(
249-
"error.type",
250-
error instanceof Error ? error.constructor.name : typeof error
251-
);
252324
span.setStatus({ code: SpanStatusCode.ERROR });
253325
} catch (telemetryError) {
254-
reportDiagnostic("failed to record Span error", telemetryError);
326+
reportDiagnostic("failed to set Span error status", telemetryError);
255327
}
256328
throw error;
257329
} finally {
@@ -294,6 +366,46 @@ export const createIsolatedOtelRuntime = (
294366
hasActiveSpan: () =>
295367
contextManager.active().getValue(localSpanKey) !== undefined,
296368
propagation,
369+
recordError(error) {
370+
const span = contextManager.active().getValue(localSpanKey) as
371+
| Span
372+
| undefined;
373+
if (!span?.isRecording()) {
374+
reportDiagnostic("ignored recordError outside a local recording Span");
375+
return;
376+
}
377+
378+
try {
379+
span.setStatus({ code: SpanStatusCode.ERROR });
380+
} catch (telemetryError) {
381+
reportDiagnostic("failed to set Span error status", telemetryError);
382+
}
383+
384+
let exception: Error | string;
385+
let errorType: string;
386+
try {
387+
exception = error instanceof Error ? error : String(error);
388+
errorType =
389+
error instanceof Error ? error.constructor.name : typeof error;
390+
} catch (telemetryError) {
391+
reportDiagnostic(
392+
"failed to normalize Span error details",
393+
telemetryError
394+
);
395+
return;
396+
}
397+
398+
try {
399+
span.recordException(exception);
400+
} catch (telemetryError) {
401+
reportDiagnostic("failed to record Span exception", telemetryError);
402+
}
403+
try {
404+
span.setAttribute("error.type", errorType);
405+
} catch (telemetryError) {
406+
reportDiagnostic("failed to set Span error type", telemetryError);
407+
}
408+
},
297409
shutdown() {
298410
if (!shutdownPromise) {
299411
shutdownPromise = (async () => {

0 commit comments

Comments
 (0)