# OTLP export (OpenTelemetry push)

Many container platforms run an in-cluster OpenTelemetry collector and expect workloads to **push** telemetry: they inject the standard `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` / `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_SERVICE_NAME` variables into every container and scrape nothing, not stdout and not a `/metrics` route. On such platforms, one App option turns on a dependency-free OTLP/HTTP pipeline:

```ts
import { App } from "@daloyjs/core";

const app = new App({
  telemetry: true,
});
```

That flag tees the app logger's output to the collector as OTLP logs and records [`http.server.request.duration`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/) per the OTel HTTP semantic conventions. With no endpoint configured it is a silent no-op, so it is safe to keep enabled in development. No OTel SDK, no loader hooks, no runtime dependencies.

**Diagram: Push pipeline**

1. **OTEL_* env vars** (platform-injected) - endpoint, tenant headers, resource attributes
2. **logger + hooks** - log lines tee; http.server.request.duration per request
3. **batched exporters** - OTLP/HTTP JSON: /v1/logs and /v1/metrics
4. **collector** - routes on tenant headers, converts, forwards
5. **Grafana** (standard dashboards) - semconv names and buckets work unchanged

The logger write sink and the semconv HTTP hook feed two batched exporters. Both post OTLP/HTTP JSON to the collector named by the platform-injected environment variables; the collector fans out to Loki-style log stores and Mimir/Prometheus-style metric stores.

## What gets exported

- **Logs**: every line the app logger writes. JSON lines are decomposed into an OTLP record: `msg` / `message` / `event` becomes the body, `level` maps to the OTLP severity, and remaining fields ship as attributes (structured metadata in Loki-style backends). Non-JSON lines ship verbatim.
- **`http.server.request.duration`**: a histogram with the spec bucket boundaries and attributes `http.request.method` (normalized to the well-known set, else `_OTHER`), `http.route` (the matched route *template*, e.g. `/books/:id`, from `ctx.routePath`), `http.response.status_code`, `url.scheme`, and `error.type` on `5xx`. Unmatched 404s record nothing (the 404 fast path builds no request context), so raw paths can never mint metric series.

## Configuration

Everything defaults from the standard environment variables. Override per signal or per exporter:

```ts
const app = new App({
  telemetry: {
    logs: true, // tee the app logger to OTLP (default true)
    metrics: true, // semconv HTTP metrics (default true)
    exporter: {
      // all optional; defaults from OTEL_EXPORTER_OTLP_* env vars
      endpoint: "http://collector.internal:4318",
      headers: { tenant_id: "acme-prod" },
      resourceAttributes: { "service.name": "orders-api" },
      flushIntervalMs: 15_000,
    },
  },
});

// flush on demand (also flushed automatically on shutdown)
await app.telemetry?.flush();
```

The conventional injected endpoint is the gRPC form (`http://host:4317`); a trailing `:4317` is rewritten to `:4318`, the collector's OTLP/HTTP port. Header values often carry multi-tenant routing credentials; DaloyJS never logs them.

## Fail-safe by contract

- A dead or misconfigured collector never affects request serving: export errors are swallowed and counted in `droppedBatches`.
- The log queue is bounded (drop-oldest); metric series are capped and attribute values length-truncated, so hostile cardinality cannot grow memory.
- Metrics use *cumulative* temporality: totals survive a failed push and the next successful push carries them.

## Standalone exporters

The pieces compose individually from `@daloyjs/core/otlp` for custom signals, for example domain-specific counters next to the built-in HTTP histogram:

```ts
import {
  createOtlpMetricsExporter,
  semconvHttpMetrics,
} from "@daloyjs/core/otlp";

const metrics = createOtlpMetricsExporter(); // null without an endpoint

// business counters with your own names and attributes
metrics?.count("orders_placed_total", { plan: "pro" });
metrics?.record("checkout_amount", { currency: "USD" }, 129.99, {
  unit: "1",
  boundaries: [10, 50, 100, 500, 1000],
});

// or install just the semconv HTTP hook on an existing app
const app = new App({ hooks: metrics ? semconvHttpMetrics(metrics) : {} });
```

## Pull vs push, and tracing

- [`app.metrics()` (Prometheus)](/docs/metrics) is the *pull* pillar: a scrape endpoint with Prometheus naming. Use it when something scrapes you. Use `telemetry` when a collector expects pushes. They coexist; the `httpMetrics()` route label also benefits from `ctx.routePath` now.
- [`otelTracing()`](/docs/tracing) remains the tracing pillar (bring your own tracer). OTLP trace export is a planned follow-up.

## Querying in Grafana

After the collector's Prometheus conversion the histogram appears as `http_server_request_duration_seconds_*`:

```promql
# p95 latency per route
histogram_quantile(0.95, sum by (http_route, le) (
  rate(http_server_request_duration_seconds_bucket[5m])
))

# request volume by status
sum by (http_response_status_code) (
  rate(http_server_request_duration_seconds_count[5m])
)
```

---

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