Devlin here. The honest summary of most observability stacks I've inherited is: somebody pinned an SDK in 2023, it grew global side effects, and now nobody can move the service off Node 18 because the SDK's instrumentation hooks don't work on the new runtime. The cost of cleaning that up is "next quarter" forever. The whole reason DaloyJS keeps its observability story small is so you never have to write that sentence again.
This post covers the four moving parts: createLogger() for structured JSON logs, requestId() for correlation, timing() for the free Server-Timing header, and otelTracing() for OpenTelemetry-shaped spans without a hard dependency on @opentelemetry/api. Everything composes through the same hooks lifecycle, which is the only contract you need to understand to extend any of it.
Why this post exists
postmortem-but-it's-the-tooling.md
# A real observability stack failure mode, lightly fictionalized:
#
# - "We have logs." Yes, in three different formats, one of them is
# console.log("got here"), and none of them carry a request id.
# - "We have tracing." There's an SDK pinned in package.json. It was
# installed in 2023. Nobody knows which spans it actually emits.
# - "We have metrics." prom-client is in two services and StatsD in
# the third. The dashboards agree on nothing.
# - "We're going to clean it up." Yes. After this quarter. With the
# migration. After the migration. After the next one.
#
# The lock-in problem is that every observability SDK wants to OWN your
# app: register globally, monkey-patch fetch, become the only logger.
# Then you can't run on Workers, you can't run on Edge, and you can't
# switch vendors without a rewrite. DaloyJS goes the other way:
# small contracts, your choice of implementation, zero global patching.
every fullstack team has this list, written or unwrittenTS · UTF-8 · LF
Structured logs in three lines
// src/log.ts — boot a structured JSON logger.
import { createLogger } from "@daloyjs/core";
export const log = createLogger({
level: process.env.LOG_LEVEL ?? "info",
bindings: {
service: "books-api",
env: process.env.NODE_ENV ?? "development",
region: process.env.FLY_REGION ?? process.env.AWS_REGION ?? "local",
},
});
// One record per line, always JSON, always with level + time:
//
// {"level":"info","time":"2026-06-03T08:42:11.108Z",
// "service":"books-api","env":"production","region":"arn1",
// "event":"boot","msg":"server starting"}
//
// Pipe stdout into Loki / CloudWatch / Datadog / your terminal,
// whatever. The structure is the same everywhere.
JSON to stdout · bindings on every line · child loggers for freeTS · UTF-8 · LF
The default createLogger is intentionally tiny: JSON to stdout, level threshold, bindings merged into every record, and a child(bindings) method that returns a new logger with extra fields baked in. That last bit is the magic ingredient — the framework uses it to give every request its own logger pre-bound to the request id.
Want pino in production? The shape is intentionally compatible:
// Want pino in production for performance? createLogger is just one
// implementation of the Logger interface. Anything matching the shape
// works — same constructor signature, no framework changes.
import pino from "pino";
import type { Logger } from "@daloyjs/core";
import { App } from "@daloyjs/core";
// pino implements .child() and .info/.warn/.error/etc. already.
// The shape is intentionally compatible, so this just works:
const log = pino({ level: "info" }) as unknown as Logger;
const app = new App({ logger: log });
// In tests:
// new App({ logger: false }) // → noopLogger, silent
// new App({ logger: noopLogger })
pino · winston · noopLogger · anything matching the Logger interfaceTS · UTF-8 · LF
Request IDs are the spine
// src/app.ts — request IDs are the spine of observability.
import { App, requestId, timing } from "@daloyjs/core";
import { log } from "./log.js";
const app = new App({ logger: log });
// Order matters. requestId() runs in beforeHandle and stamps
// ctx.state.requestId + sets x-request-id on the response.
// trustIncoming defaults to false because clients can spoof headers
// unless your edge proxy strips/rewrites them.
app.use(requestId({ trustIncoming: false }));
// timing() adds Server-Timing: app;dur=12.34 so the browser DevTools
// Network tab shows your handler time without any client code.
app.use(timing());
// Inside a handler, use ctx.log (the framework already created a
// child logger bound to the request id) instead of the top-level log.
app.route({
method: "GET",
path: "/books",
operationId: "listBooks",
responses: { 200: { description: "ok" } },
handler: async (ctx) => {
ctx.log.info({ event: "list_books" }, "listing books");
// ↑ every log line for this request carries requestId in the JSON
return { status: 200, body: { items: [] } };
},
});
requestId() + timing() + ctx.log child loggerTS · UTF-8 · LF
What you get on the wire and in the log stream:
stdout · network response
// Output for one request — note the shared requestId across lines.
{"level":"info","time":"2026-06-03T08:42:11.108Z","service":"books-api",
"requestId":"01HZQ4M8Z1F7E0SE0EH7E3WJW2","method":"GET","path":"/books",
"msg":"request received"}
{"level":"info","time":"2026-06-03T08:42:11.110Z","service":"books-api",
"requestId":"01HZQ4M8Z1F7E0SE0EH7E3WJW2","event":"list_books",
"msg":"listing books"}
{"level":"info","time":"2026-06-03T08:42:11.114Z","service":"books-api",
"requestId":"01HZQ4M8Z1F7E0SE0EH7E3WJW2","status":200,"durationMs":6,
"msg":"request handled"}
// Server-Timing on the wire (DevTools Network tab → Timing):
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
x-request-id: 01HZQ4M8Z1F7E0SE0EH7E3WJW2
server-timing: app;dur=5.91
every log line carries requestId · x-request-id mirrored on responseTS · UTF-8 · LF
Two things to highlight. First, trustIncoming: false is the safe default — clients can send any header they want, and accepting an arbitrary id from the public internet lets them collide with (or impersonate) other requests in your log stream. Second, timing() writes the standard Server-Timing header so Chrome and Firefox DevTools surface handler latency in the Network tab with zero frontend code. Free observability is the best kind.
OpenTelemetry without the hard dependency
Here's the part most frameworks get wrong: otelTracing() is shaped like the @opentelemetry/api tracer, but the framework does not import it. The contract is two small interfaces in src/tracing.ts — TracingTracer and TracingSpan — and any object that fits them works. You install the OTel SDK in your package.json, configure it in your bootstrap, and pass the tracer in. The portability story survives.
// src/tracing.ts — bring your own OTel tracer.
// DaloyJS does NOT import @opentelemetry/api. You install it, you
// configure it, you pass the tracer in. That's the whole API surface.
import { trace } from "@opentelemetry/api";
import { App, otelTracing } from "@daloyjs/core";
// 1. Wire your OTel SDK exactly as the OTel docs say (node SDK, edge
// SDK, sdk-trace-base + a custom exporter, etc.). DaloyJS does not
// care which one — it never touches the global provider.
import "./otel-bootstrap.js"; // ← your code; calls trace.setGlobalTracerProvider(...)
const tracer = trace.getTracer("books-api", "1.0.0");
const app = new App({
hooks: otelTracing({
tracer,
// Use your routing knowledge to template the span name instead of
// letting it be "GET /books/abc123" (high-cardinality nightmare).
spanName: (req) => {
const url = new URL(req.url);
const m = url.pathname.match(/^\/books\/[^/]+$/);
return m ? `${req.method} /books/:id` : `${req.method} ${url.pathname}`;
},
}),
});
tracer comes from your OTel SDK · spanName templated to keep cardinality saneTS · UTF-8 · LF
The four-hook lifecycle
# The four-hook lifecycle, in order, for a single request:
#
# 1) onRequest — starts a SERVER span and stamps HTTP semantic
# attributes:
# http.request.method, url.path, url.scheme,
# server.address, url.query, user_agent.original
# 2) beforeHandle — stores the span on ctx.state.otelSpan so handlers
# can add events / create child spans / set custom
# attributes ("user.id", "tenant.id", ...).
# 3) onError — calls span.recordException(err) and sets status
# to ERROR with err.message. Runs ONCE.
# 4) onSend — sets http.response.status_code, escalates to ERROR
# for 5xx if onError didn't already, and calls
# span.end() exactly once. Even if the handler threw.
#
# Everything is delivered through the standard Hooks contract from
# src/types.ts — no monkey-patching of fetch, no async-hooks magic,
# no surprise globals. The same hooks composition you'd write by hand.
onRequest · beforeHandle · onError · onSendTS · UTF-8 · LF
onRequestStart the SERVER span, stamp HTTP attributes.
Uses OTel semantic conventions: http.request.method, url.path, url.scheme, server.address, url.query, user_agent.original. Your dashboards work without translation.
beforeHandleExpose the span on ctx.state.otelSpan.
The stateKey is configurable. Default is otelSpan. Handlers add events / child spans / custom attributes here.
onErrorrecordException() + ERROR status.
Runs exactly once. The error is captured as a structured exception event with name and message, ready for your APM to group on.
onSendstatus_code, 5xx escalation, end().
span.end()fires exactly once per request even if the handler threw. WeakMap-keyed by Request so it's safe across async boundaries.
Adding your own semantic attributes
// Add app-specific semantic attributes inside a handler.
// Use the OTel convention names so your existing dashboards work.
import type { Context } from "@daloyjs/core";
import type { TracingSpan } from "@daloyjs/core";
app.route({
method: "GET",
path: "/books/:id",
operationId: "getBook",
responses: { 200: { description: "ok" } },
handler: async (ctx) => {
const span = (ctx.state as { otelSpan?: TracingSpan }).otelSpan;
span?.setAttribute("http.route", "/books/:id");
span?.setAttribute("books.id", ctx.params.id);
span?.setAttribute("tenant.id", ctx.state.tenantId as string);
// Bonus: tie the request id and the trace together. Anyone reading
// logs in Datadog can jump to the matching trace in Jaeger.
ctx.log.info({ event: "get_book", bookId: ctx.params.id });
return { status: 200, body: { id: ctx.params.id } };
},
});
OTel naming conventions · tie request id and trace together in logsTS · UTF-8 · LF
The escape hatch: no OTel SDK at all
The interfaces really are tiny. You can ship traces from a Worker without installing a single OTel package:
// You can use otelTracing() WITHOUT installing @opentelemetry/api.
// The framework only depends on the small TracingTracer/TracingSpan
// interfaces. Roll your own collector — useful on Workers, in tests,
// or for emitting OTLP HTTP directly from an edge function.
import { App, otelTracing } from "@daloyjs/core";
import type { TracingTracer, TracingSpan } from "@daloyjs/core";
const collected: object[] = [];
const tinyTracer: TracingTracer = {
startSpan(name, options) {
const startedAt = Date.now();
const attrs: Record<string, unknown> = { ...(options?.attributes ?? {}) };
let status: { code: number; message?: string } = { code: 0 };
const span: TracingSpan = {
setAttribute(k, v) { attrs[k] = v; },
setAttributes(o) { Object.assign(attrs, o); },
setStatus(s) { status = s; },
recordException(err) {
attrs["exception.type"] = (err as Error)?.name ?? "Error";
attrs["exception.message"] = (err as Error)?.message ?? String(err);
},
end() {
collected.push({ name, durationMs: Date.now() - startedAt, status, attrs });
// Ship to your OTLP endpoint, console, KV, whatever:
// fetch("https://otlp-http.example/v1/traces", { ... })
},
};
return span;
},
};
const app = new App({ hooks: otelTracing({ tracer: tinyTracer }) });
hand-rolled TracingTracer · 30 lines · runs on any runtime with fetchTS · UTF-8 · LF
Parent-context propagation, your way
// W3C traceparent propagation — bring your own propagator.
// DaloyJS deliberately does NOT import a propagator (they pull in
// the OTel context API and aren't edge-safe in every runtime).
// Pass any function that returns the parent context you want.
import { propagation, ROOT_CONTEXT } from "@opentelemetry/api";
const app = new App({
hooks: otelTracing({
tracer,
contextFromRequest: (req) => {
// Build a header carrier from the Web Request:
const carrier: Record<string, string> = {};
req.headers.forEach((v, k) => { carrier[k] = v; });
return propagation.extract(ROOT_CONTEXT, carrier);
},
}),
});
// Now upstream services that already started a trace (your gateway,
// a frontend RUM library, another service) get the child-of relation
// recorded automatically, with no per-handler code.
contextFromRequest · use the propagator of your choice · headers are just headersTS · UTF-8 · LF
Don't need the OTel propagator? Read traceparent off the headers yourself, build a minimal parent context object, return it. The framework does not care what shape the parent context has; it passes it through to startSpan unchanged.
Composing exporters and access logs
// otelTracing() is just hooks. You can compose your own exporter at
// the same layer — wrap startSpan to also push to your sink, or wrap
// otelTracing's onSend hook to also emit a log line.
import { otelTracing } from "@daloyjs/core";
import type { Hooks } from "@daloyjs/core";
const tracing = otelTracing({ tracer });
const tracingWithAccessLog: Hooks = {
...tracing,
onSend(res, ctx) {
tracing.onSend?.(res, ctx); // keep span termination
if (!ctx) return;
ctx.log.info({
event: "access",
status: res.status,
method: ctx.request.method,
path: new URL(ctx.request.url).pathname,
});
},
};
app.use(tracingWithAccessLog);
otelTracing returns Hooks · wrap them like any other hookTS · UTF-8 · LF
Testing observability without a backend
tests/observability.test.ts
// tests/observability.test.ts — verify spans without a real backend.
import { test } from "node:test";
import assert from "node:assert/strict";
import { App, otelTracing } from "@daloyjs/core";
import type { TracingTracer } from "@daloyjs/core";
test("emits one SERVER span per request with HTTP attributes", async () => {
const spans: Array<{ name: string; attrs: Record<string, unknown>; status: number }> = [];
const tracer: TracingTracer = {
startSpan(name, options) {
const attrs = { ...(options?.attributes ?? {}) } as Record<string, unknown>;
let statusCode = 0;
return {
setAttribute(k, v) { attrs[k] = v; },
setStatus(s) { statusCode = s.code; },
recordException() {},
end() { spans.push({ name, attrs, status: statusCode }); },
};
},
};
const app = new App({ hooks: otelTracing({ tracer }) });
app.route({
method: "GET",
path: "/ping",
operationId: "ping",
responses: { 200: { description: "ok" } },
handler: async () => ({ status: 200, body: { ok: true } }),
});
const res = await app.request("/ping");
assert.equal(res.status, 200);
assert.equal(spans.length, 1);
assert.equal(spans[0]!.name, "GET /ping");
assert.equal(spans[0]!.attrs["http.request.method"], "GET");
assert.equal(spans[0]!.attrs["http.response.status_code"], 200);
});
node:test + in-memory tracer + app.request() · fast and deterministicTS · UTF-8 · LF
Per-runtime notes
# Runtime-by-runtime observability checklist.
#
# Node / Bun (long-lived):
# - createLogger to stdout, ship via fluent-bit / vector / your agent.
# - @opentelemetry/sdk-node + the OTLP exporter you prefer.
# - requestId({ trustIncoming: true }) ONLY if your reverse proxy
# strips and rewrites x-request-id.
#
# Cloudflare Workers:
# - createLogger writes via console.log (the Workers runtime captures
# it). Logpush ships to R2/S3/Splunk.
# - For tracing: roll a small TracingTracer that POSTs to an OTLP/HTTP
# collector (or directly to vendor APIs like Honeycomb). The "no OTel
# SDK" example above is the template.
# - Inherit traceparent via contextFromRequest — propagation only needs
# reading the header; you can do that without the OTel propagator.
#
# Vercel Edge / Functions:
# - Same as Workers for Edge runtime. For Node functions, the standard
# @opentelemetry/sdk-node works if the cold-start budget allows.
# - Vercel's Observability tab already reads structured stdout JSON.
#
# AWS Lambda:
# - createLogger to stdout (CloudWatch). Add bindings: { aws_request_id }.
# - @opentelemetry/sdk-node + the Lambda layer the OTel project ships,
# or roll a tiny tracer + flush at the end of each invocation.
#
# Same App, every runtime: same Logger, same TracingTracer interface,
# different transports. The framework never assumes which one.
Node · Workers · Vercel Edge · LambdaTS · UTF-8 · LF
Same story as the rest of the framework (see the five-runtimes post): one app, swap the transport per environment, keep the handler code identical.
The pre-flight checklist
# Pre-flight observability checklist for any service.
#
# 1) requestId() at the top of the chain. trustIncoming=false unless
# your edge proxy validates and rewrites the header.
# 2) createLogger with bindings: { service, env, region, version }.
# Use ctx.log inside handlers, not the top-level instance.
# 3) timing() so the Network tab shows handler latency without
# instrumentation in the frontend.
# 4) otelTracing({ tracer, spanName }) — TEMPLATE the span name. Raw
# URLs are the most common source of cardinality explosions in
# every observability bill on earth.
# 5) Wire contextFromRequest to your propagator. A trace that starts
# at the frontend and ends in the database is the only kind worth
# paying for.
# 6) Record exceptions and stamp http.response.status_code. The
# framework does this for you — just verify in dev.
# 7) Tie logs to traces. Stamp the trace id on every log line; jump
# from a log entry to the matching span in your UI of choice.
# 8) Keep the SDK out of @daloyjs/core. The portability story dies
# the day you accept a hard dep on @opentelemetry/api.
eight items · pin it next to the runbookTS · UTF-8 · LF
Wrapping up
Observability is one of those areas where the bad decisions are invisible until you try to leave them. DaloyJS's answer is to keep every contract small enough that "leaving" is just "swap one tiny implementation for another": Logger is seven methods, TracingTracer is one method, Hooks is the same lifecycle every other middleware uses. No globals to fight, no SDK to bribe, no runtime to leave behind.
Closest neighbors: the RFC 9457 errors post for what gets recorded on the span when handlers throw, the rate-limit post for the other "tiny pluggable contract" story, and the middleware lifecycle post for the hooks that make all of this possible.
— Devlin