Skip to main content

core.tracing — distributed tracing

OpenTelemetry-compatible tracing primitives. core.tracing supplies identifiers, contexts, spans, samplers, processors, exporters, and the W3C Trace Context wire format. Weft (reverse-proxy middleware), Spindle (database), and the database / RPC drivers all instrument against this surface — third-party instrumentation MUST use it too so spans from all layers stitch into a single trace.

Spec alignment

SpecScope
OTel Trace APISpan API, SpanKind, status, attributes, events, links
OTel Trace SDKSampler, SpanProcessor, SpanExporter pipeline
W3C Trace Contexttraceparent / tracestate HTTP header encoding
OTel ResourceProcess-wide attributes (service.name, service.version)

Module layout

SubmodulePurpose
core.tracing.idTraceId (128-bit), SpanId (64-bit), generate_trace_id, generate_span_id
core.tracing.contextSpanContext, TraceFlags, TraceState, INVALID_SPAN_CONTEXT
core.tracing.attributeAttributeValue (Text / Bool / Int / Float / array), AttributeSet
core.tracing.resourceResource — process-wide attributes
core.tracing.samplerSampler protocol, SamplingDecision, SamplingResult, AlwaysOn, AlwaysOff, TraceIdRatio, ParentBased
core.tracing.spanSpan, SpanKind, SpanStatus, StatusCode, SpanEvent, SpanLink, SpanData
core.tracing.exporterSpanExporter protocol, NoopExporter, StdoutExporter, ExportResult
core.tracing.processorSpanProcessor protocol, SimpleProcessor, BatchProcessor
core.tracing.tracerTracer, TracerProvider, get_tracer, global registry
core.tracing.propagationTextMapPropagator protocol, W3CTraceContext, inject_traceparent, extract_traceparent

Pipeline

The SDK separates decision (Sampler), lifecycle (Processor), and shipping (Exporter) so each can be swapped independently.

Minimal server setup

mount core.tracing.*;
mount core.tracing.processor.{BatchConfig};
mount core.time.duration.{Duration};

set_global_tracer_provider(
TracerProvider.builder()
.with_resource(Resource.service(&"weft-edge".into(), &"1.0".into()))
.with_sampler(parent_based(always_on()))
.with_processor(
Heap.new(BatchProcessor.new(
Heap.new(StdoutExporter.new()) as Heap<dyn SpanExporter>,
)) as Heap<dyn SpanProcessor>,
)
.build()
);

let tracer = get_tracer(&"edge.handler".into(), &"1.0".into());

Call set_global_tracer_provider once at startup; get_tracer(name, version) returns per-component tracers cheaply afterwards.

Starting and ending spans

let parent = SpanContext.invalid(); // or extracted from inbound headers
let mut attrs = AttributeSet.new();
attrs.set("http.method".into(), AttributeValue.Text("GET".into()));
let links: List<SpanLink> = List.new();

let span = tracer.start_span(
&"handle_request".into(),
SpanKind.KindServer, // role of this span in the trace
attrs,
links,
&parent,
);

// Record incidents as the work progresses
span.set_attribute("http.status_code".into(), AttributeValue.Int(200));
span.add_event_now("request.validated".into());

// Finalise — future mutations on `span` are no-ops
span.set_status(SpanStatus.ok());
let finished: Maybe<SpanData> = span.end();

Identifiers

TypeBytesHex digits
TraceId1632
SpanId816

generate_trace_id() / generate_span_id() are allocation-free and never return the all-zero "invalid" value. The generator seeds from a per-process start-nanos stamp plus an atomic monotonic counter — ~20 ns per call, no syscall. Collisions across processes are cryptographically negligible under the 128-bit / 64-bit domains.

SpanKind

public type SpanKind is
| KindInternal // default — no specific role
| KindClient // outgoing request
| KindServer // incoming request
| KindProducer // emit a message for async delivery
| KindConsumer; // receive a message from async delivery

The kind informs the backend's UI layout (a "server → client" link shows as a parent → child edge; a "producer → consumer" link shows as a separate causal arrow even when the spans are in different traces).

SpanStatus / StatusCode

public type StatusCode is Unset | Ok | Error;

public type SpanStatus is { code: StatusCode, description: Maybe<Text> };

implement SpanStatus {
public fn unset() -> SpanStatus;
public fn ok() -> SpanStatus;
public fn error(description: Text) -> SpanStatus;
}

Unset is the default; instrumentation MUST only set Ok or Error when the work truly succeeded / failed — leaving Unset lets the backend infer status from HTTP semantics or rpc metadata.

Samplers

