@daloyjs/core 0.36.0 → 0.38.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. package/package.json +79 -3
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Prometheus / OpenMetrics exposition for DaloyJS.
3
+ *
4
+ * The third observability pillar alongside the structured logger
5
+ * (`logger.ts`) and the OpenTelemetry-compatible tracer (`tracing.ts`): a
6
+ * dependency-free metrics registry plus a RED (Rate / Errors / Duration)
7
+ * instrumentation hook and a Prometheus text-format renderer. Pair it with
8
+ * {@link App.metrics} for an opt-in, auth-guarded `/metrics` route, or wire
9
+ * the pieces manually:
10
+ *
11
+ * - {@link MetricsRegistry} — holds {@link Counter}, {@link Gauge}, and
12
+ * {@link Histogram} series, validates metric/label names, caps total
13
+ * cardinality, and serializes everything to the Prometheus text exposition
14
+ * format via {@link MetricsRegistry.render}.
15
+ * - {@link httpMetrics} — a `Hooks` bundle that records per-request RED
16
+ * metrics (`http_requests_total`, `http_request_duration_seconds`,
17
+ * `http_requests_in_flight`) into a registry.
18
+ *
19
+ * Everything is built on Web-standard primitives (plus optional `process.*`
20
+ * gauges guarded for non-Node runtimes), so it runs unchanged on Node, Bun,
21
+ * Deno, Cloudflare Workers, and Vercel Edge.
22
+ *
23
+ * @module
24
+ * @since 0.37.0
25
+ */
26
+ import type { BaseContext, Hooks } from "./types.js";
27
+ /**
28
+ * Default latency histogram bucket boundaries, in seconds. Mirrors the
29
+ * conventional Prometheus client defaults so dashboards and recording rules
30
+ * authored against other ecosystems work without re-bucketing.
31
+ */
32
+ export declare const DEFAULT_DURATION_BUCKETS: readonly number[];
33
+ /** Labels attached to a single metric sample. */
34
+ export type MetricLabels = Record<string, string | number>;
35
+ /** Internal: shared base for the three metric kinds. */
36
+ declare abstract class Metric {
37
+ /** Fully-qualified metric name (registry prefix already applied). */
38
+ readonly name: string;
39
+ /** `# HELP` text. */
40
+ readonly help: string;
41
+ protected readonly registry: MetricsRegistry;
42
+ constructor(registry: MetricsRegistry, name: string, help: string);
43
+ /** @internal Serialize this metric to Prometheus text (no trailing newline). */
44
+ abstract render(): string;
45
+ /** @internal Drop every recorded series, keeping the metric definition. */
46
+ abstract clear(): void;
47
+ }
48
+ /** A monotonically increasing counter (RED "Rate" + "Errors"). */
49
+ export declare class Counter extends Metric {
50
+ private series;
51
+ /**
52
+ * Increment the counter for the given label set.
53
+ *
54
+ * @param labels - Label bag (validated + sorted). Omit for an unlabelled series.
55
+ * @param value - Positive increment. Default `1`.
56
+ * @throws {Error} If `value` is negative (counters never decrease).
57
+ */
58
+ inc(labels?: MetricLabels, value?: number): void;
59
+ /** @internal */
60
+ render(): string;
61
+ /** @internal */
62
+ clear(): void;
63
+ }
64
+ /** A gauge that can move up and down (RED/USE "Utilization", "Saturation"). */
65
+ export declare class Gauge extends Metric {
66
+ private series;
67
+ /** Set the gauge to an absolute value for the given label set. */
68
+ set(labels: MetricLabels | undefined, value: number): void;
69
+ /** Increment the gauge (default `1`). */
70
+ inc(labels?: MetricLabels, value?: number): void;
71
+ /** Decrement the gauge (default `1`). */
72
+ dec(labels?: MetricLabels, value?: number): void;
73
+ /** @internal */
74
+ render(): string;
75
+ /** @internal */
76
+ clear(): void;
77
+ }
78
+ /** A cumulative histogram (RED "Duration"). */
79
+ export declare class Histogram extends Metric {
80
+ /** Sorted, de-duplicated upper bucket boundaries (the implicit `+Inf` is the total count). */
81
+ readonly bounds: readonly number[];
82
+ private series;
83
+ constructor(registry: MetricsRegistry, name: string, help: string, buckets: readonly number[]);
84
+ /**
85
+ * Record an observation (e.g. a request duration in seconds) for the given
86
+ * label set. The value lands in every bucket whose upper bound is `>=`
87
+ * the value (cumulative), plus the `_sum` and `_count` series.
88
+ */
89
+ observe(labels: MetricLabels | undefined, value: number): void;
90
+ /** @internal */
91
+ render(): string;
92
+ /** @internal */
93
+ clear(): void;
94
+ }
95
+ /** Options for {@link MetricsRegistry}. */
96
+ export interface MetricsRegistryOptions {
97
+ /** Prefix applied to every metric name. Default `"daloy_"`. Pass `""` for none. */
98
+ prefix?: string;
99
+ /**
100
+ * Hard cap on the number of distinct series **per metric**. Once reached,
101
+ * new label combinations are dropped (and counted in
102
+ * `<prefix>metrics_series_dropped_total`) — a memory-exhaustion defense
103
+ * against unbounded label cardinality. Default `5000`.
104
+ */
105
+ maxSeries?: number;
106
+ /**
107
+ * Register process/runtime gauges (`process_resident_memory_bytes`,
108
+ * `process_heap_used_bytes`, `process_uptime_seconds`) collected at scrape
109
+ * time. No-op on runtimes without `process`. Default `true`.
110
+ */
111
+ collectDefaultMetrics?: boolean;
112
+ }
113
+ /**
114
+ * The Prometheus / OpenMetrics content type, including the format version.
115
+ * Served by {@link App.metrics}.
116
+ */
117
+ export declare const PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
118
+ /**
119
+ * A registry of {@link Counter}, {@link Gauge}, and {@link Histogram} series
120
+ * that renders to the Prometheus text exposition format.
121
+ *
122
+ * Metric handles are memoized by name: calling {@link MetricsRegistry.counter}
123
+ * twice with the same name returns the same {@link Counter}. Construct one
124
+ * registry per application, hand it to {@link httpMetrics} (or
125
+ * {@link App.metrics}) for RED instrumentation, and add your own
126
+ * business metrics on the side.
127
+ *
128
+ * @since 0.37.0
129
+ */
130
+ export declare class MetricsRegistry {
131
+ /** Metric-name prefix applied to every series. */
132
+ readonly prefix: string;
133
+ private readonly maxSeries;
134
+ private readonly metrics;
135
+ private readonly collectors;
136
+ private droppedCounter;
137
+ constructor(opts?: MetricsRegistryOptions);
138
+ /**
139
+ * @internal Admission control for a new series. Returns `false` (and bumps
140
+ * the dropped-series counter) when the per-metric cardinality cap is hit.
141
+ */
142
+ _admitSeries(currentSize: number): boolean;
143
+ /** Get or create a {@link Counter}. */
144
+ counter(name: string, help?: string): Counter;
145
+ /** Get or create a {@link Gauge}. */
146
+ gauge(name: string, help?: string): Gauge;
147
+ /** Get or create a {@link Histogram} with the given (or default) buckets. */
148
+ histogram(name: string, help?: string, buckets?: readonly number[]): Histogram;
149
+ /**
150
+ * Register a callback run immediately before each {@link render}, used to
151
+ * refresh point-in-time gauges (memory, in-flight, queue depth) only when
152
+ * the endpoint is actually scraped.
153
+ */
154
+ collect(fn: () => void): void;
155
+ /**
156
+ * Serialize every registered metric to the Prometheus text exposition
157
+ * format. Runs all {@link collect} callbacks first. The output ends with a
158
+ * trailing newline, as the format requires.
159
+ */
160
+ render(): string;
161
+ /**
162
+ * Clear every recorded series value while keeping metric definitions (and
163
+ * any handles already held by instrumentation). Intended for tests.
164
+ */
165
+ reset(): void;
166
+ private getOrCreate;
167
+ private registerDefaultMetrics;
168
+ }
169
+ /** Options for {@link httpMetrics}. */
170
+ export interface HttpMetricsOptions {
171
+ /** Registry the RED metrics are recorded into. */
172
+ registry: MetricsRegistry;
173
+ /**
174
+ * Resolve the low-cardinality `route` label from the request context.
175
+ * Strongly recommended: return the route **template** (e.g. `/books/:id`),
176
+ * not the raw path, to keep series cardinality bounded. When omitted the
177
+ * request pathname is used, capped by {@link HttpMetricsOptions.maxRouteCardinality}.
178
+ */
179
+ route?: (ctx: BaseContext<any, any>) => string | undefined;
180
+ /**
181
+ * Maximum distinct values for the default (pathname-derived) `route` label
182
+ * before further values collapse to `"<other>"`. Ignored when a custom
183
+ * {@link HttpMetricsOptions.route} resolver is supplied. Default `100`.
184
+ */
185
+ maxRouteCardinality?: number;
186
+ /** Skip instrumentation for matching request paths (e.g. the scrape route). */
187
+ exclude?: (path: string) => boolean;
188
+ /** Latency histogram buckets, in seconds. Default {@link DEFAULT_DURATION_BUCKETS}. */
189
+ buckets?: readonly number[];
190
+ }
191
+ /**
192
+ * A `Hooks` bundle that records RED (Rate / Errors / Duration) HTTP metrics
193
+ * into a {@link MetricsRegistry}:
194
+ *
195
+ * - `<prefix>http_requests_total{method,route,status}` — request counter
196
+ * (rate; errors are the subset with a `5xx`/`4xx` status).
197
+ * - `<prefix>http_request_duration_seconds{method,route}` — latency histogram.
198
+ * - `<prefix>http_requests_in_flight` — gauge of concurrently-handled requests.
199
+ *
200
+ * Install it **before** registering routes (group-hook ordering) so it wraps
201
+ * them. {@link App.metrics} installs this for you.
202
+ *
203
+ * @param opts - Registry plus optional route-label / bucket configuration.
204
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
205
+ * @since 0.37.0
206
+ */
207
+ export declare function httpMetrics(opts: HttpMetricsOptions): Hooks;
208
+ export {};
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Prometheus / OpenMetrics exposition for DaloyJS.
3
+ *
4
+ * The third observability pillar alongside the structured logger
5
+ * (`logger.ts`) and the OpenTelemetry-compatible tracer (`tracing.ts`): a
6
+ * dependency-free metrics registry plus a RED (Rate / Errors / Duration)
7
+ * instrumentation hook and a Prometheus text-format renderer. Pair it with
8
+ * {@link App.metrics} for an opt-in, auth-guarded `/metrics` route, or wire
9
+ * the pieces manually:
10
+ *
11
+ * - {@link MetricsRegistry} — holds {@link Counter}, {@link Gauge}, and
12
+ * {@link Histogram} series, validates metric/label names, caps total
13
+ * cardinality, and serializes everything to the Prometheus text exposition
14
+ * format via {@link MetricsRegistry.render}.
15
+ * - {@link httpMetrics} — a `Hooks` bundle that records per-request RED
16
+ * metrics (`http_requests_total`, `http_request_duration_seconds`,
17
+ * `http_requests_in_flight`) into a registry.
18
+ *
19
+ * Everything is built on Web-standard primitives (plus optional `process.*`
20
+ * gauges guarded for non-Node runtimes), so it runs unchanged on Node, Bun,
21
+ * Deno, Cloudflare Workers, and Vercel Edge.
22
+ *
23
+ * @module
24
+ * @since 0.37.0
25
+ */
26
+ /**
27
+ * Default latency histogram bucket boundaries, in seconds. Mirrors the
28
+ * conventional Prometheus client defaults so dashboards and recording rules
29
+ * authored against other ecosystems work without re-bucketing.
30
+ */
31
+ export const DEFAULT_DURATION_BUCKETS = [
32
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
33
+ ];
34
+ /** Prometheus metric-name grammar (`[a-zA-Z_:][a-zA-Z0-9_:]*`). */
35
+ const METRIC_NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
36
+ /** Prometheus label-name grammar (`[a-zA-Z_][a-zA-Z0-9_]*`). */
37
+ const LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
38
+ /**
39
+ * Escape a `# HELP` text value per the Prometheus exposition format:
40
+ * backslash and newline only.
41
+ */
42
+ function escapeHelp(value) {
43
+ return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
44
+ }
45
+ /**
46
+ * Escape a label value per the Prometheus exposition format: backslash,
47
+ * double-quote, and newline. This is the structural defense that prevents a
48
+ * hostile label value (e.g. a user-controlled route segment) from breaking
49
+ * out of the `{...}` block and injecting forged samples.
50
+ */
51
+ function escapeLabelValue(value) {
52
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
53
+ }
54
+ /**
55
+ * Validate and normalize a label bag: every key must match the Prometheus
56
+ * label-name grammar, values are coerced to strings. Returns a new object
57
+ * with stably-sorted keys so identical label sets always serialize to the
58
+ * same series key. Rejects the reserved `le` label (used by histogram
59
+ * buckets) so user labels cannot corrupt bucket rendering.
60
+ *
61
+ * @throws {Error} If a label name is invalid or reserved.
62
+ */
63
+ function normalizeLabels(labels) {
64
+ if (!labels)
65
+ return [];
66
+ const entries = [];
67
+ for (const key of Object.keys(labels)) {
68
+ if (!LABEL_NAME_RE.test(key)) {
69
+ throw new Error(`Invalid metric label name: ${JSON.stringify(key)}.`);
70
+ }
71
+ if (key === "le") {
72
+ throw new Error('Label name "le" is reserved for histogram buckets.');
73
+ }
74
+ entries.push([key, String(labels[key])]);
75
+ }
76
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
77
+ return entries;
78
+ }
79
+ /** Build the canonical series key from sorted label entries. */
80
+ function seriesKey(entries) {
81
+ if (entries.length === 0)
82
+ return "";
83
+ let key = "";
84
+ for (const [k, v] of entries)
85
+ key += `${k}\u0000${v}\u0001`;
86
+ return key;
87
+ }
88
+ /** Render the `{k="v",...}` label block (already-sorted entries). */
89
+ function renderLabelBlock(entries, extra) {
90
+ const all = extra ? [...entries, extra] : entries;
91
+ if (all.length === 0)
92
+ return "";
93
+ const parts = [];
94
+ for (const [k, v] of all)
95
+ parts.push(`${k}="${escapeLabelValue(v)}"`);
96
+ return `{${parts.join(",")}}`;
97
+ }
98
+ /** Internal: shared base for the three metric kinds. */
99
+ class Metric {
100
+ /** Fully-qualified metric name (registry prefix already applied). */
101
+ name;
102
+ /** `# HELP` text. */
103
+ help;
104
+ registry;
105
+ constructor(registry, name, help) {
106
+ if (!METRIC_NAME_RE.test(name)) {
107
+ throw new Error(`Invalid metric name: ${JSON.stringify(name)}.`);
108
+ }
109
+ this.registry = registry;
110
+ this.name = name;
111
+ this.help = help;
112
+ }
113
+ }
114
+ /** A monotonically increasing counter (RED "Rate" + "Errors"). */
115
+ export class Counter extends Metric {
116
+ series = new Map();
117
+ /**
118
+ * Increment the counter for the given label set.
119
+ *
120
+ * @param labels - Label bag (validated + sorted). Omit for an unlabelled series.
121
+ * @param value - Positive increment. Default `1`.
122
+ * @throws {Error} If `value` is negative (counters never decrease).
123
+ */
124
+ inc(labels, value = 1) {
125
+ if (value < 0)
126
+ throw new Error("Counter increment must be non-negative.");
127
+ const entries = normalizeLabels(labels);
128
+ const key = seriesKey(entries);
129
+ const existing = this.series.get(key);
130
+ if (existing) {
131
+ existing.value += value;
132
+ return;
133
+ }
134
+ if (!this.registry._admitSeries(this.series.size))
135
+ return;
136
+ this.series.set(key, { labels: entries, value });
137
+ }
138
+ /** @internal */
139
+ render() {
140
+ const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} counter`];
141
+ for (const { labels, value } of this.series.values()) {
142
+ lines.push(`${this.name}${renderLabelBlock(labels)} ${value}`);
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+ /** @internal */
147
+ clear() {
148
+ this.series.clear();
149
+ }
150
+ }
151
+ /** A gauge that can move up and down (RED/USE "Utilization", "Saturation"). */
152
+ export class Gauge extends Metric {
153
+ series = new Map();
154
+ /** Set the gauge to an absolute value for the given label set. */
155
+ set(labels, value) {
156
+ const entries = normalizeLabels(labels);
157
+ const key = seriesKey(entries);
158
+ const existing = this.series.get(key);
159
+ if (existing) {
160
+ existing.value = value;
161
+ return;
162
+ }
163
+ if (!this.registry._admitSeries(this.series.size))
164
+ return;
165
+ this.series.set(key, { labels: entries, value });
166
+ }
167
+ /** Increment the gauge (default `1`). */
168
+ inc(labels, value = 1) {
169
+ const entries = normalizeLabels(labels);
170
+ const key = seriesKey(entries);
171
+ const existing = this.series.get(key);
172
+ if (existing) {
173
+ existing.value += value;
174
+ return;
175
+ }
176
+ if (!this.registry._admitSeries(this.series.size))
177
+ return;
178
+ this.series.set(key, { labels: entries, value });
179
+ }
180
+ /** Decrement the gauge (default `1`). */
181
+ dec(labels, value = 1) {
182
+ this.inc(labels, -value);
183
+ }
184
+ /** @internal */
185
+ render() {
186
+ const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} gauge`];
187
+ for (const { labels, value } of this.series.values()) {
188
+ lines.push(`${this.name}${renderLabelBlock(labels)} ${value}`);
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+ /** @internal */
193
+ clear() {
194
+ this.series.clear();
195
+ }
196
+ }
197
+ /** A cumulative histogram (RED "Duration"). */
198
+ export class Histogram extends Metric {
199
+ /** Sorted, de-duplicated upper bucket boundaries (the implicit `+Inf` is the total count). */
200
+ bounds;
201
+ series = new Map();
202
+ constructor(registry, name, help, buckets) {
203
+ super(registry, name, help);
204
+ const sorted = [...new Set(buckets)].sort((a, b) => a - b);
205
+ if (sorted.length === 0 || sorted.some((b) => !Number.isFinite(b))) {
206
+ throw new Error("Histogram buckets must be a non-empty list of finite numbers.");
207
+ }
208
+ this.bounds = sorted;
209
+ }
210
+ /**
211
+ * Record an observation (e.g. a request duration in seconds) for the given
212
+ * label set. The value lands in every bucket whose upper bound is `>=`
213
+ * the value (cumulative), plus the `_sum` and `_count` series.
214
+ */
215
+ observe(labels, value) {
216
+ const entries = normalizeLabels(labels);
217
+ const key = seriesKey(entries);
218
+ let s = this.series.get(key);
219
+ if (!s) {
220
+ if (!this.registry._admitSeries(this.series.size))
221
+ return;
222
+ s = { labels: entries, counts: new Array(this.bounds.length).fill(0), sum: 0, count: 0 };
223
+ this.series.set(key, s);
224
+ }
225
+ s.count += 1;
226
+ s.sum += value;
227
+ for (let i = 0; i < this.bounds.length; i++) {
228
+ if (value <= this.bounds[i])
229
+ s.counts[i] += 1;
230
+ }
231
+ }
232
+ /** @internal */
233
+ render() {
234
+ const lines = [
235
+ `# HELP ${this.name} ${escapeHelp(this.help)}`,
236
+ `# TYPE ${this.name} histogram`,
237
+ ];
238
+ for (const s of this.series.values()) {
239
+ for (let i = 0; i < this.bounds.length; i++) {
240
+ lines.push(`${this.name}_bucket${renderLabelBlock(s.labels, ["le", String(this.bounds[i])])} ${s.counts[i]}`);
241
+ }
242
+ lines.push(`${this.name}_bucket${renderLabelBlock(s.labels, ["le", "+Inf"])} ${s.count}`);
243
+ lines.push(`${this.name}_sum${renderLabelBlock(s.labels)} ${s.sum}`);
244
+ lines.push(`${this.name}_count${renderLabelBlock(s.labels)} ${s.count}`);
245
+ }
246
+ return lines.join("\n");
247
+ }
248
+ /** @internal */
249
+ clear() {
250
+ this.series.clear();
251
+ }
252
+ }
253
+ /**
254
+ * The Prometheus / OpenMetrics content type, including the format version.
255
+ * Served by {@link App.metrics}.
256
+ */
257
+ export const PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
258
+ /**
259
+ * A registry of {@link Counter}, {@link Gauge}, and {@link Histogram} series
260
+ * that renders to the Prometheus text exposition format.
261
+ *
262
+ * Metric handles are memoized by name: calling {@link MetricsRegistry.counter}
263
+ * twice with the same name returns the same {@link Counter}. Construct one
264
+ * registry per application, hand it to {@link httpMetrics} (or
265
+ * {@link App.metrics}) for RED instrumentation, and add your own
266
+ * business metrics on the side.
267
+ *
268
+ * @since 0.37.0
269
+ */
270
+ export class MetricsRegistry {
271
+ /** Metric-name prefix applied to every series. */
272
+ prefix;
273
+ maxSeries;
274
+ metrics = new Map();
275
+ collectors = [];
276
+ droppedCounter;
277
+ constructor(opts = {}) {
278
+ this.prefix = opts.prefix ?? "daloy_";
279
+ this.maxSeries = opts.maxSeries ?? 5000;
280
+ if (!Number.isInteger(this.maxSeries) || this.maxSeries <= 0) {
281
+ throw new Error("MetricsRegistry maxSeries must be a positive integer.");
282
+ }
283
+ if (opts.collectDefaultMetrics !== false)
284
+ this.registerDefaultMetrics();
285
+ }
286
+ /**
287
+ * @internal Admission control for a new series. Returns `false` (and bumps
288
+ * the dropped-series counter) when the per-metric cardinality cap is hit.
289
+ */
290
+ _admitSeries(currentSize) {
291
+ if (currentSize < this.maxSeries)
292
+ return true;
293
+ if (this.droppedCounter)
294
+ this.droppedCounter.inc();
295
+ return false;
296
+ }
297
+ /** Get or create a {@link Counter}. */
298
+ counter(name, help = name) {
299
+ return this.getOrCreate(name, () => new Counter(this, this.prefix + name, help), Counter);
300
+ }
301
+ /** Get or create a {@link Gauge}. */
302
+ gauge(name, help = name) {
303
+ return this.getOrCreate(name, () => new Gauge(this, this.prefix + name, help), Gauge);
304
+ }
305
+ /** Get or create a {@link Histogram} with the given (or default) buckets. */
306
+ histogram(name, help = name, buckets = DEFAULT_DURATION_BUCKETS) {
307
+ return this.getOrCreate(name, () => new Histogram(this, this.prefix + name, help, buckets), Histogram);
308
+ }
309
+ /**
310
+ * Register a callback run immediately before each {@link render}, used to
311
+ * refresh point-in-time gauges (memory, in-flight, queue depth) only when
312
+ * the endpoint is actually scraped.
313
+ */
314
+ collect(fn) {
315
+ this.collectors.push(fn);
316
+ }
317
+ /**
318
+ * Serialize every registered metric to the Prometheus text exposition
319
+ * format. Runs all {@link collect} callbacks first. The output ends with a
320
+ * trailing newline, as the format requires.
321
+ */
322
+ render() {
323
+ for (const fn of this.collectors)
324
+ fn();
325
+ const blocks = [];
326
+ for (const metric of this.metrics.values())
327
+ blocks.push(metric.render());
328
+ return blocks.join("\n") + "\n";
329
+ }
330
+ /**
331
+ * Clear every recorded series value while keeping metric definitions (and
332
+ * any handles already held by instrumentation). Intended for tests.
333
+ */
334
+ reset() {
335
+ for (const metric of this.metrics.values())
336
+ metric.clear();
337
+ }
338
+ getOrCreate(name, make, kind) {
339
+ const existing = this.metrics.get(name);
340
+ if (existing) {
341
+ if (!(existing instanceof kind)) {
342
+ throw new Error(`Metric ${JSON.stringify(name)} already registered with a different type.`);
343
+ }
344
+ return existing;
345
+ }
346
+ const metric = make();
347
+ this.metrics.set(name, metric);
348
+ return metric;
349
+ }
350
+ registerDefaultMetrics() {
351
+ this.droppedCounter = this.counter("metrics_series_dropped_total", "Series dropped after hitting the per-metric cardinality cap.");
352
+ if (typeof process === "undefined")
353
+ return;
354
+ const rss = this.gauge("process_resident_memory_bytes", "Resident memory size in bytes.");
355
+ const heap = this.gauge("process_heap_used_bytes", "Node.js heap used in bytes.");
356
+ const uptime = this.gauge("process_uptime_seconds", "Process uptime in seconds.");
357
+ this.collect(() => {
358
+ try {
359
+ if (typeof process.memoryUsage === "function") {
360
+ const mem = process.memoryUsage();
361
+ rss.set(undefined, mem.rss);
362
+ heap.set(undefined, mem.heapUsed);
363
+ }
364
+ if (typeof process.uptime === "function")
365
+ uptime.set(undefined, process.uptime());
366
+ }
367
+ catch {
368
+ /* memoryUsage/uptime unavailable on this runtime — skip silently */
369
+ }
370
+ });
371
+ }
372
+ }
373
+ /** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
374
+ function nowMs() {
375
+ return typeof performance !== "undefined" && typeof performance.now === "function"
376
+ ? performance.now()
377
+ : Date.now();
378
+ }
379
+ const START_TIMES = new WeakMap();
380
+ /**
381
+ * A `Hooks` bundle that records RED (Rate / Errors / Duration) HTTP metrics
382
+ * into a {@link MetricsRegistry}:
383
+ *
384
+ * - `<prefix>http_requests_total{method,route,status}` — request counter
385
+ * (rate; errors are the subset with a `5xx`/`4xx` status).
386
+ * - `<prefix>http_request_duration_seconds{method,route}` — latency histogram.
387
+ * - `<prefix>http_requests_in_flight` — gauge of concurrently-handled requests.
388
+ *
389
+ * Install it **before** registering routes (group-hook ordering) so it wraps
390
+ * them. {@link App.metrics} installs this for you.
391
+ *
392
+ * @param opts - Registry plus optional route-label / bucket configuration.
393
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
394
+ * @since 0.37.0
395
+ */
396
+ export function httpMetrics(opts) {
397
+ const { registry } = opts;
398
+ const maxRouteCardinality = opts.maxRouteCardinality ?? 100;
399
+ const buckets = opts.buckets ?? DEFAULT_DURATION_BUCKETS;
400
+ const requests = registry.counter("http_requests_total", "Total HTTP requests.");
401
+ const duration = registry.histogram("http_request_duration_seconds", "HTTP request latency in seconds.", buckets);
402
+ const inFlight = registry.gauge("http_requests_in_flight", "In-flight HTTP requests.");
403
+ const seenRoutes = new Set();
404
+ const routeLabel = (ctx) => {
405
+ if (opts.route)
406
+ return opts.route(ctx) ?? "<unknown>";
407
+ let path = "/";
408
+ try {
409
+ path = new URL(ctx.request.url).pathname;
410
+ }
411
+ catch {
412
+ /* malformed URL — fall back to "/" */
413
+ }
414
+ if (seenRoutes.has(path))
415
+ return path;
416
+ if (seenRoutes.size >= maxRouteCardinality)
417
+ return "<other>";
418
+ seenRoutes.add(path);
419
+ return path;
420
+ };
421
+ return {
422
+ onRequest(req) {
423
+ START_TIMES.set(req, nowMs());
424
+ inFlight.inc();
425
+ },
426
+ onSend(res, ctx) {
427
+ if (!ctx)
428
+ return;
429
+ const path = (() => {
430
+ try {
431
+ return new URL(ctx.request.url).pathname;
432
+ }
433
+ catch {
434
+ return "/";
435
+ }
436
+ })();
437
+ // Always balance the in-flight gauge, even for excluded paths whose
438
+ // onRequest already incremented it.
439
+ const started = START_TIMES.get(ctx.request);
440
+ START_TIMES.delete(ctx.request);
441
+ inFlight.dec();
442
+ if (opts.exclude && opts.exclude(path))
443
+ return;
444
+ const method = ctx.request.method.toUpperCase();
445
+ const route = routeLabel(ctx);
446
+ requests.inc({ method, route, status: res.status });
447
+ if (started !== undefined) {
448
+ duration.observe({ method, route }, (nowMs() - started) / 1000);
449
+ }
450
+ },
451
+ };
452
+ }
@@ -37,11 +37,6 @@ export function requestId(opts = {}) {
37
37
  ctx.state.requestId = id;
38
38
  ctx.set.headers.set(header, id);
39
39
  },
40
- onResponse(res) {
41
- // Defence in depth: also stamp on responses produced by error paths.
42
- // (No-op if already set.)
43
- void res;
44
- },
45
40
  };
46
41
  }
47
42
  const CSP_NONCE_STATE = "cspNonce";
@@ -560,11 +555,6 @@ export function cors(opts) {
560
555
  }
561
556
  return undefined;
562
557
  },
563
- onResponse(res) {
564
- // Mirror set headers onto the final response.
565
- // (No-op if already present.)
566
- void res;
567
- },
568
558
  };
569
559
  hooks[CORS_HOOK_MARKER] = true;
570
560
  hooks[CORS_ORIGIN_ALLOW_MARKER] = (origin) => allow(origin) !== null;