From a request
to a span.
A tracer, a clock and a sink: enough to put a request on a timeline and hand it to wherever your traces live. This is the whole route, in the order you will need it.
- Install
- moon add moonbitstack/moontel
- Targets
- native · js · wasm · wasm-gc
- Companion
- the API plate
Install.
The module is one dependency. The library has no build step and no global state: adding it adds a few thousand lines and nothing that runs until you call it. Generated code is there only if you ask for it, in the next section.
moon add moonbitstack/moontel
Then name the packages you use in moon.pkg. A program that
only builds spans imports trace and nothing else; the ASGI
middleware is a separate package so that a batch job does not compile a
web framework.
import {
"moonbitstack/moontel/trace",
"moonbitstack/moontel/propagate",
"moonbitstack/moontel/asgi",
// The entropy source below, only for these examples;
// your runtime may have one of its own.
"moonbitstack/moonseed",
"moonbitstack/moonseed/system",
}
A type is reached through the package that defines it:
@trace.Tracer, @propagate.extract,
@asgi.Tracing. The facade moonbitstack/moontel
re-exports all three, but a re-exported type arrives without its
constructors — so building an attribute value still means
@trace.Str(…).
Say it on the function.
Most spans wrap one function, so that is where they are written. The
function stays as it is, and a generator adds
place_traced beside it.
#tel.span("orders.place", kind=server, attr="item")
fn place(item : String) -> Int raise Sold { ... }
let receipt = place_traced(tracer, "tea", now=clock)
What the function raises is recorded on the span with an error status and raised on. The span finishes whichever way the call ends, a cancelled one included. The rest of this page builds spans by hand, which is what the generated code does.
The first trace.
Three things go in: a source of randomness, a sink, and a sampler. The
first two are the seams this library refuses to guess; the third is a
policy, and always is the one to start with.
fn main {
let recorder = @trace.Recorder::new()
let tracer = @trace.Tracer::new(
rand=random_bytes,
sink=recorder,
sampler=@trace.always,
)
let started = 1_700_000_000_000_000_000L
let span = tracer.start(
"GET /orders/{id}",
kind=@trace.Server,
at=started,
attributes=[("http.request.method", @trace.Str("GET"))],
) catch {
_ => abort("the platform has no entropy source")
}
span.set_attribute("http.response.status_code", @trace.Int(200L))
span.finish(at=started + 38_400_000L)
for data in recorder.spans() {
println("\{data.name} · \{data.duration()} ns · sampled=\{data.sampled()}")
}
}
fn random_bytes(width : Int32) -> Bytes {
@moonseed.bytes(width, @system.source) catch {
_ => abort("the platform has no entropy source")
}
}
One run
GET /orders/{id} · 38400000 ns · sampled=true
The span is not finished when start returns, and the sink
is not called until finish does. Between the two, the span
is yours: attributes, events, a status, a better name.
at is a nanosecond timestamp, and it is a parameter
because nothing in moontel reads a clock. A server hands in its runtime's
clock; a replay hands in a number. start refuses an id source
that returns the wrong number of octets, or sixteen zero octets, which is
why it raises.
A span's life.
A span records four kinds of thing: attributes, events, a status and links. All four are cheap, all four are bounded, and all four are ignored after the span ends.
Attributes and events
fn mark(span : @trace.Span, started : Int64) -> Unit {
span.set_attribute("order.total", @trace.Double(42.5))
span.set_attributes([
("order.items", @trace.Int(3L)),
("order.paid", @trace.Bool(true)),
])
span.add_event(
"cache.miss",
at=started + 1_000_000L,
attributes=[("key", @trace.Str("cart:A-1024"))],
)
}
fn end_badly(span : @trace.Span, started : Int64) -> Unit {
span.set_status(@trace.Error("card declined"))
span.record_exception(
message="card declined",
at=started + 2_000_000L,
type_="PaymentError",
)
span.finish(at=started + 3_000_000L)
span.finish(at=started + 4_000_000L) // ignored: a span ends once
}
An attribute value is one of eight shapes: a boolean, an integer, a double, a string, or an array of one of those. The arrays are separate variants because OpenTelemetry requires a homogeneous array, and because an exporter has to write a single type tag for it.
Status, in one order
The three statuses are ordered Ok above Error
above Unset, and a write is judged against that order rather
than last-write-wins. Ok is final: once a span is
Ok, a later Error does not take it back. Two
Errors may replace each other, because the message may
improve.
span.set_status(@trace.Error("card declined"))
span.record_exception(
message="card declined",
at=started + 2_000_000L,
type_="PaymentError",
)
span.finish(at=started + 3_000_000L)
span.finish(at=started + 4_000_000L) // ignored: a span ends once
record_exception writes an event named
exception with the three conventional attributes. A status
says how the operation ended; an event says what happened on the way.
Neither replaces the other.
Sampling.
A sampler decides, once, whether a trace is wanted. The decision is made
at the root and travels in the header, so every later hop agrees without
asking again. That is why the sampler a service wants is almost always
parent_based: the hop that started the trace chose for the
whole trace.
fn pick() -> @trace.ParentBased {
@trace.parent_based(root=@trace.ratio(ratio=0.1))
}
| Sampler | Keeps |
|---|---|
| always | Everything. |
| never | Nothing. |
| ratio(r) | A deterministic fraction r, chosen from the trace id. |
| parent_based(root) | Whatever the parent decided; a new trace asks root. |
ratio reads the low 64 bits of the trace id — not a clock,
not a counter — so the same trace gets the same answer in every process,
and raising the rate keeps every trace the lower rate kept. That property
is what makes the number on the home page honest: drag it and watch the
strip thin out without any mark coming back.
A sampler may also answer RecordOnly: the span is recorded
and reaches the sink, but the sampled bit stays clear and its
children are dropped. That is how a service keeps counts without keeping
traces.
Propagation.
A hop reads two headers and writes three. Both operations take the same thing: an array of name and value pairs, which is what an ASGI scope hands over and what an HTTP client's header list already is.
fn forward(
inbound_headers : Array[(String, String)],
request_headers : Array[(String, String)],
tracer : @trace.Tracer,
at : Int64,
) -> Array[(String, String)] raise {
let extracted = @propagate.extract(inbound_headers)
let span = match extracted.context {
Some(parent) =>
tracer.start("GET /orders", parent=parent, kind=@trace.Server, at~)
None =>
tracer.start("GET /orders", kind=@trace.Server, at~)
}
// The next request carries this hop's span, and the bag unchanged.
@propagate.inject(request_headers, {
context: Some(span.context()),
baggage: extracted.baggage,
})
}
One run
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-16e31247ca00e178-01 tracestate: vendor=abc
Extraction is forgiving exactly where the specification says to be. Two
traceparent headers contradict each other, so the hop starts
a new trace rather than picking one. A tracestate that does
not parse costs the hop its vendor state and not its trace, because W3C
Trace Context §3.3.1 asks for precisely that.
An outgoing request gets the headers rewritten rather than appended to,
so the context of the sender cannot survive beside the one this hop
decided on. traceparent and tracestate are
written whenever there is a context; baggage is written only
when it is not empty.
When you want the refusal instead of the forgiveness, call
@propagate.parse, parse_traceparent or
parse_tracestate: they raise Refused with the
offset, the expected shape and the field that was wrong.
The ASGI middleware.
One layer opens a server span per request, stamps it the way the semantic
conventions describe, records the response status and writes the context
back on the response. It implements the framework's Layer, so
it stacks beside the others on equal terms.
fn traced(
handler : @spec.AsgiApp,
tracer : @trace.Tracer,
now : () -> Int64,
) -> @spec.AsgiApp {
let layer = @asgi.Tracing::new(tracer~, now~)
@mid.stack([@mid.server_errors, layer], handler)
}
One run
response 200 traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-960516d9a24e7b34-01 baggage: userId=alice
Two arguments are required and both are things this library will not
guess: the tracer, and now, a clock. The rest have defaults:
the span name is the method and the path, baggage rides along, and no
path is ignored.
fn route_of(path : String) -> String {
path
}
fn named(tracer : @trace.Tracer, now : () -> Int64) -> @asgi.Tracing {
@asgi.Tracing::new(
tracer~,
now~,
name=(method, path) => "\{method} \{route_of(path)}",
ignore=["/healthz", "/metrics"],
)
}
The layer sees a path and not a route, which is why name is a
parameter: a trace backend wants GET /orders/{id}, not ten
thousand distinct paths. A 5xx marks the span as an error; a 4xx is an
answer, not a failure, and leaves the status unset. An application that
raises leaves an exception event before the error goes on.
A WebSocket or a lifespan scope is passed straight through. A server span for either needs a different set of attributes, and a wrong span is worse than a missing one.
Replay.
The same three seams make a request reproducible. Hand in a source of ids that counts, a sink that keeps everything, and a clock that is a list of numbers, and a request replays exactly: the same ids, the same timings, the same header out.
fn replay() -> Unit raise {
let recorder = @trace.Recorder::new()
let tracer = @trace.Tracer::new(
rand=width => Bytes::make(width, b'\x01'),
sink=recorder,
sampler=@trace.always,
)
let span = tracer.start("op", at=0L)
span.finish(at=900_000L)
let data = recorder.spans()[0]
println("\{data.name}: \{data.duration()} ns, sampled=\{data.sampled()}")
}
One run
op: 900000 ns, sampled=true
Recorder::take empties the recorder and returns what it
held, which is what a batch of replays needs when one tracer drives
several requests. SpanData::sampled separates the spans kept
for counting from the ones a backend should see.
The home page is one long replay: its ids and its clock are scripted, so the drawing is the same on every visit. Change the script and the waterfall changes with it.
Limits.
Every bound here is one a specification states, or one OpenTelemetry's defaults state. A span that reaches a limit counts what it dropped rather than growing without bound; a header that reaches one is refused rather than truncated.
| Thing | Limit | On the way past |
|---|---|---|
| attributes per span | 128 | counted in Dropped |
| events per span | 128 | counted in Dropped |
| links per span | 128 | counted in Dropped |
| attributes per event | 128 | counted in Dropped |
| attribute value length | unlimited by default | Limits::new can cut |
| tracestate members | 32 | header dropped |
| tracestate key, value | 256 characters each | header dropped |
| baggage members | 64 | header dropped |
| baggage header | 8192 octets | header dropped |
The per-span limits are fixed when the span starts, which is what keeps
instrumentation that loops over user data from taking the process down
with it. Limits::new moves them; the defaults are
trace_limits.
What is deliberately not here.
moontel is the trace API and the trace SDK. It is not a telemetry pipeline, and the things it leaves out are left out on purpose:
- No OTLP exporter. A sink is a trait of one method, and the transport belongs to whoever owns it. Shipping bytes to a collector means writing that method, not configuring this library.
- No metrics or logs. They are different signals with different data models; folding them into a trace library would make all three worse.
- No automatic instrumentation. Nothing wraps your HTTP client or your database driver. A span is opened where you open it, with the name you choose.
- No ambient context. There is no thread-local current span: a context is passed as a value. That is why a span is started with the parent in hand, and why there is nothing to clean up afterwards.
- No sampling by attribute. A sampler sees the four things known before the span exists. A policy that needs an attribute runs after the fact, on the sink.
These are the edges of the library, not gaps in it. If a service needs
one of them, the seam to build it on is Sink, and the code
at that seam says why.