-
Notifications
You must be signed in to change notification settings - Fork 2
Tracer Recipes
Copy-paste integrations for the frameworks and runtimes people actually deploy on.
These are not shipped in-tree. Every framework has its own context shape and
release cadence, so shipping adapters would mean tracking version drift across
projects we don't control — and a generic adapter cannot write to a context it
doesn't know (ctx.span = span), which is usually the thing you want. They are
~10 lines each, and yours keeps full type knowledge of your own context.
Everything below works with the package as-is: startActiveSpan, extract and
inject are all an adapter needs. Each example assumes a tracer in scope and
these imports:
import {
extract,
inject,
SemConv,
SpanKind,
SpanStatusCode,
} from '@tundralibs/tracer';- The shape
- Fetch-standard runtimes — Deno.serve, Bun.serve, Cloudflare Workers
- Hono
- Express
- Fastify
- Koa
- NestJS
- Oak
- h3 / Nitro / Nuxt
- SvelteKit
- Next.js
- AWS Lambda
- RadRouter / RPC
- Outbound: propagating the trace
- Tracing drivers without wrapping every call
- Tracing norm: flat spans free, nested spans with a witness
- Serverless: flush before the runtime freezes
- Testing traces
Every inbound adapter is the same four steps:
-
extract the inbound
traceparent, so this service joins the caller's trace instead of starting a new one - open a
SERVERspan - run the handler inside it, so everything below parents automatically
- record the status on the way out
Frameworks differ only in where headers live, how you learn the status, and whether the handler is wrapped or signalled. Two families:
-
Wrapping (
await next()returns) — the span closes naturally whenstartActiveSpan's callback settles. Hono, Koa, Oak, SvelteKit. -
Signalling (
next()/done()returns immediately) — the response lifecycle ends the span. Express, Fastify.end()is idempotent, so calling it from an event handler is safe.
Covers Deno.serve, Bun.serve, Cloudflare Workers, and
@tundralibs/compat/webserver — anything whose handler is
(Request) => Response.
import {
extract,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const myHandler = (_req: Request) => Promise.resolve(new Response('ok'));
const traced =
(handler: (req: Request) => Promise<Response>) =>
(req: Request): Promise<Response> => {
const { pathname } = new URL(req.url);
return tracer.startActiveSpan(
`${req.method} ${pathname}`,
{ kind: SpanKind.SERVER, parent: extract(req.headers) },
async (span) => {
span.setAttributes({
[SemConv.HTTP_REQUEST_METHOD]: req.method,
[SemConv.URL_PATH]: pathname,
});
const res = await handler(req);
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, res.status);
if (res.status >= 500) span.setStatus(SpanStatusCode.ERROR);
return res;
},
);
};
Deno.serve(traced(myHandler));c.req.raw is the underlying Request, so the headers are Fetch-standard.
Prefer c.req.routePath over the resolved path — /orders/:id groups in a
backend, /orders/42 does not.
// Deno resolves Hono from JSR; under plain Node/Bun use a bare
// `from 'hono'` instead.
import { Hono } from 'jsr:@hono/hono@^4.13.1';
import {
extract,
SemConv,
type Span,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const app = new Hono<{ Variables: { span: Span } }>();
app.use('*', (c, next) =>
tracer.startActiveSpan(
`${c.req.method} ${c.req.routePath}`,
{ kind: SpanKind.SERVER, parent: extract(c.req.raw.headers) },
async (span) => {
c.set('span', span); // your context, your types
await next();
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, c.res.status);
if (c.res.status >= 500) span.setStatus(SpanStatusCode.ERROR);
},
));Express signals rather than wraps: next() returns immediately, so the response
lifecycle has to end the span.
// Deno resolves Express via the `npm:` specifier; under plain
// Node/Bun use a bare `from 'express'` instead.
import express, {
type NextFunction,
type Request,
type Response,
} from 'npm:express@^5.2.1';
import {
extract,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const app = express();
app.use((req: Request, res: Response, next: NextFunction) => {
tracer.startActiveSpan(
`${req.method} ${req.path}`,
{ kind: SpanKind.SERVER, parent: extract(req.headers) },
(span) => {
res.on('finish', () => {
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, res.statusCode);
if (res.statusCode >= 500) span.setStatus(SpanStatusCode.ERROR);
span.end();
});
next();
},
);
});Downstream spans still parent correctly even though next() returns
immediately: async work started inside the active scope keeps the context
through its continuations, which is exactly what AsyncLocalStorage guarantees.
Same signalling shape as Express, via hooks. onRequest opens the span,
the raw response's finish closes it.
// Deno resolves Fastify via the `npm:` specifier; under plain
// Node/Bun use a bare `from 'fastify'` instead.
import Fastify from 'npm:fastify@^5.11.3';
import {
extract,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const fastify = Fastify();
fastify.addHook('onRequest', (request, reply, done) => {
tracer.startActiveSpan(
`${request.method} ${request.routeOptions?.url ?? request.url}`,
{ kind: SpanKind.SERVER, parent: extract(request.headers) },
(span) => {
reply.raw.on('finish', () => {
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, reply.statusCode);
if (reply.statusCode >= 500) span.setStatus(SpanStatusCode.ERROR);
span.end();
});
done();
},
);
});request.routeOptions.url is the route template; fall back to request.url
only when no route matched, and expect that to be high-cardinality.
Koa wraps — await next() returns once the downstream chain finishes.
// Koa ships no types of its own, so they come from DefinitelyTyped and the
// middleware signature has to be annotated explicitly; under plain Node/Bun
// use bare `from 'koa'` with `@types/koa` installed.
import Koa from 'npm:koa@^3.2.1';
import type { Context, Next } from 'npm:@types/koa@^3.0.0';
import {
extract,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const app = new Koa();
app.use((ctx: Context, next: Next) =>
tracer.startActiveSpan(
`${ctx.method} ${ctx._matchedRoute ?? ctx.path}`,
{ kind: SpanKind.SERVER, parent: extract(ctx.headers) },
async (span) => {
ctx.span = span;
await next();
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, ctx.status);
if (ctx.status >= 500) span.setStatus(SpanStatusCode.ERROR);
},
)
);An interceptor is the natural fit, but it returns an Observable, and that changes which API to use.
startActiveSpan ends the span as soon as its callback returns a value that is
not a promise — so handing it an Observable would end the span the instant the
stream is constructed, recording a ~0ms duration. Use startSpan for the
manual lifetime and activeSpan.run to make it the parent, then close it in
finalize:
// Deno resolves Nest and RxJS via the `npm:` specifier; under plain
// Node/Bun use bare `from '@nestjs/common'` / `from 'rxjs/operators'`.
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from 'npm:@nestjs/common@^11.1.29';
import type { Observable } from 'npm:rxjs@^7.8.2';
import { finalize, tap } from 'npm:rxjs@^7.8.2/operators';
import {
activeSpan,
extract,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
@Injectable()
export class TracingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest();
const res = context.switchToHttp().getResponse();
const span = tracer.startSpan(
`${req.method} ${context.getClass().name}.${context.getHandler().name}`,
{ kind: SpanKind.SERVER, parent: extract(req.headers) },
);
return activeSpan.run(span, () =>
next.handle().pipe(
tap({
next: () =>
span.setAttribute(
SemConv.HTTP_RESPONSE_STATUS_CODE,
res.statusCode,
),
error: (err) => {
span.recordException(err);
span.setStatus(SpanStatusCode.ERROR, String(err?.message ?? err));
},
}),
finalize(() => span.end()),
));
}
}The same applies to any callback returning a non-promise thenable or stream:
startActiveSpan cannot know when it finished, so own the lifetime yourself.
import { Application } from 'jsr:@oak/oak@^17.1.4';
import { extract, SemConv, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const app = new Application();
app.use((ctx, next) =>
tracer.startActiveSpan(
`${ctx.request.method} ${ctx.request.url.pathname}`,
{ kind: SpanKind.SERVER, parent: extract(ctx.request.headers) },
async (span) => {
await next();
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, ctx.response.status);
},
)
);h3 handlers wrap, and its helpers keep this runtime-agnostic.
// Deno resolves h3 via the `npm:` specifier; under plain Node/Bun use
// a bare `from 'h3'` instead.
import {
defineEventHandler,
getRequestHeaders,
getResponseStatus,
type H3Event,
} from 'npm:h3@^1.15.11';
import { extract, SemConv, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const handler = (_event: H3Event) => Promise.resolve({ ok: true });
export default defineEventHandler((event) =>
tracer.startActiveSpan(
`${event.method} ${event.path}`,
{ kind: SpanKind.SERVER, parent: extract(getRequestHeaders(event)) },
async (span) => {
const result = await handler(event);
span.setAttribute(
SemConv.HTTP_RESPONSE_STATUS_CODE,
getResponseStatus(event),
);
return result;
},
)
);The handle hook wraps the whole request, and resolve(event) returns the
Response — so the status is available directly.
// src/hooks.server.ts
// Deno resolves SvelteKit via the `npm:` specifier; inside a scaffolded
// app use a bare `from '@sveltejs/kit'` instead.
import type { Handle } from 'npm:@sveltejs/kit@^2.70.2';
import {
extract,
SemConv,
type Span,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
// src/app.d.ts — SvelteKit's own way to type what you put on `locals`.
declare global {
namespace App {
interface Locals {
span: Span;
}
}
}
export const handle: Handle = ({ event, resolve }) =>
tracer.startActiveSpan(
`${event.request.method} ${event.route.id ?? event.url.pathname}`,
{ kind: SpanKind.SERVER, parent: extract(event.request.headers) },
async (span) => {
event.locals.span = span; // typed via App.Locals
const response = await resolve(event);
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, response.status);
if (response.status >= 500) span.setStatus(SpanStatusCode.ERROR);
return response;
},
);event.route.id is the route template (/orders/[id]); prefer it over
url.pathname.
Route handlers are Fetch-standard, so wrap them like any other handler:
// app/api/orders/route.ts
// No Next.js import: a route handler is Fetch-standard. `traced` is the
// wrapper from the Fetch-standard runtimes recipe above.
declare const traced: (
handler: (req: Request) => Promise<Response>,
) => (req: Request) => Promise<Response>;
declare function listOrders(): Promise<unknown>;
export const GET = traced(async (req: Request) => {
return Response.json(await listOrders());
});with traced from Fetch-standard runtimes.
Next.js middleware (
middleware.ts) runs on the Edge runtime and is deliberately short-lived — it is the wrong place to own a span, because the exporter may not get a chance to flush. Trace in the route handler.
An API Gateway event carries the headers, so the trace continues from the caller. Note the flush — see the next section for why it is not optional.
// Deno resolves the Lambda event types via the `npm:` specifier; under
// plain Node/Bun use a bare `from 'aws-lambda'` with
// `@types/aws-lambda` installed.
import type {
APIGatewayProxyEventV2,
Context,
} from 'npm:@types/aws-lambda@^8.10.162';
import { extract, SemConv, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const businessLogic = (_event: APIGatewayProxyEventV2) =>
Promise.resolve({ ok: true });
export const handler = async (
event: APIGatewayProxyEventV2,
_context: Context,
) =>
tracer.startActiveSpan(
`${event.requestContext.http.method} ${event.routeKey ?? event.rawPath}`,
{ kind: SpanKind.SERVER, parent: extract(event.headers ?? {}) },
async (span) => {
try {
const result = await businessLogic(event);
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, 200);
return result;
} finally {
// The runtime may freeze the instant the promise settles.
await tracer.shutdown();
}
},
);If the backend is AWS X-Ray, it needs a timestamp-prefixed trace id and rejects
pure-random ones — supply an idGenerator, see the README.
Both are generic over their middleware type, so the same body works — supply your own context type and read headers from wherever your context keeps them:
import { extract, SpanKind, Tracer } from '@tundralibs/tracer';
// Needs a separate install: deno add @tundralibs/radrouter
import { RadRouter } from '@tundralibs/radrouter';
const tracer = new Tracer({ serviceName: 'orders' });
type AppCtx = { route: string; headers: Headers };
type AppMw = (ctx: AppCtx, next: () => Promise<void>) => Promise<void>;
const tracing: AppMw = (ctx, next) =>
tracer.startActiveSpan(
ctx.route,
{ kind: SpanKind.SERVER, parent: extract(ctx.headers) },
() => next(),
);
const router = new RadRouter<AppMw>();
router.use(tracing);Open a CLIENT span and inject traceparent, so the callee joins this trace
rather than starting its own.
For API clients built on @tundralibs/restler
(≥ 1.1) this is wiring, not code — restler's two hooks compose through the
ambient store with no coupling in either direction:
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 GitHubAPI: new (
token: string,
opts: Partial<RESTlerOptions>,
) => unknown;
const api = new GitHubAPI(token, {
witness: tracer.wrapClient, // CLIENT span per outbound request
headerProvider: tracer.propagation, // traceparent — carries THAT span's id
});headerProvider runs inside the witnessed window, so the header carries the
per-request span, not the distant server span. Either hook also works alone:
headerProvider by itself still propagates (downstream parents to whatever
span is active), witness by itself still draws the client edge in the
service map. For raw fetch — or any client without a header seam — wrap it
yourself:
import {
inject,
SemConv,
SpanKind,
SpanStatusCode,
Tracer,
} from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const tracedFetch = (input: string, init: RequestInit = {}) =>
tracer.startActiveSpan(
`${init.method ?? 'GET'} ${new URL(input).pathname}`,
{ kind: SpanKind.CLIENT },
async (span) => {
const headers = new Headers(init.headers);
headers.set('traceparent', inject(span.context));
const res = await fetch(input, { ...init, headers });
span.setAttribute(SemConv.HTTP_RESPONSE_STATUS_CODE, res.status);
if (res.status >= 500) span.setStatus(SpanStatusCode.ERROR);
return res;
},
);The same shape wraps a database call — CLIENT kind, db.* attributes, no
header injection:
import { SemConv, SpanKind, Tracer } from '@tundralibs/tracer';
const tracer = new Tracer({ serviceName: 'orders' });
const db = {
query: (_sql: string, _params: unknown[]) => Promise.resolve([]),
};
const tracedQuery = (sql: string, params: unknown[]) =>
tracer.startActiveSpan(
'db.query',
{ kind: SpanKind.CLIENT },
async (span) => {
span.setAttributes({
[SemConv.DB_SYSTEM]: 'postgres',
[SemConv.DB_OPERATION_NAME]: sql.split(' ')[0],
});
return await db.query(sql, params);
},
);Set
db.query.textonly if you are certain the statement carries no user data — it is a common way to leak PII into a trace backend.
Wrapping works, but only if you remember it at every call site.
@tundralibs/drivers engines are Options/Events subclasses that already
emit the hooks a tracer wants, so you can instrument once, per engine, with
no dependency in either direction — drivers never learns about tracer, and
tracer never learns about drivers.
import { SemConv, SpanKind, SpanStatusCode, Tracer } from '@tundralibs/tracer';
// Needs a separate install: deno add @tundralibs/drivers
import type { EngineQueryResult } from '@tundralibs/drivers';
const tracer = new Tracer({ serviceName: 'orders' });
/** Whatever `@tundralibs/drivers` engine you wired up. */
type QueryEngine = {
on(
event: 'query',
cb: (instanceId: string, result: EngineQueryResult) => void,
): void;
on(
event: 'error',
cb: (instanceId: string, error: Error) => void,
): void;
};
/** Attach tracing to a query engine. Call once, at wire-up. */
export function traceEngine(engine: QueryEngine, dbSystem: string): void {
engine.on('query', (_instanceId, result) => {
// The event fires as the query completes, so reconstruct the window from
// the reported duration rather than guessing at it.
const end = new Date();
const span = tracer.startSpan('db.query', {
kind: SpanKind.CLIENT,
startTime: new Date(end.getTime() - result.time),
attributes: {
[SemConv.DB_SYSTEM]: dbSystem,
'db.rows_affected': result.count,
},
});
span.end(end);
});
engine.on('error', (_instanceId, error) => {
const span = tracer.startSpan('db.error', { kind: SpanKind.CLIENT });
span.recordException(error);
span.setStatus(SpanStatusCode.ERROR);
span.end();
});
}Why this parents correctly. The event is emitted during the query call, so
the ambient active span is still the caller's — the span created in the handler
lands under whatever request span was open, with no context threading. Same
mechanism that makes startSpan parent automatically everywhere else.
Other events worth hanging spans off: slowQuery, transactionBegin /
transactionCommit / transactionRollback (they carry a transactionId, so a
whole transaction can be one span), connectionFailed, and notice.
EngineQueryResultcarries{ id, query, count, time }. Puttingresult.queryon the span puts the statement in your trace backend — treat it exactly likedb.query.textabove: sanitise, or leave it off.
For @tundralibs/norm, see the next section — it forwards these driver
events AND adds an operation layer on top.
norm gives you two layers. Layer 1 is events — norm re-emits the driver's
query/slowQuery (metadata only: no SQL text or params ever cross norm's
bus) and adds its own operation-level call event. Both yield retrospective
spans that parent to whatever request span is active:
import { SpanKind, Tracer } from '@tundralibs/tracer';
// Needs a separate install: deno add @tundralibs/norm
import type { NormEvents } from '@tundralibs/norm';
const tracer = new Tracer({ serviceName: 'orders' });
// Your configured Norm instance.
declare const norm: {
on<K extends keyof NormEvents>(event: K, cb: NormEvents[K]): void;
};
// Per-query spans — same idea as the drivers recipe, via norm's bus.
// Signature: (engineId, queryId, timeMs, isSlow, transactionId)
norm.on('query', (_engine, _qid, timeMs, _slow, txId) => {
const end = new Date();
const span = tracer.startSpan('db.query', {
kind: SpanKind.CLIENT,
startTime: new Date(end.getTime() - timeMs),
});
if (txId) span.setAttribute('db.transaction_id', txId);
span.end(end);
});
// Per-OPERATION spans — norm's own layer. `id` is the same ULID returned in
// the operation's NormResult envelope, so spans correlate with results.
// Signature: (entity, op, timeMs, isSlow, id)
norm.on('call', (entity, op, timeMs, _slow, id) => {
const end = new Date();
tracer.startSpan(`norm.${entity}.${op}`, {
kind: SpanKind.INTERNAL,
startTime: new Date(end.getTime() - timeMs),
attributes: { 'norm.entity': entity, 'norm.result_id': id },
}).end(end);
});Attach per-query tracing at one level — norm's bus or the engine, not both, or every query gets two spans. norm's forwarded events are the privacy-safe choice (no statement text); the engine's carry the SQL.
Layer 2 is the witness — event spans are flat: nothing links a call
span to the queries it caused, because a span created in an event handler is
never active while the operation runs. The witness closes exactly that gap:
import { Tracer } from '@tundralibs/tracer';
// Needs a separate install: deno add @tundralibs/norm
import { Norm, type NormConfig } from '@tundralibs/norm';
const tracer = new Tracer({ serviceName: 'orders' });
declare const database: NonNullable<NormConfig['database']>;
const norm = new Norm({ database, witness: tracer.wrap });
// tracer.wrap is the bound Witness adapter — equivalent to wiring
// startActiveSpan(info.name, { attributes: info.attributes }, fn) by hand,
// with non-OTLP-representable attribute values dropped for you.GET /orders ← request span (middleware)
└─ norm.Orders.find ← witness: ACTIVE while the operation runs
├─ db.query ← event span parents here automatically
└─ db.query (relation load)
With the witness on, drop the call handler (the witness span replaces it —
keeping both double-reports every operation) and keep the query handler for
the children. The gap between an operation span and its child query spans is
norm's own overhead — validation, hooks, and per-cell crypto on encrypted
columns — surfaced per operation, for free.
On Lambda, Cloudflare Workers and similar, the runtime may freeze or discard
the instance the moment your handler's promise settles. A
BatchSpanProcessor holding a partial batch loses it — silently, so it looks
exactly like "tracing isn't working".
Two ways to avoid it:
import {
BatchSpanProcessor,
ConsoleExporter,
SpanKind,
Tracer,
} from '@tundralibs/tracer';
const processor = new BatchSpanProcessor(new ConsoleExporter());
const tracer = new Tracer({ serviceName: 'orders', exporter: processor });
const handle = (_req: Request) => Promise.resolve(new Response('ok'));
// Cloudflare supplies these ambiently via `@cloudflare/workers-types`.
type Env = Record<string, string>;
type ExecutionContext = { waitUntil(promise: Promise<unknown>): void };
// 1. Flush explicitly before returning.
await tracer.shutdown();
// 2. Cloudflare Workers: let the flush outlive the response.
export default {
fetch(req: Request, env: Env, ctx: ExecutionContext) {
return tracer.startActiveSpan(
'handler',
{ kind: SpanKind.SERVER },
async (span) => {
const res = await handle(req);
ctx.waitUntil(processor.forceFlush());
return res;
},
);
},
};For very short invocations, consider skipping BatchSpanProcessor entirely and
exporting per span — one round-trip is cheaper than losing the trace.
MemoryExporter buffers spans so you can assert on them directly:
import { MemoryExporter, Tracer } from '@tundralibs/tracer';
const exporter = new MemoryExporter();
const tracer = new Tracer({ serviceName: 'test', exporter });
tracer.startActiveSpan('work', (span) => span.setAttribute('ok', true));
exporter.find('work')?.attributes.ok; // true
exporter.reset(); // between casesBehind a BatchSpanProcessor, call await processor.forceFlush() before
asserting — spans are queued, not exported, until a batch or the timer fires.
For deterministic ids, inject a fixed byte source:
import {
createRandomIdGenerator,
MemoryExporter,
Tracer,
} from '@tundralibs/tracer';
const exporter = new MemoryExporter();
const idGenerator = createRandomIdGenerator(
(n) => new Uint8Array(n).fill(0x0a),
);
new Tracer({ serviceName: 'test', idGenerator, exporter });