-
Notifications
You must be signed in to change notification settings - Fork 2
Tracer
Distributed tracing that shows where a request actually spent its time — across functions, and across services. Completes the observability triad with Slogger (logs) and MetroMan (metrics).
The exporter path — create a span, end it, ship OTLP/JSON over fetch
— runs unchanged on Workers and in the browser. One caveat: automatic
context propagation (startActiveSpan, via @tundralibs/ambient's
AsyncLocalStorage) needs node:async_hooks, which Workers expose
under the nodejs_compat flag but plain browsers do not — it throws
there. Use startSpan() and thread the parent explicitly instead when
targeting a browser.
A log line says one event happened. A trace says where the time went:
checkout ──────────────────────────────── 210ms
auth.verify ──── 40ms
db.query ──────── 120ms
POST /charge ───── 45ms ← a different service, same trace
Each box is a span. Spans nest automatically — a span opened inside
another becomes its child at any call depth and across every await — because
the active span lives in an ambient async context
instead of being threaded through function signatures. W3C traceparent
propagation carries the trace across process boundaries.
Deno:
deno add @tundralibs/tracerBun:
bunx jsr add @tundralibs/tracerNode.js:
npx jsr add @tundralibs/tracerstartActiveSpan makes the span active for the callback's whole lifetime, so
nothing below needs a span parameter:
import { ConsoleExporter, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({
serviceName: 'orders',
exporter: new ConsoleExporter(),
});
const db = { query: (_sql: string) => Promise.resolve() };
const chargeCard = () => Promise.resolve();
await tracer.startActiveSpan('checkout', async (span) => {
span.setAttribute('order.id', 'ord_42');
await tracer.startActiveSpan('db.query', async () => {
await db.query('SELECT …'); // auto-parents to `checkout`
});
await chargeCard(); // spans started in here parent too — no threading
});extract joins the caller's trace on the way in; inject hands it onward:
import { extract, inject, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const request = new Request('https://orders.internal/checkout', {
method: 'POST',
});
const route = '/checkout';
// Inbound — join the caller's trace (or start one if there's no header).
const parent = extract(request.headers);
await tracer.startActiveSpan(
`${request.method} ${route}`,
{ kind: SpanKind.SERVER, parent },
async (span) => {
// Outbound — pass the trace on, so the callee's spans join this trace.
await fetch('https://payments.internal/charge', {
method: 'POST',
headers: { traceparent: inject(span.context) },
});
},
);For clients built on @tundralibs/restler (≥ 1.1) the outbound side is two
lines of wiring — a CLIENT span per request, traceparent carrying that
request's own span id:
import { Tracer } from '@tundralibs/tracer';
// Needs a separate install: deno add @tundralibs/restler
import type { RESTlerOptions } from '@tundralibs/restler';
const tracer = new Tracer({ serviceName: 'orders' });
const token = 'secret';
// Your own RESTler subclass.
declare const PaymentsAPI: new (
token: string,
opts: Partial<RESTlerOptions>,
) => unknown;
const api = new PaymentsAPI(token, {
witness: tracer.wrapClient, // span per outbound request
headerProvider: tracer.propagation, // traceparent per request
});Slogger's contextProvider is called for every record, so trace ids land on
every log line — click a log, jump to its trace:
// Needs a separate install: deno add @tundralibs/slogger
import { LogManager, SyslogSeverities } from '@tundralibs/slogger';
import { Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const log = LogManager.createSlogger({
appName: 'orders',
level: SyslogSeverities.INFO,
contextProvider: tracer.logContext, // ← the whole integration
});
log.info('charging'); // → { traceId: '4bf92f…', spanId: '00f067…' }tracer.logContext emits the canonical camelCase keys — the exact names
slogger's otelLogFormatter hoists into the OTel log record's first-class
TraceId/SpanId fields, so logs arrive in a backend already linked to their
traces, and the load-bearing key names live in code rather than in docs.
Composing with the ambient request bag stays one line:
contextProvider: () => ({ ...ambient.get(), ...tracer.logContext() }),ratioSampler derives its decision from the trace id, so every service that
uses the same ratio agrees — traces stay complete end-to-end rather than
fragmenting:
import { ratioSampler, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({
serviceName: 'orders',
sampler: ratioSampler(0.1), // 10% of traces, whole
});Child spans inherit the parent's decision — a trace is always sampled whole or not at all. Unsampled spans still carry and propagate their context, so correlation keeps working even when nothing is exported.
Tracer never sees your framework's context, so you write the ~10-line adapter
and keep full type knowledge — including writing back to ctx:
import { extract, type Span, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
// Your framework's context — you know its real shape, Tracer doesn't.
type Ctx = {
request: { headers: Headers; method: string };
route: string;
response: { status: number };
span?: Span;
};
async function tracing(ctx: Ctx, next: () => Promise<void>) {
const parent = extract(ctx.request.headers);
return tracer.startActiveSpan(
`${ctx.request.method} ${ctx.route}`,
{ kind: SpanKind.SERVER, parent },
async (span) => {
ctx.span = span; // your ctx, your types
await next();
span.setAttribute('http.status_code', ctx.response.status);
},
);
}See Recipes for ready-made adapters — Hono, Express, Fastify, Koa, NestJS, Oak, h3, SvelteKit, Next.js, Lambda and Workers.
The OTLP exporter lives behind its own subpath, so importing the tracer never
pulls an HTTP client into a CLI or worker that only creates spans. Wrap it in
BatchSpanProcessor — otherwise every ending span costs one HTTP round-trip:
import { BatchSpanProcessor, Tracer } from '@tundralibs/tracer';
import { OTLPExporter } from '@tundralibs/tracer/exporters/otlp';
// Read from wherever your runtime keeps secrets (Deno.env, process.env, …).
declare const otlpKey: string;
const tracer = new Tracer({
serviceName: 'orders',
exporter: new BatchSpanProcessor(
new OTLPExporter({
baseURL: 'http://localhost:4318', // collector root, not the signal path
headers: { 'x-api-key': otlpKey },
// Export failures are silent by design — this is how you see them.
onExportError: (err) => console.error('otlp export failed', err),
}),
{ maxExportBatchSize: 512, scheduledDelayMs: 5000 },
),
});
// Before exit, so buffered spans are not lost.
await tracer.shutdown();OTLP over HTTP with a JSON payload only. gRPC and protobuf are out of scope — run the OTel Collector, which accepts JSON and re-exports in whatever your backend wants.
Backends key their UI off these exact strings, so SemConv keeps them a
compile-time concern rather than a typo:
import { SemConv, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const span = tracer.startSpan('GET /orders/42');
span.setAttributes({
[SemConv.HTTP_REQUEST_METHOD]: 'GET',
[SemConv.HTTP_RESPONSE_STATUS_CODE]: 200,
[SemConv.URL_PATH]: '/orders/42',
});Ids are random by default. Override when a backend needs a specific format — AWS X-Ray requires a timestamp-prefixed trace id and rejects pure-random ones — or to make ids deterministic in tests. A custom generator is smoke-tested once at construction, because malformed ids are silently dropped by collectors:
import { type IdGenerator, Tracer } from '@tundralibs/tracer';
declare const myGenerator: IdGenerator;
new Tracer({ serviceName: 'orders', idGenerator: myGenerator });-
Nothing on a span throws. Tracing is observability; it must never break
the code it observes. Export failures are swallowed, writes after
end()are ignored, and a malformed inboundtraceparentjust means "start a new trace". - Config errors throw loudly at construction — that is the one place a mistake is cheap to surface.
-
Core is dependency-light:
ambient+utils. The OTLP exporter lives behind its own subpath so an HTTP client is never pulled into the core graph.
- Concepts - Spans, the lifecycle, and why nesting is automatic
- Propagation - W3C Trace Context across service boundaries
- Sampling - Head-based sampling and why children never re-sample
- Exporters - The exporter contract, batching, and writing your own
- OTLP - Shipping to a real backend, and the encodings that decide whether spans arrive
- Recipes - Framework adapters for 12 runtimes and frameworks
- Roadmap - What is deliberately not built yet
MIT