Search docs

Jump between documentation pages.

Browse docs

Tracing with OpenTelemetry

DaloyJS ships otelTracing(opts), a hook factory that produces a Hooks object compatible with @opentelemetry/api. It starts a SERVER-kind span per HTTP request, attaches the standard HTTP semantic-convention attributes, exposes the span on ctx.state for handlers, and ends the span exactly once when the response is sent.

The framework does not depend on @opentelemetry/api. You pass any tracer that implements the minimal TracingTracer interface, so the same hook works on Node with the OTel SDK, on Workers with a custom exporter, or in tests with an in-memory fake.

One span per request
  1. 01optionalExtract contextcontextFromRequest reads traceparent / B3
  2. 02onRequeststart SERVER span + http.request.method, url.path
  3. 03handlerctx.state.otelSpan for events & child spans
  4. 04onSendhttp.response.status_code, recordException on errors
  5. 05exactly oncespan.end()5xx escalates to setStatus(ERROR)
The hook starts a SERVER span and attaches request attributes on onRequest, exposes the span on ctx.state during beforeHandle, then records the status code (and any exception) on onSend before ending the span exactly once.

Quick start

ts
import { trace } from "@opentelemetry/api";
import { App, otelTracing } from "@daloyjs/core";

const tracer = trace.getTracer("my-service");

const app = new App({
  hooks: otelTracing({ tracer }),
});

That single hook gives every request:

  • http.request.method, url.path, url.scheme, server.address (host without port), server.port (when present), url.query, user_agent.original set on onRequest.
  • http.response.status_code set on onSend.
  • recordException + setStatus(ERROR) on thrown errors, and ERROR escalation for any 5xx response.
  • A guaranteed single span.end() per request, even if both onError and onSend fire.

Reading the active span in handlers

The active span is exposed on ctx.state.otelSpan (key configurable via stateKey). Use it to add events, child spans, or extra attributes from inside a handler:

ts
import { type TracingSpan } from "@daloyjs/core";
import { z } from "zod";

const CreateOrder = z.object({
  items: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive() })),
});

app.route({
  method: "POST",
  path: "/orders",
  operationId: "createOrder",
  request: { body: CreateOrder },
  responses: {
    201: {
      description: "created",
      body: z.object({ id: z.string(), itemCount: z.number() }),
    },
  },
  handler: async ({ state, body }) => {
    const span = state.otelSpan as TracingSpan | undefined;
    span?.setAttribute("order.size", body.items.length);
    span?.setAttributes?.({ "tenant.id": state.tenantId as string });
    return { status: 201 as const, body: { id: "ord_123", itemCount: body.items.length } };
  },
});

Customizing span name and attributes

All extractors are optional. They are merged on top of the defaults so you only need to override what you care about.

ts
otelTracing({
  tracer,
  spanName: (req) => `HTTP ${req.method} ${new URL(req.url).pathname}`,
  attributesFromRequest: (req) => ({
    "tenant.id": req.headers.get("x-tenant-id") ?? "unknown",
  }),
  attributesFromResponse: (res) => ({
    "http.response.body.size": Number(res.headers.get("content-length") ?? 0),
  }),
});

Propagating upstream context

DaloyJS does not bundle a propagator. If you want parent-span continuation from traceparent / B3 headers, use contextFromRequest to wire your propagator's extract in:

ts
import { context, propagation, trace } from "@opentelemetry/api";

otelTracing({
  tracer: trace.getTracer("my-service"),
  contextFromRequest: (req) =>
    propagation.extract(context.active(), req.headers, {
      get: (headers, key) => headers.get(key) ?? undefined,
      keys: (headers) => Array.from(headers.keys()),
    }),
  onSpanStart: (_req, span) => {
    span.setAttribute("component", "daloy");
  },
});

End-to-end with Jaeger (OTLP)

The repository ships a runnable example plus a Jaeger service in the examples/observability/ Docker stack, so you can watch real spans land in a trace UI without writing any exporter code. Because otelTracing() only needs a tracer that matches the small TracingTracer interface, the example wires in a dependency-free OTLP/HTTP exporter (about 120 lines of web-standard fetch + crypto) that ships spans straight to Jaeger's OTLP receiver, no @opentelemetry/* SDK required.

1. Start Jaeger

sh
docker compose -f examples/observability/docker-compose.yml up jaeger
# Jaeger UI:            http://localhost:16686
# OTLP/HTTP receiver:   http://localhost:4318/v1/traces

2. Run the demo app

sh
node --import tsx examples/otel-tracing-demo.ts
# DaloyJS OTel tracing demo running at http://localhost:3002
# Exporting OTLP spans to: http://localhost:4318/v1/traces

3. Generate traffic and open Jaeger

sh
curl localhost:3002/orders
curl -X POST localhost:3002/orders -d '{"item":"book","total":42}' -H 'content-type: application/json'
curl localhost:3002/slow    # a span with visible duration
curl localhost:3002/boom    # an ERROR span with an exception event

# Continue a trace started by an upstream service (W3C traceparent):
curl localhost:3002/orders \
  -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'

Open http://localhost:16686, pick the daloy-otel-demo service, and you will see one SERVER span per request: the /boom span flagged as an error with an exception event, the /slow span showing its real duration, and the traceparent request stitched into the upstream trace as a child span.

In production you usually swap the demo exporter for the real SDK, trace.getTracer("svc") from @opentelemetry/api backed by @opentelemetry/sdk-node and an OTLP exporter. The otelTracing() call does not change; only the tracer you pass in does.

Lifecycle and limitations

  • Request outcomes. Matched routes, unmatched requests (404 / 405), and OPTIONS preflight responses all end with http.response.status_code on the same span.
  • No global side effects. The hook never touches globalThis, never installs a propagator, and never imports an OTel SDK, it stays adapter-portable.
  • Single end. If a handler throws, the same span is marked errored and ended once during onSend; later onError / repeat onSend invocations are no-ops.
  • Composes with other hooks. Combine otelTracing(...) with requestId(...), secureHeaders(...), etc., DaloyJS merges global, group, and per-route hooks pipeline-style.

Tree-shake-friendly subpath

ts
// Main barrel:
import { otelTracing } from "@daloyjs/core";

// Or, to keep your bundle minimal:
import { otelTracing } from "@daloyjs/core/tracing";