@daloyjs/core 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -584,7 +584,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
584
584
 
585
585
  ## Status
586
586
 
587
- DaloyJS is at **`1.1.0`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
587
+ DaloyJS is at **`1.1.1`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
588
588
 
589
589
  The release-candidate train that led here was largely adversarial: `rc.1` through `rc.9` carried remediations from live over-the-wire engagements against realistic multi-tenant apps, and the findings clustered in one place worth naming — **composition**, not individual modules. A `responseCache()` mounted ahead of the network-identity gates silently disabled them; the same order left `rateLimit()` never counting the requests a cache hit or an idempotent replay served; a forwarded-header resolver read the one `X-Forwarded-For` slot an attacker controls; `idempotency()` replayed a stored `Set-Cookie`. Each is fixed, each has a regression test, and several are now refuse-to-boot guards so the unsafe wiring cannot ship quietly. See the [CHANGELOG](CHANGELOG.md) for the full train and [boot guards](https://daloyjs.dev/docs/security/boot-guards) for the orders the framework now refuses.
590
590
 
@@ -666,6 +666,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
666
666
  - `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
667
667
  - `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
668
668
  - `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
669
+ - `new App({ telemetry: true })` native OpenTelemetry OTLP push export at `@daloyjs/core/otlp`: one flag tees the app logger to the collector as OTLP logs and records `http.server.request.duration` per the OTel HTTP semantic conventions (spec attributes incl. `http.route` from the matched route template via the new `ctx.routePath`, spec bucket boundaries), pushed as dependency-free OTLP/HTTP JSON to the endpoint in the standard `OTEL_EXPORTER_OTLP_*` env vars — zero config on collector-based platforms that inject them, silent no-op without them. Standalone `createOtlpLogExporter()` / `createOtlpMetricsExporter()` (cumulative temporality — totals survive failed pushes) / `semconvHttpMetrics()` exports; fail-safe by contract (bounded queues, series-cardinality cap, a dead collector never affects serving, tenant-routing header values never logged).
669
670
  - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense); `responseCache()` needs no wiring at all — it reads the resolved tenant itself and refuses to boot if mounted ahead of `tenancy()`. Runnable `examples/multitenancy-demo.ts`.
670
671
  - `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
671
672
  - `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
package/dist/app.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { WebSocketRegistry, type WebSocketHandler } from "./websocket.js";
2
2
  import { type Logger } from "./logger.js";
3
+ import { type AppTelemetry, type TelemetryOptions } from "./otlp.js";
3
4
  import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
4
5
  import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
5
6
  import { type DocsAssetOptions, type DocsAuthLauncherOptions, type DocsContentSecurityPolicyOptions, type RedocConfiguration, type ScalarJsonValue, type ScalarReferenceConfiguration, type SwaggerUiConfiguration } from "./docs.js";
@@ -74,6 +75,18 @@ export interface AppOptions {
74
75
  preset?: SecurityPreset;
75
76
  /** Validate handler responses against declared response schemas. Default: true. */
76
77
  validateResponses?: boolean;
78
+ /**
79
+ * OpenTelemetry OTLP push export. `true` (or an options object) exports the
80
+ * app logger's output as OTLP logs and records `http.server.request.duration`
81
+ * per the OTel HTTP semantic conventions, pushed to the collector named by
82
+ * the standard `OTEL_EXPORTER_OTLP_*` environment variables. A silent no-op
83
+ * when no endpoint is configured, so it is safe to keep enabled in
84
+ * development. Export failures never affect request serving. See
85
+ * {@link TelemetryOptions}.
86
+ *
87
+ * @since 1.2.0
88
+ */
89
+ telemetry?: boolean | TelemetryOptions;
77
90
  /** Hard cap on request body size in bytes. Default: 1 MiB. */
78
91
  bodyLimitBytes?: number;
79
92
  /** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
@@ -934,6 +947,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
934
947
  readonly options: Required<Pick<AppOptions, "validateResponses" | "bodyLimitBytes" | "requestTimeoutMs">> & AppOptions;
935
948
  /** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
936
949
  readonly log: Logger;
950
+ /**
951
+ * OTLP telemetry wiring created by the `telemetry` option; `undefined` when
952
+ * the option is off. Exposed for tests and advanced flushing.
953
+ *
954
+ * @since 1.2.0
955
+ */
956
+ readonly telemetry?: AppTelemetry;
937
957
  /**
938
958
  * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
939
959
  *
package/dist/app.js CHANGED
@@ -3,6 +3,7 @@ import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
3
  import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
4
4
  import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
5
5
  import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
6
+ import { createAppTelemetry } from "./otlp.js";
6
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
7
8
  import { isSchemaValidatedResponse } from "./internal-response.js";
8
9
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
@@ -374,6 +375,13 @@ export class App {
374
375
  options;
375
376
  /** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
376
377
  log;
378
+ /**
379
+ * OTLP telemetry wiring created by the `telemetry` option; `undefined` when
380
+ * the option is off. Exposed for tests and advanced flushing.
381
+ *
382
+ * @since 1.2.0
383
+ */
384
+ telemetry;
377
385
  /**
378
386
  * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
379
387
  *
@@ -505,12 +513,23 @@ export class App {
505
513
  jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
506
514
  ...resolved,
507
515
  };
516
+ // Telemetry is resolved before the logger so the logger's write sink can
517
+ // tee into the OTLP log exporter. Inert when no endpoint is configured.
518
+ const telemetryOpt = options.telemetry;
519
+ this.telemetry =
520
+ telemetryOpt === undefined || telemetryOpt === false
521
+ ? undefined
522
+ : createAppTelemetry(telemetryOpt === true ? {} : telemetryOpt);
523
+ const telemetryWrite = this.telemetry?.logWrite;
508
524
  this.log =
509
525
  options.logger === false
510
526
  ? noopLogger
511
527
  : options.logger && typeof options.logger.info === "function"
512
528
  ? options.logger
513
- : createLogger({ level: options.logger?.level ?? "info" });
529
+ : createLogger({
530
+ level: options.logger?.level ?? "info",
531
+ ...(telemetryWrite !== undefined ? { write: telemetryWrite } : {}),
532
+ });
514
533
  this.warnOnEnvMismatch();
515
534
  this.assertDisconnectStatusCode();
516
535
  assertBehindProxy(this.options.behindProxy);
@@ -522,6 +541,20 @@ export class App {
522
541
  this.maybeInstallCrashHandlers();
523
542
  this.maybeMountDocs();
524
543
  this.maybeMountAsyncAPI();
544
+ if (this.telemetry !== undefined) {
545
+ if (this.telemetry.hooks !== undefined)
546
+ this.use(this.telemetry.hooks);
547
+ const telemetry = this.telemetry;
548
+ this.onClose(() => telemetry.flush());
549
+ this.log.info({
550
+ event: "telemetry.otlp",
551
+ active: telemetry.endpoint !== null,
552
+ // Endpoint only — OTLP headers may carry tenant credentials.
553
+ endpoint: telemetry.endpoint ?? undefined,
554
+ }, telemetry.endpoint !== null
555
+ ? "OTLP telemetry export active"
556
+ : "Telemetry enabled but no OTEL_EXPORTER_OTLP_ENDPOINT configured; export disabled");
557
+ }
525
558
  }
526
559
  /**
527
560
  * Validate {@link AppOptions.disconnectStatusCode}.
@@ -2683,6 +2716,8 @@ export class App {
2683
2716
  // perimeter hooks can reject unauthenticated callers without consuming
2684
2717
  // an attacker-controlled request stream.
2685
2718
  ctx = createPreBodyContext(request, getUrl, match.params);
2719
+ // Matched route template for low-cardinality labels (`http.route`).
2720
+ ctx.routePath = def.path;
2686
2721
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2687
2722
  // requests for the common no-decorator case. The decorations spread only
2688
2723
  // fires when `app.decorate()` was actually called.
@@ -3502,21 +3537,21 @@ function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
3502
3537
  return sentResult.then((sent) => {
3503
3538
  if (sent instanceof Response)
3504
3539
  final = sent;
3505
- return finishFinalize(final, hooks, stripFingerprint);
3540
+ return finishFinalize(final, ctx, hooks, stripFingerprint);
3506
3541
  });
3507
3542
  }
3508
3543
  if (sentResult instanceof Response)
3509
3544
  final = sentResult;
3510
3545
  }
3511
- return finishFinalize(final, hooks, stripFingerprint);
3546
+ return finishFinalize(final, ctx, hooks, stripFingerprint);
3512
3547
  }
3513
- function finishFinalize(res, hooks, stripFingerprint) {
3548
+ function finishFinalize(res, ctx, hooks, stripFingerprint) {
3514
3549
  if (stripFingerprint) {
3515
3550
  res.headers.delete("server");
3516
3551
  res.headers.delete("x-powered-by");
3517
3552
  }
3518
3553
  if (hooks.onResponse !== undefined) {
3519
- const onResponseResult = hooks.onResponse(res);
3554
+ const onResponseResult = hooks.onResponse(res, ctx);
3520
3555
  if (isPromiseLike(onResponseResult)) {
3521
3556
  return onResponseResult.then(() => res);
3522
3557
  }
@@ -3703,6 +3738,7 @@ class RequestContext {
3703
3738
  body = undefined;
3704
3739
  state;
3705
3740
  set;
3741
+ routePath = undefined;
3706
3742
  _q = undefined;
3707
3743
  _qBuilder = undefined;
3708
3744
  _qSet = false;
package/dist/index.d.ts CHANGED
@@ -95,6 +95,8 @@ export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, pagination
95
95
  export type { PaginationLink, PageLinkOptions, PageLinks, PaginationQueryOptions, PaginationParams, PaginationQuerySchema, } from "./pagination.js";
96
96
  export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
97
97
  export type { MetricLabels, MetricsRegistryOptions, HttpMetricsOptions } from "./metrics.js";
98
+ export { createOtlpLogExporter, createOtlpMetricsExporter, createAppTelemetry, semconvHttpMetrics, HTTP_SERVER_REQUEST_DURATION_BUCKETS, } from "./otlp.js";
99
+ export type { OtlpExporterOptions, OtlpLogExporter, OtlpMetricsExporter, OtlpHistogramOptions, SemconvHttpMetricsOptions, TelemetryOptions, AppTelemetry, } from "./otlp.js";
98
100
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
99
101
  export type { FileFieldSchema, FileFieldOptions, FileMagicBytesOption, FileMagicBytesSignature, MultipartObjectOptions, MultipartShape, UploadedFile, } from "./multipart.js";
100
102
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTe
48
48
  export { responseCache, MemoryResponseCacheStore, RESPONSE_CACHE_HOOK_MARKER, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
49
49
  export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
50
50
  export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
51
+ export { createOtlpLogExporter, createOtlpMetricsExporter, createAppTelemetry, semconvHttpMetrics, HTTP_SERVER_REQUEST_DURATION_BUCKETS, } from "./otlp.js";
51
52
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
52
53
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
53
54
  export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
package/dist/metrics.js CHANGED
@@ -401,6 +401,10 @@ export function httpMetrics(opts) {
401
401
  const routeLabel = (ctx) => {
402
402
  if (opts.route)
403
403
  return opts.route(ctx) ?? "<unknown>";
404
+ // The router stamps the matched route template on the context; it is
405
+ // low-cardinality by construction, so no cap bookkeeping is needed.
406
+ if (typeof ctx.routePath === "string")
407
+ return ctx.routePath;
404
408
  let path = "/";
405
409
  try {
406
410
  path = new URL(ctx.request.url).pathname;
package/dist/otlp.d.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * OpenTelemetry OTLP push export — logs, metrics, and semantic-convention
3
+ * HTTP server instrumentation, with zero runtime dependencies.
4
+ *
5
+ * Many container platforms run an in-cluster OTel collector and expect
6
+ * workloads to **push** telemetry: they inject the standard
7
+ * `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` /
8
+ * `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_SERVICE_NAME` variables into every
9
+ * container and scrape nothing — not stdout, not a `/metrics` route. The
10
+ * OTel Node SDK covers that with monkey-patching auto-instrumentation, but
11
+ * its ESM loader hooks are fragile on modern Node and unavailable on edge
12
+ * runtimes. This module is the framework-native alternative:
13
+ *
14
+ * - {@link createOtlpLogExporter} — batched OTLP/HTTP JSON log export;
15
+ * tee your logger's `write` sink into it.
16
+ * - {@link createOtlpMetricsExporter} — cumulative counters + histograms
17
+ * pushed as OTLP/HTTP JSON.
18
+ * - {@link semconvHttpMetrics} — a `Hooks` bundle emitting
19
+ * `http.server.request.duration` exactly per the OTel HTTP semantic
20
+ * conventions (names, attributes, bucket boundaries), so standard Grafana
21
+ * dashboards work unchanged.
22
+ * - `new App({ telemetry: true })` wires all of the above automatically
23
+ * (see {@link TelemetryOptions}).
24
+ *
25
+ * Everything is transport-portable (`fetch` + web-standard primitives) and
26
+ * **fail-safe by contract**: a dead or misconfigured collector never affects
27
+ * request serving — bounded queues, dropped-batch counters, no retry storms,
28
+ * and cumulative metric temporality so totals survive failed pushes.
29
+ *
30
+ * @module
31
+ * @since 1.2.0
32
+ */
33
+ import type { Hooks } from "./types.js";
34
+ /**
35
+ * Shared configuration for {@link createOtlpLogExporter} and
36
+ * {@link createOtlpMetricsExporter}. Every field falls back to the standard
37
+ * `OTEL_*` environment variables, so on platforms that inject them no
38
+ * explicit configuration is needed at all.
39
+ *
40
+ * @since 1.2.0
41
+ */
42
+ export interface OtlpExporterOptions {
43
+ /**
44
+ * Collector base URL. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`. The
45
+ * conventional injected value is the gRPC form (`http://host:4317`); a
46
+ * trailing `:4317` is rewritten to `:4318`, the collector's OTLP/HTTP port.
47
+ * The signal path (`/v1/logs`, `/v1/metrics`) is appended automatically.
48
+ */
49
+ endpoint?: string;
50
+ /**
51
+ * Extra request headers. Defaults to `OTEL_EXPORTER_OTLP_HEADERS`
52
+ * (`key=value,key2=value2`). Multi-tenant collectors route on these
53
+ * (e.g. `tenant_id=...`), so treat the values as credentials: DaloyJS
54
+ * never logs them and never includes them in errors.
55
+ */
56
+ headers?: Record<string, string>;
57
+ /**
58
+ * OTLP resource attributes. Defaults to `OTEL_RESOURCE_ATTRIBUTES`
59
+ * (`key=value,...`) plus `service.name` from `OTEL_SERVICE_NAME` when not
60
+ * already present.
61
+ */
62
+ resourceAttributes?: Record<string, string>;
63
+ /**
64
+ * Flush cadence in ms on runtimes with timers (`unref`'d — never keeps the
65
+ * process alive). Defaults: 5000 (logs), 15000 (metrics). Set `0` to
66
+ * disable the timer and flush manually / on shutdown only.
67
+ */
68
+ flushIntervalMs?: number;
69
+ /** Injectable transport for tests. Default `globalThis.fetch`. */
70
+ fetch?: typeof fetch;
71
+ }
72
+ /**
73
+ * Batched OTLP/HTTP JSON **log** exporter returned by
74
+ * {@link createOtlpLogExporter}.
75
+ *
76
+ * @since 1.2.0
77
+ */
78
+ export interface OtlpLogExporter {
79
+ /**
80
+ * Queue one log line (no trailing newline). JSON lines are decomposed:
81
+ * `msg`/`message`/`event` becomes the record body, `level` maps to the
82
+ * OTLP severity, and every remaining field ships as a string attribute
83
+ * (surfacing as structured metadata in Loki-style backends). Non-JSON
84
+ * lines ship verbatim as the body.
85
+ */
86
+ pushLine(line: string): void;
87
+ /** Push pending records now. Never rejects; failures count as drops. */
88
+ flush(): Promise<void>;
89
+ /** Batches dropped because the queue overflowed or the collector errored. */
90
+ readonly droppedBatches: number;
91
+ }
92
+ /**
93
+ * Histogram configuration for {@link OtlpMetricsExporter.record}.
94
+ *
95
+ * @since 1.2.0
96
+ */
97
+ export interface OtlpHistogramOptions {
98
+ /** UCUM unit, e.g. `"s"` or `"{token}"`. */
99
+ unit: string;
100
+ /** Explicit bucket upper bounds, ascending. */
101
+ boundaries: readonly number[];
102
+ }
103
+ /**
104
+ * Cumulative OTLP/HTTP JSON **metrics** exporter returned by
105
+ * {@link createOtlpMetricsExporter}. Counters and histograms use cumulative
106
+ * temporality, so a failed push is self-healing: the totals are retained and
107
+ * the next successful push carries them.
108
+ *
109
+ * @since 1.2.0
110
+ */
111
+ export interface OtlpMetricsExporter {
112
+ /** Add `value` (default `1`, must be finite and non-negative) to the counter series `name` + `attributes`. */
113
+ count(name: string, attributes: Record<string, string>, value?: number): void;
114
+ /** Record one observation into the histogram series `name` + `attributes`. */
115
+ record(name: string, attributes: Record<string, string>, value: number, options: OtlpHistogramOptions): void;
116
+ /** Push the current state now. Never rejects; failures count as drops. */
117
+ flush(): Promise<void>;
118
+ /** Pushes dropped because the collector rejected/errored, plus series dropped at the cardinality cap. */
119
+ readonly droppedBatches: number;
120
+ }
121
+ /**
122
+ * Spec-defined default bucket boundaries for `http.server.request.duration`,
123
+ * in seconds (OTel HTTP semantic conventions).
124
+ *
125
+ * @since 1.2.0
126
+ */
127
+ export declare const HTTP_SERVER_REQUEST_DURATION_BUCKETS: readonly number[];
128
+ /**
129
+ * Create a batched OTLP/HTTP JSON log exporter, or `null` when no endpoint is
130
+ * configured (local development, tests) so callers can no-op cheaply.
131
+ *
132
+ * Fail-safe by contract: {@link OtlpLogExporter.flush} never rejects, a dead
133
+ * collector cannot grow the queue past its cap (oldest records are dropped
134
+ * and counted), and export work is decoupled from request handling.
135
+ *
136
+ * @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
137
+ * @returns The exporter, or `null` when no endpoint is configured.
138
+ * @since 1.2.0
139
+ */
140
+ export declare function createOtlpLogExporter(opts?: OtlpExporterOptions): OtlpLogExporter | null;
141
+ /**
142
+ * Create a cumulative OTLP/HTTP JSON metrics exporter, or `null` when no
143
+ * endpoint is configured.
144
+ *
145
+ * Counters export as monotonic cumulative sums and histograms as cumulative
146
+ * explicit-bounds histograms, so state survives failed pushes (the next
147
+ * successful push carries the accumulated totals). Total series are capped
148
+ * ({@link OtlpMetricsExporter.droppedBatches} counts series refused at the
149
+ * cap) and attribute values are length-truncated — both cardinality guards
150
+ * against hostile or buggy attribute sources.
151
+ *
152
+ * @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
153
+ * @returns The exporter, or `null` when no endpoint is configured.
154
+ * @since 1.2.0
155
+ */
156
+ export declare function createOtlpMetricsExporter(opts?: OtlpExporterOptions): OtlpMetricsExporter | null;
157
+ /** Options for {@link semconvHttpMetrics}. */
158
+ export interface SemconvHttpMetricsOptions {
159
+ /** Skip instrumentation for matching request paths (e.g. a metrics scrape route). */
160
+ exclude?: (path: string) => boolean;
161
+ }
162
+ /**
163
+ * A `Hooks` bundle recording `http.server.request.duration` per the OTel HTTP
164
+ * semantic conventions into an {@link OtlpMetricsExporter}:
165
+ *
166
+ * - unit `s`, spec bucket boundaries
167
+ * ({@link HTTP_SERVER_REQUEST_DURATION_BUCKETS});
168
+ * - attributes `http.request.method` (well-known set, else `_OTHER`),
169
+ * `http.response.status_code`, `url.scheme`, `http.route` (the matched
170
+ * route **template** via `ctx.routePath`), and `error.type` (the status
171
+ * code, on `5xx` only).
172
+ *
173
+ * Only requests that produce a request context are recorded. On the
174
+ * unmatched-404 fast path the framework skips context construction entirely
175
+ * (a deliberate allocation optimization), so unmatched floods record nothing
176
+ * — which is also the strongest possible cardinality guarantee: a hostile
177
+ * client can never mint metric series from raw paths.
178
+ *
179
+ * Install it **before** registering routes (group-hook ordering), or let
180
+ * `new App({ telemetry: true })` install it for you.
181
+ *
182
+ * @param sink - Metrics exporter the duration histogram is recorded into.
183
+ * @param opts - Optional path exclusion.
184
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
185
+ * @since 1.2.0
186
+ */
187
+ export declare function semconvHttpMetrics(sink: OtlpMetricsExporter, opts?: SemconvHttpMetricsOptions): Hooks;
188
+ /**
189
+ * Configuration for the `App` `telemetry` option. Passing `true` is
190
+ * equivalent to `{}` — everything defaults on, reading the standard `OTEL_*`
191
+ * environment variables. When no `OTEL_EXPORTER_OTLP_ENDPOINT` is present
192
+ * (and no explicit `exporter.endpoint` is given) the option is a silent
193
+ * no-op, so it is safe to leave enabled in development.
194
+ *
195
+ * @since 1.2.0
196
+ */
197
+ export interface TelemetryOptions {
198
+ /**
199
+ * Tee the app logger's output to the collector as OTLP logs. Default
200
+ * `true`. Applies only when the app constructs its own logger (`logger`
201
+ * omitted, `{ level }`, or defaulted) — a caller-supplied `Logger`
202
+ * instance controls its own sink and is not intercepted.
203
+ */
204
+ logs?: boolean;
205
+ /**
206
+ * Record `http.server.request.duration` per the OTel HTTP semantic
207
+ * conventions and push it to the collector. Default `true`.
208
+ */
209
+ metrics?: boolean;
210
+ /** Endpoint/header/resource/interval overrides shared by both signals. */
211
+ exporter?: OtlpExporterOptions;
212
+ }
213
+ /**
214
+ * Internal wiring bundle produced by {@link createAppTelemetry} and consumed
215
+ * by the `App` constructor. Exposed for advanced composition and tests.
216
+ *
217
+ * @since 1.2.0
218
+ */
219
+ export interface AppTelemetry {
220
+ /** Log exporter, or `null` when logs are disabled or unconfigured. */
221
+ logs: OtlpLogExporter | null;
222
+ /** Metrics exporter, or `null` when metrics are disabled or unconfigured. */
223
+ metrics: OtlpMetricsExporter | null;
224
+ /** Hooks to install when HTTP metrics are active. */
225
+ hooks: Hooks | undefined;
226
+ /** Logger `write` sink that tees to stdout and the log exporter. */
227
+ logWrite: ((line: string) => void) | undefined;
228
+ /** Resolved collector endpoint (no signal path), or `null` when inactive. */
229
+ endpoint: string | null;
230
+ /** Flush both signals. Never rejects. */
231
+ flush(): Promise<void>;
232
+ }
233
+ /**
234
+ * Build the exporters, hooks, and logger sink for the `App` `telemetry`
235
+ * option. Returns an inert bundle (all `null`/`undefined`) when no endpoint
236
+ * is configured, so `telemetry: true` costs nothing in development.
237
+ *
238
+ * @param options - The resolved {@link TelemetryOptions}.
239
+ * @returns The wiring bundle.
240
+ * @since 1.2.0
241
+ */
242
+ export declare function createAppTelemetry(options: TelemetryOptions): AppTelemetry;
package/dist/otlp.js ADDED
@@ -0,0 +1,527 @@
1
+ /**
2
+ * OpenTelemetry OTLP push export — logs, metrics, and semantic-convention
3
+ * HTTP server instrumentation, with zero runtime dependencies.
4
+ *
5
+ * Many container platforms run an in-cluster OTel collector and expect
6
+ * workloads to **push** telemetry: they inject the standard
7
+ * `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` /
8
+ * `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_SERVICE_NAME` variables into every
9
+ * container and scrape nothing — not stdout, not a `/metrics` route. The
10
+ * OTel Node SDK covers that with monkey-patching auto-instrumentation, but
11
+ * its ESM loader hooks are fragile on modern Node and unavailable on edge
12
+ * runtimes. This module is the framework-native alternative:
13
+ *
14
+ * - {@link createOtlpLogExporter} — batched OTLP/HTTP JSON log export;
15
+ * tee your logger's `write` sink into it.
16
+ * - {@link createOtlpMetricsExporter} — cumulative counters + histograms
17
+ * pushed as OTLP/HTTP JSON.
18
+ * - {@link semconvHttpMetrics} — a `Hooks` bundle emitting
19
+ * `http.server.request.duration` exactly per the OTel HTTP semantic
20
+ * conventions (names, attributes, bucket boundaries), so standard Grafana
21
+ * dashboards work unchanged.
22
+ * - `new App({ telemetry: true })` wires all of the above automatically
23
+ * (see {@link TelemetryOptions}).
24
+ *
25
+ * Everything is transport-portable (`fetch` + web-standard primitives) and
26
+ * **fail-safe by contract**: a dead or misconfigured collector never affects
27
+ * request serving — bounded queues, dropped-batch counters, no retry storms,
28
+ * and cumulative metric temporality so totals survive failed pushes.
29
+ *
30
+ * @module
31
+ * @since 1.2.0
32
+ */
33
+ /** Log-queue and batching caps (drop-oldest beyond the queue cap). */
34
+ const LOG_MAX_BATCH = 100;
35
+ const LOG_MAX_QUEUE = 1_000;
36
+ const LOG_FLUSH_INTERVAL_MS = 5_000;
37
+ /** Longest log body / attribute value shipped before truncation. */
38
+ const LOG_MAX_FIELD_LENGTH = 8_192;
39
+ const METRICS_FLUSH_INTERVAL_MS = 15_000;
40
+ /**
41
+ * Cardinality guard: total metric series (counter + histogram) before new
42
+ * series are dropped and counted. A hostile client must not be able to mint
43
+ * unbounded series through attribute values.
44
+ */
45
+ const METRICS_MAX_SERIES = 2_000;
46
+ /** Longest metric attribute value before truncation (cardinality + memory guard). */
47
+ const METRICS_MAX_ATTR_LENGTH = 256;
48
+ /**
49
+ * Spec-defined default bucket boundaries for `http.server.request.duration`,
50
+ * in seconds (OTel HTTP semantic conventions).
51
+ *
52
+ * @since 1.2.0
53
+ */
54
+ export const HTTP_SERVER_REQUEST_DURATION_BUCKETS = [
55
+ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
56
+ ];
57
+ /** OTLP severity numbers keyed by the framework logger's level names. */
58
+ const SEVERITY = {
59
+ trace: [1, "TRACE"],
60
+ debug: [5, "DEBUG"],
61
+ info: [9, "INFO"],
62
+ warn: [13, "WARN"],
63
+ error: [17, "ERROR"],
64
+ fatal: [21, "FATAL"],
65
+ };
66
+ /** Known HTTP methods per the semconv `http.request.method` well-known set. */
67
+ const KNOWN_METHODS = new Set([
68
+ "GET",
69
+ "HEAD",
70
+ "POST",
71
+ "PUT",
72
+ "DELETE",
73
+ "CONNECT",
74
+ "OPTIONS",
75
+ "TRACE",
76
+ "PATCH",
77
+ ]);
78
+ /** Portable environment lookup (Node/Bun/Deno-with-node-compat; undefined elsewhere). */
79
+ function envVar(name) {
80
+ const env = globalThis.process
81
+ ?.env;
82
+ return env?.[name];
83
+ }
84
+ /** Parse `key=value,key2=value2` lists (OTEL_EXPORTER_OTLP_HEADERS / OTEL_RESOURCE_ATTRIBUTES). */
85
+ function parseKvList(raw) {
86
+ const out = {};
87
+ if (!raw)
88
+ return out;
89
+ for (const pair of raw.split(",")) {
90
+ const trimmed = pair.trim();
91
+ const idx = trimmed.indexOf("=");
92
+ if (idx <= 0)
93
+ continue;
94
+ out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
95
+ }
96
+ return out;
97
+ }
98
+ function truncate(value, max) {
99
+ return value.length <= max ? value : value.slice(0, max);
100
+ }
101
+ function toAttrList(record, maxLength) {
102
+ return Object.entries(record).map(([key, value]) => ({
103
+ key,
104
+ value: { stringValue: truncate(value, maxLength) },
105
+ }));
106
+ }
107
+ function resolveTransport(signalPath, opts) {
108
+ const rawEndpoint = opts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT");
109
+ if (!rawEndpoint)
110
+ return null;
111
+ const base = rawEndpoint.replace(/\/+$/, "").replace(/:4317$/, ":4318");
112
+ const headers = { "content-type": "application/json" };
113
+ Object.assign(headers, parseKvList(envVar("OTEL_EXPORTER_OTLP_HEADERS")), opts.headers ?? {});
114
+ const resource = parseKvList(envVar("OTEL_RESOURCE_ATTRIBUTES"));
115
+ const serviceName = envVar("OTEL_SERVICE_NAME");
116
+ if (serviceName && resource["service.name"] === undefined) {
117
+ resource["service.name"] = serviceName;
118
+ }
119
+ Object.assign(resource, opts.resourceAttributes ?? {});
120
+ return {
121
+ url: base + signalPath,
122
+ headers,
123
+ resourceAttributes: toAttrList(resource, LOG_MAX_FIELD_LENGTH),
124
+ fetchImpl: opts.fetch ?? fetch,
125
+ };
126
+ }
127
+ /** Start an `unref`'d repeating flush where the runtime supports timers. */
128
+ function startFlushTimer(intervalMs, flush) {
129
+ if (intervalMs <= 0 || typeof setInterval !== "function")
130
+ return;
131
+ const timer = setInterval(() => void flush(), intervalMs);
132
+ timer.unref?.();
133
+ }
134
+ function toLogRecord(line) {
135
+ let level = "info";
136
+ let body = line;
137
+ const attributes = [];
138
+ try {
139
+ const parsed = JSON.parse(line);
140
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
141
+ throw new Error();
142
+ if (typeof parsed.level === "string")
143
+ level = parsed.level;
144
+ const msg = parsed.msg ?? parsed.message ?? parsed.event;
145
+ body = typeof msg === "string" ? msg : line;
146
+ for (const [key, value] of Object.entries(parsed)) {
147
+ if (key === "level" || key === "msg" || key === "message")
148
+ continue;
149
+ attributes.push({
150
+ key,
151
+ value: {
152
+ stringValue: truncate(typeof value === "string" ? value : JSON.stringify(value) ?? "null", LOG_MAX_FIELD_LENGTH),
153
+ },
154
+ });
155
+ }
156
+ }
157
+ catch {
158
+ // Not a JSON object (boot banner etc.) — ship verbatim as the body.
159
+ }
160
+ const [severityNumber, severityText] = SEVERITY[level] ?? SEVERITY.info;
161
+ return {
162
+ timeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
163
+ severityNumber,
164
+ severityText,
165
+ body: { stringValue: truncate(body, LOG_MAX_FIELD_LENGTH) },
166
+ attributes,
167
+ };
168
+ }
169
+ /**
170
+ * Create a batched OTLP/HTTP JSON log exporter, or `null` when no endpoint is
171
+ * configured (local development, tests) so callers can no-op cheaply.
172
+ *
173
+ * Fail-safe by contract: {@link OtlpLogExporter.flush} never rejects, a dead
174
+ * collector cannot grow the queue past its cap (oldest records are dropped
175
+ * and counted), and export work is decoupled from request handling.
176
+ *
177
+ * @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
178
+ * @returns The exporter, or `null` when no endpoint is configured.
179
+ * @since 1.2.0
180
+ */
181
+ export function createOtlpLogExporter(opts = {}) {
182
+ const transport = resolveTransport("/v1/logs", opts);
183
+ if (transport === null)
184
+ return null;
185
+ const { url, headers, resourceAttributes, fetchImpl } = transport;
186
+ const queue = [];
187
+ let dropped = 0;
188
+ let flushing = false;
189
+ async function flush() {
190
+ if (flushing || queue.length === 0)
191
+ return;
192
+ flushing = true;
193
+ try {
194
+ while (queue.length > 0) {
195
+ const batch = queue.splice(0, LOG_MAX_BATCH);
196
+ const payload = {
197
+ resourceLogs: [
198
+ {
199
+ resource: { attributes: resourceAttributes },
200
+ scopeLogs: [{ scope: { name: "daloyjs" }, logRecords: batch }],
201
+ },
202
+ ],
203
+ };
204
+ const res = await fetchImpl(url, {
205
+ method: "POST",
206
+ headers,
207
+ body: JSON.stringify(payload),
208
+ });
209
+ if (!res.ok)
210
+ dropped += 1;
211
+ }
212
+ }
213
+ catch {
214
+ dropped += 1;
215
+ queue.length = 0; // never let a dead collector grow the queue
216
+ }
217
+ finally {
218
+ flushing = false;
219
+ }
220
+ }
221
+ startFlushTimer(opts.flushIntervalMs ?? LOG_FLUSH_INTERVAL_MS, flush);
222
+ return {
223
+ pushLine(line) {
224
+ if (queue.length >= LOG_MAX_QUEUE) {
225
+ queue.shift();
226
+ dropped += 1;
227
+ }
228
+ queue.push(toLogRecord(line.endsWith("\n") ? line.trimEnd() : line));
229
+ },
230
+ flush,
231
+ get droppedBatches() {
232
+ return dropped;
233
+ },
234
+ };
235
+ }
236
+ /**
237
+ * Create a cumulative OTLP/HTTP JSON metrics exporter, or `null` when no
238
+ * endpoint is configured.
239
+ *
240
+ * Counters export as monotonic cumulative sums and histograms as cumulative
241
+ * explicit-bounds histograms, so state survives failed pushes (the next
242
+ * successful push carries the accumulated totals). Total series are capped
243
+ * ({@link OtlpMetricsExporter.droppedBatches} counts series refused at the
244
+ * cap) and attribute values are length-truncated — both cardinality guards
245
+ * against hostile or buggy attribute sources.
246
+ *
247
+ * @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
248
+ * @returns The exporter, or `null` when no endpoint is configured.
249
+ * @since 1.2.0
250
+ */
251
+ export function createOtlpMetricsExporter(opts = {}) {
252
+ const transport = resolveTransport("/v1/metrics", opts);
253
+ if (transport === null)
254
+ return null;
255
+ const { url, headers, resourceAttributes, fetchImpl } = transport;
256
+ const counters = new Map();
257
+ const histograms = new Map();
258
+ let dropped = 0;
259
+ let flushing = false;
260
+ let dirty = false;
261
+ function seriesKey(name, entries) {
262
+ let key = name;
263
+ for (const [k, v] of entries)
264
+ key += "|" + k + "=" + v;
265
+ return key;
266
+ }
267
+ function sortedEntries(attributes) {
268
+ return Object.entries(attributes)
269
+ .map(([k, v]) => [k, truncate(v, METRICS_MAX_ATTR_LENGTH)])
270
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
271
+ }
272
+ function atCapacity() {
273
+ if (counters.size + histograms.size < METRICS_MAX_SERIES)
274
+ return false;
275
+ dropped += 1;
276
+ return true;
277
+ }
278
+ async function flush() {
279
+ if (flushing || !dirty)
280
+ return;
281
+ flushing = true;
282
+ try {
283
+ const now = (BigInt(Date.now()) * 1000000n).toString();
284
+ const sumsByName = new Map();
285
+ for (const s of counters.values()) {
286
+ const group = sumsByName.get(s.name);
287
+ if (group === undefined)
288
+ sumsByName.set(s.name, [s]);
289
+ else
290
+ group.push(s);
291
+ }
292
+ const histsByName = new Map();
293
+ for (const h of histograms.values()) {
294
+ const group = histsByName.get(h.name);
295
+ if (group === undefined)
296
+ histsByName.set(h.name, [h]);
297
+ else
298
+ group.push(h);
299
+ }
300
+ const metrics = [];
301
+ for (const [name, group] of sumsByName) {
302
+ metrics.push({
303
+ name,
304
+ unit: "1",
305
+ sum: {
306
+ aggregationTemporality: 2, // cumulative
307
+ isMonotonic: true,
308
+ dataPoints: group.map((s) => ({
309
+ attributes: s.attributes,
310
+ startTimeUnixNano: s.startTimeUnixNano,
311
+ timeUnixNano: now,
312
+ asDouble: s.total,
313
+ })),
314
+ },
315
+ });
316
+ }
317
+ for (const [name, group] of histsByName) {
318
+ metrics.push({
319
+ name,
320
+ unit: group[0].unit,
321
+ histogram: {
322
+ aggregationTemporality: 2, // cumulative
323
+ dataPoints: group.map((h) => ({
324
+ attributes: h.attributes,
325
+ startTimeUnixNano: h.startTimeUnixNano,
326
+ timeUnixNano: now,
327
+ // uint64 fields use the string JSON mapping in OTLP.
328
+ count: String(h.count),
329
+ sum: h.sum,
330
+ bucketCounts: h.bucketCounts.map(String),
331
+ explicitBounds: [...h.boundaries],
332
+ })),
333
+ },
334
+ });
335
+ }
336
+ const payload = {
337
+ resourceMetrics: [
338
+ {
339
+ resource: { attributes: resourceAttributes },
340
+ scopeMetrics: [{ scope: { name: "daloyjs" }, metrics }],
341
+ },
342
+ ],
343
+ };
344
+ const res = await fetchImpl(url, {
345
+ method: "POST",
346
+ headers,
347
+ body: JSON.stringify(payload),
348
+ });
349
+ if (res.ok)
350
+ dirty = false;
351
+ else
352
+ dropped += 1;
353
+ }
354
+ catch {
355
+ dropped += 1; // totals retained; cumulative temporality self-heals
356
+ }
357
+ finally {
358
+ flushing = false;
359
+ }
360
+ }
361
+ startFlushTimer(opts.flushIntervalMs ?? METRICS_FLUSH_INTERVAL_MS, flush);
362
+ return {
363
+ count(name, attributes, value = 1) {
364
+ if (!Number.isFinite(value) || value < 0)
365
+ return;
366
+ const entries = sortedEntries(attributes);
367
+ const key = seriesKey(name, entries);
368
+ let s = counters.get(key);
369
+ if (s === undefined) {
370
+ if (atCapacity())
371
+ return;
372
+ s = {
373
+ name,
374
+ attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
375
+ total: 0,
376
+ startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
377
+ };
378
+ counters.set(key, s);
379
+ }
380
+ s.total += value;
381
+ dirty = true;
382
+ },
383
+ record(name, attributes, value, options) {
384
+ if (!Number.isFinite(value))
385
+ return;
386
+ const entries = sortedEntries(attributes);
387
+ const key = seriesKey(name, entries);
388
+ let h = histograms.get(key);
389
+ if (h === undefined) {
390
+ if (atCapacity())
391
+ return;
392
+ h = {
393
+ name,
394
+ unit: options.unit,
395
+ boundaries: options.boundaries,
396
+ attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
397
+ count: 0,
398
+ sum: 0,
399
+ bucketCounts: new Array(options.boundaries.length + 1).fill(0),
400
+ startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
401
+ };
402
+ histograms.set(key, h);
403
+ }
404
+ h.count += 1;
405
+ h.sum += value;
406
+ let bucket = h.boundaries.findIndex((bound) => value <= bound);
407
+ if (bucket === -1)
408
+ bucket = h.boundaries.length;
409
+ h.bucketCounts[bucket] += 1;
410
+ dirty = true;
411
+ },
412
+ flush,
413
+ get droppedBatches() {
414
+ return dropped;
415
+ },
416
+ };
417
+ }
418
+ /** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
419
+ function nowMs() {
420
+ return typeof performance !== "undefined" && typeof performance.now === "function"
421
+ ? performance.now()
422
+ : Date.now();
423
+ }
424
+ const SEMCONV_START_TIMES = new WeakMap();
425
+ /**
426
+ * A `Hooks` bundle recording `http.server.request.duration` per the OTel HTTP
427
+ * semantic conventions into an {@link OtlpMetricsExporter}:
428
+ *
429
+ * - unit `s`, spec bucket boundaries
430
+ * ({@link HTTP_SERVER_REQUEST_DURATION_BUCKETS});
431
+ * - attributes `http.request.method` (well-known set, else `_OTHER`),
432
+ * `http.response.status_code`, `url.scheme`, `http.route` (the matched
433
+ * route **template** via `ctx.routePath`), and `error.type` (the status
434
+ * code, on `5xx` only).
435
+ *
436
+ * Only requests that produce a request context are recorded. On the
437
+ * unmatched-404 fast path the framework skips context construction entirely
438
+ * (a deliberate allocation optimization), so unmatched floods record nothing
439
+ * — which is also the strongest possible cardinality guarantee: a hostile
440
+ * client can never mint metric series from raw paths.
441
+ *
442
+ * Install it **before** registering routes (group-hook ordering), or let
443
+ * `new App({ telemetry: true })` install it for you.
444
+ *
445
+ * @param sink - Metrics exporter the duration histogram is recorded into.
446
+ * @param opts - Optional path exclusion.
447
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
448
+ * @since 1.2.0
449
+ */
450
+ export function semconvHttpMetrics(sink, opts = {}) {
451
+ return {
452
+ onRequest(req) {
453
+ SEMCONV_START_TIMES.set(req, nowMs());
454
+ },
455
+ onResponse(res, ctx) {
456
+ const request = ctx?.request;
457
+ if (request === undefined)
458
+ return;
459
+ const started = SEMCONV_START_TIMES.get(request);
460
+ if (started === undefined)
461
+ return;
462
+ SEMCONV_START_TIMES.delete(request);
463
+ let pathname = "/";
464
+ let scheme = "http";
465
+ try {
466
+ const parsed = new URL(request.url);
467
+ pathname = parsed.pathname;
468
+ scheme = parsed.protocol.replace(":", "");
469
+ }
470
+ catch {
471
+ /* malformed URL — keep the fallbacks */
472
+ }
473
+ if (opts.exclude !== undefined && opts.exclude(pathname))
474
+ return;
475
+ const rawMethod = request.method.toUpperCase();
476
+ const attributes = {
477
+ "http.request.method": KNOWN_METHODS.has(rawMethod) ? rawMethod : "_OTHER",
478
+ "http.response.status_code": String(res.status),
479
+ "url.scheme": scheme,
480
+ };
481
+ // Spec: http.route only when a route template is known. Never the raw
482
+ // pathname — an unmatched-path flood must not mint metric series.
483
+ const routePath = ctx?.routePath;
484
+ if (typeof routePath === "string")
485
+ attributes["http.route"] = routePath;
486
+ if (res.status >= 500)
487
+ attributes["error.type"] = String(res.status);
488
+ sink.record("http.server.request.duration", attributes, (nowMs() - started) / 1000, { unit: "s", boundaries: HTTP_SERVER_REQUEST_DURATION_BUCKETS });
489
+ },
490
+ };
491
+ }
492
+ /**
493
+ * Build the exporters, hooks, and logger sink for the `App` `telemetry`
494
+ * option. Returns an inert bundle (all `null`/`undefined`) when no endpoint
495
+ * is configured, so `telemetry: true` costs nothing in development.
496
+ *
497
+ * @param options - The resolved {@link TelemetryOptions}.
498
+ * @returns The wiring bundle.
499
+ * @since 1.2.0
500
+ */
501
+ export function createAppTelemetry(options) {
502
+ const exporterOpts = options.exporter ?? {};
503
+ const logs = options.logs === false ? null : createOtlpLogExporter(exporterOpts);
504
+ const metrics = options.metrics === false ? null : createOtlpMetricsExporter(exporterOpts);
505
+ const logWrite = logs === null
506
+ ? undefined
507
+ : (line) => {
508
+ const proc = globalThis.process;
509
+ if (proc?.stdout?.write !== undefined)
510
+ proc.stdout.write(line + "\n");
511
+ // eslint-disable-next-line no-console
512
+ else
513
+ console.log(line);
514
+ logs.pushLine(line);
515
+ };
516
+ const endpointRaw = exporterOpts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT") ?? null;
517
+ return {
518
+ logs,
519
+ metrics,
520
+ hooks: metrics === null ? undefined : semconvHttpMetrics(metrics),
521
+ logWrite,
522
+ endpoint: endpointRaw,
523
+ async flush() {
524
+ await Promise.all([logs?.flush(), metrics?.flush()]);
525
+ },
526
+ };
527
+ }
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:6f1d01b1-95cd-5475-994d-9d54a26deac5",
4
+ "serialNumber": "urn:uuid:3d6cece6-6c87-5815-9301-8b0be8579da5",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-08-07T14:15:51.163Z",
7
+ "timestamp": "2026-08-20T11:51:20.093Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.1.0"
12
+ "version": "1.2.0"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@1.1.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.2.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.1.0",
24
+ "version": "1.2.0",
25
25
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
26
- "purl": "pkg:npm/@daloyjs/core@1.1.0",
26
+ "purl": "pkg:npm/@daloyjs/core@1.2.0",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.1.0",
49
+ "tagId": "swidtag--daloyjs-core-1.2.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.1.0",
51
+ "version": "1.2.0",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@1.1.0",
60
+ "ref": "pkg:npm/@daloyjs/core@1.2.0",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-1.1.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.1.0-6f1d01b1-95cd-5475-994d-9d54a26deac5",
5
+ "name": "@daloyjs/core-1.2.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.2.0-3d6cece6-6c87-5815-9301-8b0be8579da5",
7
7
  "creationInfo": {
8
- "created": "2026-08-07T14:15:51.163Z",
8
+ "created": "2026-08-20T11:51:20.093Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "1.1.0",
19
+ "versionInfo": "1.2.0",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@1.1.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.2.0"
31
31
  }
32
32
  ]
33
33
  }
package/dist/types.d.ts CHANGED
@@ -268,6 +268,15 @@ export interface BaseContext<P extends string, R extends RequestSchemas | undefi
268
268
  body: InferRequest<R, P>["body"];
269
269
  /** Mutable per-request state. Plugin-augmented context lives here. */
270
270
  state: AppState & Record<string, unknown>;
271
+ /**
272
+ * The matched route's path **template** (e.g. `/books/:id`), set as soon as
273
+ * routing succeeds. `undefined` on framework-synthesised contexts (404/405
274
+ * error contexts, OPTIONS preflight). Use it for low-cardinality metric
275
+ * labels (`http.route`) instead of the raw request path.
276
+ *
277
+ * @since 1.2.0
278
+ */
279
+ routePath?: string;
271
280
  /** Convenience response helpers (do not bypass schema validation). */
272
281
  set: {
273
282
  status?: number;
@@ -299,6 +308,13 @@ export interface PreBodyContext<P extends string = string> {
299
308
  body: undefined;
300
309
  /** Mutable per-request state shared with later hooks and the handler. */
301
310
  state: AppState & Record<string, unknown>;
311
+ /**
312
+ * The matched route's path template (routing has already succeeded when
313
+ * `preBody` runs). See {@link BaseContext.routePath}.
314
+ *
315
+ * @since 1.2.0
316
+ */
317
+ routePath?: string;
302
318
  /** Response headers/status available to a short-circuiting perimeter hook. */
303
319
  set: {
304
320
  status?: number;
@@ -376,8 +392,8 @@ export interface Hooks {
376
392
  * the existing response. Multiple `onSend` hooks compose pipeline-style.
377
393
  */
378
394
  onSend?: (res: Response, ctx: BaseContext<any, any> | undefined) => void | Response | Promise<void | Response>;
379
- /** Fire-and-forget observer of the final outgoing `Response` (logging, metrics). Runs last; cannot alter the response. */
380
- onResponse?: (res: Response) => void | Promise<void>;
395
+ /** Fire-and-forget observer of the final outgoing `Response` (logging, metrics). Runs last; cannot alter the response. `ctx` is `undefined` when the error occurred before context was built. */
396
+ onResponse?: (res: Response, ctx?: BaseContext<any, any> | undefined) => void | Promise<void>;
381
397
  }
382
398
  /**
383
399
  * Declarative description of one HTTP endpoint. The single source of truth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops \u2014 distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -154,6 +154,10 @@
154
154
  "types": "./dist/metrics.d.ts",
155
155
  "import": "./dist/metrics.js"
156
156
  },
157
+ "./otlp": {
158
+ "types": "./dist/otlp.d.ts",
159
+ "import": "./dist/otlp.js"
160
+ },
157
161
  "./fetch-resilience": {
158
162
  "types": "./dist/fetch-resilience.d.ts",
159
163
  "import": "./dist/fetch-resilience.js"