@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/dist/app.js CHANGED
@@ -2,7 +2,8 @@ import { Router } from "./router.js";
2
2
  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
- import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
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";
@@ -12,7 +13,7 @@ import { COMPRESSION_HOOK_MARKER } from "./compression.js";
12
13
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
13
14
  import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
14
15
  import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
15
- import { Scheduler } from "./scheduler.js";
16
+ import { Scheduler, } from "./scheduler.js";
16
17
  import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
17
18
  import { assertBehindProxy } from "./conn-info.js";
18
19
  const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
@@ -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
  *
@@ -477,7 +485,9 @@ export class App {
477
485
  }
478
486
  get globalCorsAllows() {
479
487
  if (this._globalCorsAllowsCache === undefined) {
480
- this._globalCorsAllowsCache = corsOriginAllowsFromHooks([this.options.hooks ?? {}]);
488
+ this._globalCorsAllowsCache = corsOriginAllowsFromHooks([
489
+ this.options.hooks ?? {},
490
+ ]);
481
491
  }
482
492
  return this._globalCorsAllowsCache;
483
493
  }
@@ -490,7 +500,10 @@ export class App {
490
500
  _coldPathHooksCache;
491
501
  get coldPathHooks() {
492
502
  if (this._coldPathHooksCache === undefined) {
493
- this._coldPathHooksCache = mergeHooks([this.options.hooks ?? {}, ...this.groupHooks]);
503
+ this._coldPathHooksCache = mergeHooks([
504
+ this.options.hooks ?? {},
505
+ ...this.groupHooks,
506
+ ]);
494
507
  }
495
508
  return this._coldPathHooksCache;
496
509
  }
@@ -505,12 +518,26 @@ export class App {
505
518
  jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
506
519
  ...resolved,
507
520
  };
521
+ // Telemetry is resolved before the logger so the logger's write sink can
522
+ // tee into the OTLP log exporter. Inert when no endpoint is configured.
523
+ const telemetryOpt = options.telemetry;
524
+ this.telemetry =
525
+ telemetryOpt === undefined || telemetryOpt === false
526
+ ? undefined
527
+ : createAppTelemetry(telemetryOpt === true ? {} : telemetryOpt);
528
+ const telemetryWrite = this.telemetry?.logWrite;
508
529
  this.log =
509
530
  options.logger === false
510
531
  ? noopLogger
511
- : options.logger && typeof options.logger.info === "function"
532
+ : options.logger &&
533
+ typeof options.logger.info === "function"
512
534
  ? options.logger
513
- : createLogger({ level: options.logger?.level ?? "info" });
535
+ : createLogger({
536
+ level: options.logger?.level ?? "info",
537
+ ...(telemetryWrite !== undefined
538
+ ? { write: telemetryWrite }
539
+ : {}),
540
+ });
514
541
  this.warnOnEnvMismatch();
515
542
  this.assertDisconnectStatusCode();
516
543
  assertBehindProxy(this.options.behindProxy);
@@ -522,6 +549,20 @@ export class App {
522
549
  this.maybeInstallCrashHandlers();
523
550
  this.maybeMountDocs();
524
551
  this.maybeMountAsyncAPI();
552
+ if (this.telemetry !== undefined) {
553
+ if (this.telemetry.hooks !== undefined)
554
+ this.use(this.telemetry.hooks);
555
+ const telemetry = this.telemetry;
556
+ this.onClose(() => telemetry.flush());
557
+ this.log.info({
558
+ event: "telemetry.otlp",
559
+ active: telemetry.endpoint !== null,
560
+ // Endpoint only — OTLP headers may carry tenant credentials.
561
+ endpoint: telemetry.endpoint ?? undefined,
562
+ }, telemetry.endpoint !== null
563
+ ? "OTLP telemetry export active"
564
+ : "Telemetry enabled but no OTEL_EXPORTER_OTLP_ENDPOINT configured; export disabled");
565
+ }
525
566
  }
526
567
  /**
527
568
  * Validate {@link AppOptions.disconnectStatusCode}.
@@ -626,7 +667,9 @@ export class App {
626
667
  secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
627
668
  corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
628
669
  csrf: o.csrf === "off" ? "off" : "on",
629
- crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined ? "default" : o.crashOnUnhandledRejection,
670
+ crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
671
+ ? "default"
672
+ : o.crashOnUnhandledRejection,
630
673
  trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
631
674
  bodyLimitBytes: this.options.bodyLimitBytes,
632
675
  requestTimeoutMs: this.options.requestTimeoutMs,
@@ -652,7 +695,8 @@ export class App {
652
695
  if (this.options.secureDefaults === false)
653
696
  return;
654
697
  if (this.options.secureHeaders !== false) {
655
- const opts = this.options.secureHeaders && typeof this.options.secureHeaders === "object"
698
+ const opts = this.options.secureHeaders &&
699
+ typeof this.options.secureHeaders === "object"
656
700
  ? this.options.secureHeaders
657
701
  : {};
658
702
  const auto = secureHeadersMiddleware(opts);
@@ -661,7 +705,9 @@ export class App {
661
705
  }
662
706
  // Opt-in load-shedding pressure monitor.
663
707
  if (this.options.loadShedding) {
664
- const lsOpts = typeof this.options.loadShedding === "object" ? this.options.loadShedding : {};
708
+ const lsOpts = typeof this.options.loadShedding === "object"
709
+ ? this.options.loadShedding
710
+ : {};
665
711
  this.groupHooks.push(loadSheddingMiddleware(lsOpts));
666
712
  }
667
713
  }
@@ -768,7 +814,10 @@ export class App {
768
814
  return;
769
815
  if (this.options.corsCrossOriginGuard === false)
770
816
  return;
771
- if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
817
+ if (method !== "POST" &&
818
+ method !== "PUT" &&
819
+ method !== "PATCH" &&
820
+ method !== "DELETE") {
772
821
  return;
773
822
  }
774
823
  const origin = request.headers.get("origin");
@@ -783,7 +832,9 @@ export class App {
783
832
  // are identical on both paths.
784
833
  const fastHeaderOrigin = getOriginFast(origin);
785
834
  if (fastHeaderOrigin !== undefined) {
786
- const fastReqOrigin = typeof requestUrl === "string" ? getOriginFast(requestUrl) : requestUrl.origin;
835
+ const fastReqOrigin = typeof requestUrl === "string"
836
+ ? getOriginFast(requestUrl)
837
+ : requestUrl.origin;
787
838
  if (fastReqOrigin !== undefined) {
788
839
  if (fastHeaderOrigin === fastReqOrigin)
789
840
  return;
@@ -802,7 +853,9 @@ export class App {
802
853
  // Malformed Origin header — refuse loudly.
803
854
  throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
804
855
  }
805
- const reqOrigin = typeof requestUrl === "string" ? new URL(requestUrl).origin : requestUrl.origin;
856
+ const reqOrigin = typeof requestUrl === "string"
857
+ ? new URL(requestUrl).origin
858
+ : requestUrl.origin;
806
859
  if (originUrl.origin === reqOrigin)
807
860
  return;
808
861
  if (corsOriginAllows.some((allows) => allows(origin)))
@@ -907,7 +960,8 @@ export class App {
907
960
  * `test`, which is a known, non-production answer).
908
961
  */
909
962
  isEnvIndeterminate() {
910
- if (this.options.env !== undefined || this.options.production !== undefined) {
963
+ if (this.options.env !== undefined ||
964
+ this.options.production !== undefined) {
911
965
  return false;
912
966
  }
913
967
  const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
@@ -1067,7 +1121,8 @@ export class App {
1067
1121
  // follow them there because its `keyGenerator` is caller-supplied and may
1068
1122
  // read `ctx.state`, so the unsafe order is refused instead.
1069
1123
  const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
1070
- if (replayBeforeBudget !== undefined && this.bootGuard.error === undefined) {
1124
+ if (replayBeforeBudget !== undefined &&
1125
+ this.bootGuard.error === undefined) {
1071
1126
  this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
1072
1127
  `${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
1073
1128
  `effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
@@ -1161,7 +1216,8 @@ export class App {
1161
1216
  // refusal must stay visible — but drop the stack, so a client cannot
1162
1217
  // multiply the bytes it pushes into the error tier by replaying the header.
1163
1218
  // The actionable message is logged once per process by the warn above.
1164
- refusal[OMIT_STACK_IN_LOG] = true;
1219
+ refusal[OMIT_STACK_IN_LOG] =
1220
+ true;
1165
1221
  throw refusal;
1166
1222
  }
1167
1223
  /**
@@ -1214,11 +1270,15 @@ export class App {
1214
1270
  };
1215
1271
  const generate = async () => generateOpenAPI(this, {
1216
1272
  info: resolveInfo(),
1217
- ...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
1273
+ ...(this.options.openapi?.servers
1274
+ ? { servers: this.options.openapi.servers }
1275
+ : {}),
1218
1276
  ...(this.options.openapi?.securitySchemes
1219
1277
  ? { securitySchemes: this.options.openapi.securitySchemes }
1220
1278
  : {}),
1221
- ...(this.options.openapi?.webhooks ? { webhooks: this.options.openapi.webhooks } : {}),
1279
+ ...(this.options.openapi?.webhooks
1280
+ ? { webhooks: this.options.openapi.webhooks }
1281
+ : {}),
1222
1282
  });
1223
1283
  this.route({
1224
1284
  method: "GET",
@@ -1246,7 +1306,9 @@ export class App {
1246
1306
  summary: "OpenAPI 3.1 document (YAML)",
1247
1307
  acknowledgeNoResponseBodySchema: true,
1248
1308
  responses: {
1249
- 200: { description: "OpenAPI 3.1 document for this application, in YAML." },
1309
+ 200: {
1310
+ description: "OpenAPI 3.1 document for this application, in YAML.",
1311
+ },
1250
1312
  },
1251
1313
  handler: async () => ({
1252
1314
  status: 200,
@@ -1275,7 +1337,9 @@ export class App {
1275
1337
  ...(ui === "redoc"
1276
1338
  ? { ...opts.csp, allowBlobWorkers: opts.csp?.allowBlobWorkers ?? true }
1277
1339
  : opts.csp),
1278
- ...(docsConnectOrigins.length ? { connectOrigins: docsConnectOrigins } : {}),
1340
+ ...(docsConnectOrigins.length
1341
+ ? { connectOrigins: docsConnectOrigins }
1342
+ : {}),
1279
1343
  });
1280
1344
  this.route({
1281
1345
  method: "GET",
@@ -1360,7 +1424,9 @@ export class App {
1360
1424
  */
1361
1425
  mountAsyncAPI(opts) {
1362
1426
  const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
1363
- const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
1427
+ const yamlPath = opts.yamlPath === false
1428
+ ? null
1429
+ : (opts.yamlPath ?? "/asyncapi.yaml");
1364
1430
  const uiPath = (opts.path ?? "/asyncapi");
1365
1431
  const tags = opts.tags ?? ["AsyncAPI"];
1366
1432
  const resolveInfo = () => {
@@ -1383,7 +1449,9 @@ export class App {
1383
1449
  // Framework-owned bodies on the AsyncAPI surface, like mountDocs above.
1384
1450
  acknowledgeNoResponseBodySchema: true,
1385
1451
  responses: {
1386
- 200: { description: "AsyncAPI 3.0 document for this application's WebSocket channels." },
1452
+ 200: {
1453
+ description: "AsyncAPI 3.0 document for this application's WebSocket channels.",
1454
+ },
1387
1455
  },
1388
1456
  handler: async () => ({ status: 200, body: await generate() }),
1389
1457
  });
@@ -1396,7 +1464,9 @@ export class App {
1396
1464
  summary: "AsyncAPI 3.0 document (YAML)",
1397
1465
  acknowledgeNoResponseBodySchema: true,
1398
1466
  responses: {
1399
- 200: { description: "AsyncAPI 3.0 document for this application, in YAML." },
1467
+ 200: {
1468
+ description: "AsyncAPI 3.0 document for this application, in YAML.",
1469
+ },
1400
1470
  },
1401
1471
  handler: async () => ({
1402
1472
  status: 200,
@@ -1541,7 +1611,10 @@ export class App {
1541
1611
  ...corsOriginAllowsFromHooks([globalHookLayer]),
1542
1612
  ...corsOriginAllows,
1543
1613
  ];
1544
- const securityMarkers = securityMarkersFromHooks([globalHookLayer, ...sources]);
1614
+ const securityMarkers = securityMarkersFromHooks([
1615
+ globalHookLayer,
1616
+ ...sources,
1617
+ ]);
1545
1618
  // Capture the decorations of this route's scope at registration time so the
1546
1619
  // dispatch hot path reads the scope-local bag rather than the root app's.
1547
1620
  // `this.decorations` is this scope's own bag (the root's, or the child's
@@ -1608,7 +1681,9 @@ export class App {
1608
1681
  return this.addHttpShorthand("HEAD", path, options, handler);
1609
1682
  }
1610
1683
  addHttpShorthand(method, path, options, possibleHandler) {
1611
- if (options === null || typeof options !== "object" || typeof possibleHandler !== "function") {
1684
+ if (options === null ||
1685
+ typeof options !== "object" ||
1686
+ typeof possibleHandler !== "function") {
1612
1687
  throw new TypeError(`app.${method.toLowerCase()}(): expected (path, contract, handler); opaque responses require an explicit contract with acknowledgeNoResponseBodySchema: true`);
1613
1688
  }
1614
1689
  const contract = options;
@@ -1734,7 +1809,9 @@ export class App {
1734
1809
  */
1735
1810
  readinesscheck(opts = {}) {
1736
1811
  this.registerHealthRoute("readinesscheck", opts, () => {
1737
- if (this.draining || this.pendingPlugins.size > 0 || this.pluginBootError.failed) {
1812
+ if (this.draining ||
1813
+ this.pendingPlugins.size > 0 ||
1814
+ this.pluginBootError.failed) {
1738
1815
  return {
1739
1816
  status: 503,
1740
1817
  body: { status: "not-ready" },
@@ -1769,8 +1846,13 @@ export class App {
1769
1846
  *
1770
1847
  * Call this **before** registering the routes you want measured — like any
1771
1848
  * `app.use(...)` middleware, the instrumentation only wraps routes added
1772
- * afterwards. Pass `opts.registry` to register custom application metrics
1773
- * that are rendered alongside the built-in HTTP series.
1849
+ * afterwards. Calling it after routes already exist logs a
1850
+ * `metrics.late_install` warning listing the uninstrumented paths.
1851
+ * Pass `opts.registry` to register custom application metrics that are
1852
+ * rendered alongside the built-in HTTP series. Default series names are
1853
+ * unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
1854
+ * construct the registry with `prefix: "daloy_"` if you want the old
1855
+ * names.
1774
1856
  *
1775
1857
  * @param opts - Path, auth, rate-limit, registry, and label configuration.
1776
1858
  * @returns `this` for chaining.
@@ -1779,7 +1861,9 @@ export class App {
1779
1861
  metrics(opts = {}) {
1780
1862
  const path = (opts.path ?? "/metrics");
1781
1863
  const registry = opts.registry ?? new MetricsRegistry();
1782
- const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1864
+ const rateLimitConfig = opts.rateLimit === false
1865
+ ? null
1866
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1783
1867
  const token = opts.token;
1784
1868
  // Refuse-to-boot: an unauthenticated metrics scrape in production is a
1785
1869
  // documented info-disclosure surface (route inventory, latency
@@ -1793,6 +1877,27 @@ export class App {
1793
1877
  `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
1794
1878
  `to acknowledge that this scrape endpoint is reachable without credentials.`);
1795
1879
  }
1880
+ // Group hooks only wrap routes registered *after* this call. Framework
1881
+ // auto-mounts (docs / health / metrics / reporting) are tagged and
1882
+ // ignored; anything else already on `this.routes` is a footgun.
1883
+ const skipLateTags = new Set([
1884
+ "Docs",
1885
+ "Observability",
1886
+ "Health",
1887
+ "Reporting",
1888
+ ]);
1889
+ const late = this.routes.filter((r) => {
1890
+ const tags = r.tags ?? [];
1891
+ return !tags.some((t) => skipLateTags.has(t));
1892
+ });
1893
+ if (late.length > 0) {
1894
+ const listed = late.slice(0, 20).map((r) => `${r.method} ${r.path}`);
1895
+ this.log.warn({
1896
+ event: "metrics.late_install",
1897
+ count: late.length,
1898
+ routes: listed,
1899
+ }, `app.metrics() was called after ${late.length} route(s) were registered; those routes are not RED-instrumented. Call app.metrics() before app.get/post/...`);
1900
+ }
1796
1901
  // Install RED instrumentation as a group hook so it wraps every route
1797
1902
  // registered after this call. Always exclude the scrape path itself, plus
1798
1903
  // any caller-supplied predicate.
@@ -1805,7 +1910,9 @@ export class App {
1805
1910
  exclude,
1806
1911
  }));
1807
1912
  this._coldPathHooksCache = undefined;
1808
- const buckets = rateLimitConfig ? new Map() : null;
1913
+ const buckets = rateLimitConfig
1914
+ ? new Map()
1915
+ : null;
1809
1916
  const trustProxyHeaders = appTrustsProxyHeaders(this.options);
1810
1917
  this.route({
1811
1918
  method: "GET",
@@ -1821,7 +1928,10 @@ export class App {
1821
1928
  const now = Date.now();
1822
1929
  const entry = buckets.get(key);
1823
1930
  if (!entry || entry.resetMs <= now) {
1824
- buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
1931
+ buckets.set(key, {
1932
+ count: 1,
1933
+ resetMs: now + rateLimitConfig.windowMs,
1934
+ });
1825
1935
  }
1826
1936
  else {
1827
1937
  entry.count++;
@@ -1889,7 +1999,9 @@ export class App {
1889
1999
  */
1890
2000
  cron(def, handler) {
1891
2001
  if (this.scheduler === undefined) {
1892
- const scheduler = new Scheduler({ logger: this.log.child({ component: "scheduler" }) });
2002
+ const scheduler = new Scheduler({
2003
+ logger: this.log.child({ component: "scheduler" }),
2004
+ });
1893
2005
  this.scheduler = scheduler;
1894
2006
  scheduler.start();
1895
2007
  // Drain the scheduler during the post-drain close phase so periodic
@@ -1912,7 +2024,9 @@ export class App {
1912
2024
  const isHealth = kind === "healthcheck";
1913
2025
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
1914
2026
  const path = (opts.path ?? defaultPath);
1915
- const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
2027
+ const rateLimitConfig = opts.rateLimit === false
2028
+ ? null
2029
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1916
2030
  const token = opts.token;
1917
2031
  // Refuse-to-boot: unauthenticated health/ready probes in
1918
2032
  // production are a documented info-disclosure surface (process uptime,
@@ -1926,7 +2040,9 @@ export class App {
1926
2040
  `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
1927
2041
  `to acknowledge that this probe is reachable without credentials.`);
1928
2042
  }
1929
- const buckets = rateLimitConfig ? new Map() : null;
2043
+ const buckets = rateLimitConfig
2044
+ ? new Map()
2045
+ : null;
1930
2046
  const trustProxyHeaders = appTrustsProxyHeaders(this.options);
1931
2047
  this.route({
1932
2048
  method: "GET",
@@ -1942,7 +2058,10 @@ export class App {
1942
2058
  const now = Date.now();
1943
2059
  const entry = buckets.get(key);
1944
2060
  if (!entry || entry.resetMs <= now) {
1945
- buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
2061
+ buckets.set(key, {
2062
+ count: 1,
2063
+ resetMs: now + rateLimitConfig.windowMs,
2064
+ });
1946
2065
  }
1947
2066
  else {
1948
2067
  entry.count++;
@@ -2000,8 +2119,12 @@ export class App {
2000
2119
  if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
2001
2120
  throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
2002
2121
  }
2003
- const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
2004
- const buckets = rateLimitConfig ? new Map() : null;
2122
+ const rateLimitConfig = opts.rateLimit === false
2123
+ ? null
2124
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
2125
+ const buckets = rateLimitConfig
2126
+ ? new Map()
2127
+ : null;
2005
2128
  const trustProxyHeaders = appTrustsProxyHeaders(this.options);
2006
2129
  const log = this.log;
2007
2130
  // Only log report bodies when explicitly enabled. In
@@ -2020,7 +2143,10 @@ export class App {
2020
2143
  const now = Date.now();
2021
2144
  const entry = buckets.get(key);
2022
2145
  if (!entry || entry.resetMs <= now) {
2023
- buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
2146
+ buckets.set(key, {
2147
+ count: 1,
2148
+ resetMs: now + rateLimitConfig.windowMs,
2149
+ });
2024
2150
  }
2025
2151
  else {
2026
2152
  entry.count++;
@@ -2110,8 +2236,11 @@ export class App {
2110
2236
  this.assertSecureHookConfig(config.hooks);
2111
2237
  // Child apps share the parent's router/routes/etc. Disable docs auto-mount
2112
2238
  // on the child so it does not re-register the parent's `/openapi.json` and
2113
- // `/docs` routes (which would throw "Duplicate route").
2114
- const child = new App({ ...this.options, docs: false });
2239
+ // `/docs` routes (which would throw "Duplicate route"). Disable telemetry
2240
+ // too: spreading `this.options` would otherwise construct a second pair of
2241
+ // OTLP exporters, `unref`'d flush timers, and a duplicate `telemetry.otlp`
2242
+ // boot line per group/plugin. The parent's wiring is copied below.
2243
+ const child = new App({ ...this.options, docs: false, telemetry: false });
2115
2244
  child.router = this.router;
2116
2245
  child.routes = this.routes;
2117
2246
  child.webSocketRoutes = this.webSocketRoutes;
@@ -2119,7 +2248,10 @@ export class App {
2119
2248
  child.bootGuard = this.bootGuard;
2120
2249
  child.log = this.log;
2121
2250
  child.prefix = joinPath(this.prefix, prefix);
2122
- child.groupHooks = [...this.groupHooks, ...(config.hooks ? [config.hooks] : [])];
2251
+ child.groupHooks = [
2252
+ ...this.groupHooks,
2253
+ ...(config.hooks ? [config.hooks] : []),
2254
+ ];
2123
2255
  child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
2124
2256
  child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
2125
2257
  child.groupAuth = config.auth ?? this.groupAuth;
@@ -2139,6 +2271,7 @@ export class App {
2139
2271
  child.shutdownListeners = this.shutdownListeners;
2140
2272
  child.pendingPlugins = this.pendingPlugins;
2141
2273
  child.pluginBootError = this.pluginBootError;
2274
+ child.telemetry = this.telemetry;
2142
2275
  register(child);
2143
2276
  return this;
2144
2277
  }
@@ -2171,7 +2304,8 @@ export class App {
2171
2304
  // "set only if absent" semantics mean the second installation would be
2172
2305
  // a silent no-op).
2173
2306
  if (hooks[SECURE_HEADERS_MARKER] === true) {
2174
- const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] === true);
2307
+ const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
2308
+ true);
2175
2309
  if (autoIdx >= 0)
2176
2310
  this.groupHooks.splice(autoIdx, 1);
2177
2311
  }
@@ -2243,14 +2377,16 @@ export class App {
2243
2377
  * @throws Error if `key` is already decorated and `opts.override` is not `true`.
2244
2378
  */
2245
2379
  decorate(key, value, opts = {}) {
2246
- if (Object.prototype.hasOwnProperty.call(this.decorations, key) && opts.override !== true) {
2380
+ if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
2381
+ opts.override !== true) {
2247
2382
  // Namespace-protected decorators. Refuse to silently
2248
2383
  // shadow an existing decoration; emit a once-per-process warn naming
2249
2384
  // both decorators on the explicit-override path.
2250
2385
  throw new Error(`decorate(): key "${key}" is already decorated. ` +
2251
2386
  `Pass { override: true } to replace, or rename to avoid the collision.`);
2252
2387
  }
2253
- if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
2388
+ if (opts.override === true &&
2389
+ Object.prototype.hasOwnProperty.call(this.decorations, key)) {
2254
2390
  this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
2255
2391
  }
2256
2392
  const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
@@ -2322,7 +2458,10 @@ export class App {
2322
2458
  const dedupKey = name ? (seed ? `${name}#${seed}` : name) : undefined;
2323
2459
  const dependencies = descriptor?.dependencies ?? [];
2324
2460
  const stateful = descriptor?.stateful ?? false;
2325
- if (stateful && !name && this.isProduction() && this.options.secureDefaults !== false) {
2461
+ if (stateful &&
2462
+ !name &&
2463
+ this.isProduction() &&
2464
+ this.options.secureDefaults !== false) {
2326
2465
  throw new Error("register(): anonymous stateful plugin refused in production. " +
2327
2466
  "Declare { name } (and optional { seed }) so the plugin can be deduplicated.");
2328
2467
  }
@@ -2372,7 +2511,9 @@ export class App {
2372
2511
  throw err;
2373
2512
  });
2374
2513
  this.pendingPlugins.add(tracked);
2375
- void tracked.finally(() => this.pendingPlugins.delete(tracked)).catch(() => { });
2514
+ void tracked
2515
+ .finally(() => this.pendingPlugins.delete(tracked))
2516
+ .catch(() => { });
2376
2517
  }
2377
2518
  firePluginInstalled(event) {
2378
2519
  if (this.pluginInstalledListeners.length === 0)
@@ -2391,7 +2532,9 @@ export class App {
2391
2532
  this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
2392
2533
  }
2393
2534
  }
2394
- return promises.length > 0 ? Promise.all(promises).then(() => undefined) : undefined;
2535
+ return promises.length > 0
2536
+ ? Promise.all(promises).then(() => undefined)
2537
+ : undefined;
2395
2538
  }
2396
2539
  /**
2397
2540
  * Wait until every async plugin registered with {@link App.register} has
@@ -2610,7 +2753,9 @@ export class App {
2610
2753
  }
2611
2754
  if (coldPreBody !== undefined) {
2612
2755
  const guardResult = coldPreBody(ctx);
2613
- const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
2756
+ const guarded = isPromiseLike(guardResult)
2757
+ ? await guardResult
2758
+ : guardResult;
2614
2759
  if (guarded instanceof Response) {
2615
2760
  copyContextHeaders(ctx, guarded);
2616
2761
  if (!guarded.headers.has("x-request-id")) {
@@ -2622,7 +2767,9 @@ export class App {
2622
2767
  }
2623
2768
  if (coldGuards !== undefined) {
2624
2769
  const guardResult = coldGuards(ctx);
2625
- const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
2770
+ const guarded = isPromiseLike(guardResult)
2771
+ ? await guardResult
2772
+ : guardResult;
2626
2773
  if (guarded instanceof Response) {
2627
2774
  copyContextHeaders(ctx, guarded);
2628
2775
  if (!guarded.headers.has("x-request-id")) {
@@ -2670,7 +2817,7 @@ export class App {
2670
2817
  }
2671
2818
  throw new NotFoundError(`No route for ${request.method} ${pathname}`);
2672
2819
  }
2673
- const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
2820
+ const { def, hooks, mergedHooks: allHooks, hasFinalizeHook, } = match.handler;
2674
2821
  activeErrorHook = allHooks.onError;
2675
2822
  activeResponseHook = allHooks.onResponse;
2676
2823
  activeSendHook = allHooks.onSend;
@@ -2683,6 +2830,8 @@ export class App {
2683
2830
  // perimeter hooks can reject unauthenticated callers without consuming
2684
2831
  // an attacker-controlled request stream.
2685
2832
  ctx = createPreBodyContext(request, getUrl, match.params);
2833
+ // Matched route template for low-cardinality labels (`http.route`).
2834
+ ctx.routePath = def.path;
2686
2835
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2687
2836
  // requests for the common no-decorator case. The decorations spread only
2688
2837
  // fires when `app.decorate()` was actually called.
@@ -2698,7 +2847,9 @@ export class App {
2698
2847
  Object.assign(state, routeDecorations);
2699
2848
  if (allHooks.preBody !== undefined) {
2700
2849
  const preBodyResult = allHooks.preBody(ctx);
2701
- const preBody = isPromiseLike(preBodyResult) ? await preBodyResult : preBodyResult;
2850
+ const preBody = isPromiseLike(preBodyResult)
2851
+ ? await preBodyResult
2852
+ : preBodyResult;
2702
2853
  const overriddenId = state.requestId;
2703
2854
  if (typeof overriddenId === "string" && overriddenId.length > 0) {
2704
2855
  requestId = overriddenId;
@@ -2722,7 +2873,9 @@ export class App {
2722
2873
  ctx = isPromiseLike(validatedCtx) ? await validatedCtx : validatedCtx;
2723
2874
  if (allHooks.beforeHandle !== undefined) {
2724
2875
  const beforeResult = allHooks.beforeHandle(ctx);
2725
- const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
2876
+ const before = isPromiseLike(beforeResult)
2877
+ ? await beforeResult
2878
+ : beforeResult;
2726
2879
  // Honor any request id override applied by middleware (e.g. the
2727
2880
  // `requestId()` Hooks bundle replaces the framework-generated value
2728
2881
  // with a trusted incoming header or a user-supplied generator).
@@ -2748,7 +2901,9 @@ export class App {
2748
2901
  let result = isPromiseLike(runResult) ? await runResult : runResult;
2749
2902
  if (allHooks.afterHandle !== undefined) {
2750
2903
  const afterResult = allHooks.afterHandle(ctx, result);
2751
- const afterReturn = isPromiseLike(afterResult) ? await afterResult : afterResult;
2904
+ const afterReturn = isPromiseLike(afterResult)
2905
+ ? await afterResult
2906
+ : afterResult;
2752
2907
  if (afterReturn !== undefined)
2753
2908
  result = afterReturn;
2754
2909
  }
@@ -2842,7 +2997,9 @@ export class App {
2842
2997
  // `err instanceof HttpError` first: the framework's own thrown
2843
2998
  // problem errors short-circuit before any signal/option lookup.
2844
2999
  const isHttp = err instanceof HttpError;
2845
- const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
3000
+ const disconnectCode = isHttp
3001
+ ? 0
3002
+ : (this.options.disconnectStatusCode ?? 499);
2846
3003
  if (disconnectCode > 0 && request.signal?.aborted === true) {
2847
3004
  log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
2848
3005
  const res = new Response(null, {
@@ -2900,7 +3057,9 @@ export class App {
2900
3057
  * @returns Fulfills with the `Response` produced by the matching handler.
2901
3058
  */
2902
3059
  request(input, init) {
2903
- const url = typeof input === "string" && input.startsWith("/") ? `http://test.local${input}` : input;
3060
+ const url = typeof input === "string" && input.startsWith("/")
3061
+ ? `http://test.local${input}`
3062
+ : input;
2904
3063
  const req = url instanceof Request ? url : new Request(url, init);
2905
3064
  return this.fetch(req);
2906
3065
  }
@@ -3057,7 +3216,9 @@ function inferOperationId(method, path) {
3057
3216
  const suffix = path
3058
3217
  .slice(1)
3059
3218
  .split("/")
3060
- .map((segment) => segment.startsWith(":") ? `By${capitalizeWords(segment.slice(1))}` : capitalizeWords(segment))
3219
+ .map((segment) => segment.startsWith(":")
3220
+ ? `By${capitalizeWords(segment.slice(1))}`
3221
+ : capitalizeWords(segment))
3061
3222
  .join("");
3062
3223
  return `${method.toLowerCase()}${suffix}`;
3063
3224
  }
@@ -3085,7 +3246,9 @@ function joinPath(a, b) {
3085
3246
  function healthRouteKey(request, trustProxyHeaders) {
3086
3247
  if (!trustProxyHeaders)
3087
3248
  return "global";
3088
- return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
3249
+ return (request.headers.get("x-real-ip") ??
3250
+ request.headers.get("fly-client-ip") ??
3251
+ "global");
3089
3252
  }
3090
3253
  /** True when the app declared a trusted reverse-proxy posture. */
3091
3254
  function appTrustsProxyHeaders(options) {
@@ -3209,7 +3372,9 @@ export function topoSortExtensions(exts) {
3209
3372
  const bHeaders = b.responseHeaders;
3210
3373
  if (!bHeaders || bHeaders.length === 0)
3211
3374
  continue;
3212
- const overlap = bHeaders.map((h) => h.toLowerCase()).filter((h) => aSet.has(h));
3375
+ const overlap = bHeaders
3376
+ .map((h) => h.toLowerCase())
3377
+ .filter((h) => aSet.has(h));
3213
3378
  if (overlap.length === 0)
3214
3379
  continue;
3215
3380
  const declared = (a.before ?? []).includes(b.name) ||
@@ -3271,11 +3436,16 @@ function securityMarkersFromHooks(layers) {
3271
3436
  hasCsrf,
3272
3437
  hasAuth,
3273
3438
  cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
3274
- replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex ? replayName : null,
3439
+ replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex
3440
+ ? replayName
3441
+ : null,
3275
3442
  };
3276
3443
  }
3277
3444
  function isStateChangingMethod(method) {
3278
- return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
3445
+ return (method === "POST" ||
3446
+ method === "PUT" ||
3447
+ method === "PATCH" ||
3448
+ method === "DELETE");
3279
3449
  }
3280
3450
  /**
3281
3451
  * Extract the pathname from a fully-qualified request URL without
@@ -3385,7 +3555,9 @@ function getOriginFast(url) {
3385
3555
  return url.slice(0, end);
3386
3556
  }
3387
3557
  function mergeHooks(layers) {
3388
- const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
3558
+ const pick = (key) => layers
3559
+ .map((h) => h[key])
3560
+ .filter((f) => typeof f === "function");
3389
3561
  const requiredScopes = requiredScopesFromHooks(layers);
3390
3562
  const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
3391
3563
  const hooks = {
@@ -3415,7 +3587,9 @@ function requiredScopesFromHooks(layers) {
3415
3587
  }
3416
3588
  function stampRequiredScopes(hooks, scopes) {
3417
3589
  if (scopes.length > 0) {
3418
- hooks[REQUIRE_SCOPES_HOOK_MARKER] = [...scopes];
3590
+ hooks[REQUIRE_SCOPES_HOOK_MARKER] = [
3591
+ ...scopes,
3592
+ ];
3419
3593
  }
3420
3594
  }
3421
3595
  function mergeBeforeHandle(beforeHandle, requiredScopes) {
@@ -3487,7 +3661,9 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
3487
3661
  return {
3488
3662
  ...(configuration ?? {}),
3489
3663
  authentication: {
3490
- ...(authentication && typeof authentication === "object" && !Array.isArray(authentication)
3664
+ ...(authentication &&
3665
+ typeof authentication === "object" &&
3666
+ !Array.isArray(authentication)
3491
3667
  ? authentication
3492
3668
  : {}),
3493
3669
  preferredSecurityScheme,
@@ -3502,21 +3678,21 @@ function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
3502
3678
  return sentResult.then((sent) => {
3503
3679
  if (sent instanceof Response)
3504
3680
  final = sent;
3505
- return finishFinalize(final, hooks, stripFingerprint);
3681
+ return finishFinalize(final, ctx, hooks, stripFingerprint);
3506
3682
  });
3507
3683
  }
3508
3684
  if (sentResult instanceof Response)
3509
3685
  final = sentResult;
3510
3686
  }
3511
- return finishFinalize(final, hooks, stripFingerprint);
3687
+ return finishFinalize(final, ctx, hooks, stripFingerprint);
3512
3688
  }
3513
- function finishFinalize(res, hooks, stripFingerprint) {
3689
+ function finishFinalize(res, ctx, hooks, stripFingerprint) {
3514
3690
  if (stripFingerprint) {
3515
3691
  res.headers.delete("server");
3516
3692
  res.headers.delete("x-powered-by");
3517
3693
  }
3518
3694
  if (hooks.onResponse !== undefined) {
3519
- const onResponseResult = hooks.onResponse(res);
3695
+ const onResponseResult = hooks.onResponse(res, ctx);
3520
3696
  if (isPromiseLike(onResponseResult)) {
3521
3697
  return onResponseResult.then(() => res);
3522
3698
  }
@@ -3524,7 +3700,9 @@ function finishFinalize(res, hooks, stripFingerprint) {
3524
3700
  return res;
3525
3701
  }
3526
3702
  function isPromiseLike(value) {
3527
- return value !== null && typeof value === "object" && typeof value.then === "function";
3703
+ return (value !== null &&
3704
+ typeof value === "object" &&
3705
+ typeof value.then === "function");
3528
3706
  }
3529
3707
  /**
3530
3708
  * Allocation-free finalizer for the common case (no `onSend`/`onResponse`
@@ -3703,6 +3881,7 @@ class RequestContext {
3703
3881
  body = undefined;
3704
3882
  state;
3705
3883
  set;
3884
+ routePath = undefined;
3706
3885
  _q = undefined;
3707
3886
  _qBuilder = undefined;
3708
3887
  _qSet = false;
@@ -3753,7 +3932,10 @@ function validateContext(ctx, def, opts) {
3753
3932
  const rawParams = ctx.params;
3754
3933
  const buildHeaders = () => ctx.headers;
3755
3934
  const buildQuery = () => ctx.query;
3756
- const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
3935
+ const hasSchema = def.request?.params ||
3936
+ def.request?.query ||
3937
+ def.request?.headers ||
3938
+ def.request?.body;
3757
3939
  const finishContext = () => ctx;
3758
3940
  if (!hasSchema) {
3759
3941
  return finishContext();
@@ -3797,7 +3979,8 @@ function validateContext(ctx, def, opts) {
3797
3979
  const declared = request.headers.get("content-length");
3798
3980
  if (declared !== null) {
3799
3981
  const declaredBytes = Number(declared);
3800
- if (Number.isFinite(declaredBytes) && declaredBytes > opts.bodyLimitBytes) {
3982
+ if (Number.isFinite(declaredBytes) &&
3983
+ declaredBytes > opts.bodyLimitBytes) {
3801
3984
  throw new PayloadTooLargeError(opts.bodyLimitBytes);
3802
3985
  }
3803
3986
  }
@@ -3870,7 +4053,7 @@ function toIssues(issues) {
3870
4053
  return issues.map((i) => ({
3871
4054
  message: i.message,
3872
4055
  path: (i.path ?? [])
3873
- .map((p) => (typeof p === "object" && p && "key" in p ? p.key : p))
4056
+ .map((p) => typeof p === "object" && p && "key" in p ? p.key : p)
3874
4057
  .join("."),
3875
4058
  }));
3876
4059
  }
@@ -4001,7 +4184,8 @@ async function readBodySlow(req, ct, limit, multipart) {
4001
4184
  typeof v.arrayBuffer === "function";
4002
4185
  if (isFile) {
4003
4186
  files++;
4004
- if (multipart?.maxFileBytes !== undefined && v.size > multipart.maxFileBytes) {
4187
+ if (multipart?.maxFileBytes !== undefined &&
4188
+ v.size > multipart.maxFileBytes) {
4005
4189
  throw new PayloadTooLargeError(multipart.maxFileBytes);
4006
4190
  }
4007
4191
  }
@@ -4230,7 +4414,8 @@ function withTimeout(p, ms, request) {
4230
4414
  const OMIT_STACK_IN_LOG = Symbol.for("daloyjs.error.omitStackInLog");
4231
4415
  function serializeErr(err) {
4232
4416
  if (err instanceof Error) {
4233
- if (err[OMIT_STACK_IN_LOG] === true) {
4417
+ if (err[OMIT_STACK_IN_LOG] ===
4418
+ true) {
4234
4419
  return { name: err.name, message: err.message };
4235
4420
  }
4236
4421
  return { name: err.name, message: err.message, stack: err.stack };