Skip to content

telemetry

Package @theoven/telemetry
Adds to context nothing — it is middleware
Endpoints none
Creates files none
Creates tables none
Status shipped
Terminal window
bun add @theoven/telemetry @opentelemetry/api
src/app.ts
import { telemetry } from '@theoven/telemetry'
export const app = createApp().use(telemetry())
src/telemetry.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
new NodeSDK({ traceExporter: new OTLPTraceExporter() }).start()

Import that before your app and every request is traced.

Wraps every request in an OpenTelemetry span, named by route pattern, with the standard HTTP attributes on it. It extracts an incoming traceparent so a request from another service continues that trace, and exposes span() and traceIds() for the parts of your own work worth seeing separately.

It is middleware, not a service — there is no ctx.telemetry. Tracing is something that happens to a request, not something a handler reaches for.

This brick adds no endpoints. It never mounts a metrics or trace endpoint; your SDK’s exporter sends data out, and nothing needs to be scraped from your app.

GET /users/:id ← not GET /users/8f14e45f

http.route carries the pattern and url.path carries the actual path. A backend that sees a million distinct span names cannot aggregate anything, so the pattern is what goes in the name.

This works because core exposes ctx.routePattern — reconstructing it from the path would mean inventing a rule about which segments are ids, which is wrong on the first slug.

A 404 or a 422 is the server working correctly: a client asked for something that is not there, or sent something invalid. Marking those as failed spans gives you an error rate made of other people’s typos, and nobody looks at a dashboard that is always red.

That holds whether the status was returned or thrown — throw new NotFound() is the documented way to answer 404 here, so it is classified by its status like anything else. Only a 5xx records an exception.

An incoming traceparent is extracted before the span is created, so a request arriving from another service continues that trace rather than starting a disconnected root. Without it, the distributed part of distributed tracing quietly does not work.

import { traceIds } from '@theoven/telemetry'
ctx.log.info('charged card', { amount, ...traceIds() })

A trace you cannot reach from a log line, and a log line you cannot reach from a trace, are two tools instead of one.

import { span } from '@theoven/telemetry'
const rates = await span('fetch-rates', () => fetch(url).then((r) => r.json()))

For the parts of a request worth seeing separately — a third-party call, an expensive query. It ends the span whether the work returns or throws.

Registered once, it applies to everything:

src/app.ts
export const app = createApp().use(
telemetry({
name: 'checkout-api',
version: '2.1.0',
ignore: ['/health', '/metrics'],
attributes: (ctx) => ({ 'app.tenant': ctx.header('x-tenant-id') ?? 'none' }),
}),
)

attributes runs per request, so anything on the context is available — a tenant, a plan, an API-key id. Keep them low-cardinality: an attribute with a distinct value per request is a dimension your backend cannot group by, and is charged for.

Inside a handler, add spans for what is worth separating and stamp trace ids on logs:

src/routes/checkout.post.ts
import { span, traceIds } from '@theoven/telemetry'
export const auth = true
export default async ({ body, db, log }) => {
const quote = await span('pricing.quote', () => priceBasket(body.items), {
'basket.size': body.items.length,
})
const charge = await span('stripe.charge', () => gateway.charge(quote.total))
log.info('charged card', { amount: quote.total, ...traceIds() })
return db.insert(orders).values({ charge: charge.id }).returning()
}

span() takes an optional third argument of attributes, and ends the span whether the work returns or throws.

Nothing — no files, no tables, no endpoints. Spans go wherever your SDK’s exporter sends them.

Request spans, named by route pattern ✓
traceparent extraction — continues an upstream trace ✓
Manual spans via span() ✓
Trace/span ids for logs via traceIds() ✓
Automatic DB / queue / outbound-fetch instrumentation ✗ — wrap with span()
Metrics and logs signals ✗ — traces only
Sampling, exporters, resource attributes your SDK’s, not this brick’s

What fails at boot: nothing. With no SDK registered, OpenTelemetry’s no-op tracer is used and the middleware costs almost nothing — so this brick is safe to leave registered in tests and in development.

Option Default
name @theoven/core tracer name, as it appears in your backend
version — tracer version
ignore /health, /healthz, /_oven/* paths never traced; * suffix matches a prefix
attributes — (ctx) => Attributes, for a tenant id or plan
useRoutePattern true name spans by pattern rather than path

Health checks are ignored by default because a liveness probe every second is the loudest thing in a trace backend and tells you nothing. Setting ignore replaces that list rather than adding to it.

  • Requests only. Database queries, queue jobs and outbound fetch are not instrumented automatically — wrap them with span(), or use the relevant OpenTelemetry instrumentation.
  • Traces, not metrics or logs. The other two signals are not wired up.
  • No sampling configuration here — that belongs to your SDK, which is where it already is.

Against a recording tracer registered globally the way a real SDK would be, asserting on the spans actually produced — names, attributes, statuses, exceptions. A test against a mock tracer proves the mock works.

The 4xx-versus-5xx test caught a real bug while it was being written: the middleware classified every thrown error as a failure regardless of status, which would have put every throw new NotFound() into the error rate — the exact mistake the rule exists to prevent, arrived at from the other side.