From d91040849efb252d85159862f58825783b424443 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Sat, 7 Nov 2026 13:11:26 -0500 Subject: [PATCH] feat: add context api --- README.md | 11 +- docs/context.md | 186 ++++++++++++++++++++++++ index.js | 17 ++- lib/context.js | 198 ++++++++++++++++++++++++++ lib/core.js | 8 -- lib/flush.js | 3 +- lib/instrumentations.js | 94 ++++++++---- lib/span.js | 34 +++-- lib/trace-context.js | 77 ++++++++++ package-lock.json | 64 +++++++++ package.json | 3 + test/fixtures/otel-context-manager.js | 139 ++++++++++++++++++ test/test-context.js | 95 ++++++++++++ test/test-http2.js | 14 +- test/test-otel-context-adapter.js | 133 +++++++++++++++++ test/test-sampler.js | 7 +- 16 files changed, 1022 insertions(+), 61 deletions(-) create mode 100644 docs/context.md create mode 100644 lib/context.js create mode 100644 lib/trace-context.js create mode 100644 test/fixtures/otel-context-manager.js create mode 100644 test/test-context.js create mode 100644 test/test-otel-context-adapter.js diff --git a/README.md b/README.md index eadc52b..3336cac 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,19 @@ process.on('SIGTERM', async () => { A flush that times out rejects, and the `finally` exits anyway. +### Context + +`context` reads and sets the [Context](https://opentelemetry.io/docs/specs/otel/context/) +microtel propagates. See [docs/context.md](./docs/context.md). + ## Instrumented operations When tracing is active, spans are created automatically for the following operations. Per the OpenTelemetry [semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/), span names must be low cardinality. Node.js core has no route concept, so spans are -named `{method}` and the request details live in the attributes. Requests the +named `{method}` and the request details live in the attributes. A server span +is named `{method} {target}` when a framework sets the target through +[`context.setTarget()`](./docs/context.md#contextsettargetctx-target). Requests the exporter itself sends to the collector's export path are never traced, on either side; other requests, including other requests to the collector's host, are. @@ -135,6 +142,7 @@ Attributes set on server spans: | `network.protocol.version` | HTTP version (e.g. `1.1`) | Always | | `user_agent.original` | Client `User-Agent` header | When the header is present | | `http.response.status_code` | Response status code | When response finishes | +| `http.route` | The target a framework set | When a target is set | | `error.type` | HTTP status code as string | On 5xx responses | The address attributes follow the proxy headers the way OpenTelemetry's @@ -180,6 +188,7 @@ Attributes set on HTTP/2 server spans: | `network.protocol.version` | `2.0` | Always | | `user_agent.original` | Client `User-Agent` header | When the header is present | | `http.response.status_code` | Response status code | When the response is sent | +| `http.route` | The target a framework set | When a target is set | | `error.type` | HTTP status code as string | On 5xx responses | The address attributes follow the same rules as for HTTP/1 server diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..c1b5b8d --- /dev/null +++ b/docs/context.md @@ -0,0 +1,186 @@ +# Context + +microtel propagates a [Context] across async execution. microtel sets its +values while tracing is active, or a registered adapter can supply them. This +document has two sections, for different readers of the API: + +- [API consumers](#api-consumers), such as web frameworks and logging + libraries, read and set values in the current Context. +- [Context adapters](#context-adapters) get and set microtel's values in + another implementation's Context objects, so both share one current + Context. + +## API consumers + +`microtel.context` holds a closed set of values, each with its own accessor. +There is no string-keyed access. Libraries that need other values keep them +in their own `AsyncLocalStorage`. These functions work the same whichever +adapter is registered. + +Each accessor takes the Context to read or write. Get the current Context +once with `context.active()` and pass it to every accessor you call: + +```js +const ctx = context.active(); +const traceContext = context.getTraceContext(ctx); +const target = context.getTarget(ctx); +``` + +### `context.active()` + +- Returns: {any} The current Context, or `undefined` when none is current. + +The Context is an opaque value. Pass it to the accessors without reading it +directly. + +### Type: `TraceContext` + +A read-only object. Modifying any of its fields is undefined behavior. + +- `traceId` {string} The [trace-id]. +- `spanId` {string} The span's ID, propagated as the [parent-id]. +- `traceFlags` {number} The [trace-flags]. +- `traceState` {Object|undefined} The [tracestate header], as an object with: + - `serialize()` Returns: {string} The header. +- `isRemote` {boolean|undefined} [IsRemote], `true` when the trace context + came from another process. + +### `context.getTraceContext(ctx)` + +- `ctx` {any} A Context from `context.active()`. +- Returns: {TraceContext|undefined} + +Returns the trace context of the span in `ctx`, or `undefined` when it +has none. + +### `context.getTarget(ctx)` + +- `ctx` {any} A Context from `context.active()`. +- Returns: {string|undefined} + +Returns the target of the operation being traced in `ctx`. It is a +low-cardinality name for what the operation is performed against, such as +the route template `/users/:id`. Returns `undefined` when none was set or +`ctx` has no operation. + +### `context.setTarget(ctx, target)` + +- `ctx` {any} A Context from `context.active()`. +- `target` {string} A low-cardinality name for what the operation is + performed against, such as the route template `/users/:id`. +- Returns: {boolean} + +Sets the target of the operation being traced in `ctx`, which microtel +records on the operation's span. Returns `false` when `ctx` has no +operation, and `true` otherwise. microtel puts an operation in the Context of +each incoming request it traces. Only the code that resolved the target, such +as a framework's router, should set it. + +The target is the low-cardinality part of a span name that says what the +operation acts on, like `{target}` in an [HTTP span name], the method in an +[RPC span name], or `{destination}` in a [messaging span name]. + +## Context adapters + +### Adapter and store + +microtel treats each Context as an opaque value. It never reads or builds +one directly, only through the adapter, so an adapter can represent a +Context however it likes, such as its own Context objects with values of its +own next to microtel's. + +Two objects with separate roles handle Contexts: + +- The **adapter** defines what a Context is. It gets microtel's values from + a Context and makes new Contexts with values set. It never decides which + Context is current. +- The **store** tracks which Context is current in each async execution. It + holds Contexts without looking inside them. + +microtel starts with a default adapter, whose Contexts hold only microtel's +values, and a store of its own. + +### `context.registerAdapter(adapter)` + +- `adapter` {Object} An [adapter](#adapter-interface). + +Replaces the default adapter and returns a new [store](#store), which the +adapter uses to make its Contexts current. + +Call it as soon as the process starts, before microtel traces any request. A +request already in flight keeps its Context in the old store, so the requests +it makes after the call start new traces. + +It can be called once per Node.js environment, such as the main thread or a +worker. A second call throws `ERR_INVALID_STATE`. + +### Adapter interface + +An adapter implements the Context API's [Get value] and [Set value] for +microtel's [keys](#keys). + +#### `adapter.getValue(context, key)` + +- `context` A Context this adapter made. +- `key` {string} One of the [keys](#keys). + +Returns microtel's value for `key` in `context`. + +#### `adapter.setValues(context, values)` + +- `context` A Context this adapter made, or `undefined` for the root + Context. +- `values` {Object} Values by [key](#keys). + +Returns a new Context with each key of `values` set, or removed when its +value is `undefined`. + +#### Keys + +- `traceContext` The trace context `context.getTraceContext()` returns. +- `operation` {Operation} The metadata of the operation being traced. See + [`Operation`](#type-operation). + +microtel may set other keys. An adapter maps the keys it understands onto its +own Context and stores the rest as they are, so a key microtel adds needs no +change to the adapter. + +#### Type: `Operation` + +The metadata of the operation being traced. It is shared by every Context +derived while the operation is traced, so a value set on it stays visible +until the operation ends. + +- `target` {string|undefined} See + [`context.getTarget()`](#contextgettargetctx). microtel reads it when the + operation's span ends, and `context.setTarget()` writes it. + +### Store + +The store's methods are the Context API's [optional global operations]. + +#### `store.active()` + +Returns the current Context, or `undefined` for the root Context. + +#### `store.with(context, fn[, ...args])` + +Calls `fn` with `context` current, and returns what `fn` returns. + +#### `store.attach(context)` + +Makes `context` current, and returns a token whose `dispose()` restores the +previous Context. + +[Context]: https://opentelemetry.io/docs/specs/otel/context/ +[Get value]: https://opentelemetry.io/docs/specs/otel/context/#get-value +[HTTP span name]: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name +[IsRemote]: https://opentelemetry.io/docs/specs/otel/trace/api/#isremote +[messaging span name]: https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name +[optional global operations]: https://opentelemetry.io/docs/specs/otel/context/#optional-global-operations +[parent-id]: https://www.w3.org/TR/trace-context/#parent-id +[RPC span name]: https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#name +[Set value]: https://opentelemetry.io/docs/specs/otel/context/#set-value +[trace-flags]: https://www.w3.org/TR/trace-context/#trace-flags +[trace-id]: https://www.w3.org/TR/trace-context/#trace-id +[tracestate header]: https://www.w3.org/TR/trace-context/#tracestate-header diff --git a/index.js b/index.js index 0a438ea..a9a8f0f 100644 --- a/index.js +++ b/index.js @@ -4,13 +4,28 @@ // // init() activates tracing (at most once per process, and it cannot be // stopped), isActive() reports state, and flush() forces an export whose -// returned promise resolves once it has finished. +// returned promise resolves once it has finished. context reads and sets +// the propagated context. const { start, isActive } = require('./lib/core'); const { flush } = require('./lib/flush'); +const { + getCurrentContext, + getTarget, + getTraceContext, + registerAdapter, + setTarget, +} = require('./lib/context'); module.exports = { init: start, isActive, flush, + context: { + active: getCurrentContext, + getTarget, + getTraceContext, + registerAdapter, + setTarget, + }, }; diff --git a/lib/context.js b/lib/context.js new file mode 100644 index 0000000..a977493 --- /dev/null +++ b/lib/context.js @@ -0,0 +1,198 @@ +'use strict'; + +// The Context microtel propagates. A context adapter gets and sets +// microtel's values in Context objects, and a ContextStore tracks the +// current one. Another implementation can register its own adapter, so +// both share the current Context. See docs/context.md. +// https://opentelemetry.io/docs/specs/otel/context/ + +const { AsyncLocalStorage } = require('node:async_hooks'); + +const { TraceContext } = require('./trace-context'); +const { + invalidArgTypeError, + invalidArgValueError, + validateObject, + validateString, +} = require('./validate'); + +// The methods an adapter must implement. +const kAdapterMethods = ['getValue', 'setValues']; + +// One frozen field per key. The null prototype makes any other key read as +// undefined. +class DefaultContext { + constructor(traceContext, operation, baggage) { + this.traceContext = traceContext; + this.operation = operation; + this.baggage = baggage; + Object.freeze(this); + } +} +delete DefaultContext.prototype.constructor; +Object.setPrototypeOf(DefaultContext.prototype, null); + +const kRootContext = new DefaultContext(undefined, undefined, undefined); + +class DefaultContextAdapter { + getValue(context, key) { + return context[key]; + } + + // A key present in values wins, even when undefined. + setValues(context = kRootContext, values) { + return new DefaultContext( + 'traceContext' in values ? values.traceContext : context.traceContext, + 'operation' in values ? values.operation : context.operation, + 'baggage' in values ? values.baggage : context.baggage, + ); + } +} + +// The Context API's optional global operations. +// https://opentelemetry.io/docs/specs/otel/context/#optional-global-operations +class ContextStore { + #storage = new AsyncLocalStorage(); + + active() { + return this.#storage.getStore(); + } + + with(context, fn, ...args) { + return this.#storage.run(context, fn, ...args); + } + + attach(context) { + return this.#storage.withScope(context); + } +} + +// The adapter defines what a Context is, and gets and sets values in one. +// The store tracks which Context is current in each async execution. The +// default store is made only when instrumentations are enabled, so loading +// microtel without enabling them creates no AsyncLocalStorage. +let adapter = new DefaultContextAdapter(); +let store = null; + +function enableContextStore() { + store ??= new ContextStore(); +} + +function isValidTarget(target) { + return typeof target === 'string' && target !== ''; +} + +// Makes a request's Context current for its handler. Each request starts +// from the root Context, with the span as its operation and the inbound +// baggage header. The token is never disposed, so the Context stays current +// after the request ends. Returns the request's Operation, which the caller +// keeps to read the target when the span ends, without the adapter. +// https://nodejs.org/api/async_context.html#asynclocalstoragewithscopestore +// TODO(legendecas): Return the token too, once a caller needs to restore the +// previous Context. +function enterRequest(span) { + const context = adapter.setValues(undefined, { + traceContext: TraceContext.fromSpan(span), + // The span itself is the Operation, since it already holds the target. + // TODO(legendecas): Give the Operation an object of its own, so an + // adapter can't reach the span through it. + operation: span, + baggage: span.baggage, + }); + store.attach(context); + return getOperation(context); +} + +// The current Context, or undefined for the root Context. +function getCurrentContext() { + return store?.active(); +} + +// Get TraceContext from the given context. +// A malformed value from an adapter reads as absent rather than throwing. +// The SDK "SHOULD provide safe defaults for missing or invalid arguments". +// A TraceContext microtel built from a span's own IDs needs no check. +// https://opentelemetry.io/docs/specs/otel/error-handling/#basic-error-handling-principles +function getTraceContext(ctx) { + if (ctx == null) return undefined; + const traceContext = adapter.getValue(ctx, 'traceContext'); + if (traceContext instanceof TraceContext) return traceContext; + return TraceContext.isValid(traceContext) ? traceContext : undefined; +} + +// Get Operation from the given context. +function getOperation(context) { + if (context == null) return undefined; + const operation = adapter.getValue(context, 'operation'); + return typeof operation === 'object' && operation !== null + ? operation + : undefined; +} + +// Get `operation.target` from the given context. +function getTarget(ctx) { + const target = getOperation(ctx)?.target; + return isValidTarget(target) ? target : undefined; +} + +// Set `operation.target` in the given context. +// The low-cardinality part of a span name, like {target} for HTTP or +// {destination} for messaging, which "SHOULD describe the entity that the +// operation is performed against". Returns false without an operation. +// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name +// https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name +function setTarget(ctx, target) { + validateString(target, 'target'); + if (target === '') + throw invalidArgValueError('target', target, 'must be a non-empty string'); + const operation = getOperation(ctx); + if (operation === undefined) return false; + operation.target = target; + return true; +} + +// Get inbound baggage header from the given context. +// TODO(legendecas): Add a baggage API with structured access, following the +// W3C Baggage format and the OpenTelemetry Baggage API, and document the key. +// https://www.w3.org/TR/baggage/ +// https://opentelemetry.io/docs/specs/otel/baggage/api/ +function getBaggage(context) { + if (context == null) return undefined; + const baggage = adapter.getValue(context, 'baggage'); + return typeof baggage === 'string' && baggage !== '' ? baggage : undefined; +} + +// Once per Node.js environment, before any request is traced. +function registerAdapter(newAdapter) { + if (!(adapter instanceof DefaultContextAdapter)) { + const err = new Error( + 'Invalid state: a context adapter is already registered', + ); + err.code = 'ERR_INVALID_STATE'; + throw err; + } + validateObject(newAdapter, 'adapter'); + for (const name of kAdapterMethods) { + if (typeof newAdapter[name] !== 'function') + throw invalidArgTypeError( + `adapter.${name}`, + 'Function', + newAdapter[name], + ); + } + adapter = newAdapter; + store = new ContextStore(); + return store; +} + +module.exports = { + enableContextStore, + enterRequest, + getBaggage, + getCurrentContext, + getTarget, + getTraceContext, + isValidTarget, + registerAdapter, + setTarget, +}; diff --git a/lib/core.js b/lib/core.js index de1f576..ce1e4a9 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,7 +1,6 @@ 'use strict'; const { randomUUID } = require('node:crypto'); -const { AsyncLocalStorage } = require('node:async_hooks'); const { validateHeaderName, validateHeaderValue } = require('node:http'); const { configureSampler } = require('./sampler'); @@ -22,7 +21,6 @@ let endpoint = null; let active = false; let filter = null; // null = all modules enabled; Set = only listed modules. let exclude = null; // null = nothing excluded; Set = listed modules disabled. -let spanStorage = null; let collectorHost = null; // Normalized host (e.g. "localhost:4318") for self-trace suppression. let exportUrl = null; // The collector's full export URL, fixed after start. let serviceName = kDefaultServiceName; @@ -32,11 +30,6 @@ let exportHeaders = null; // Copied at init, sent with every export request. let resourceAttributes = null; // A copy of options.resourceAttributes, made at init. let instanceId = null; // Random per-process identity, made at init. -function getSpanStorage() { - spanStorage ??= new AsyncLocalStorage(); - return spanStorage; -} - // A filter or exclude entry matches the module itself; a broader entry // also matches its subscopes, so 'node:http' covers node:http/client and // node:http/server. @@ -259,7 +252,6 @@ module.exports = { start, isActive: () => active, getEndpoint: () => endpoint, - getSpanStorage, isModuleEnabled, getCollectorHost: () => collectorHost, getExportUrl: () => exportUrl, diff --git a/lib/flush.js b/lib/flush.js index c4ca388..54f2901 100644 --- a/lib/flush.js +++ b/lib/flush.js @@ -186,7 +186,8 @@ function spanToOtlp(span) { if (span.parentSpanId) otlpSpan.parentSpanId = span.parentSpanId; - if (span.tracestate) otlpSpan.traceState = span.tracestate; + const tracestate = span.traceState?.serialize(); + if (tracestate) otlpSpan.traceState = tracestate; if (events.length > 0) otlpSpan.events = events; diff --git a/lib/instrumentations.js b/lib/instrumentations.js index 4f2724e..53df545 100644 --- a/lib/instrumentations.js +++ b/lib/instrumentations.js @@ -13,15 +13,29 @@ const { } = require('./span'); const { parseForwarded } = require('./forwarded'); const { - isModuleEnabled, - getSpanStorage, - getCollectorHost, - getExportUrl, -} = require('./core'); - -// Cached at subscription time to avoid a per-event AsyncLocalStorage -// lookup. -let spanStorage; + enableContextStore, + enterRequest, + getBaggage, + getCurrentContext, + getTraceContext, + isValidTarget, +} = require('./context'); +const { isModuleEnabled, getCollectorHost, getExportUrl } = require('./core'); + +// A server request's Operation, read for its target when the span ends. +const kOperation = Symbol('kOperation'); + +// "HTTP span names SHOULD be `{method} {target}`", with http.route as the +// server target. +// https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name +function applyTarget(span, operation) { + // An adapter's operation can hold any value, such as a route a framework + // set without going through context.setTarget(). + const target = operation?.target; + if (!isValidTarget(target)) return; + span.setAttribute('http.route', target); + span.name = `${span.name} ${target}`; +} // Shared client-span response handling. The caller decides when the span // ends. @@ -221,8 +235,9 @@ function onHttpServerRequestStart({ request, response, socket }) { const method = request.method || 'GET'; // Per the OTel semantic conventions span names must be low cardinality. - // Core has no route concept, so only the method is used; the path lives - // in an attribute. + // Core has no route concept, so the span starts named by the method, and + // the target a framework sets is appended when the span ends; the path + // lives in an attribute. const span = new Span(method, SPAN_KIND_SERVER, { parent, // The tracestate and baggage are forwarded verbatim; neither is @@ -231,10 +246,10 @@ function onHttpServerRequestStart({ request, response, socket }) { baggage, }); - // The span deliberately stays in the AsyncLocalStorage after the request - // ends, so work started while handling it still links to the server - // span as its parent. - spanStorage.enterWith(span); + // The span deliberately stays current after the request ends, so work + // started while handling it still links to the server span as its + // parent. + const operation = enterRequest(span); // Everything from here on serves only the export, which an unsampled // span never reaches: the attributes, the stash the response handlers @@ -264,6 +279,7 @@ function onHttpServerRequestStart({ request, response, socket }) { // The span is stashed on the response so the response handlers do not // need a second AsyncLocalStorage lookup. response[kSpan] = span; + response[kOperation] = operation; // Response 'close' without 'finish' means the connection was terminated // before the response completed. Request 'close' cannot be used for @@ -278,6 +294,7 @@ function onHttpServerRequestStart({ request, response, socket }) { function onServerResponseClose() { const span = this[kSpan]; if (span.endTime === undefined && !this.writableEnded) { + applyTarget(span, this[kOperation]); span.setStatus(STATUS_ERROR, 'request closed before response'); span.end(); } @@ -300,6 +317,7 @@ function onHttpServerResponseFinish({ response }) { span.setAttribute('error.type', `${statusCode}`); } + applyTarget(span, response[kOperation]); span.end(); } @@ -349,10 +367,17 @@ function onHttpClientRequestCreated({ request }) { const exportUrl = getExportUrl(); if (authority === getCollectorHost() && path === exportUrl.pathname) return; - const parent = spanStorage.getStore(); + // TODO(legendecas): Skip the trace when the current Context suppresses + // tracing, as opentelemetry-js does with suppressTracing() for its + // exporters' own requests. The undici handler needs the same check. + const context = getCurrentContext(); + const parent = getTraceContext(context); const method = request.method || 'GET'; - const span = new Span(method, SPAN_KIND_CLIENT, { parent }); + const span = new Span(method, SPAN_KIND_CLIENT, { + parent, + baggage: getBaggage(context), + }); // The attributes and the stash the response and error handlers read // serve only the export, which an unsampled span never reaches. @@ -383,7 +408,8 @@ function onHttpClientRequestCreated({ request }) { // A caller-supplied tracestate or baggage is replaced rather than // appended, and a stale value is removed when the span has none to // inherit, so it does not travel with the new trace context. - if (span.tracestate) request.setHeader('tracestate', span.tracestate); + const tracestate = span.traceState?.serialize(); + if (tracestate) request.setHeader('tracestate', tracestate); else request.removeHeader('tracestate'); if (span.baggage) request.setHeader('baggage', span.baggage); else request.removeHeader('baggage'); @@ -464,7 +490,7 @@ function onHttp2ServerStreamStart({ stream, headers }) { // read. The span is still made current, so work started while handling // the request links to it as its parent. if (!span.isSampled) { - spanStorage.enterWith(span); + enterRequest(span); return; } @@ -508,10 +534,11 @@ function onHttp2ServerStreamStart({ stream, headers }) { return; } - // The span deliberately stays in the AsyncLocalStorage after the stream - // ends, so work started while handling it still links to the server - // span as its parent. - spanStorage.enterWith(span); + // The span deliberately stays current after the stream ends, so work + // started while handling it still links to the server span as its + // parent. + const operation = enterRequest(span); + stream[kOperation] = operation; } // Fires when the response headers are submitted, so the status code is @@ -548,8 +575,10 @@ function onHttp2ServerStreamClose({ stream }) { // the usual stream API on aborts, so writableEnded cannot distinguish // them; the aborted flag marks resets, and headersSent is false whenever // the server never responded at all. - if (span.endTime === undefined && (stream.aborted || !stream.headersSent)) { - span.setStatus(STATUS_ERROR, 'request closed before response'); + if (span.endTime === undefined) { + applyTarget(span, stream[kOperation]); + if (stream.aborted || !stream.headersSent) + span.setStatus(STATUS_ERROR, 'request closed before response'); } span.end(); @@ -560,6 +589,7 @@ function onHttp2ServerStreamError({ stream, error }) { if (span == null) return; if (span.endTime === undefined) { + applyTarget(span, stream[kOperation]); span.setStatus(STATUS_ERROR, error?.message || 'stream error'); span.end(); } @@ -605,10 +635,14 @@ let lastOriginAddress; let lastOriginPort; function onUndiciRequestCreate({ request }) { - const parent = spanStorage.getStore(); + const context = getCurrentContext(); + const parent = getTraceContext(context); const method = request.method || 'GET'; - const span = new Span(method, SPAN_KIND_CLIENT, { parent }); + const span = new Span(method, SPAN_KIND_CLIENT, { + parent, + baggage: getBaggage(context), + }); // The attributes and the stash the response handlers read serve only // the export, which an unsampled span never reaches. @@ -636,8 +670,8 @@ function onUndiciRequestCreate({ request }) { if (request.addHeader) { setUndiciHeader(request, 'traceparent', span.toTraceparent()); - if (span.tracestate) - setUndiciHeader(request, 'tracestate', span.tracestate); + const tracestate = span.traceState?.serialize(); + if (tracestate) setUndiciHeader(request, 'tracestate', tracestate); else removeUndiciHeader(request, 'tracestate'); if (span.baggage) setUndiciHeader(request, 'baggage', span.baggage); else removeUndiciHeader(request, 'baggage'); @@ -663,7 +697,7 @@ function onUndiciRequestTrailers({ request }) { const subscribe = (name, handler) => dc.subscribe(name, guard(name, handler)); function enableInstrumentations() { - spanStorage = getSpanStorage(); + enableContextStore(); if (isModuleEnabled('node:http/server')) { subscribe('http.server.request.start', onHttpServerRequestStart); diff --git a/lib/span.js b/lib/span.js index e45b3a8..b0ed0e5 100644 --- a/lib/span.js +++ b/lib/span.js @@ -5,6 +5,7 @@ const { performance } = require('node:perf_hooks'); const { addSpan } = require('./flush'); const { generateTraceId, generateSpanId } = require('./id'); const { getSampledTracestate, sampleRoot } = require('./sampler'); +const { TraceState } = require('./trace-context'); // SpanKind and StatusCode values per the OTLP trace proto: // https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto @@ -53,6 +54,7 @@ class SpanAttributes { 'error.type' = undefined; 'http.request.method' = undefined; 'http.response.status_code' = undefined; + 'http.route' = undefined; 'network.peer.address' = undefined; 'network.peer.port' = undefined; 'network.protocol.version' = undefined; @@ -71,8 +73,9 @@ Object.setPrototypeOf(SpanAttributes.prototype, null); // V8's fast mode, unlike a __proto__: null literal. The header carries no // tracestate or baggage, so those stay undefined. class RemoteParent { - tracestate = undefined; + traceState = undefined; baggage = undefined; + isRemote = true; constructor(traceId, spanId, traceFlags) { this.traceId = traceId; this.spanId = spanId; @@ -92,13 +95,12 @@ class Span { parentSpanId; name; kind; - // Raw W3C tracestate header value, forwarded verbatim to child - // requests. Undefined when no tracestate was received. - tracestate; // Raw W3C baggage header value, forwarded verbatim like tracestate: // never parsed, never modified, and not a span attribute, so it never // appears in the OTLP payload. Undefined when no baggage was received. baggage; + // Set through context.setTarget(), as the request's operation. + target; // Timestamps are epoch milliseconds. The start comes from the wall // clock; the end is derived as start + a monotonic duration, so spans // survive a backward clock step while keeping human-readable absolute @@ -121,6 +123,10 @@ class Span { #status; #traceFlags; #remoteParent; + // The W3C tracestate, forwarded verbatim to child requests and serialized + // only when the header is sent or the span is exported. Undefined when + // there is none. + #traceState; constructor(name, kind, options) { const parent = options?.parent; @@ -136,8 +142,9 @@ class Span { this.traceId = parent.traceId; this.parentSpanId = parent.spanId; this.#traceFlags = parent.traceFlags; - // Any parent but a local span came from another process. - this.#remoteParent = !(parent instanceof Span); + // "true if the SpanContext was propagated from a remote parent". + // https://opentelemetry.io/docs/specs/otel/trace/api/#isremote + this.#remoteParent = parent.isRemote === true; } else { this.#remoteParent = false; // A root span starts the trace, so it makes the sampling decision. @@ -151,10 +158,13 @@ class Span { this.#traceFlags = kTraceFlagRandom; } } - // An explicit tracestate wins, children inherit their parent's, and a - // sampled root starts one with its sampling threshold. - this.tracestate = - options?.tracestate ?? parent?.tracestate ?? rootTracestate; + // An explicit tracestate header wins, children inherit their parent's + // trace state, and a sampled root starts one with its sampling threshold. + const tracestate = options?.tracestate ?? rootTracestate; + this.#traceState = + tracestate === undefined + ? parent?.traceState + : new TraceState(tracestate); // Baggage follows the same inheritance: an explicit inbound value // wins, children inherit their parent's. this.baggage = options?.baggage ?? parent?.baggage; @@ -172,6 +182,10 @@ class Span { return this.#traceFlags; } + get traceState() { + return this.#traceState; + } + get isSampled() { return (this.#traceFlags & kTraceFlagSampled) !== 0; } diff --git a/lib/trace-context.js b/lib/trace-context.js new file mode 100644 index 0000000..729bfab --- /dev/null +++ b/lib/trace-context.js @@ -0,0 +1,77 @@ +'use strict'; + +// The W3C Trace Context a span propagates: its trace context and trace +// state. +// https://www.w3.org/TR/trace-context/ + +// "trace-id = 32HEXDIGLC" and "parent-id = 16HEXDIGLC", all zeros invalid. +// https://www.w3.org/TR/trace-context/#trace-id +// https://www.w3.org/TR/trace-context/#parent-id +const kTraceId = /^(?!0{32})[0-9a-f]{32}$/; +const kSpanId = /^(?!0{16})[0-9a-f]{16}$/; + +// A tracestate header, behind the serialize() of OpenTelemetry's TraceState, +// so a trace state from either side is kept the same way and serialized only +// when the header is sent or the span is exported. +// https://opentelemetry.io/docs/specs/otel/trace/api/#tracestate +// TODO(legendecas): Add the operations a TraceState "MUST provide": get, +// add, update, and delete a key-value pair, each returning a new TraceState. +class TraceState { + #header; + + constructor(header) { + this.#header = header; + } + + // Technically, serialization happens at mutation as mutation is less likely + // to happen than serialization. + serialize() { + return this.#header; + } +} + +// The fields of a SpanContext. +// https://opentelemetry.io/docs/specs/otel/trace/api/#spancontext +class TraceContext { + constructor(traceId, spanId, traceFlags, traceState, isRemote) { + this.traceId = traceId; + this.spanId = spanId; + this.traceFlags = traceFlags; + this.traceState = traceState; + this.isRemote = isRemote; + Object.freeze(this); + } + + // The trace context a span's children inherit, which is local to them. + static fromSpan(span) { + return new TraceContext( + span.traceId, + span.spanId, + span.traceFlags, + span.traceState, + false, + ); + } + + // "true if the SpanContext has a non-zero TraceID and a non-zero SpanID", + // with the field types checked too. + // https://opentelemetry.io/docs/specs/otel/trace/api/#isvalid + static isValid(value) { + return ( + typeof value === 'object' && + value !== null && + kTraceId.test(value.traceId) && + kSpanId.test(value.spanId) && + Number.isInteger(value.traceFlags) && + value.traceFlags >= 0 && + value.traceFlags <= 0xff && + (value.traceState === undefined || + typeof value.traceState?.serialize === 'function') + ); + } +} + +module.exports = { + TraceContext, + TraceState, +}; diff --git a/package-lock.json b/package-lock.json index 59dbb61..7b1ceb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "license": "MIT", "devDependencies": { + "@opentelemetry/api": "1.10.0", + "@opentelemetry/core": "3.1.0", + "@opentelemetry/sdk-trace": "3.1.0", "@opentelemetry/semantic-conventions": "1.43.0", "esbuild": "0.28.2", "prettier": "3.9.10" @@ -459,6 +462,67 @@ "node": ">=18" } }, + "node_modules/@opentelemetry/api": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz", + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-3.1.0.tgz", + "integrity": "sha512-TS/6jqU5gilzjvjGnsTEKVFF/0MMAtBc2psxaX39IGCBYLat83/iHxYBWn+O6WB+pnyB0SwiH46rvTEp/OeYxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": ">=22.15.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.10.0 <1.11.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-3.1.0.tgz", + "integrity": "sha512-51dObapLj5PFRoQ+V4G4pZuQhzgFw/fC4m6F6Qa7rhjkrPyldroR34uOhL1vQqbpR02WdRh0JmsxyNlh2YNNvg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "3.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": ">=22.15.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.10.0 <1.11.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-3.1.0.tgz", + "integrity": "sha512-MlK9KhpKp4Gkz7o9OjwdWgSLCms1mr5SSDykqwaBq4NUsB6IhM4ihXpC6fZek3LGuCB8KIueQAPpcgU1Q3UYkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "3.1.0", + "@opentelemetry/resources": "3.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": ">=22.15.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.10.0 <1.11.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", diff --git a/package.json b/package.json index ad5debd..a35363e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "format:check": "prettier --check ." }, "devDependencies": { + "@opentelemetry/api": "1.10.0", + "@opentelemetry/core": "3.1.0", + "@opentelemetry/sdk-trace": "3.1.0", "@opentelemetry/semantic-conventions": "1.43.0", "esbuild": "0.28.2", "prettier": "3.9.10" diff --git a/test/fixtures/otel-context-manager.js b/test/fixtures/otel-context-manager.js new file mode 100644 index 0000000..d72b6fc --- /dev/null +++ b/test/fixtures/otel-context-manager.js @@ -0,0 +1,139 @@ +'use strict'; + +// An OpenTelemetry ContextManager backed by microtel's context store, to test +// the context adapter interface. + +const { ROOT_CONTEXT, createContextKey, trace } = require('@opentelemetry/api'); +const { + RPCType, + TraceState, + deleteRPCMetadata, + getRPCMetadata, + setRPCMetadata, +} = require('@opentelemetry/core'); + +// microtel's operation over OpenTelemetry's RPC metadata, where framework +// instrumentations set the target as the route. +class RPCMetadataOperation { + #metadata; + + constructor(metadata) { + this.#metadata = metadata; + } + + get target() { + return this.#metadata.route; + } + + set target(target) { + this.#metadata.route = target; + } +} + +// How each of microtel's keys reads from and writes to an OpenTelemetry +// Context. set() removes the value when it is undefined. +const kMicrotelKeyMappings = { + __proto__: null, + traceContext: { + // A SpanContext already has the fields of microtel's trace context. + get: (ctx) => trace.getSpanContext(ctx), + // microtel's TraceState only serializes, so it is parsed into a full + // OpenTelemetry TraceState. + // TODO(legendecas): remove parsing when microtel TraceState has + // structured API. + set(ctx, value) { + if (value === undefined) return trace.deleteSpan(ctx); + const { traceId, spanId, traceFlags, traceState, isRemote } = value; + const spanContext = { traceId, spanId, traceFlags, isRemote }; + if (traceState) + spanContext.traceState = new TraceState(traceState.serialize()); + return trace.setSpanContext(ctx, spanContext); + }, + }, + operation: { + get(ctx) { + const metadata = getRPCMetadata(ctx); + return metadata && new RPCMetadataOperation(metadata); + }, + set(ctx, value) { + if (value === undefined) return deleteRPCMetadata(ctx); + return setRPCMetadata(ctx, { + type: RPCType.HTTP, + route: value.target, + span: trace.getSpan(ctx), + }); + }, + }, +}; + +// A key without an OpenTelemetry equivalent is kept under a context key of +// its own, so a key microtel adds needs no change here. +function opaqueMapping(key) { + const contextKey = createContextKey(`microtel ${key}`); + return { + get: (ctx) => ctx.getValue(contextKey), + set: (ctx, value) => + value === undefined + ? ctx.deleteValue(contextKey) + : ctx.setValue(contextKey, value), + }; +} + +const mappingOf = (key) => kMicrotelKeyMappings[key] ?? opaqueMapping(key); + +// Gets and sets microtel's values in OpenTelemetry Contexts. +class MicrotelContextAdapter { + getValue(ctx, key) { + return mappingOf(key).get(ctx); + } + + setValues(ctx = ROOT_CONTEXT, values) { + for (const [key, value] of Object.entries(values)) + ctx = mappingOf(key).set(ctx, value); + return ctx; + } +} + +// The ContextManager interface over microtel's store, in place of +// AsyncLocalStorageContextManager's own AsyncLocalStorage. Event emitters are +// not bound, which these tests don't need. +class MicrotelContextManager { + #store; + + constructor(microtel) { + this.#store = microtel.context.registerAdapter( + new MicrotelContextAdapter(), + ); + } + + active() { + return this.#store.active() ?? ROOT_CONTEXT; + } + + with(ctx, fn, thisArg, ...args) { + const cb = thisArg == null ? fn : fn.bind(thisArg); + return this.#store.with(ctx, cb, ...args); + } + + bind(ctx, target) { + if (typeof target !== 'function') return target; + const manager = this; + return function (...args) { + return manager.with(ctx, () => target.apply(this, args)); + }; + } + + attach(ctx) { + return this.#store.attach(ctx); + } + + enable() { + return this; + } + + disable() { + return this; + } +} + +module.exports = { MicrotelContextManager }; diff --git a/test/test-context.js b/test/test-context.js new file mode 100644 index 0000000..877bc5b --- /dev/null +++ b/test/test-context.js @@ -0,0 +1,95 @@ +'use strict'; + +const assert = require('node:assert'); +const { after, before, describe, it } = require('node:test'); +const { context } = require('..'); +const { + startTracing, + startHTTPServer, + flushAndCollect, + getServerSpan, + getClientSpan, + rawRequest, + httpGet, + spanAttrs, + closeAll, +} = require('./common/otel'); + +describe('context outside a request', () => { + it('reports an empty context', () => { + const ctx = context.active(); + assert.strictEqual(ctx, undefined); + assert.strictEqual(context.getTraceContext(ctx), undefined); + assert.strictEqual(context.getTarget(ctx), undefined); + assert.strictEqual(context.setTarget(ctx, '/a'), false); + assert.strictEqual(context.getTraceContext(null), undefined); + }); + + it('validates the target', () => { + assert.throws(() => context.setTarget(undefined, 1), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => context.setTarget(undefined, ''), { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); +}); + +describe('context with tracing', () => { + let collector; + + before(async () => { + collector = await startTracing(); + }); + + after(() => collector.close()); + + it('exposes the server request context to the handler', async () => { + const received = {}; + const backend = await startHTTPServer((req, res) => { + received[req.url] = req.headers; + res.end('ok'); + }); + + let traceContext; + let target; + const frontend = await startHTTPServer(async (req, res) => { + const ctx = context.active(); + traceContext = context.getTraceContext(ctx); + context.setTarget(ctx, '/users/:id'); + target = context.getTarget(ctx); + await httpGet(backend.port, '/child'); + res.end('ok'); + }); + + await rawRequest(frontend.port, '/users/1', { + traceparent: `00-${'a'.repeat(32)}-${'1'.repeat(16)}-01`, + tracestate: 'vendor=state', + baggage: 'user=alice', + }); + const spans = await flushAndCollect(collector); + await closeAll(frontend, backend); + + const server = getServerSpan(spans, '/users/1'); + const { traceState, ...ids } = traceContext; + assert.deepStrictEqual(ids, { + traceId: server.traceId, + spanId: server.spanId, + traceFlags: 1, + isRemote: false, + }); + assert.strictEqual(traceState.serialize(), 'vendor=state'); + assert.ok(Object.isFrozen(traceContext)); + + assert.strictEqual(target, '/users/:id'); + assert.strictEqual(server.name, 'GET /users/:id'); + assert.strictEqual(spanAttrs(server)['http.route'], '/users/:id'); + + assert.strictEqual( + getClientSpan(spans, '/child').parentSpanId, + server.spanId, + ); + assert.strictEqual(received['/child'].tracestate, 'vendor=state'); + assert.strictEqual(received['/child'].baggage, 'user=alice'); + }); +}); diff --git a/test/test-http2.js b/test/test-http2.js index e1aad7d..ca2efd0 100644 --- a/test/test-http2.js +++ b/test/test-http2.js @@ -13,7 +13,7 @@ const { spanAttrs, closeAll, } = require('./common/otel'); -const { getSpanStorage } = require('../lib/core'); +const { getCurrentContext } = require('../lib/context'); // Starts a plain HTTP/2 server for traced requests. The handler receives // (stream, headers). Exposes port and close(). @@ -443,9 +443,9 @@ describe('otel HTTP/2 server spans', () => { let currentAfterUnsampledStart; const onStart = ({ headers }) => { if (headers?.[':path'] === '/destroyed') - currentAfterStart = getSpanStorage().getStore(); + currentAfterStart = getCurrentContext(); if (headers?.[':path'] === '/destroyed-unsampled') - currentAfterUnsampledStart = getSpanStorage().getStore(); + currentAfterUnsampledStart = getCurrentContext(); }; dc.subscribe('http2.server.stream.start', onStart); const warnings = []; @@ -491,13 +491,11 @@ describe('otel HTTP/2 server spans', () => { ); // The ended span must not be left current: no handler runs for the // destroyed stream, and later subscribers share this context. - assert.strictEqual( - currentAfterStart?.endTime, - undefined, + assert.ok( + currentAfterStart == null, 'The ended span must not be left current', ); - // An unsampled destroyed stream leaves no span current either. Its - // span never ended, so the endTime check above could not tell. + // An unsampled destroyed stream leaves no span current either. assert.ok( currentAfterUnsampledStart == null, 'The unsampled destroyed span must not be left current', diff --git a/test/test-otel-context-adapter.js b/test/test-otel-context-adapter.js new file mode 100644 index 0000000..7b4bc65 --- /dev/null +++ b/test/test-otel-context-adapter.js @@ -0,0 +1,133 @@ +'use strict'; + +const assert = require('node:assert'); +const { after, before, describe, it } = require('node:test'); +const { context, trace } = require('@opentelemetry/api'); +const { getRPCMetadata } = require('@opentelemetry/core'); +const { + InMemorySpanExporter, + SimpleSpanProcessor, + TracerProvider, +} = require('@opentelemetry/sdk-trace'); +const microtel = require('..'); +const { MicrotelContextManager } = require('./fixtures/otel-context-manager'); +const { + startTracing, + startHTTPServer, + flushAndCollect, + getServerSpan, + getClientSpan, + rawRequest, + httpGet, + spanAttrs, + closeAll, +} = require('./common/otel'); + +// This is a compatibility test. +// OpenTelemetry keeps its Contexts in microtel's store through a context +// adapter, so spans from either side parent spans from the other. + +const exporter = new InMemorySpanExporter(); +context.setGlobalContextManager(new MicrotelContextManager(microtel)); +trace.setGlobalTracerProvider( + new TracerProvider({ + spanProcessors: [new SimpleSpanProcessor({ exporter })], + }), +); +const tracer = trace.getTracer('test'); + +const finishedSpan = (name) => + exporter.getFinishedSpans().find((span) => span.name === name); + +describe('otel context adapter', () => { + let collector; + let backend; + const received = {}; + + before(async () => { + collector = await startTracing(); + backend = await startHTTPServer((req, res) => { + received[req.url] = req.headers; + res.end('ok'); + }); + }); + + after(() => closeAll(backend, collector)); + + it('refuses a second adapter', () => { + assert.throws( + () => microtel.context.registerAdapter({ getValue() {}, setValues() {} }), + { code: 'ERR_INVALID_STATE' }, + ); + }); + + it('shares a microtel server span with OpenTelemetry', async () => { + let activeSpanContext; + let microtelTarget; + const frontend = await startHTTPServer((req, res) => { + activeSpanContext = trace.getActiveSpan().spanContext(); + // Framework instrumentations report the route this way. + getRPCMetadata(context.active()).route = '/users/:id'; + microtelTarget = microtel.context.getTarget(microtel.context.active()); + tracer.startActiveSpan('work', async (work) => { + await httpGet(backend.port, '/down'); + work.end(); + res.end('ok'); + }); + }); + + await rawRequest(frontend.port, '/users/1', { + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + tracestate: 'vendor=state', + }); + const spans = await flushAndCollect(collector); + await frontend.close(); + + const server = getServerSpan(spans, '/users/1'); + assert.strictEqual(server.parentSpanId, 'b7ad6b7169203331'); + const { traceState, ...ids } = activeSpanContext; + assert.deepStrictEqual(ids, { + traceId: server.traceId, + spanId: server.spanId, + traceFlags: 1, + isRemote: false, + }); + assert.strictEqual(traceState.serialize(), 'vendor=state'); + assert.strictEqual(microtelTarget, '/users/:id'); + assert.strictEqual(server.name, 'GET /users/:id'); + assert.strictEqual(spanAttrs(server)['http.route'], '/users/:id'); + + const work = finishedSpan('work'); + assert.strictEqual(work.spanContext().traceId, server.traceId); + assert.strictEqual(work.parentSpanContext.spanId, server.spanId); + assert.strictEqual( + work.spanContext().traceState.serialize(), + 'vendor=state', + ); + + const down = getClientSpan(spans, '/down'); + assert.strictEqual(down.parentSpanId, work.spanContext().spanId); + assert.strictEqual( + received['/down'].traceparent, + `00-${server.traceId}-${down.spanId}-01`, + ); + assert.strictEqual(received['/down'].tracestate, 'vendor=state'); + }); + + it('parents microtel client spans to OpenTelemetry spans', async () => { + await tracer.startActiveSpan('root', async (root) => { + assert.strictEqual( + microtel.context.getTraceContext(microtel.context.active()).spanId, + root.spanContext().spanId, + ); + await httpGet(backend.port, '/root'); + root.end(); + }); + const spans = await flushAndCollect(collector); + + const root = finishedSpan('root').spanContext(); + const client = getClientSpan(spans, '/root'); + assert.strictEqual(client.traceId, root.traceId); + assert.strictEqual(client.parentSpanId, root.spanId); + }); +}); diff --git a/test/test-sampler.js b/test/test-sampler.js index e783dce..f3acc1f 100644 --- a/test/test-sampler.js +++ b/test/test-sampler.js @@ -79,7 +79,10 @@ describe('otel root sampler', () => { const sampled = span.traceId.slice(18) >= '80000000000000'; decisions.add(sampled); assert.strictEqual(span.traceFlags, sampled ? 0x03 : 0x02); - assert.strictEqual(span.tracestate, sampled ? 'ot=th:8' : undefined); + assert.strictEqual( + span.traceState?.serialize(), + sampled ? 'ot=th:8' : undefined, + ); assert.ok(span.toTraceparent().endsWith(sampled ? '-03' : '-02')); } assert.strictEqual(decisions.size, 2); @@ -94,7 +97,7 @@ describe('otel root sampler', () => { }); const child = new Span('GET', SPAN_KIND_CLIENT, { parent }); assert.strictEqual(child.traceFlags, traceFlags); - assert.strictEqual(child.tracestate, 'vendor=state'); + assert.strictEqual(child.traceState.serialize(), 'vendor=state'); } }); });