Skip to content

Support external parenting of Client-Side Spans #4362

@lbwexler

Description

@lbwexler

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions