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. They do not scrape stdout or a /metrics route. On such platforms, one App option turns on a dependency-free OTLP/HTTP pipeline:
That flag tees the app logger's output to the collector as OTLP logs and records http.server.request.duration 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. There is no OTel SDK, loader hook, or runtime dependency.
- 01platform-injectedOTEL_* env varsendpoint, tenant headers, resource attributes
- 02logger + hookslog lines tee, http.server.request.duration per request
- 03batched exportersOTLP/HTTP JSON: /v1/logs and /v1/metrics
- 04collectorroutes on tenant headers, converts, forwards
- 05standard dashboardsGrafanasemconv names and buckets work unchanged
What gets exported
- Logs: every line the app logger writes. JSON lines are decomposed into an OTLP record:
msg/message/eventbecomes the body,levelmaps 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 attributeshttp.request.method(normalized to the well-known set, else_OTHER),http.route(the matched route template, e.g./books/:id, fromctx.routePath),http.response.status_code,url.scheme, anderror.typeon5xx. 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:
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. Spec values are percent-encoded (tenant_id=a%2Cb arrives as a,b).
Fail-safe by contract
- A dead or misconfigured collector never affects request serving: export errors are swallowed and counted in
droppedBatches. That counter mixes overflowed log lines, failed POSTs, and series refused at the cardinality cap, so do not treat it as "POSTs that failed" alone. - Every export POST is aborted after 5 seconds (override with
exporter.flushTimeoutMs) so a blackholed collector cannot latch the flusher forever. Redirects are refused, so tenant headers cannot follow a 302 off-box. - 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:
Serverless and isolate runtimes
Long-lived Node, Bun, and Deno processes flush on an unref'd interval and again on graceful shutdown. Cloudflare Workers, Vercel isolates, and AWS Lambda freeze when the handler returns, so the interval never runs. Use the adapters:
toFetchHandlerfrom@daloyjs/core/cloudflarecallsctx.waitUntil(app.telemetry.flush())after each request.toFetchHandler/toWebHandlerfrom@daloyjs/core/vercelusesglobalThis.waitUntilwhen the runtime provides it.toLambdaHandlerawaits the flush before returning, so the export is billed as part of the invocation.
Workers without nodejs_compat cannot read process.env. Pass telemetry: { exporter: { endpoint, headers } } from the Worker bindings instead of relying on OTEL_*.
What this does not do
- A caller-supplied
Loggerinstance (pino, winston, a custom sink) is not intercepted. Tee it yourself withcreateOtlpLogExporterand your logger's write hook. The built-in logger, or{ level }, is teed automatically. - Export uses raw
fetch. It does not go throughfetchGuard. The collector URL is operator config (the same trust class as a database URL), and in-cluster collectors are typically private IPs that SSRF defaults would refuse. - There is no OTLP trace export yet. Keep using
otelTracing()with a bring-your-own tracer. - Calling
app.fetchdirectly on an isolate, skipping the adapter, will queue telemetry and mostly never send it. UsetoFetchHandler/toLambdaHandler, or callawait app.telemetry.flush()yourself.
Pull vs push, and tracing
app.metrics()(Prometheus) is the pull pillar: a scrape endpoint with Prometheus naming. Use it when something scrapes you. Usetelemetrywhen a collector expects pushes. They coexist. ThehttpMetrics()route label also benefits fromctx.routePathnow.otelTracing()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_*: