Summary
Add a parentTraceparent?: string field to SpanConfig so callers can parent a new client
span under an externally-received W3C trace context without having to construct a stub
Span object by hand.
Motivation
Hoist client tracing models the parent of a new span via SpanConfig.parent: Span. That's
the right shape when the parent is already a span you created locally — but it forces callers
into an awkward workaround any time the parent context arrives as a W3C traceparent header
value from somewhere other than an outgoing FetchService call.
Realistic cases where you receive a traceparent string rather than a live Span:
- A WebSocket message payload carries a server-side traceparent and you want the client's
handler span to chain into the server's trace.
- Server-Sent Events or similar push channels do the same.
- A message-queue / background-worker style consumer receives a traceparent on the message
and wants its local spans to join the producer's trace.
- A third-party integration hands you a traceparent in some non-HTTP medium.
All of these are distributed-trace scenarios that already work cleanly on the server side —
hoist-core's TraceContextService.restoreContextFromTraceparent(String): Scope is the
direct equivalent. The client side is missing the parallel primitive.
Current workaround
Because SpanConfig.parent requires an actual Span object, apps end up writing something
like this to parse the traceparent and synthesize a minimal "stub" Span whose fields are
overwritten post-construction:
function parentSpanFromTraceparent(tp: string | undefined): Span | undefined {
if (!tp) return undefined;
const parts = tp.split('-');
if (parts.length !== 4 || parts[0] !== '00') return undefined;
const [, traceId, spanId, flags] = parts;
if (!/^[0-9a-f]{32}$/.test(traceId) || traceId === '0'.repeat(32)) return undefined;
if (!/^[0-9a-f]{16}$/.test(spanId) || spanId === '0'.repeat(16)) return undefined;
const sampled = (parseInt(flags || '00', 16) & 0x01) === 0x01;
// Manufacture a Span whose generated ids we then overwrite. The stub is never exported —
// `Runner` only exports spans it created — so it only functions as a parenting hint.
const stub = new Span({name: 'remote-parent'});
stub.traceId = traceId;
stub.spanId = spanId;
stub.sampled = sampled;
return stub;
}
// at the call site...
await this.newSpan({
name: 'my.span',
parent: parentSpanFromTraceparent(incomingTraceparent)
}).run(async ({span}) => { ... });
This works, but:
- It leans on implementation details (fields on
Span being writable, genSpanId() being
called in the constructor and then immediately overwritten).
- It invites subtle bugs around sampling, zero-id handling, and malformed headers — every
app reinvents these checks.
- It's not discoverable. A consumer looking at
SpanConfig has no hint that a
traceparent-string path exists or how to wire one up.
- It produces a stub Span object that appears briefly in memory solely to be consumed by the
child's constructor — a purely implementation-detail side effect.
The workaround is a workaround for a missing primitive, not a reasonable user pattern.
Proposed API
Extend SpanConfig:
export interface SpanConfig {
name: string;
kind?: SpanKind;
tags?: PlainObject;
parent?: Span;
/**
* W3C Trace Context `traceparent` header value (`00-<traceId>-<spanId>-<flags>`) to use
* as the parent context. Ignored if `parent` is also set. Malformed or all-zero headers
* are silently dropped — the span becomes a root span. Honors the sampled flag.
*/
parentTraceparent?: string;
startTime?: number;
caller?: NameSource;
sampled?: boolean;
}
Span's constructor resolves parent first; if unset and parentTraceparent is present,
parse+validate once and inherit traceId / sampled exactly as an explicit parent does,
setting the parentSpanId field for export accordingly. Malformed or all-zero headers are
silently dropped so bad input on the wire never surfaces as a runtime error on the critical
path.
At call sites this collapses the workaround to:
await this.newSpan({
name: 'my.span',
parentTraceparent: incomingTraceparent
}).run(async ({span}) => { ... });
Symmetry with server
Hoist-core already has the direct equivalent:
Scope scope = traceContextService.restoreContextFromTraceparent(traceparent)
try {
span('my.span').run { ... } // inherits parent context
} finally {
scope?.close()
}
Adding parentTraceparent to SpanConfig gives the React side the same ergonomic story for
non-HTTP propagation, and mirrors the existing inbound-HTTP path (where HoistFilter
already extracts traceparent and restores context for the server's request span).
Scope
Small, additive change:
- Field on
SpanConfig (docs + type).
- A few lines in
Span constructor (parse + apply; or an internal helper).
- A couple of unit tests: malformed → root, all-zero ids → root, valid → child inheriting
traceId and reporting correct parentSpanId in toJSON, sampled=false propagates.
No breaking changes; pure addition. Existing users of parent: Span are untouched.
Optional follow-ons
- Consider exposing a
TraceService.createFromTraceparent(name, traceparent) shortcut for
callers that want to create a span outside of a Runner / newSpan flow — analogous to
the server's TraceContextService.restoreContextFromTraceparent when the caller doesn't
want to manage a Scope. Nice to have; not required for the base feature.
- If this lands, the same pattern could apply to other SDK surface that currently only
accepts Span as a parent — e.g. Runner.newSpan overloads.
issue.md
Displaying issue.md.
Summary
Add a
parentTraceparent?: stringfield toSpanConfigso callers can parent a new clientspan under an externally-received W3C trace context without having to construct a stub
Spanobject by hand.Motivation
Hoist client tracing models the parent of a new span via
SpanConfig.parent: Span. That'sthe right shape when the parent is already a span you created locally — but it forces callers
into an awkward workaround any time the parent context arrives as a W3C
traceparentheadervalue from somewhere other than an outgoing
FetchServicecall.Realistic cases where you receive a traceparent string rather than a live
Span:handler span to chain into the server's trace.
and wants its local spans to join the producer's trace.
All of these are distributed-trace scenarios that already work cleanly on the server side —
hoist-core's
TraceContextService.restoreContextFromTraceparent(String): Scopeis thedirect equivalent. The client side is missing the parallel primitive.
Current workaround
Because
SpanConfig.parentrequires an actualSpanobject, apps end up writing somethinglike this to parse the traceparent and synthesize a minimal "stub" Span whose fields are
overwritten post-construction:
This works, but:
Spanbeing writable,genSpanId()beingcalled in the constructor and then immediately overwritten).
app reinvents these checks.
SpanConfighas no hint that atraceparent-string path exists or how to wire one up.
child's constructor — a purely implementation-detail side effect.
The workaround is a workaround for a missing primitive, not a reasonable user pattern.
Proposed API
Extend
SpanConfig:Span's constructor resolvesparentfirst; if unset andparentTraceparentis present,parse+validate once and inherit
traceId/sampledexactly as an explicit parent does,setting the
parentSpanIdfield for export accordingly. Malformed or all-zero headers aresilently dropped so bad input on the wire never surfaces as a runtime error on the critical
path.
At call sites this collapses the workaround to:
Symmetry with server
Hoist-core already has the direct equivalent:
Adding
parentTraceparenttoSpanConfiggives the React side the same ergonomic story fornon-HTTP propagation, and mirrors the existing inbound-HTTP path (where
HoistFilteralready extracts
traceparentand restores context for the server's request span).Scope
Small, additive change:
SpanConfig(docs + type).Spanconstructor (parse + apply; or an internal helper).traceIdand reporting correctparentSpanIdintoJSON, sampled=false propagates.No breaking changes; pure addition. Existing users of
parent: Spanare untouched.Optional follow-ons
TraceService.createFromTraceparent(name, traceparent)shortcut forcallers that want to create a span outside of a
Runner/newSpanflow — analogous tothe server's
TraceContextService.restoreContextFromTraceparentwhen the caller doesn'twant to manage a
Scope. Nice to have; not required for the base feature.accepts
Spanas a parent — e.g.Runner.newSpanoverloads.issue.md
Displaying issue.md.