# Tracing with OpenTelemetry

DaloyJS ships `otelTracing(opts)`, a hook factory that produces a `Hooks` object compatible with [`@opentelemetry/api`](https://www.npmjs.com/package/@opentelemetry/api). It starts a **SERVER-kind span** per HTTP request, attaches the standard [HTTP semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/http/http-spans/), 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.

**Diagram: One span per request**

1. **Extract context** (optional) - contextFromRequest reads traceparent / B3
2. **onRequest** - start SERVER span + http.request.method, url.path
3. **handler** - ctx.state.otelSpan for events & child spans
4. **onSend** - http.response.status_code, recordException on errors
5. **span.end()** (exactly once) - 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` (redacted, see below), `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.

### `url.query` is redacted by default

Query strings often carry OAuth `code` values, `access_token`s, and presigned-URL signatures. Since 2.0.0, `otelTracing()` runs the query through `sanitizeUrlQueryForLog`, the same rules the logger applies to URLs (see [Logging](/docs/logging#redaction-secure-by-default)): values of sensitive keys and JWT or credential-shaped values become `[REDACTED]` before the attribute reaches your tracing backend. The `redactQuery` option (since 1.3.7) changes that:

```ts
// Default: ?page=2&code=abc -> url.query = "page=2&code=%5BREDACTED%5D"
otelTracing({ tracer });

// Your own redaction. Receives the raw query (no "?"); return undefined to omit.
otelTracing({
  tracer,
  redactQuery: (query) => (query.includes("session=") ? undefined : query),
});

// Raw query, verbatim. Unsafe: tokens and signatures reach the backend.
otelTracing({ tracer, redactQuery: false });
```

## 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.post(
  "/orders",
  {
    operationId: "createOrder",
    request: { body: CreateOrder },
    responses: {
      201: {
        description: "created",
        body: z.object({ id: z.string(), itemCount: z.number() }),
      },
    },
  },
  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.
- The hook has no global side effects. It 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";
```

---

Source: https://daloyjs.dev/docs/tracing