Skip to content

std::trace

Spans, and the context that has to survive a fiber changing worker.

A service that cannot be traced is not a candidate, and docs/positioning.md claims Khora should be one wherever a team compares Go, backend TypeScript or application Rust. Nothing in std emitted so much as a log line before this module.

Why this is std’s and the exporter is not

Section titled “Why this is std’s and the exporter is not”

The observability design note argues it in full and the short version is ecosystem.md’s rule: what belongs here is the layer everyone would otherwise re-derive subtly wrong, and for tracing that is two things.

The vocabulary, because two packages that disagree about what a span is cannot compose into one trace. And propagation — a span’s parent surviving a spawn, a steal, a wake and a cancellation — which is not a library concern at all: it is a property of the scheduler, and it is exactly where every other ecosystem’s tracing leaks.

current is the span this fiber is inside, and it is the propagation the paragraph above promises. around installs a span for the duration of a body and puts back the enclosing one on every path out, including a raise and a cancellation; a handler’s start reads it and takes the trace id, the sampling decision and the parent from it.

A fiber inherits its spawner’s current span, copied at the moment the child is created, so a request that fans out into four fibers is one trace rather than five. A copy rather than a share: what a child opens is the child’s, and a slot both could write would leave the spawner holding a span it never entered.

Why ambient at all, when the rest of this language is about saying what you use: an effect operation runs where the effect is performed, not where the handler was installed, so start cannot see the around above it on the stack and cannot require a capability of its own. Threading the span through every signature was the alternative, and it is the one that makes a library untraceable unless every function in it agrees to carry a parameter it has no use for.

This did not work until it did. For as long as this paragraph has been here, Span::parent was written 0 at every call site in the repository: a nested around began a second trace, and the OTLP exporter carried a comment saying so. The claim was the design and not the behavior.

What is not here: OTLP, Datadog’s protocol, Prometheus, batching, retry, and every sampling policy beyond a head sampler. All of it is a vendor’s release cadence attached to a network protocol, which is the same argument that keeps Postgres out of std.

The model is OpenTelemetry’s, and that is a one-way decision

Section titled “The model is OpenTelemetry’s, and that is a one-way decision”

Datadog spans are a service, a resource and flat string tags; OpenTelemetry spans carry typed attributes, events and a status. Rendering OTel into Datadog loses information and the reverse cannot be done at all — which is why Datadog ships an OTel exporter and not the other way round. So the model is the superset and the narrower vendor drops what it cannot hold.

What makes this cheap in Khora specifically

Section titled “What makes this cheap in Khora specifically”

A capability is an interception point. Instrumenting one is another handler:

fn traced(inner: FsRead, tracer: Tracer) -> FsRead {
handler for FsRead {
read: fn path => around(tracer, "fs.read", fn () => inner.read(path)),
..
}
}

Every existing caller of FsRead is instrumented and none of them changed. That is what OpenTelemetry achieves with bytecode agents, and here it falls out of capability-passing because that is what a capability is.

Types: Value · Attribute · Status · Context · Span

Effects: Tracer

Methods: Tracer · Context

Trait implementations: Eq for Context · Show for Context

Functions: around · around_result · current · text · number · flag

pub type Value =
| Text(String)
| Number(Int)
| Decimal(Float)
| Flag(Bool);

What an attribute can hold.

The smallest thing that matches OpenTelemetry’s AnyValue without inheriting its variance. A vendor that only has strings renders these; one that has types keeps them.

pub type Attribute = {
pub name: String,
pub value: Value,
};

A named value on a span.

pub type Status =
| Ok
| Failed(String);

How a span ended.

| Ok

Nothing went wrong, which is the common case and carries nothing.

| Failed(String)

Something did, and this is what it was.

A string rather than a typed error, because a span crosses into a protocol that has no idea what Khora’s rows are. The typed error stays where it was raised; this is its rendering.

pub type Context = {
pub trace_high: Int,
pub trace_low: Int,
pub span: Int,
pub sampled: Bool,
};

A trace and a span within it, in the shape the wire format uses.

Two Ints for the trace id because W3C’s is 128 bits and Khora’s integer is 64. Naming the halves is better than a string that has to be parsed at every hop.

pub sampled: Bool

Whether this trace is being recorded. Decided once, at the root, and carried unchanged — a trace that is half sampled is not a trace.

pub type Span = {
pub context: Context,
pub parent: Int,
pub name: String,
};

A span in progress.

pub parent: Int

The span this one is inside, or zero at the root.

pub effect Tracer {
start: (String, List<Attribute>) -> Span,
finish: (Span, Status) -> (),
event: (Span, String, List<Attribute>) -> (),
}

Starting, ending and annotating spans.

Monomorphic on purpose. A scoped around would be generic in the body’s result, and an effect field that is itself polymorphic is a rank-2 type — a different feature from ordinary generics, and one observability.md flagged as an open question. It is not needed: around below is an ordinary generic function that takes the tracer as an argument, which gets the scoping without the type system change.

start: (String, List) -> Span

Begins a span inside whatever is current.

finish: (Span, Status) ->()

Ends it. Idempotent is not promised; end a span once.

event: (Span, String, List) ->()

Something that happened during the span, with no duration of its own.

impl Tracer
pub fn none() -> Tracer

A tracer that records nothing, as cheaply as possible.

The default, and it has to be nearly free. If tracing costs when it is off it gets turned off, and then it does not exist. This allocates one span value with a zero context and does nothing else — no attributes are read, no strings are built, and with Perceus the list the caller passed is released at the call.

impl Context
pub fn trace_id(self) -> String

The trace id, as thirty-two lower-case hex digits.

The form every collector indexes and every log line carries, and the reason it is here rather than assembled by each caller: a trace id written as two decimal integers cannot be pasted into the search box of anything.

pub fn span_id(self) -> String

The span id, as sixteen lower-case hex digits.

pub fn none() -> Context

A context that names no trace.

pub fn is_valid(self) -> Bool

Whether this context refers to a trace at all.

W3C says an all-zero trace id or span id is invalid, which is what makes zero usable as “none” without a separate flag.

pub fn to_traceparent(self) -> String

The traceparent header for this context.

00-{32 hex}-{16 hex}-{2 hex}, which is version zero of the W3C trace context — a stable, tiny, vendor-neutral spec, which is why it is in std when the exporters are not.

pub fn of_traceparent(header: String) -> Option<Context>

The context a traceparent header names, or None if it does not.

Refuses anything it does not fully understand, including a version it has not heard of. A header half-read is a trace joined to the wrong parent, which is worse than a trace that starts fresh.

impl Eq for Context
fn eq(self, other: Context) -> Bool
impl Show for Context
fn show(self) -> String
pub fn around<A, 'ef, 'er>(tracer: Tracer, name: String, body: () -> A with 'ef raises 'er) -> A with 'ef raises 'er

Runs body inside a span, and ends the span whichever way it leaves.

The scoped form, as a plain function rather than a field on the effect, so that no rank-2 type is needed. Takes the tracer explicitly because that is what makes it ordinary.

Three ways out, and the two that matter are the ones a let and a call cannot see. Written as start, body(), finish in a row, a span would be finished exactly when nothing goes wrong — and a span closed only on the happy path is worse than no span, because a trace with a dangling parent is read as still running.

So the finish is registered with a region before the body starts, the same way std::db::transaction registers its rollback, and for the same reason: a region’s finalizers run on every path out because every path out already releases the binding holding the region. A body that returns marks the span Ok and settles it; a body that raises or is canceled leaves the finalizer to close it, and it closes as Failed because that is what happened.

raises 'er is the other half: it carries the body’s own failures out, and a body that does no fallible work instantiates the row empty and needs no ! at the call. The row has nothing to do with cancellation — a body is canceled at its loops, blocking calls and calls whatever its row, and the span closes as Failed on the way out.

pub fn around_result<A, E: Show, 'ef, 'er>(tracer: Tracer, name: String, body: () -> Result<A, E> with 'ef raises 'er) -> Result<A, E> with 'ef raises 'er

The same, for a body that reports its failure as a value.

The difference from around is what reaches the span’s status: this reads the Result and puts the error’s own text on it, where around can only say that the body did not return. Reach for this when the failure has something to say, which is most of the time.

pub fn current() -> Option<Context>

The span this fiber is inside, or None at the top.

A handler’s start is expected to call this, and it is the whole of what a handler has to do to produce a trace rather than a pile of unrelated one-span traces: take the trace id and the parent from what comes back, or begin a fresh trace when nothing does.

start: fn (name, _attributes) => match current() {
Option::Some(inside) => {
context: { trace_high: inside.trace_high, trace_low: inside.trace_low,
span: random.int(), sampled: inside.sampled },
parent: inside.span,
name: name,
},
Option::None => { ... a new trace ... },
}

Why an operation has to ask rather than be told

Section titled “Why an operation has to ask rather than be told”

An effect operation runs where the effect is performed, not where the handler was installed, so it cannot see the around above it on the stack and cannot require a capability of its own. The enclosing span therefore has to be somewhere ambient, and the only thing that survives what a span has to survive — a spawn, a steal, a wake and a cancellation — is the fiber. This reads a slot the runtime keeps per fiber, which around sets for the duration of a body and puts back on every path out.

A fiber inherits its spawner’s current span at the moment it is created, so work that fans out stays one trace. It inherits a copy: a span the child opens afterwards is the child’s, and does not become the parent’s when the child returns.

pub fn text(name: String, value: String) -> Attribute

A Text attribute, which is most of them.

pub fn number(name: String, value: Int) -> Attribute

A Number attribute.

pub fn flag(name: String, value: Bool) -> Attribute

A Flag attribute.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource