@daloyjs/core 1.1.1 → 1.2.1

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
@@ -664,8 +664,9 @@ The framework refuses to start (or to construct) when configuration is unsafe:
664
664
  - `idempotency()` with `Idempotency-Key` fingerprinting + byte-for-byte response replay, in-flight `409`, `422` on key reuse with a different payload, and a pluggable `IdempotencyStore` (in-memory default) at `@daloyjs/core/idempotency`.
665
665
  - `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, proactive `varyHeaders` keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` whose in-memory default is bounded on both entry count and retained bytes) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie`, `private`/`no-store`/`no-cache`, or `Vary: *` responses, and strips `Age`/hop-by-hop/`X-Request-Id` from stored entries so a hit never replays another request's correlation id. **Fail-closed on every principal dimension (CWE-524):** the key is the full _effective request URI_ including the authority (RFC 9111 §4), so hostnames never share entries; requests carrying `Authorization` **or** `Cookie` bypass the shared cache unless a `principal` names the caller (then each gets its own entry) or the header is explicitly declared shareable; a tenant resolved by `tenancy()` is folded into the key automatically — with a boot guard that refuses to start if the cache is mounted ahead of `tenancy()`; and the response's **own `Vary` header** is honoured as a secondary key (RFC 9111 §4.1), so the `Vary: Origin` written by `cors()` and the `Vary: Accept-Encoding` written by `compression()` keep one caller's allowed origin — or their gzipped bytes — from being served to the next, with each variant stored separately so they all stay warm. Complements `etag()`/`compression()`, which do not cache bodies.
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
- - `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`.
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`, `route` from the matched template) plus unprefixed process gauges (`process_resident_memory_bytes`), exposition-injection-safe name/label validation, a per-metric cardinality cap (`daloy_metrics_series_dropped_total`), and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + portable `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). Pull scrape is for long-lived processes; on Workers / Lambda / Vercel use `telemetry: true` (OTLP push). 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 5s export timeout, a dead collector never affects serving, tenant-routing header values never logged). Isolate runtimes flush per request through `toFetchHandler` (Cloudflare `waitUntil`, Vercel) and `toLambdaHandler` (awaited); Node/Bun/Deno flush on an interval plus shutdown.
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.
@@ -18,7 +18,11 @@
18
18
  import type { App } from "../app.js";
19
19
  /** Module shape expected by the Cloudflare Workers runtime as `export default`. */
20
20
  export interface ExportedFetchHandler<Env = unknown> {
21
- /** Worker entry point: forwards the request to {@link App.fetch}. `env`/`ctx` are accepted but unused by the adapter. */
21
+ /**
22
+ * Worker entry point: forwards the request to {@link App.fetch}. After the
23
+ * response is produced, `ctx.waitUntil` is used to flush OTLP telemetry so
24
+ * the isolate stays alive long enough for the export POST to finish.
25
+ */
22
26
  fetch: (request: Request, env?: Env, ctx?: ExecutionContextLike) => Promise<Response>;
23
27
  }
24
28
  interface ExecutionContextLike {
@@ -28,6 +32,9 @@ interface ExecutionContextLike {
28
32
  /**
29
33
  * Wrap an {@link App} in the `{ fetch }` object expected by Cloudflare Workers and other web-standard hosts.
30
34
  *
35
+ * After each request, OTLP telemetry is flushed via `ctx.waitUntil` so the
36
+ * isolate stays alive long enough for the export POST to finish.
37
+ *
31
38
  * @param app - The DaloyJS {@link App} that serves each incoming request.
32
39
  * @returns An {@link ExportedFetchHandler} suitable as the module's `export default`.
33
40
  */
@@ -1,11 +1,25 @@
1
1
  /**
2
2
  * Wrap an {@link App} in the `{ fetch }` object expected by Cloudflare Workers and other web-standard hosts.
3
3
  *
4
+ * After each request, OTLP telemetry is flushed via `ctx.waitUntil` so the
5
+ * isolate stays alive long enough for the export POST to finish.
6
+ *
4
7
  * @param app - The DaloyJS {@link App} that serves each incoming request.
5
8
  * @returns An {@link ExportedFetchHandler} suitable as the module's `export default`.
6
9
  */
7
10
  export function toFetchHandler(app) {
8
11
  return {
9
- fetch: (req) => app.fetch(req),
12
+ async fetch(req, _env, ctx) {
13
+ const res = await app.fetch(req);
14
+ const telemetry = app.telemetry;
15
+ if (telemetry !== undefined) {
16
+ const pending = telemetry.flush();
17
+ if (ctx?.waitUntil !== undefined)
18
+ ctx.waitUntil(pending);
19
+ else
20
+ void pending;
21
+ }
22
+ return res;
23
+ },
10
24
  };
11
25
  }
@@ -20,6 +20,9 @@ export function toLambdaHandler(app) {
20
20
  return responseToLambda(badRequestResponse(), isV2Event(event));
21
21
  }
22
22
  const response = await app.fetch(request);
23
+ // Lambda freezes when the handler resolves; await the export so OTLP
24
+ // actually leaves the isolate. `flush()` never rejects.
25
+ await app.telemetry?.flush();
23
26
  return responseToLambda(response, isV2Event(event));
24
27
  };
25
28
  }
@@ -49,6 +52,7 @@ export function toLambdaStreamHandler(app) {
49
52
  return;
50
53
  }
51
54
  await streamLambdaResponse(await app.fetch(request), rawStream, runtime);
55
+ await app.telemetry?.flush();
52
56
  });
53
57
  }
54
58
  function eventToRequest(event) {
@@ -75,13 +79,17 @@ function eventToRequest(event) {
75
79
  : (event.path ?? event.requestContext?.path ?? "/");
76
80
  const host = headers.get("host") ?? event.requestContext?.domainName ?? "localhost";
77
81
  const proto = headers.get("x-forwarded-proto") ?? "https";
78
- const rawQueryString = isV2Event(event) ? (event.rawQueryString ?? "") : queryStringForV1(event);
82
+ const rawQueryString = isV2Event(event)
83
+ ? (event.rawQueryString ?? "")
84
+ : queryStringForV1(event);
79
85
  const qs = rawQueryString ? `?${rawQueryString}` : "";
80
86
  const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
81
87
  const url = `${proto}://${host}${path}${qs}`;
82
88
  const init = { method, headers };
83
89
  if (method !== "GET" && method !== "HEAD" && event.body != null) {
84
- init.body = event.isBase64Encoded ? base64ToBytes(event.body) : event.body;
90
+ init.body = event.isBase64Encoded
91
+ ? base64ToBytes(event.body)
92
+ : event.body;
85
93
  }
86
94
  const request = new Request(url, init);
87
95
  // Fulfil the conn-info contract with the caller address API Gateway saw
@@ -171,8 +179,7 @@ function badRequestResponse() {
171
179
  }, { status: 400, headers: { "content-type": "application/problem+json" } });
172
180
  }
173
181
  function lambdaStreamingRuntime() {
174
- const runtime = globalThis
175
- .awslambda;
182
+ const runtime = globalThis.awslambda;
176
183
  if (!runtime ||
177
184
  typeof runtime.streamifyResponse !== "function" ||
178
185
  typeof runtime.HttpResponseStream?.from !== "function") {
@@ -182,7 +189,10 @@ function lambdaStreamingRuntime() {
182
189
  }
183
190
  async function streamLambdaResponse(response, rawStream, runtime) {
184
191
  const { headers, cookies } = responseHeaders(response);
185
- const metadata = { statusCode: response.status, headers };
192
+ const metadata = {
193
+ statusCode: response.status,
194
+ headers,
195
+ };
186
196
  if (cookies.length)
187
197
  metadata.multiValueHeaders = { "set-cookie": cookies };
188
198
  const responseStream = runtime.HttpResponseStream.from(rawStream, metadata);
@@ -32,6 +32,11 @@ export type RouteHandlers = Record<(typeof NEXT_METHODS)[number], WebHandler>;
32
32
  /**
33
33
  * Wrap an {@link App} as a single web-standard fetch handler.
34
34
  *
35
+ * After the response is produced, any OTLP telemetry is flushed. When the
36
+ * runtime exposes `globalThis.waitUntil` (Vercel Fluid / Edge), that is used
37
+ * so the export can finish after the response is sent; otherwise the flush
38
+ * is started fire-and-forget.
39
+ *
35
40
  * @param app - The DaloyJS {@link App} that serves each incoming request.
36
41
  * @returns A {@link WebHandler} delegating to {@link App.fetch}.
37
42
  */
@@ -1,12 +1,37 @@
1
- const NEXT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
1
+ const NEXT_METHODS = [
2
+ "GET",
3
+ "POST",
4
+ "PUT",
5
+ "PATCH",
6
+ "DELETE",
7
+ "OPTIONS",
8
+ "HEAD",
9
+ ];
2
10
  /**
3
11
  * Wrap an {@link App} as a single web-standard fetch handler.
4
12
  *
13
+ * After the response is produced, any OTLP telemetry is flushed. When the
14
+ * runtime exposes `globalThis.waitUntil` (Vercel Fluid / Edge), that is used
15
+ * so the export can finish after the response is sent; otherwise the flush
16
+ * is started fire-and-forget.
17
+ *
5
18
  * @param app - The DaloyJS {@link App} that serves each incoming request.
6
19
  * @returns A {@link WebHandler} delegating to {@link App.fetch}.
7
20
  */
8
21
  export function toWebHandler(app) {
9
- return (req) => app.fetch(req);
22
+ return async (req) => {
23
+ const res = await app.fetch(req);
24
+ const telemetry = app.telemetry;
25
+ if (telemetry !== undefined) {
26
+ const pending = telemetry.flush();
27
+ const waitUntil = globalThis.waitUntil;
28
+ if (typeof waitUntil === "function")
29
+ waitUntil(pending);
30
+ else
31
+ void pending;
32
+ }
33
+ return res;
34
+ };
10
35
  }
11
36
  /**
12
37
  * Build the default `{ fetch }` export expected by Vercel Node.js Functions
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,19 @@ 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. Isolate
85
+ * runtimes must go through `toFetchHandler` / `toLambdaHandler` so a
86
+ * per-request flush actually runs. See {@link TelemetryOptions}.
87
+ *
88
+ * @since 1.2.0
89
+ */
90
+ telemetry?: boolean | TelemetryOptions;
77
91
  /** Hard cap on request body size in bytes. Default: 1 MiB. */
78
92
  bodyLimitBytes?: number;
79
93
  /** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
@@ -643,15 +657,15 @@ export interface MetricsRouteOptions {
643
657
  */
644
658
  registry?: MetricsRegistry;
645
659
  /**
646
- * Resolve the low-cardinality `route` label. Strongly recommended: return
647
- * the route template (e.g. `/books/:id`) instead of the raw path.
648
- * Forwarded to {@link httpMetrics}.
660
+ * Resolve the low-cardinality `route` label. When omitted, the matched
661
+ * route template (`ctx.routePath`, e.g. `/books/:id`) is used. Forwarded
662
+ * to {@link httpMetrics}.
649
663
  */
650
664
  route?: HttpMetricsOptions["route"];
651
665
  /**
652
- * Maximum distinct values for the default pathname-derived `route` label
653
- * before further values collapse to `"<other>"`. Forwarded to
654
- * {@link httpMetrics}. Default `100`.
666
+ * Maximum distinct values for the pathname fallback `route` label (no
667
+ * template on the context). Forwarded to {@link httpMetrics}. Default
668
+ * `100`.
655
669
  */
656
670
  maxRouteCardinality?: number;
657
671
  /** Latency histogram buckets, in seconds. Forwarded to {@link httpMetrics}. */
@@ -934,6 +948,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
934
948
  readonly options: Required<Pick<AppOptions, "validateResponses" | "bodyLimitBytes" | "requestTimeoutMs">> & AppOptions;
935
949
  /** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
936
950
  readonly log: Logger;
951
+ /**
952
+ * OTLP telemetry wiring created by the `telemetry` option; `undefined` when
953
+ * the option is off. Exposed for tests and advanced flushing.
954
+ *
955
+ * @since 1.2.0
956
+ */
957
+ readonly telemetry?: AppTelemetry;
937
958
  /**
938
959
  * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
939
960
  *
@@ -1423,8 +1444,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1423
1444
  *
1424
1445
  * Call this **before** registering the routes you want measured — like any
1425
1446
  * `app.use(...)` middleware, the instrumentation only wraps routes added
1426
- * afterwards. Pass `opts.registry` to register custom application metrics
1427
- * that are rendered alongside the built-in HTTP series.
1447
+ * afterwards. Calling it after routes already exist logs a
1448
+ * `metrics.late_install` warning listing the uninstrumented paths.
1449
+ * Pass `opts.registry` to register custom application metrics that are
1450
+ * rendered alongside the built-in HTTP series. Default series names are
1451
+ * unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
1452
+ * construct the registry with `prefix: "daloy_"` if you want the old
1453
+ * names.
1428
1454
  *
1429
1455
  * @param opts - Path, auth, rate-limit, registry, and label configuration.
1430
1456
  * @returns `this` for chaining.