Context Propagation — Rules for Connected Traces
SkillDev toolsRules for passing ctx: Context as the first parameter to internal methods so that @trace.span() produces connected trace hierarchies. Use when adding context propagation, reviewing ctx usage, fixing broken traces, or when code uses @trace.span(), Context, or this._ctx.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Context Propagation — Rules for Connected Traces skill
What this skill tells your AI
The instructions your AI receives, as published by dxos/dxos in .agents/skills/context-propagation/SKILL.md and read by ahel’s review.
Every internal method takes ctx: Context as its first parameter (Go-style). The @trace.span() decorator detects ctx as args[0], uses it as the parent span, and replaces it with a child context before calling the method body. This enables hierarchical OTEL traces without Zone.js.
For how the tracing system works internally (TracingBackend, RemoteSpan, TRACE_PROCESSOR, all APIs), see the tracing skill.
import { Context } from '@dxos/context';
Why This Matters
The @trace.span() decorator reads a parent span from the incoming ctx attribute (TRACE_SPAN_ATTRIBUTE). If ctx has no parent span (e.g., from Context.default()), the decorator creates an orphaned root span — disconnected from the trace hierarchy. Every method between an entry point and a @trace.span() method must accept and forward ctx, even if the intermediate method itself has no @trace.span().
Which Methods Need ctx
ctx: Context is always the first parameter. Not in an options bag, not as the last parameter, not optional.
Add ctx: Context as first parameter to methods on any call chain that reaches:
EdgeHttpClientmethods — all HTTP calls to EDGE services.EdgeWsConnection.send/EdgeClient.send— all WebSocket messages to EDGE.- Swarm / WebRTC connections (mesh replication, peer discovery).
- Feed replication (Hypercore feed read/write over the network).
- Credential notarization (writes that hit the control pipeline and propagate to peers).
Every method in the call chain — from entry point through signal managers, messengers, replicators, down to the terminal networking call — must accept ctx: Context as its first parameter and forward it.
Do not add ctx to:
- Public user-facing APIs — these create
ctx = Context.default()internally and forward it. - RPC service methods (proto-generated interface) — the signature is fixed by the proto definition; you cannot add
ctxas a parameter. Instead, readoptions.ctxwhich is provided byRpcPeerviaContextRpcCodecwith the caller's trace context already decoded. Forwardoptions.ctx(oroptions?.ctx ?? Context.default()) to internal methods. - React components and hooks — UI code should not propagate ctx; create
Context.default()at the boundary. - Infrastructure / plumbing — worker setup, service registry wiring, serialization.
- Pure local operations — in-memory data transforms, UI state, local database reads.
- Leaf utility methods — small methods that don't call other methods (getters, simple lookups, validation helpers).
- Test utilities — test builders, test helpers (unless they call production code that requires ctx, in which case pass
Context.default()).
Key entry points
| Class / layer | Role |
|---|---|
ClientServicesHost (service-host.ts) | Top-level root — initialize(ctx), close(ctx) propagate to everything below. |
DataSpaceManager | Manages space lifecycle — createSpace, acceptSpace, close are networking paths. |
DataSpace | Per-space networking — control pipeline, data pipeline, epoch creation, feed replication. |
IdentityManager | Identity HALO operations — createIdentity, updateProfile write credentials over the network. |
EchoHost / CoreDatabase | Database sync layer — document loading, flushing, index updates flow to Automerge replication. |
SpaceProtocol / mesh layer | Swarm connections, signaling, WebRTC — open, close, updateTopology. |
Rules
1. Direct calls: forward the caller's ctx
Any sync or async call inside a method should receive the method's ctx.
@trace.span()
async open(ctx: Context): Promise<void> {
await this._initPipeline(ctx);
await this._loadData(ctx);
}
For public user-facing methods, create a root context and forward it:
async createSpace(options: CreateSpaceOptions = {}): Promise<Space> {
const ctx = Context.default();
return this._createSpaceInternal(ctx, options);
}
For RPC service methods, use options.ctx which carries the caller's trace context propagated across the wire by ContextRpcCodec:
testCall: async (req: TestRequest, options?: RequestOptions) => {
const ctx = options?.ctx ?? Context.default();
return this._handleTestCall(ctx, req);
},
2. Event subscriptions and callbacks: use lifecycle ctx
Callbacks are new entry points without a caller-provided ctx. Use this._ctx from Resource or a manually managed Context.
this._spaceStateMachine.onFeedAdmitted.set(async (info) => {
await this._handleFeedAdmitted(this._ctx, info);
});
this.stateUpdate.on(this._ctx, () => {
this._refresh(this._ctx);
});
Lifecycle ctx is correct because it is scoped to the object's lifetime. When the class uses @trace.resource({ lifecycle: true }), this._ctx carries the lifecycle span's trace context, so callbacks appear as children of the lifecycle span rather than orphaned roots or stale children of the _open span.
3. Detached async work: use class lifecycle ctx
setTimeout, scheduleTask, DeferredTask, scheduleMicroTask, queueMicrotask run outside the caller's call stack. Use this._ctx.
this._updateTask = new DeferredTask(this._ctx, () => this._executeQueries(this._ctx));
scheduleMicroTask(this._ctx, async () => {
await this._handleFeedAdmission(this._ctx, info);
});
Never use Context.default() for detached work — it creates an orphaned context nobody disposes.
4. Parallel fan-out: share the parent ctx
Pass the same ctx to each branch. The @trace.span() decorator derives independent child contexts.
@trace.span()
async initializeAll(ctx: Context): Promise<void> {
await Promise.all([
this._initA(ctx),
this._initB(ctx),
this._initC(ctx),
]);
}
5. Reassign ctx to the return value of trace.spanStart()
The manual span API trace.spanStart() returns a derived Context carrying the new span on TRACE_SPAN_ATTRIBUTE. Callers MUST reassign the local ctx to the returned value so that downstream @trace.span() methods see this span as their parent.
// ✅ CORRECT — ctx reassigned, downstream @trace.span methods nest under the manual span.
ctx = trace.spanStart({
id: spanId,
instance: this,
methodName: 'acceptInvitation',
parentCtx: ctx,
op: 'invitation.guest',
}) ?? ctx;
ctx.onDispose(() => trace.spanEnd(spanId));
await this._handleGuestFlow(ctx, ...); // inherits the manual span as parent
// ❌ WRONG — return value ignored; downstream spans don't see this one.
trace.spanStart({ id: spanId, instance: this, methodName: 'acceptInvitation', parentCtx: ctx, ... });
ctx.onDispose(() => trace.spanEnd(spanId));
await this._handleGuestFlow(ctx, ...); // ctx unchanged → handleGuestFlow's @trace.span attaches to ctx's parent, becoming a sibling of the manual span (or a new root if ctx had no parent)
When parentCtx is not null and the span is actually created, the returned ctx is a parentCtx.derive({ attributes: { [TRACE_SPAN_ATTRIBUTE]: newSpanContext } }) — i.e., the original parentCtx with TRACE_SPAN_ATTRIBUTE overridden to the new span. Leaving ctx unchanged means downstream @trace.span methods read the OLD TRACE_SPAN_ATTRIBUTE from parentCtx and attach to that span, so they end up as siblings of the manual span rather than children. They only become a new root trace when the old ctx had no parent span at all. Either way, the onDispose(spanEnd) pattern remains correct — disposing the original parentCtx cascades to the derived one.
6. Complete call chain: every method between entry point and @trace.span must forward ctx
Critical rule: every intermediate method between an entry point and a @trace.span() method must accept and forward ctx, even if the intermediate method itself has no @trace.span(). Otherwise the trace chain is broken.
// ❌ WRONG — breaks trace chain with Context.default() at the last step
class SpaceList {
private _setupSpacesStream(): void {
stream.subscribe((data) => {
scheduleMicroTask(this._ctx, async () => {
await spaceProxy._processUpdate(data); // no ctx
});
});
}
}
class SpaceProxy {
async _processUpdate(data: Data): Promise<void> {
// no ctx
await this._initialize(); // no ctx
}
private async _initialize(): Promise<void> {
// no ctx
await this._initializeDb(Context.default()); // orphaned root!
}
@trace.span()
private async _initializeDb(_ctx: Context): Promise<void> {
// _ctx has no parent span — creates disconnected root span
}
}
// ✅ CORRECT — ctx flows through entire chain
class SpaceList {
private _setupSpacesStream(): void {
stream.subscribe((data) => {
scheduleMicroTask(this._ctx, async () => {
await spaceProxy._processUpdate(this._ctx, data); // lifecycle ctx
});
});
}
}
class SpaceProxy {
async _processUpdate(ctx: Context, data: Data): Promise<void> {
await this._initialize(ctx); // forwards ctx
}
private async _initialize(ctx: Context): Promise<void> {
await this._initializeDb(ctx); // forwards ctx
}
@trace.span()
private async _initializeDb(_ctx: Context): Promise<void> {
// _ctx has parent span from lifecycle context — connected hierarchy
}
}
How Context Flows
User code (no ctx)
→ public API creates ctx = Context.default()
→ intermediate method forwards ctx (no @trace.span — passes as-is)
→ @trace.span() method receives ctx
decorator: reads parent span, creates child span, replaces ctx
→ method body receives child ctx
→ next method forwards child ctx
→ @trace.span() method — creates grandchild span
→ ...
RPC boundary:
caller's @trace.span() sets TRACE_SPAN_ATTRIBUTE on ctx
→ RpcPeer.call: ContextRpcCodec.encode(ctx) → W3C traceparent on wire
──── network ────
→ RpcPeer handler: ContextRpcCodec.decode(data) → options.ctx with parent span
→ service method reads options.ctx → forwards to internal methods
→ @trace.span() method — child of the remote caller's span
Callback / detached async work:
event fires → callback uses this._ctx (lifecycle context)
→ intermediate method forwards this._ctx
→ @trace.span() method — creates root span tied to object lifecycle
Who Provides the Root Context
| Situation | Root ctx |
|---|---|
| Public API entry point | Context.default() |
| RPC service method (proto-generated) | options.ctx (decoded from caller's W3C trace context by ContextRpcCodec; falls back to Context.default() if absent) |
| Callback / event handler | this._ctx (lifecycle) |
| Detached async work | this._ctx (lifecycle) |
Quick Reference
| Situation | What ctx to use |
|---|---|
| Direct call inside a method | Forward the method's ctx |
| Public API entry point | Context.default() |
| RPC service method (proto-generated) | options.ctx (from RPC) |
| Event / callback subscription | Lifecycle this._ctx |
| setTimeout / scheduleTask / DeferredTask | Lifecycle this._ctx |
| queueMicrotask | Lifecycle this._ctx |
| Promise.all fan-out | Same ctx for all branches |
| No caller ctx available | Lifecycle this._ctx |
After trace.spanStart() | ctx = trace.spanStart(...) ?? ctx |
No Orphaned Internal Spans
Every @trace.span() method that runs inside a service or resource MUST have a parent span. An orphaned root span (one with no parentSpanID) means the trace context chain is broken — the span is disconnected from the trace tree and provides no useful correlation.
Allowed root spans (no parent):
- Public API entry points —
Client.initialize(),HaloProxy.createIdentity(),SpaceList.create()— these are user-initiated actions that start a new trace. - Stream/subscription callbacks using
this._ctxfrom a@trace.resource({ lifecycle: true })class — the lifecycle span IS the root, and the callback span is its child.
Disallowed orphaned spans:
- Any
@trace.span()method inside aResourcesubclass that has no parent. Fix: addlifecycle: trueto the class's@trace.resource()sothis._ctxcarries the lifecycle span's trace context. - Any
@trace.span()method called from an RPC handler that ignoresoptions.ctx. Fix: readoptions?.ctx ?? Context.default()and forward it. - Any intermediate method that creates
Context.default()instead of forwarding the caller'sctx.
When adding a new @trace.span() to an internal method, verify that at least one of these is true:
- The method accepts
ctx: Contextas its first parameter and the caller forwards a traced context. - The method runs inside a
@trace.resource({ lifecycle: true })class and usesthis._ctx(which carries the lifecycle span). - The method is an RPC handler that reads
options.ctx.
If none of these hold, the span will be orphaned. Either fix the call chain or don't add @trace.span().
Common Mistakes
- Breaking the chain with
Context.default()in an intermediate method — if a method callschild(Context.default())wherechildhas@trace.span(), the span is an orphaned root. The intermediate method must acceptctxfrom its caller and forward it. - Passing
this._ctxin a direct call when the method hasctxin scope — breaks trace hierarchy, creates a new root instead of a child span. - Passing
ctxin a callback orscheduleTask— captures a stale context whose span has already ended. However,this._ctxfrom lifecycle contexts still works as a parent becauseTRACE_SPAN_ATTRIBUTEstores W3C strings that remain valid after the span ends. - Ignoring the return value of
trace.spanStart()—spanStartreturns a derived ctx carrying the new span. Downstream@trace.spanmethods called with the unchangedctxwill see the span's parent, not the span itself, making them siblings of the manual span (or, if the parent ctx had no span, a new root). Reassignctx = trace.spanStart(...) ?? ctx;before forwarding. - Adding
ctxto Node.js protocol methods (e.g.,[Symbol.for('nodejs.util.inspect.custom')]) — fixed-signature methods that don't support extra parameters. - Adding
ctxto public APIs — user-facing methods should not exposeContext; createContext.default()internally. - Ignoring
options.ctxin RPC service methods —RpcPeerprovidesoptions.ctxwith the caller's trace context decoded from W3C headers. Useoptions?.ctx ?? Context.default(), notthis._ctxor a bareContext.default(). Usingthis._ctxbreaks the cross-process trace hierarchy; usingContext.default()discards the propagated trace.
ESLint Enforcement
The dxos-plugin/require-context-param rule warns on class methods missing ctx: Context as the first parameter. Configured in eslint.config.mjs for core SDK packages with exemptions for public API classes.
Audit
See AUDIT.md for a detailed review of context propagation compliance across the codebase, including known gaps and prioritized recommendations.
Signals
- GitHub stars
- 520
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
context-propagation- Source
- github.com/dxos/dxos