public type SamplingDecision is
| Drop // span not recorded, not sampled
| RecordOnly // recorded for internal aggregation, not exported
| RecordAndSample; // recorded AND exported (sets sampled flag in traceparent)

public type SamplingResult is { decision: SamplingDecision, trace_state: TraceState };
SamplerDecision rule
AlwaysOnRecordAndSample always
AlwaysOffDrop always
TraceIdRatio(r)Deterministic by trace_id — sampled iff trace_id_high < r · 2^64; same ratio across services when they share trace_id
ParentBased(root, …)Inherits parent sampled flag; consults root only for root spans

Factory helpers: always_on(), always_off(), trace_id_ratio(r), parent_based(root) — each returns a Heap<dyn Sampler> ready to hand to TracerProvider.builder().with_sampler(...).

The sampler contract is pureshould_sample(parent, trace_id, name, kind, attributes) MUST have no side effects, since it can be invoked on any executor thread and is cached against the trace id.

Processors

ProcessorModeWhen to use
SimpleProcessorSynchronous — exports on end()Tests, development, small services where latency isn't critical
BatchProcessorBounded channel + worker taskAll production workloads

BatchProcessor.new(exporter) spawns a detached worker task (via spawn_detached). Configuration options:

public type BatchConfig is {
max_queue_size: Int, // hard cap; default 2048
scheduled_delay: Duration, // flush cadence; default 5 s
max_export_batch_size: Int, // per-export limit; default 512
export_timeout: Duration, // per-export deadline; default 30 s
};

When the queue is full, on_end drops the span — per OTel default policy, it is better to lose a few spans than to stall the hot path. force_flush(timeout) drains the batch buffer, e.g. during graceful shutdown.

Exporters

In-tree:

ExporterFormat
NoopExporterDiscards everything — for tests that just want a valid TracerProvider
StdoutExporterOne line of JSON per span, Mutex-serialised writer

Out-of-tree exporters — OTLP/gRPC, OTLP/HTTP, Jaeger, Zipkin, Datadog — live in separate cogs and implement the SpanExporter protocol:

public type SpanExporter is protocol {
fn export(&self, spans: &List<SpanData>) -> ExportResult;
fn shutdown(&self, timeout: Duration) -> ExportResult;
};

public type ExportResult is Success | Failure(Text);

W3C Trace Context propagation

mount core.tracing.propagation.{W3CTraceContext, TextMapPropagator};

let prop = W3CTraceContext.new();

// Server side — inbound headers → parent SpanContext
let parent = prop.extract(&request.headers);

// Client side — outbound headers
let mut headers: List<(Text, Text)> = List.new();
prop.inject(&child_span.context(), &mut headers);

Header format:

traceparent: 00-<trace_id:32hex>-<span_id:16hex>-<flags:2hex>
tracestate: vendor1=value1,vendor2=value2
FieldVersionFormat
traceparent"00" (only version defined)55-byte fixed-width hex
tracestateanyCSV of key=value; TraceState enforces the W3C cap of 32 members, 256-char keys/values

The TextMapPropagator protocol is pluggable — B3, X-Ray, Jaeger-legacy, and custom formats slot in via the same two-method contract. Weft's middleware layer consumes an injected propagator via the context system; swapping formats is a single line of config.

Attributes

AttributeValue covers Text / Bool / Int / Float, plus array-of- primitive variants for list-valued attributes. Limits apply per OTel SDK spec:

LimitDefault
ATTRIBUTE_COUNT_LIMIT (per span, per resource, per event, per link)128
SPAN_EVENT_LIMIT128
SPAN_LINK_LIMIT128
ATTRIBUTE_VALUE_LENGTH_LIMITunbounded (not a hard cap today)

Over-limit additions are dropped silently — traces must never fail a request because instrumentation mis-sized an attribute set.

Performance notes

OperationCost
generate_trace_id / generate_span_id~20 ns, no allocation, no syscall
SpanContext.clone()~5 ns (32-byte POD + trace-state Shared clone)
tracer.start_span(...)1 × SpanInner + 1 × Shared<Mutex<…>>; sampler adds ~10 ns for AlwaysOn, ~50 ns for ParentBased
span.set_attribute(...)Mutex-guarded AttributeSet.set — bounded linear scan
BatchProcessor.on_endsingle try_send on a bounded channel — ~80 ns under contention
W3CTraceContext.injecthex-encode + list push; ~200 ns, no heap other than the two header strings

See also

  • stdlib/metrics — aggregate instrumentation; complementary to per-event traces.
  • stdlib/context — the tracer provider and propagator are registered as context resources.
  • stdlib/net/weft — server-side middleware that auto-injects a server-kind span per request.