Observability (OpenTelemetry)

@zuke/otel is a zero-runtime-dependency plugin that exports a run as OpenTelemetry traces and metrics over OTLP/HTTP JSON — hand-rolled, so core stays OTel-free. Register it on a run and each state transition ships to a collector: one run span with a child span per executed target, plus run/suspend/outcome counters.

The otel() plugin

// jsr:@zuke/otel
export function otel(configure?: Configure<OtelSettings>): Plugin

Called with no arguments — or with no endpoint configured, whether by setter or by OTEL_EXPORTER_OTLP_ENDPOINT — the plugin is inert, so it's safe to register unconditionally. Otherwise configure gets an OtelSettings with fluent setters, each returning this:

endpoint(url): this            // base; /v1/traces & /v1/metrics appended
tracesEndpoint(url): this      // verbatim
metricsEndpoint(url): this     // verbatim
serviceName(name): this
header(name, value): this
headers(map): this
resourceAttribute(name, value): this
resourceAttributes(map): this
timeout(duration): this        // "5s"; default 10s

What it exports

The plugin observes onRunStateChange against the durable RunRecord:

SignalWhenShape
trace run settles Run span (createdAtupdatedAt) + one child span per executed target.
zuke.run.started fresh run begins Counter, tagged zuke.build / zuke.root_target.
zuke.run.suspended run parks at a .waitsFor gate Counter per waiting target, tagged zuke.build / zuke.target / trigger.
zuke.runs run settles Counter tagged outcome = succeeded/failed/cancelled.

Run span attributes: zuke.run.id, zuke.build, zuke.root_target, zuke.actor, zuke.run.status. Target span attributes: zuke.target, zuke.target.status; a failed target's span is marked error with its redacted message. Metrics are OTLP delta Sums. Only executed targets get a span, and a run is exported once (the dedup set is capped at 1024).

Setter ↔ env config

Every setter has an env fallback, so the same registration works locally and in CI; an explicit setter always wins over its env var.

SetterEnv var
.endpoint(url)OTEL_EXPORTER_OTLP_ENDPOINT (base; /v1/traces & /v1/metrics appended)
.tracesEndpoint(url)OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (verbatim)
.metricsEndpoint(url)OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (verbatim)
.serviceName(name)OTEL_SERVICE_NAME (else "zuke")
.header(s)OTEL_EXPORTER_OTLP_HEADERS (k1=v1,k2=v2)
.resourceAttribute(s)OTEL_RESOURCE_ATTRIBUTES
.timeout(duration)— (setter only; "5s" shape, default 10s)

Trace continuity across suspend/resume

A run that suspends and resumes in a different process still lands as one trace: the trace id is SHA-256(runId) truncated to 16 bytes, and each target span id is a stable hash of (runId, target). Every process derives the same ids, so the whole run — including the gap while it was suspended — reassembles into one complete, gap-spanning trace.

This requires a state store: the exported spans and counters come from the durable run record, so a store-less build exports nothing.

Setup examples

import { run } from "jsr:@zuke/core";
import { otel } from "jsr:@zuke/otel";
await run(MyBuild, {
  plugins: [
    otel((s) => s.endpoint("http://localhost:4318").serviceName("my-build").header("authorization", "Bearer …")),
  ],
});
// Config entirely from OTEL_* env — same registration works locally and in CI:
await run(MyBuild, { plugins: [otel()] });

Export is best-effort: a down or slow collector never fails the build beyond the configured timeout. Output is secret-free — the same redacted projection zuke runs show prints. Available as the new @zuke/otel package on JSR.