ObservabilityOpenTelemetryProduction

Observability Without Lock-In: Structured Logs and OpenTelemetry-Compatible Tracing

How DaloyJS gives you per-request structured logs, correlated request IDs, Server-Timing, and OpenTelemetry-shaped spans — without taking a hard dependency on @opentelemetry/api. The result is a single observability story that runs identically on Node, Bun, Workers, and Vercel Edge, with any tracer you bring.

Devlin DuldulaoFullstack cloud engineer13 min read

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
bash
# 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 unwritten

Structured logs in three lines

src/log.ts
ts
// 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 free

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:

src/log.ts
ts
// 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 interface

Request IDs are the spine

src/app.ts
ts
// 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 logger

What you get on the wire and in the log stream:

stdout · network response
bash
// 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 response

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.tsTracingTracer 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
ts
// 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 sane

The four-hook lifecycle

NOTES.md
bash
# 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 · onSend
onRequest

Start 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.

beforeHandle

Expose the span on ctx.state.otelSpan.

The stateKey is configurable. Default is otelSpan. Handlers add events / child spans / custom attributes here.

onError

recordException() + 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.

onSend

status_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

src/routes/books.ts
ts
// 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 logs

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:

src/tracing.ts
ts
// 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 fetch

Parent-context propagation, your way

src/tracing.ts
ts
// 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 headers

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

src/tracing.ts
ts
// 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 hook

Testing observability without a backend

tests/observability.test.ts
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 deterministic

Per-runtime notes

NOTES.md
bash
# 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 · Lambda

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

NOTES.md
bash
# 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 runbook

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

Devlin Duldulao

Ten years of fullstack, writing TypeScript from Norway. Once spent a long evening discovering that the reason a span had no parent was because the propagator wasn't installed, not because the universe was conspiring against him. Has been mildly more empathetic about observability complaints ever since.