Keelv0.86.0
Docs / Telemetry

Distributed tracing — spans, W3C trace context, and an OTLP exporter — with no SDK.

import { setTelemetry, Tracer, otlpExporter, tracing, trace } from "@shaferllc/keel/core";

setTelemetry(
  new Tracer({
    serviceName: "api",
    exporter: otlpExporter({ url: "http://localhost:4318/v1/traces" }),
    sampleRatio: 0.1, // 10% of traces in production
  }),
);

// in your HTTP kernel
this.use(tracing()); // a server span per request
// your own spans, anywhere
await trace("charge", async (span) => {
  span.setAttributes({ "order.id": order.id });
  await stripe.charge(order);
});

Why there's no SDK here

The OpenTelemetry Node SDK is a large tree of packages that assumes a Node process. What a trace actually is, though, is small: an id, a parent, a start and an end, some attributes — and a documented JSON shape to POST them in.

That's what this is. It speaks OTLP/HTTP over fetch, so it runs on Workers as happily as on Node, and any OTLP collector accepts it — Jaeger, Tempo, Honeycomb, Grafana, Datadog. You don't get the SDK's auto-instrumentation of every library under the sun; you get the part that matters, in about 400 lines you can read.

Spans

trace(name, fn) opens a span, runs your function inside it, and closes it — even if the function throws, in which case the error is recorded on the span and rethrown.

const receipt = await trace("charge", async (span) => {
  span.setAttributes({ "order.id": id, currency: "USD" });
  span.addEvent("calling stripe");

  return stripe.charge(id); // a throw here marks the span failed, then propagates
});

Spans nest automatically. A trace() inside another trace() becomes its child, sharing the trace id — you don't thread anything through:

await trace("checkout", async () => {
  await trace("reserve-stock", async () => { … }); // child
  await trace("charge", async () => { … }); // sibling of the above
});

That works across await boundaries, and across concurrent traces, because the current span is tracked in AsyncLocalStorage rather than a global. Two requests in flight at once don't get tangled.

From anywhere inside a span:

setAttributes({ tenant: "acme" }); // add to the current span
addEvent("cache miss", { key }); // a timestamped annotation
currentSpan(); // the Span itself, or undefined

All of these are no-ops outside a span, so instrumented code stays safe to call from a script or a test.

HTTP requests

tracing() opens a server span per request, records the method, path, and status, and closes it when the response is sent.

this.use(tracing());

A 5xx marks the span failed; a 404 doesn't — that's a valid answer, not a fault.

It also writes a traceparent header onto the response, so when a user says "this page was slow", you can look up their exact trace.

/health/*, /metrics, and /favicon.ico are ignored by default — they're noise. Change that with ignore, and name spans yourself with name:

this.use(
  tracing({
    ignore: (path) => path.startsWith("/internal"),
    name: (method, path) => `${method} ${path.replace(/\/\d+/, "/:id")}`,
  }),
);

Following a trace between services

This is the point of tracing: one id spanning every service a request touches.

Incoming. tracing() reads the caller's traceparent header and makes your span a child of theirs, so both land in the same trace. A missing or malformed header just starts a fresh trace — never an error.

Outgoing. injectTraceContext() puts the current context on your request headers, so the service you call joins this trace instead of starting its own:

await fetch(url, {
  headers: injectTraceContext({ accept: "application/json" }),
});

parseTraceparent() and traceparent() are the two halves on their own, if you need to carry the context somewhere odd — a queue payload, say.

Connecting logs to traces

traceIds() returns the current trace_id and span_id. Bind them to your logger and every line becomes a jumping-off point into the trace it came from:

const log = logger().child(traceIds());
log.info("charging card", { orderId }); // carries trace_id + span_id

Sampling

Recording every trace in production is expensive. sampleRatio records a fraction:

new Tracer({ sampleRatio: 0.1 }); // 10%

The decision is made once, at the root span, and inherited by every child — because half a trace is worse than no trace. An unsampled span still runs (your code is unaffected); it just isn't exported.

Exporters

Exporter Use
otlpExporter({ url, headers, resource }) Any OTLP/HTTP collector. Production.
consoleExporter() Prints each span. Local development, no collector needed.
MemoryExporter Collects spans in memory. Tests.
otlpExporter({
  url: "https://api.honeycomb.io/v1/traces",
  headers: { "x-honeycomb-team": env("HONEYCOMB_KEY") },
  resource: { "service.name": "api", "deployment.environment": "prod" },
});

Spans are buffered and sent in batches (100 by default, via batchSize).

Flush before the process — or the isolate — goes away, or the last few spans die with it:

onShutdown(() => flushTelemetry());

On Workers, call it at the end of a request (ideally inside waitUntil).

Testing

MemoryExporter collects spans so you can assert on them:

import { Tracer, MemoryExporter, setTelemetry, trace } from "@shaferllc/keel/core";

const exporter = new MemoryExporter();
setTelemetry(new Tracer({ exporter, batchSize: 1 })); // batchSize 1 = export immediately

await trace("charge", async (span) => span.setAttributes({ "order.id": 1 }));

const span = exporter.named("charge")[0];
assert.equal(span.status, "unset");
assert.equal(span.attributes["order.id"], 1);

exporter.trace(traceId) returns every span in one trace; exporter.clear() empties it between tests.


API reference

trace(name, fn, options?)

trace<T>(name: string, fn: (span: Span) => Promise<T> | T, options?: SpanOptions): Promise<T>

Run fn inside a span. The span is current for the duration, ends when fn settles, and records a throw before rethrowing it.

currentSpan() / setAttributes(attrs) / addEvent(name, attrs?)

Reach the span in scope. All no-ops outside one.

traceIds()

traceIds(): { trace_id?: string; span_id?: string } — the ids to hang on a log line.

tracing(options?)

tracing(options?: TracingOptions): MiddlewareHandler

A server span per request. Options: ignore(path) (default: /health, /metrics, /favicon.ico), name(method, path).

Tracer

new Tracer(options: TracerOptions)

Option Meaning
serviceName Added to every span as service.name
exporter Where finished spans go. Omit and nothing is exported
sampleRatio 0–1, decided once at the root. Default 1
enabled false turns tracing off
resource Attributes describing the service, sent with each batch
batchSize Export once this many spans are buffered. Default 100

Methods: startSpan(name, options?), trace(name, fn, options?), flush().

Span

setAttribute(k, v) / setAttributes(attrs) / addEvent(name, attrs?) / setStatus(status, message?) / recordException(error) / end(), plus a context ({ traceId, spanId, sampled }).

setTelemetry(tracer) / telemetry() / flushTelemetry()

Register the active tracer, read it, and drain its buffer.

Trace context

parseTraceparent(header)SpanContext | null (null on anything malformed). traceparent(context) → the header string. injectTraceContext(headers?) → headers with the current context added.

Exporters

otlpExporter({ url, headers?, resource? }), consoleExporter(), and MemoryExporter (.spans, .named(name), .trace(traceId), .clear()).

Interfaces & types

SpanData, SpanContext, SpanEvent, SpanKind (internal | server | client | producer | consumer), SpanStatus (unset | ok | error), SpanAttributes, SpanExporter, TracerOptions, TracingOptions, OtlpOptions.