@usequark/quark-core 2.5.4

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 (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,287 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, test } from "node:test";
3
+ import { createLogger, requestLogger } from "../src/logger.js";
4
+
5
+ /** Helper: captures console output during a callback */
6
+ const captureConsole = async (fn) => {
7
+ const output = { log: [], warn: [], error: [] };
8
+ const orig = {
9
+ log: console.log,
10
+ warn: console.warn,
11
+ error: console.error,
12
+ };
13
+
14
+ console.log = (...args) => output.log.push(args.join(" "));
15
+ console.warn = (...args) => output.warn.push(args.join(" "));
16
+ console.error = (...args) => output.error.push(args.join(" "));
17
+
18
+ try {
19
+ await fn();
20
+ } finally {
21
+ console.log = orig.log;
22
+ console.warn = orig.warn;
23
+ console.error = orig.error;
24
+ }
25
+ return output;
26
+ };
27
+
28
+ describe("Logger", () => {
29
+ let originalNodeEnv;
30
+ let originalLogLevel;
31
+
32
+ beforeEach(() => {
33
+ originalNodeEnv = process.env.NODE_ENV;
34
+ originalLogLevel = process.env.LOG_LEVEL;
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (originalNodeEnv === undefined) {
39
+ delete process.env.NODE_ENV;
40
+ } else {
41
+ process.env.NODE_ENV = originalNodeEnv;
42
+ }
43
+ if (originalLogLevel === undefined) {
44
+ delete process.env.LOG_LEVEL;
45
+ } else {
46
+ process.env.LOG_LEVEL = originalLogLevel;
47
+ }
48
+ });
49
+
50
+ test("creates logger with default options", () => {
51
+ const log = createLogger();
52
+ assert.ok(log.debug);
53
+ assert.ok(log.info);
54
+ assert.ok(log.warn);
55
+ assert.ok(log.error);
56
+ assert.ok(log.fatal);
57
+ assert.ok(log.child);
58
+ });
59
+
60
+ test("default logger instance exists with name 'quark'", async () => {
61
+ process.env.NODE_ENV = "production";
62
+ const log = createLogger({ name: "quark" });
63
+ const output = await captureConsole(() => {
64
+ log.info("hello");
65
+ });
66
+ const entry = JSON.parse(output.log[0]);
67
+ assert.equal(entry.name, "quark");
68
+ });
69
+
70
+ test("log level filtering: debug hidden at info level", async () => {
71
+ process.env.NODE_ENV = "production";
72
+ const log = createLogger({ name: "test", level: "info" });
73
+
74
+ const output = await captureConsole(() => {
75
+ log.debug("should be hidden");
76
+ log.info("should be visible");
77
+ });
78
+
79
+ assert.equal(output.log.length, 1);
80
+ const entry = JSON.parse(output.log[0]);
81
+ assert.equal(entry.msg, "should be visible");
82
+ });
83
+
84
+ test("log level filtering: debug visible at debug level", async () => {
85
+ process.env.NODE_ENV = "production";
86
+ const log = createLogger({ name: "test", level: "debug" });
87
+
88
+ const output = await captureConsole(() => {
89
+ log.debug("visible");
90
+ });
91
+
92
+ assert.equal(output.log.length, 1);
93
+ const entry = JSON.parse(output.log[0]);
94
+ assert.equal(entry.msg, "visible");
95
+ assert.equal(entry.level, "debug");
96
+ });
97
+
98
+ test("respects LOG_LEVEL env var", async () => {
99
+ process.env.NODE_ENV = "production";
100
+ process.env.LOG_LEVEL = "warn";
101
+ const log = createLogger({ name: "test" });
102
+
103
+ const output = await captureConsole(() => {
104
+ log.info("hidden");
105
+ log.warn("visible");
106
+ });
107
+
108
+ assert.equal(output.log.length, 0);
109
+ assert.equal(output.warn.length, 1);
110
+ const entry = JSON.parse(output.warn[0]);
111
+ assert.equal(entry.msg, "visible");
112
+ });
113
+
114
+ test("child logger inherits parent context", async () => {
115
+ process.env.NODE_ENV = "production";
116
+ const parent = createLogger({
117
+ name: "parent",
118
+ level: "info",
119
+ context: { service: "web" },
120
+ });
121
+
122
+ const child = parent.child({ requestId: "abc-123" });
123
+
124
+ const output = await captureConsole(() => {
125
+ child.info("child message");
126
+ });
127
+
128
+ const entry = JSON.parse(output.log[0]);
129
+ assert.equal(entry.name, "parent");
130
+ assert.equal(entry.service, "web");
131
+ assert.equal(entry.requestId, "abc-123");
132
+ assert.equal(entry.msg, "child message");
133
+ });
134
+
135
+ test("child logger merges context without mutating parent", async () => {
136
+ process.env.NODE_ENV = "production";
137
+ const parent = createLogger({
138
+ name: "parent",
139
+ level: "info",
140
+ context: { a: 1 },
141
+ });
142
+
143
+ parent.child({ b: 2 });
144
+
145
+ const output = await captureConsole(() => {
146
+ parent.info("parent only");
147
+ });
148
+
149
+ const entry = JSON.parse(output.log[0]);
150
+ assert.equal(entry.a, 1);
151
+ assert.equal(entry.b, undefined);
152
+ });
153
+
154
+ test("structured output contains required fields", async () => {
155
+ process.env.NODE_ENV = "production";
156
+ const log = createLogger({ name: "fields-test", level: "debug" });
157
+
158
+ const output = await captureConsole(() => {
159
+ log.info("test message", { extra: "data" });
160
+ });
161
+
162
+ const entry = JSON.parse(output.log[0]);
163
+ assert.ok(entry.timestamp);
164
+ assert.equal(entry.level, "info");
165
+ assert.equal(entry.name, "fields-test");
166
+ assert.equal(entry.msg, "test message");
167
+ assert.equal(entry.extra, "data");
168
+ // timestamp should be ISO 8601
169
+ assert.ok(!Number.isNaN(Date.parse(entry.timestamp)));
170
+ });
171
+
172
+ test("error and fatal use console.error", async () => {
173
+ process.env.NODE_ENV = "production";
174
+ const log = createLogger({ name: "test", level: "debug" });
175
+
176
+ const output = await captureConsole(() => {
177
+ log.error("err msg");
178
+ log.fatal("fatal msg");
179
+ });
180
+
181
+ assert.equal(output.error.length, 2);
182
+ const errEntry = JSON.parse(output.error[0]);
183
+ assert.equal(errEntry.level, "error");
184
+ const fatalEntry = JSON.parse(output.error[1]);
185
+ assert.equal(fatalEntry.level, "fatal");
186
+ });
187
+
188
+ test("warn uses console.warn", async () => {
189
+ process.env.NODE_ENV = "production";
190
+ const log = createLogger({ name: "test", level: "debug" });
191
+
192
+ const output = await captureConsole(() => {
193
+ log.warn("warn msg");
194
+ });
195
+
196
+ assert.equal(output.warn.length, 1);
197
+ const entry = JSON.parse(output.warn[0]);
198
+ assert.equal(entry.level, "warn");
199
+ });
200
+
201
+ test("dev mode outputs colorized non-JSON string", async () => {
202
+ delete process.env.NODE_ENV;
203
+ const log = createLogger({ name: "dev-test", level: "debug" });
204
+
205
+ const output = await captureConsole(() => {
206
+ log.info("hello dev");
207
+ });
208
+
209
+ assert.equal(output.log.length, 1);
210
+ const line = output.log[0];
211
+ // Should NOT be valid JSON in dev mode
212
+ assert.throws(() => JSON.parse(line));
213
+ // Should contain the message and logger name
214
+ assert.ok(line.includes("hello dev"));
215
+ assert.ok(line.includes("dev-test"));
216
+ assert.ok(line.includes("INFO"));
217
+ });
218
+
219
+ test("call-site data overrides default context", async () => {
220
+ process.env.NODE_ENV = "production";
221
+ const log = createLogger({
222
+ name: "test",
223
+ level: "info",
224
+ context: { region: "us-east" },
225
+ });
226
+
227
+ const output = await captureConsole(() => {
228
+ log.info("override", { region: "eu-west" });
229
+ });
230
+
231
+ const entry = JSON.parse(output.log[0]);
232
+ assert.equal(entry.region, "eu-west");
233
+ });
234
+ });
235
+
236
+ describe("requestLogger", () => {
237
+ test("creates child logger with request fields", async () => {
238
+ process.env.NODE_ENV = "production";
239
+ const parent = createLogger({ name: "web", level: "info" });
240
+ const getReqLogger = requestLogger(parent);
241
+
242
+ const request = {
243
+ method: "GET",
244
+ url: "/api/users?page=1",
245
+ headers: { "x-request-id": "req-456" },
246
+ };
247
+
248
+ const output = await captureConsole(() => {
249
+ const reqLog = getReqLogger(request);
250
+ reqLog.info("handling request");
251
+ });
252
+
253
+ // First log is "request started", second is "handling request"
254
+ assert.equal(output.log.length, 2);
255
+
256
+ const startEntry = JSON.parse(output.log[0]);
257
+ assert.equal(startEntry.msg, "request started");
258
+ assert.equal(startEntry.requestId, "req-456");
259
+ assert.equal(startEntry.method, "GET");
260
+ assert.equal(startEntry.path, "/api/users");
261
+
262
+ const handleEntry = JSON.parse(output.log[1]);
263
+ assert.equal(handleEntry.msg, "handling request");
264
+ assert.equal(handleEntry.requestId, "req-456");
265
+ });
266
+
267
+ test("generates requestId when header is missing", async () => {
268
+ process.env.NODE_ENV = "production";
269
+ const parent = createLogger({ name: "web", level: "info" });
270
+ const getReqLogger = requestLogger(parent);
271
+
272
+ const request = {
273
+ method: "POST",
274
+ url: "/api/data",
275
+ headers: {},
276
+ };
277
+
278
+ const output = await captureConsole(() => {
279
+ getReqLogger(request);
280
+ });
281
+
282
+ const entry = JSON.parse(output.log[0]);
283
+ assert.ok(entry.requestId);
284
+ assert.equal(entry.method, "POST");
285
+ assert.equal(entry.path, "/api/data");
286
+ });
287
+ });
package/src/mail.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Builds the dev mail SMTP URL from individual MAIL_* environment variables.
3
+ * Used only for local development (Mailpit). Production email uses SMTP_HOST/SMTP_PORT
4
+ * configured in email.js.
5
+ *
6
+ * @returns {string} SMTP URL (e.g. "smtp://localhost:1025")
7
+ */
8
+ function getDevMailUrl() {
9
+ const host = process.env.MAIL_HOST || "localhost";
10
+ const port = process.env.MAIL_SMTP_PORT || "1025";
11
+
12
+ return `smtp://${host}:${port}`;
13
+ }
14
+
15
+ /**
16
+ * Builds mail Web UI URL from individual environment variables.
17
+ * Useful for displaying the Mailpit URL in logs or configuration.
18
+ *
19
+ * @returns {string} Mail UI URL (e.g. "http://localhost:8025")
20
+ */
21
+ function getDevMailUiUrl() {
22
+ const host = process.env.MAIL_HOST || "localhost";
23
+ const port = process.env.MAIL_UI_PORT || "8025";
24
+
25
+ return `http://${host}:${port}`;
26
+ }
27
+
28
+ /**
29
+ * Gets dev mail SMTP configuration for the Nodemailer transport.
30
+ * Reads from MAIL_HOST and MAIL_SMTP_PORT environment variables.
31
+ *
32
+ * @returns {{ host: string, port: number, url: string }}
33
+ */
34
+ export const getDevMailConfig = () => {
35
+ const host = process.env.MAIL_HOST || "localhost";
36
+ const port = process.env.MAIL_SMTP_PORT || "1025";
37
+
38
+ return {
39
+ host,
40
+ port: parseInt(port, 10),
41
+ url: getDevMailUrl(),
42
+ };
43
+ };
44
+
45
+ // Backwards-compatible aliases (deprecated - use getDevMailConfig, getDevMailUrl, getDevMailUiUrl)
46
+ export const getMailSmtpConfig = getDevMailConfig;
47
+ export const getMailSmtpUrl = getDevMailUrl;
48
+ export const getMailUiUrl = getDevMailUiUrl;
49
+
50
+ export { getDevMailUiUrl, getDevMailUrl };
package/src/metrics.js ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * @techstream/quark-core - Metrics Module
3
+ * Lightweight application metrics with zero external dependencies.
4
+ * Supports counters, gauges, and histograms with optional labels.
5
+ * Exports Prometheus-compatible text format.
6
+ */
7
+
8
+ /**
9
+ * @typedef {"counter" | "gauge" | "histogram"} MetricType
10
+ *
11
+ * @typedef {Object} MetricOptions
12
+ * @property {string} name - Metric name (e.g. "http_requests_total")
13
+ * @property {string} help - Human-readable description
14
+ * @property {string[]} [labelNames] - Allowed label keys
15
+ *
16
+ * @typedef {Object} HistogramOptions
17
+ * @property {string} name
18
+ * @property {string} help
19
+ * @property {string[]} [labelNames]
20
+ * @property {number[]} [buckets] - Histogram bucket boundaries in ascending order
21
+ */
22
+
23
+ /** Default histogram buckets (in seconds) */
24
+ const DEFAULT_BUCKETS = [
25
+ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
26
+ ];
27
+
28
+ /**
29
+ * Serialises a label object into a Prometheus label string.
30
+ * @param {Record<string, string>} labels
31
+ * @returns {string} e.g. `{method="GET",path="/api/health"}`
32
+ */
33
+ const escapeLabel = (v) =>
34
+ String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
35
+
36
+ const labelsToKey = (labels) => {
37
+ const keys = Object.keys(labels).sort();
38
+ if (keys.length === 0) return "";
39
+ return `{${keys.map((k) => `${k}="${escapeLabel(labels[k])}"`).join(",")}}`;
40
+ };
41
+
42
+ // ─── Counter ──────────────────────────────────────────────────
43
+
44
+ class Counter {
45
+ /** @param {MetricOptions} opts */
46
+ constructor(opts) {
47
+ this.name = opts.name;
48
+ this.help = opts.help;
49
+ this.labelNames = opts.labelNames || [];
50
+ /** @type {Map<string, number>} */
51
+ this.values = new Map();
52
+ }
53
+
54
+ /**
55
+ * Increment counter.
56
+ * @param {Record<string, string>} [labels]
57
+ * @param {number} [value=1]
58
+ */
59
+ inc(labels = {}, value = 1) {
60
+ if (value < 0) throw new Error("Counter can only be incremented");
61
+ const key = labelsToKey(labels);
62
+ this.values.set(key, (this.values.get(key) || 0) + value);
63
+ }
64
+
65
+ /** Reset all values */
66
+ reset() {
67
+ this.values.clear();
68
+ }
69
+
70
+ /** @returns {string} Prometheus text */
71
+ serialize() {
72
+ const lines = [
73
+ `# HELP ${this.name} ${this.help}`,
74
+ `# TYPE ${this.name} counter`,
75
+ ];
76
+ for (const [key, val] of this.values) {
77
+ lines.push(`${this.name}${key} ${val}`);
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+ }
82
+
83
+ // ─── Gauge ────────────────────────────────────────────────────
84
+
85
+ class Gauge {
86
+ /** @param {MetricOptions} opts */
87
+ constructor(opts) {
88
+ this.name = opts.name;
89
+ this.help = opts.help;
90
+ this.labelNames = opts.labelNames || [];
91
+ /** @type {Map<string, number>} */
92
+ this.values = new Map();
93
+ }
94
+
95
+ /** Set gauge to a specific value */
96
+ set(labels = {}, value = 0) {
97
+ this.values.set(labelsToKey(labels), value);
98
+ }
99
+
100
+ /** Increment gauge */
101
+ inc(labels = {}, value = 1) {
102
+ const key = labelsToKey(labels);
103
+ this.values.set(key, (this.values.get(key) || 0) + value);
104
+ }
105
+
106
+ /** Decrement gauge */
107
+ dec(labels = {}, value = 1) {
108
+ const key = labelsToKey(labels);
109
+ this.values.set(key, (this.values.get(key) || 0) - value);
110
+ }
111
+
112
+ /** Reset all values */
113
+ reset() {
114
+ this.values.clear();
115
+ }
116
+
117
+ /** @returns {string} Prometheus text */
118
+ serialize() {
119
+ const lines = [
120
+ `# HELP ${this.name} ${this.help}`,
121
+ `# TYPE ${this.name} gauge`,
122
+ ];
123
+ for (const [key, val] of this.values) {
124
+ lines.push(`${this.name}${key} ${val}`);
125
+ }
126
+ return lines.join("\n");
127
+ }
128
+ }
129
+
130
+ // ─── Histogram ────────────────────────────────────────────────
131
+
132
+ class Histogram {
133
+ /** @param {HistogramOptions} opts */
134
+ constructor(opts) {
135
+ this.name = opts.name;
136
+ this.help = opts.help;
137
+ this.labelNames = opts.labelNames || [];
138
+ this.buckets = opts.buckets || DEFAULT_BUCKETS;
139
+ /** @type {Map<string, { buckets: number[], sum: number, count: number }>} */
140
+ this.values = new Map();
141
+ }
142
+
143
+ /** @private */
144
+ _getOrCreate(key) {
145
+ if (!this.values.has(key)) {
146
+ this.values.set(key, {
147
+ buckets: new Array(this.buckets.length).fill(0),
148
+ sum: 0,
149
+ count: 0,
150
+ });
151
+ }
152
+ return this.values.get(key);
153
+ }
154
+
155
+ /**
156
+ * Observe a value (e.g. request duration in seconds).
157
+ * @param {Record<string, string>} [labels]
158
+ * @param {number} value
159
+ */
160
+ observe(labels = {}, value) {
161
+ const key = labelsToKey(labels);
162
+ const data = this._getOrCreate(key);
163
+ data.sum += value;
164
+ data.count += 1;
165
+ for (let i = 0; i < this.buckets.length; i++) {
166
+ if (value <= this.buckets[i]) {
167
+ data.buckets[i] += 1;
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Returns a timer function. Call the returned function to record
174
+ * elapsed time in seconds.
175
+ * @param {Record<string, string>} [labels]
176
+ * @returns {() => number} stop function - returns elapsed seconds
177
+ */
178
+ startTimer(labels = {}) {
179
+ const start = performance.now();
180
+ return () => {
181
+ const elapsed = (performance.now() - start) / 1000;
182
+ this.observe(labels, elapsed);
183
+ return elapsed;
184
+ };
185
+ }
186
+
187
+ /** Reset all values */
188
+ reset() {
189
+ this.values.clear();
190
+ }
191
+
192
+ /** @returns {string} Prometheus text */
193
+ serialize() {
194
+ const lines = [
195
+ `# HELP ${this.name} ${this.help}`,
196
+ `# TYPE ${this.name} histogram`,
197
+ ];
198
+ for (const [key, data] of this.values) {
199
+ // Parse existing labels for bucket lines
200
+ const lblPrefix = key ? `${key.slice(0, -1)},` : "{";
201
+ const lblSuffix = key ? "}" : "}";
202
+
203
+ for (let i = 0; i < this.buckets.length; i++) {
204
+ const le = this.buckets[i];
205
+ lines.push(
206
+ `${this.name}_bucket${lblPrefix}le="${le}"${lblSuffix} ${data.buckets[i]}`,
207
+ );
208
+ }
209
+ lines.push(
210
+ `${this.name}_bucket${lblPrefix}le="+Inf"${lblSuffix} ${data.count}`,
211
+ );
212
+ lines.push(`${this.name}_sum${key} ${data.sum}`);
213
+ lines.push(`${this.name}_count${key} ${data.count}`);
214
+ }
215
+ return lines.join("\n");
216
+ }
217
+ }
218
+
219
+ // ─── Registry ─────────────────────────────────────────────────
220
+
221
+ /**
222
+ * A metrics registry that holds all metric instances
223
+ * and can serialize them to Prometheus exposition format.
224
+ */
225
+ class MetricsRegistry {
226
+ constructor() {
227
+ /** @type {Map<string, Counter | Gauge | Histogram>} */
228
+ this.metrics = new Map();
229
+ }
230
+
231
+ /**
232
+ * Create and register a counter.
233
+ * @param {MetricOptions} opts
234
+ * @returns {Counter}
235
+ */
236
+ counter(opts) {
237
+ if (this.metrics.has(opts.name)) {
238
+ return /** @type {Counter} */ (this.metrics.get(opts.name));
239
+ }
240
+ const c = new Counter(opts);
241
+ this.metrics.set(opts.name, c);
242
+ return c;
243
+ }
244
+
245
+ /**
246
+ * Create and register a gauge.
247
+ * @param {MetricOptions} opts
248
+ * @returns {Gauge}
249
+ */
250
+ gauge(opts) {
251
+ if (this.metrics.has(opts.name)) {
252
+ return /** @type {Gauge} */ (this.metrics.get(opts.name));
253
+ }
254
+ const g = new Gauge(opts);
255
+ this.metrics.set(opts.name, g);
256
+ return g;
257
+ }
258
+
259
+ /**
260
+ * Create and register a histogram.
261
+ * @param {HistogramOptions} opts
262
+ * @returns {Histogram}
263
+ */
264
+ histogram(opts) {
265
+ if (this.metrics.has(opts.name)) {
266
+ return /** @type {Histogram} */ (this.metrics.get(opts.name));
267
+ }
268
+ const h = new Histogram(opts);
269
+ this.metrics.set(opts.name, h);
270
+ return h;
271
+ }
272
+
273
+ /**
274
+ * Get a registered metric by name.
275
+ * @param {string} name
276
+ * @returns {Counter | Gauge | Histogram | undefined}
277
+ */
278
+ get(name) {
279
+ return this.metrics.get(name);
280
+ }
281
+
282
+ /** Reset all metrics */
283
+ resetAll() {
284
+ for (const m of this.metrics.values()) {
285
+ m.reset();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Serialize all metrics to Prometheus exposition format.
291
+ * @returns {string}
292
+ */
293
+ serialize() {
294
+ const blocks = [];
295
+ for (const metric of this.metrics.values()) {
296
+ blocks.push(metric.serialize());
297
+ }
298
+ return `${blocks.join("\n\n")}\n`;
299
+ }
300
+
301
+ /** Number of registered metrics */
302
+ get size() {
303
+ return this.metrics.size;
304
+ }
305
+ }
306
+
307
+ // ─── Default Instance & Factory ───────────────────────────────
308
+
309
+ /**
310
+ * Creates a new metrics registry.
311
+ * @returns {MetricsRegistry}
312
+ */
313
+ export const createMetrics = () => new MetricsRegistry();
314
+
315
+ /**
316
+ * Default application-wide metrics registry.
317
+ * Register HTTP metrics, business metrics, etc. on this instance.
318
+ */
319
+ export const metrics = createMetrics();
320
+
321
+ // ─── Pre-registered HTTP metrics ──────────────────────────────
322
+
323
+ /** Total HTTP requests (labels: method, path, status) */
324
+ export const httpRequestsTotal = metrics.counter({
325
+ name: "http_requests_total",
326
+ help: "Total number of HTTP requests",
327
+ labelNames: ["method", "path", "status"],
328
+ });
329
+
330
+ /** HTTP request duration in seconds (labels: method, path) */
331
+ export const httpRequestDuration = metrics.histogram({
332
+ name: "http_request_duration_seconds",
333
+ help: "HTTP request duration in seconds",
334
+ labelNames: ["method", "path"],
335
+ });
336
+
337
+ /** Currently in-flight HTTP requests */
338
+ export const httpRequestsInFlight = metrics.gauge({
339
+ name: "http_requests_in_flight",
340
+ help: "Number of HTTP requests currently being processed",
341
+ labelNames: ["method"],
342
+ });
343
+
344
+ /** Total application errors (labels: type) */
345
+ export const appErrorsTotal = metrics.counter({
346
+ name: "app_errors_total",
347
+ help: "Total number of application errors",
348
+ labelNames: ["type"],
349
+ });
350
+
351
+ // ─── Pre-registered Queue metrics ─────────────────────────────
352
+
353
+ /** Number of jobs currently waiting in each queue (label: queue) */
354
+ export const jobQueueDepth = metrics.gauge({
355
+ name: "job_queue_depth",
356
+ help: "Number of jobs currently waiting in each queue",
357
+ labelNames: ["queue"],
358
+ });
359
+
360
+ /**
361
+ * Total job processing attempts (labels: queue, status - completed | failed).
362
+ * For jobs with retries > 1, each attempt increments this counter independently.
363
+ */
364
+ export const jobsProcessedTotal = metrics.counter({
365
+ name: "jobs_processed_total",
366
+ help: "Total number of job processing attempts (each retry counts separately)",
367
+ labelNames: ["queue", "status"],
368
+ });
369
+
370
+ /** Job processing duration in seconds (labels: queue, name) */
371
+ export const jobDuration = metrics.histogram({
372
+ name: "job_duration_seconds",
373
+ help: "Job processing duration in seconds",
374
+ labelNames: ["queue", "name"],
375
+ buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
376
+ });
377
+
378
+ // Re-export classes for advanced usage
379
+ export { Counter, Gauge, Histogram, MetricsRegistry };