telemetry
| Package | @theoven/telemetry |
| Adds to context | nothing — it is middleware |
| Endpoints | none |
| Creates files | none |
| Creates tables | none |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/telemetry @opentelemetry/apiimport { telemetry } from '@theoven/telemetry'
export const app = createApp().use(telemetry())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.
What it does
Section titled “What it does”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.
Endpoints
Section titled “Endpoints”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.
Spans are named by route
Section titled “Spans are named by route”GET /users/:id ← not GET /users/8f14e45fhttp.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.
Only 5xx is an error
Section titled “Only 5xx is an error”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.
Distributed traces actually connect
Section titled “Distributed traces actually connect”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.
Correlating logs
Section titled “Correlating logs”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.
Spans of your own
Section titled “Spans of your own”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:
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:
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.
What it creates
Section titled “What it creates”Nothing — no files, no tables, no endpoints. Spans go wherever your SDK’s exporter sends them.
Capabilities
Section titled “Capabilities”| 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.
Configuration
Section titled “Configuration”| 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.
Limitations
Section titled “Limitations”- Requests only. Database queries, queue jobs and outbound
fetchare not instrumented automatically — wrap them withspan(), 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.
How it is verified
Section titled “How it is verified”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.