[ Operations ]
@sdxc/trace-context
W3C Trace Context, carried from each invocation to its jobs and requests
- Installs with
- @sdxc/crypto@sdxc/logger@sdxc/result
- Used by
- uptime, auth-saas, reader, blog, books
- Source
- packages/trace-context
W3C Trace Context, carried from each invocation to its jobs and requests.
W3C Trace Context defines the two headers every hop of a
distributed request shares: traceparent (trace id, the calling span's id, a flags byte) and
tracestate (up to 32 vendor entries). This package reads and writes both (Level 1, plus the
Level 2 random flag) and binds one trace to each invocation, so anything running inside it can
name the invocation's span as its parent without being handed the trace.
Installation
npm add @sdxc/trace-context
The middleware runs on the remix router and stamps the
log from @sdxc/logger; parsers answer a Result
from @sdxc/result. @sdxc/jobs
carries the current trace into every job it enqueues, and
@sdxc/api-client injects it into every request.
@sdxc/trace-context/middleware/cli runs on @sdxc/cli.
Usage
Give every request a trace
import { createLogger } from "@sdxc/logger";
import { log } from "@sdxc/logger/middleware";
import { trace } from "@sdxc/trace-context/middleware";
import { createRouter } from "remix/router";
let logger = createLogger({ service: "api" });
let router = createRouter({ middleware: [log(logger), trace()] });
router.get("/", (ctx) => Response.json({ traceId: ctx.trace.traceId }));
trace() continues the caller's traceparent or starts a new trace, publishes it as ctx.trace,
and sets trace_id, span_id, trace_flags and parent_span_id on the request's log. Mount it
directly after log(logger).
Give every command run a trace
import { createCLI } from "@sdxc/cli";
import { log } from "@sdxc/logger/middleware/cli";
import { trace } from "@sdxc/trace-context/middleware/cli";
let cli = createCLI({ name: "app", commands, middleware: [log(logger), trace()] });
The command line's trace() continues the trace in the TRACEPARENT and TRACESTATE
environment variables, OpenTelemetry's carrier for processes, or starts one. Every child process
ctx.exec starts receives TRACEPARENT naming the run's span, so a script that calls another
traced command keeps one trace_id.
Propagate it to outbound requests
import { inject, withTrace } from "@sdxc/trace-context";
let headers = new Headers({ Accept: "application/json" });
inject(headers);
let items = await fetch("https://api.example.com/items", { headers });
let forwarded = await stub.fetch(withTrace(request));
Both read the current trace, so they work anywhere inside the request, however deep. inject
writes into headers you are building; withTrace copies a request you are forwarding and replaces
the trace headers it arrived with, so the callee sees this span as its parent.
Bind a trace outside a router
import { currentLog } from "@sdxc/logger";
import { inject, runWithTrace, startTrace, traceFields } from "@sdxc/trace-context";
let trace = startTrace();
await runWithTrace(trace, async () => {
currentLog()?.set(traceFields(trace));
let headers = new Headers();
inject(headers);
await fetch("https://api.example.com/sync", { headers });
});
runWithTrace makes a trace current for everything fn awaits; a nested call restores the outer
trace when it returns. traceFields names the trace as OpenTelemetry names it for logs.
Read and write the headers
import { isSuccess, unwrap } from "@sdxc/result";
import { parse, stringify } from "@sdxc/trace-context/traceparent";
import * as TraceStateHeader from "@sdxc/trace-context/tracestate";
let parent = parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
if (isSuccess(parent)) parent.data.sampled; // true
let state = unwrap(TraceStateHeader.parse("rojo=00f067aa0ba902b7,congo=t61rcWkgMzE"));
let updated = unwrap(state.set("congo", "new"));
TraceStateHeader.stringify(updated); // "congo=new,rojo=00f067aa0ba902b7"
API
@sdxc/trace-context
startTrace(options?: TraceContext.StartOptions): TraceContext
A new root trace with random ids, flagged random. options.sampled defaults to true,
options.state to TraceState.EMPTY.
continueTrace(parent: TraceParent.Value, state?: TraceState): TraceContext
The caller's trace id and flags, a new span id, and the caller's span as parentSpanId.
extract(headers: Headers): TraceContext
Continues the trace the headers carry and always answers one: a missing or invalid traceparent
starts a new trace, and an invalid tracestate beside a valid one is dropped.
inject(headers: Headers, trace?: TraceContext, propagation?: TraceContext.Propagation): void
Writes traceparent naming trace.spanId as parent, plus tracestate when the state has
entries. trace defaults to currentTrace(), and nothing is written without one. Headers already
present are kept. propagation is "all" (default), "traceparent" or "none".
withTrace(request: Request, trace?: TraceContext): Request
A copy of request carrying trace (the current one by default) in place of its own trace
headers; the body moves to the copy. Without a trace, the request itself is returned.
toTraceParent(trace: TraceContext): string
The traceparent value naming trace.spanId as parent, for a channel without headers such as an
RPC argument.
currentTrace(): TraceContext | undefined
The trace bound to the running invocation, undefined outside one.
runWithTrace<T>(trace: TraceContext, fn: () => T): T
Runs fn with trace current and answers what it returns; the outer trace is current again once
it returns.
traceFields(trace: TraceContext): TraceFields
{ trace_id, span_id, parent_span_id, trace_flags } for a log; parent_span_id is undefined
for a root.
@sdxc/trace-context/middleware
trace(options?: TraceMiddleware.Options)
Continues or starts the request's trace, binds it for currentTrace(), publishes it as
ctx.trace and sets traceFields() on the current log. A trace already current is joined.
options.accept(request) returning false starts a new trace and notes the refused header as
trace.rejected on the log.
CurrentTrace
The context key trace() sets, for reading the trace where the middleware chain's types are
unknown: ctx.get(CurrentTrace).
@sdxc/trace-context/middleware/cli
trace()
@sdxc/cli middleware continuing the trace in the
TRACEPARENT environment variable (with TRACESTATE), or starting one when it is missing or
invalid. It binds the trace for currentTrace(), publishes it as ctx.trace under CurrentTrace,
sets traceFields() on the current log, and sets TRACEPARENT (and TRACESTATE) on every child
ctx.exec starts unless the call passes its own TRACEPARENT. A trace already current is joined.
@sdxc/trace-context/traceparent
parse(value: string): Result<TraceParent.Value, TraceParentParseError>
Reads version-traceid-parentid-flags. A version above 00 is read by the 00 rules and may
carry more fields after a -.
stringify(value): string
Writes version 00 from traceId, parentId, sampled and random; a parsed value is accepted
as is.
TraceParentParseError
Its code is "malformed", "invalid-version" (ff), "invalid-trace-id" or
"invalid-parent-id" (all zero).
@sdxc/trace-context/tracestate
TraceState
An immutable, ordered list of vendor entries, most recently updated first. TraceState.EMPTY,
size, get(key), entries(), delete(key), and set(key, value), which answers a
Result with a new state holding key first, refusing an invalid entry or a 33rd key.
parse(value: string): Result<TraceState, TraceStateParseError>
Reads a comma-separated list, trimming whitespace around members and skipping empty ones.
stringify(state: TraceState, options?: TraceState.StringifyOptions): string
Writes the list within options.maxLength (default 512), dropping entries over 128 characters
first and then the rightmost entries. The empty state writes "".
TraceStateParseError
Its code is "malformed", "invalid-key", "invalid-value", "duplicate-key" or
"too-many" (over 32 entries).
Types
TraceContext holds traceId (32 hex digits), spanId (this invocation's span), parentSpanId
(the caller's span, null for a root), sampled, random and state.
TraceContext.StartOptions and TraceContext.Propagation type the options above. TraceFields
is what traceFields answers. TraceParent.Value is a parsed header: version, traceId,
parentId, the raw flags byte and the sampled and random bits. TraceParent.ErrorCode,
TraceState.ErrorCode, TraceState.StringifyOptions and TraceMiddleware.Options name the
codes and options above.
Pattern: Join A Callee Reached Over RPC
An RPC call carries no headers, so the caller passes the trace as an argument and the callee binds it for the work it does.
import { isSuccess } from "@sdxc/result";
import { continueTrace, runWithTrace, startTrace, toTraceParent } from "@sdxc/trace-context";
import { parse } from "@sdxc/trace-context/traceparent";
await stub.subscribe(subject, toTraceParent(ctx.trace));
export class Subscriptions {
async subscribe(subject: string, traceparent: string) {
let parent = parse(traceparent);
let trace = isSuccess(parent) ? continueTrace(parent.data) : startTrace();
return runWithTrace(trace, () => this.store(subject));
}
}
Pattern: Refuse Caller-Chosen Traces On A Public Endpoint
A public endpoint that continues any traceparent lets a caller pick the trace_id its requests
are logged under. Accept the header only from callers you trust; everyone else gets a new trace.
import { createLogger } from "@sdxc/logger";
import { log } from "@sdxc/logger/middleware";
import { trace } from "@sdxc/trace-context/middleware";
import { createRouter } from "remix/router";
let logger = createLogger({ service: "public-api" });
let router = createRouter({
middleware: [
log(logger),
trace({ accept: (request) => request.headers.has("X-Internal-Caller") }),
],
});
Pattern: Keep Vendor State From A Third Party
tracestate can carry upstream vendors' data. Send a third-party API only the traceparent, or no
trace at all.
import { inject } from "@sdxc/trace-context";
let partner = new Headers({ "Content-Type": "application/json" });
inject(partner, undefined, "traceparent");
await fetch("https://partner.example.com/orders", { method: "POST", headers: partner, body });
let analytics = new Headers();
inject(analytics, undefined, "none");
Passing undefined as the trace keeps the default, the current trace.
Pattern: Add Your Own Vendor Entry
A vendor updating its entry moves it to the front of tracestate. Start a trace with your entry
already set, or add it to a continued one before propagating.
import { isSuccess, unwrap } from "@sdxc/result";
import { extract, inject, startTrace } from "@sdxc/trace-context";
import { TraceState } from "@sdxc/trace-context/tracestate";
let root = startTrace({ sampled: false, state: unwrap(TraceState.EMPTY.set("acme", "region:eu")) });
let incoming = extract(request.headers);
let set = incoming.state.set("acme", "region:eu");
let trace = isSuccess(set) ? { ...incoming, state: set.data } : incoming;
let headers = new Headers();
inject(headers, trace);