@adalo/metrics 0.1.172 → 0.1.174

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 (60) hide show
  1. package/README.md +7 -0
  2. package/__tests__/httpMetricsRedisCollector.test.js +203 -0
  3. package/__tests__/httpMetricsRedisRecorder.test.js +60 -0
  4. package/__tests__/httpMetricsRedisStore.test.js +431 -0
  5. package/docs/http-metrics-redis.md +19 -0
  6. package/lib/health/healthCheckClient.js +1 -1
  7. package/lib/health/healthCheckClient.js.map +1 -1
  8. package/lib/health/healthCheckWorker.d.ts.map +1 -1
  9. package/lib/health/healthCheckWorker.js +15 -1
  10. package/lib/health/healthCheckWorker.js.map +1 -1
  11. package/lib/index.d.ts +4 -0
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +44 -0
  14. package/lib/index.js.map +1 -1
  15. package/lib/metrics/baseMetricsClient.d.ts +2 -0
  16. package/lib/metrics/baseMetricsClient.d.ts.map +1 -1
  17. package/lib/metrics/baseMetricsClient.js +6 -3
  18. package/lib/metrics/baseMetricsClient.js.map +1 -1
  19. package/lib/metrics/httpMetricsRedisCollector.d.ts +50 -0
  20. package/lib/metrics/httpMetricsRedisCollector.d.ts.map +1 -0
  21. package/lib/metrics/httpMetricsRedisCollector.js +117 -0
  22. package/lib/metrics/httpMetricsRedisCollector.js.map +1 -0
  23. package/lib/metrics/httpMetricsRedisRecorder.d.ts +48 -0
  24. package/lib/metrics/httpMetricsRedisRecorder.d.ts.map +1 -0
  25. package/lib/metrics/httpMetricsRedisRecorder.js +86 -0
  26. package/lib/metrics/httpMetricsRedisRecorder.js.map +1 -0
  27. package/lib/metrics/httpMetricsRedisStore.d.ts +88 -0
  28. package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -0
  29. package/lib/metrics/httpMetricsRedisStore.js +223 -0
  30. package/lib/metrics/httpMetricsRedisStore.js.map +1 -0
  31. package/lib/metrics/metricsClient.d.ts +34 -27
  32. package/lib/metrics/metricsClient.d.ts.map +1 -1
  33. package/lib/metrics/metricsClient.js +35 -37
  34. package/lib/metrics/metricsClient.js.map +1 -1
  35. package/lib/metrics/metricsDatabaseClient.d.ts.map +1 -1
  36. package/lib/metrics/metricsDatabaseClient.js +6 -1
  37. package/lib/metrics/metricsDatabaseClient.js.map +1 -1
  38. package/lib/metrics/metricsProcessTypeUtils.d.ts +58 -0
  39. package/lib/metrics/metricsProcessTypeUtils.d.ts.map +1 -0
  40. package/lib/metrics/metricsProcessTypeUtils.js +86 -0
  41. package/lib/metrics/metricsProcessTypeUtils.js.map +1 -0
  42. package/lib/metrics/metricsQueueRedisClient.d.ts.map +1 -1
  43. package/lib/metrics/metricsQueueRedisClient.js +5 -0
  44. package/lib/metrics/metricsQueueRedisClient.js.map +1 -1
  45. package/lib/metrics/metricsRedisClient.d.ts.map +1 -1
  46. package/lib/metrics/metricsRedisClient.js +7 -1
  47. package/lib/metrics/metricsRedisClient.js.map +1 -1
  48. package/package.json +5 -5
  49. package/src/health/healthCheckClient.js +1 -1
  50. package/src/health/healthCheckWorker.js +18 -1
  51. package/src/index.ts +4 -0
  52. package/src/metrics/baseMetricsClient.js +4 -1
  53. package/src/metrics/httpMetricsRedisCollector.js +131 -0
  54. package/src/metrics/httpMetricsRedisRecorder.js +74 -0
  55. package/src/metrics/httpMetricsRedisStore.js +208 -0
  56. package/src/metrics/metricsClient.js +34 -53
  57. package/src/metrics/metricsDatabaseClient.js +7 -1
  58. package/src/metrics/metricsProcessTypeUtils.js +98 -0
  59. package/src/metrics/metricsQueueRedisClient.js +6 -0
  60. package/src/metrics/metricsRedisClient.js +12 -1
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ const {
4
+ BaseMetricsClient
5
+ } = require('./baseMetricsClient');
6
+ const {
7
+ HttpMetricsRedisStore,
8
+ buildFieldKey
9
+ } = require('./httpMetricsRedisStore');
10
+
11
+ /**
12
+ * Drain worker: reads HTTP aggregates from Redis (written by {@link HttpMetricsRedisRecorder}),
13
+ * applies them to `app_requests_*` counters, then pushes the registry to the VM-agent (same as {@link BaseMetricsClient}).
14
+ * **Minimal usage:** `{ redisClient }` only. Redis keys use segment **`web`** unless you pass **`redisProcessTypeForKeys`**.
15
+ * `processType` / `appName` / `dynoId` follow {@link BaseMetricsClient} defaults (e.g. `BUILD_DYNO_PROCESS_TYPE`) and do **not** select Redis hash names.
16
+ * Always passes `blockNodeDefaultMetrics: true` (HTTP-focused registry).
17
+ *
18
+ * @extends BaseMetricsClient
19
+ */
20
+ class HttpMetricsRedisCollector extends BaseMetricsClient {
21
+ /**
22
+ * @param {Object} [config]
23
+ * @param {import('redis').RedisClient} config.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
24
+ * @param {string} [config.appName] Application name (defaults per {@link BaseMetricsClient})
25
+ * @param {string} [config.dynoId] Dyno/instance ID
26
+ * @param {string} [config.processType] Label `process_type` on push (default from env / base)
27
+ * @param {boolean} [config.enabled] Enable collection and push
28
+ * @param {boolean} [config.logValues] Log metric JSON to console
29
+ * @param {string} [config.pushgatewayUrl] VM-agent import URL
30
+ * @param {string} [config.pushgatewaySecret] Basic auth secret (Base64)
31
+ * @param {number} [config.intervalSec] Push interval (seconds)
32
+ * @param {boolean} [config.removeOldMetrics] Clear old series on shutdown where supported
33
+ * @param {function} [config.startupValidation] Run before first push
34
+ * @param {boolean} [config.disablePushgateway] Skip POST to VM-agent
35
+ * @param {string} [config.redisProcessTypeForKeys] Segment in Redis keys for HTTP hashes (default **`web`**). Optional; only if writers use a non-`web` segment.
36
+ * @param {number} [config.ttlSec] Passed to {@link HttpMetricsRedisStore} (should match writers)
37
+ */
38
+ constructor(config = {}) {
39
+ const {
40
+ redisClient
41
+ } = config;
42
+ if (redisClient == null) {
43
+ throw new Error('HttpMetricsRedisCollector: redisClient is required');
44
+ }
45
+ super({
46
+ ...config,
47
+ blockNodeDefaultMetrics: true
48
+ });
49
+ const keyProcessType = config.redisProcessTypeForKeys || 'web';
50
+ this.defaultLabelsWithoutDynoId = {
51
+ app: this.appName,
52
+ process_type: keyProcessType
53
+ };
54
+ this._store = new HttpMetricsRedisStore({
55
+ redisClient,
56
+ appName: this.appName,
57
+ processType: keyProcessType,
58
+ ttlSec: config.ttlSec
59
+ });
60
+
61
+ /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
62
+ this._httpCounterPrimedKeys = new Set();
63
+ this.createCounter({
64
+ name: 'app_requests_total',
65
+ help: 'Total number of HTTP requests',
66
+ labelNames: this.withDefaultLabelsWithoutDynoId(['method', 'route', 'status_code']),
67
+ useLabelsWithoutDynoId: true
68
+ });
69
+ this.createCounter({
70
+ name: 'app_requests_total_duration',
71
+ help: 'Total duration of HTTP requests in milliseconds',
72
+ labelNames: this.withDefaultLabelsWithoutDynoId(['method', 'route', 'status_code']),
73
+ useLabelsWithoutDynoId: true
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Drains Redis into counters, then runs gauge updates and VM-agent push ({@link BaseMetricsClient#_pushMetrics}).
79
+ * @returns {Promise<void>}
80
+ */
81
+ pushMetrics = async () => {
82
+ if (this._store && this.countersFunctions?.app_requests_total && this.countersFunctions?.app_requests_total_duration) {
83
+ const {
84
+ ok,
85
+ rows
86
+ } = await this._store.drainRows();
87
+ if (ok && rows.length > 0) {
88
+ const applyCount = (labels, value) => this.countersFunctions.app_requests_total(labels, value);
89
+ const applyDur = (labels, value) => this.countersFunctions.app_requests_total_duration(labels, value);
90
+ let primedAny = false;
91
+ for (const row of rows) {
92
+ const key = buildFieldKey(row.labels.method, row.labels.route, row.labels.status_code);
93
+ if (!this._httpCounterPrimedKeys.has(key)) {
94
+ this._httpCounterPrimedKeys.add(key);
95
+ applyCount(row.labels, 0);
96
+ applyDur(row.labels, 0);
97
+ primedAny = true;
98
+ }
99
+ }
100
+ if (primedAny) {
101
+ await this.gatewayPush();
102
+ }
103
+ for (const row of rows) {
104
+ applyCount(row.labels, row.count);
105
+ if (row.dur > 0) {
106
+ applyDur(row.labels, row.dur);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return this._pushMetrics();
112
+ };
113
+ }
114
+ module.exports = {
115
+ HttpMetricsRedisCollector
116
+ };
117
+ //# sourceMappingURL=httpMetricsRedisCollector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpMetricsRedisCollector.js","names":["BaseMetricsClient","require","HttpMetricsRedisStore","buildFieldKey","HttpMetricsRedisCollector","constructor","config","redisClient","Error","blockNodeDefaultMetrics","keyProcessType","redisProcessTypeForKeys","defaultLabelsWithoutDynoId","app","appName","process_type","_store","processType","ttlSec","_httpCounterPrimedKeys","Set","createCounter","name","help","labelNames","withDefaultLabelsWithoutDynoId","useLabelsWithoutDynoId","pushMetrics","countersFunctions","app_requests_total","app_requests_total_duration","ok","rows","drainRows","length","applyCount","labels","value","applyDur","primedAny","row","key","method","route","status_code","has","add","gatewayPush","count","dur","_pushMetrics","module","exports"],"sources":["../../src/metrics/httpMetricsRedisCollector.js"],"sourcesContent":["const { BaseMetricsClient } = require('./baseMetricsClient')\nconst {\n HttpMetricsRedisStore,\n buildFieldKey,\n} = require('./httpMetricsRedisStore')\n\n/**\n * Drain worker: reads HTTP aggregates from Redis (written by {@link HttpMetricsRedisRecorder}),\n * applies them to `app_requests_*` counters, then pushes the registry to the VM-agent (same as {@link BaseMetricsClient}).\n * **Minimal usage:** `{ redisClient }` only. Redis keys use segment **`web`** unless you pass **`redisProcessTypeForKeys`**.\n * `processType` / `appName` / `dynoId` follow {@link BaseMetricsClient} defaults (e.g. `BUILD_DYNO_PROCESS_TYPE`) and do **not** select Redis hash names.\n * Always passes `blockNodeDefaultMetrics: true` (HTTP-focused registry).\n *\n * @extends BaseMetricsClient\n */\nclass HttpMetricsRedisCollector extends BaseMetricsClient {\n /**\n * @param {Object} [config]\n * @param {import('redis').RedisClient} config.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).\n * @param {string} [config.appName] Application name (defaults per {@link BaseMetricsClient})\n * @param {string} [config.dynoId] Dyno/instance ID\n * @param {string} [config.processType] Label `process_type` on push (default from env / base)\n * @param {boolean} [config.enabled] Enable collection and push\n * @param {boolean} [config.logValues] Log metric JSON to console\n * @param {string} [config.pushgatewayUrl] VM-agent import URL\n * @param {string} [config.pushgatewaySecret] Basic auth secret (Base64)\n * @param {number} [config.intervalSec] Push interval (seconds)\n * @param {boolean} [config.removeOldMetrics] Clear old series on shutdown where supported\n * @param {function} [config.startupValidation] Run before first push\n * @param {boolean} [config.disablePushgateway] Skip POST to VM-agent\n * @param {string} [config.redisProcessTypeForKeys] Segment in Redis keys for HTTP hashes (default **`web`**). Optional; only if writers use a non-`web` segment.\n * @param {number} [config.ttlSec] Passed to {@link HttpMetricsRedisStore} (should match writers)\n */\n constructor(config = {}) {\n const { redisClient } = config\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisCollector: redisClient is required')\n }\n\n super({\n ...config,\n blockNodeDefaultMetrics: true,\n })\n\n const keyProcessType = config.redisProcessTypeForKeys || 'web'\n\n this.defaultLabelsWithoutDynoId = {\n app: this.appName,\n process_type: keyProcessType,\n }\n\n this._store = new HttpMetricsRedisStore({\n redisClient,\n appName: this.appName,\n processType: keyProcessType,\n ttlSec: config.ttlSec,\n })\n\n /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */\n this._httpCounterPrimedKeys = new Set()\n\n this.createCounter({\n name: 'app_requests_total',\n help: 'Total number of HTTP requests',\n labelNames: this.withDefaultLabelsWithoutDynoId([\n 'method',\n 'route',\n 'status_code',\n ]),\n useLabelsWithoutDynoId: true,\n })\n\n this.createCounter({\n name: 'app_requests_total_duration',\n help: 'Total duration of HTTP requests in milliseconds',\n labelNames: this.withDefaultLabelsWithoutDynoId([\n 'method',\n 'route',\n 'status_code',\n ]),\n useLabelsWithoutDynoId: true,\n })\n }\n\n /**\n * Drains Redis into counters, then runs gauge updates and VM-agent push ({@link BaseMetricsClient#_pushMetrics}).\n * @returns {Promise<void>}\n */\n pushMetrics = async () => {\n if (\n this._store &&\n this.countersFunctions?.app_requests_total &&\n this.countersFunctions?.app_requests_total_duration\n ) {\n const { ok, rows } = await this._store.drainRows()\n if (ok && rows.length > 0) {\n const applyCount = (labels, value) =>\n this.countersFunctions.app_requests_total(labels, value)\n const applyDur = (labels, value) =>\n this.countersFunctions.app_requests_total_duration(labels, value)\n\n let primedAny = false\n for (const row of rows) {\n const key = buildFieldKey(\n row.labels.method,\n row.labels.route,\n row.labels.status_code\n )\n if (!this._httpCounterPrimedKeys.has(key)) {\n this._httpCounterPrimedKeys.add(key)\n applyCount(row.labels, 0)\n applyDur(row.labels, 0)\n primedAny = true\n }\n }\n if (primedAny) {\n await this.gatewayPush()\n }\n for (const row of rows) {\n applyCount(row.labels, row.count)\n if (row.dur > 0) {\n applyDur(row.labels, row.dur)\n }\n }\n }\n }\n return this._pushMetrics()\n }\n}\n\nmodule.exports = { HttpMetricsRedisCollector }\n"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Records HTTP request aggregates only to Redis (no in-process Prometheus counters on this path).
3
+ * Pair with {@link HttpMetricsRedisCollector} on a drain process to flush into counters and push to the VM-agent.
4
+ *
5
+ * @see HttpMetricsRedisStore
6
+ */
7
+ export class HttpMetricsRedisRecorder {
8
+ /**
9
+ * @param {Object} opts
10
+ * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
11
+ * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.
12
+ * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).
13
+ * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
14
+ */
15
+ constructor({ redisClient, appName, processType, ttlSec }?: {
16
+ redisClient: import('redis').RedisClient;
17
+ appName?: string | undefined;
18
+ processType?: string | undefined;
19
+ ttlSec?: number | undefined;
20
+ });
21
+ processType: string;
22
+ appName: string;
23
+ _store: HttpMetricsRedisStore;
24
+ /**
25
+ * @param {Object} params
26
+ * @param {string} params.method
27
+ * @param {string} params.route
28
+ * @param {number} params.status_code
29
+ * @param {number} params.duration
30
+ */
31
+ trackHttpRequest({ method, route, status_code, duration }: {
32
+ method: string;
33
+ route: string;
34
+ status_code: number;
35
+ duration: number;
36
+ }): void;
37
+ /**
38
+ * Express middleware: appends a `finish` listener and writes one aggregate row per request to Redis.
39
+ * Does not check `METRICS_ENABLED`; the app should only mount this when HTTP Redis metrics should run.
40
+ *
41
+ * @param {import('http').IncomingMessage} req
42
+ * @param {import('http').ServerResponse} res
43
+ * @param {function} next
44
+ */
45
+ trackHttpRequestMiddleware: (req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void;
46
+ }
47
+ import { HttpMetricsRedisStore } from "./httpMetricsRedisStore";
48
+ //# sourceMappingURL=httpMetricsRedisRecorder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpMetricsRedisRecorder.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACb,OAAO;QACP,WAAW;QACX,MAAM;OAgB9B;IARC,oBAA8B;IAC9B,gBAA8B;IAC9B,8BAKE;IAGJ;;;;;;OAMG;IACH;QAL0B,MAAM,EAArB,MAAM;QACS,KAAK,EAApB,MAAM;QACS,WAAW,EAA1B,MAAM;QACS,QAAQ,EAAvB,MAAM;aAIhB;IAED;;;;;;;OAOG;IACH,kCAJW,OAAO,MAAM,EAAE,eAAe,OAC9B,OAAO,MAAM,EAAE,cAAc,0BAsBvC;CACF"}
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+
3
+ const {
4
+ HttpMetricsRedisStore
5
+ } = require('./httpMetricsRedisStore');
6
+
7
+ /**
8
+ * Records HTTP request aggregates only to Redis (no in-process Prometheus counters on this path).
9
+ * Pair with {@link HttpMetricsRedisCollector} on a drain process to flush into counters and push to the VM-agent.
10
+ *
11
+ * @see HttpMetricsRedisStore
12
+ */
13
+ class HttpMetricsRedisRecorder {
14
+ /**
15
+ * @param {Object} opts
16
+ * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
17
+ * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.
18
+ * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).
19
+ * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
20
+ */
21
+ constructor({
22
+ redisClient,
23
+ appName,
24
+ processType = 'web',
25
+ ttlSec
26
+ } = {}) {
27
+ if (redisClient == null) {
28
+ throw new Error('HttpMetricsRedisRecorder: redisClient is required');
29
+ }
30
+ const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app';
31
+ this.processType = processType;
32
+ this.appName = resolvedAppName;
33
+ this._store = new HttpMetricsRedisStore({
34
+ redisClient,
35
+ appName: resolvedAppName,
36
+ processType,
37
+ ttlSec
38
+ });
39
+ }
40
+
41
+ /**
42
+ * @param {Object} params
43
+ * @param {string} params.method
44
+ * @param {string} params.route
45
+ * @param {number} params.status_code
46
+ * @param {number} params.duration
47
+ */
48
+ trackHttpRequest({
49
+ method,
50
+ route,
51
+ status_code,
52
+ duration
53
+ }) {
54
+ this._store.record(method, route, status_code, duration);
55
+ }
56
+
57
+ /**
58
+ * Express middleware: appends a `finish` listener and writes one aggregate row per request to Redis.
59
+ * Does not check `METRICS_ENABLED`; the app should only mount this when HTTP Redis metrics should run.
60
+ *
61
+ * @param {import('http').IncomingMessage} req
62
+ * @param {import('http').ServerResponse} res
63
+ * @param {function} next
64
+ */
65
+ trackHttpRequestMiddleware = (req, res, next) => {
66
+ if (req.method === 'OPTIONS') {
67
+ next();
68
+ return;
69
+ }
70
+ const start = Date.now();
71
+ res.on('finish', () => {
72
+ const route = req.route?.path || req.path || 'unknown';
73
+ this.trackHttpRequest({
74
+ method: req.method,
75
+ route,
76
+ status_code: res.statusCode,
77
+ duration: Date.now() - start
78
+ });
79
+ });
80
+ next();
81
+ };
82
+ }
83
+ module.exports = {
84
+ HttpMetricsRedisRecorder
85
+ };
86
+ //# sourceMappingURL=httpMetricsRedisRecorder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpMetricsRedisRecorder.js","names":["HttpMetricsRedisStore","require","HttpMetricsRedisRecorder","constructor","redisClient","appName","processType","ttlSec","Error","resolvedAppName","process","env","BUILD_APP_NAME","_store","trackHttpRequest","method","route","status_code","duration","record","trackHttpRequestMiddleware","req","res","next","start","Date","now","on","path","statusCode","module","exports"],"sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"sourcesContent":["const { HttpMetricsRedisStore } = require('./httpMetricsRedisStore')\n\n/**\n * Records HTTP request aggregates only to Redis (no in-process Prometheus counters on this path).\n * Pair with {@link HttpMetricsRedisCollector} on a drain process to flush into counters and push to the VM-agent.\n *\n * @see HttpMetricsRedisStore\n */\nclass HttpMetricsRedisRecorder {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).\n * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.\n * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).\n * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).\n */\n constructor({ redisClient, appName, processType = 'web', ttlSec } = {}) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisRecorder: redisClient is required')\n }\n const resolvedAppName =\n appName || process.env.BUILD_APP_NAME || 'unknown-app'\n this.processType = processType\n this.appName = resolvedAppName\n this._store = new HttpMetricsRedisStore({\n redisClient,\n appName: resolvedAppName,\n processType,\n ttlSec,\n })\n }\n\n /**\n * @param {Object} params\n * @param {string} params.method\n * @param {string} params.route\n * @param {number} params.status_code\n * @param {number} params.duration\n */\n trackHttpRequest({ method, route, status_code, duration }) {\n this._store.record(method, route, status_code, duration)\n }\n\n /**\n * Express middleware: appends a `finish` listener and writes one aggregate row per request to Redis.\n * Does not check `METRICS_ENABLED`; the app should only mount this when HTTP Redis metrics should run.\n *\n * @param {import('http').IncomingMessage} req\n * @param {import('http').ServerResponse} res\n * @param {function} next\n */\n trackHttpRequestMiddleware = (req, res, next) => {\n if (req.method === 'OPTIONS') {\n next()\n return\n }\n\n const start = Date.now()\n res.on('finish', () => {\n const route = req.route?.path || req.path || 'unknown'\n\n this.trackHttpRequest({\n method: req.method,\n route,\n status_code: res.statusCode,\n duration: Date.now() - start,\n })\n })\n\n next()\n }\n}\n\nmodule.exports = { HttpMetricsRedisRecorder }\n"],"mappings":";;AAAA,MAAM;EAAEA;AAAsB,CAAC,GAAGC,OAAO,CAAC,yBAAyB,CAAC;;AAEpE;AACA;AACA;AACA;AACA;AACA;AACA,MAAMC,wBAAwB,CAAC;EAC7B;AACF;AACA;AACA;AACA;AACA;AACA;EACEC,WAAWA,CAAC;IAAEC,WAAW;IAAEC,OAAO;IAAEC,WAAW,GAAG,KAAK;IAAEC;EAAO,CAAC,GAAG,CAAC,CAAC,EAAE;IACtE,IAAIH,WAAW,IAAI,IAAI,EAAE;MACvB,MAAM,IAAII,KAAK,CAAC,mDAAmD,CAAC;IACtE;IACA,MAAMC,eAAe,GACnBJ,OAAO,IAAIK,OAAO,CAACC,GAAG,CAACC,cAAc,IAAI,aAAa;IACxD,IAAI,CAACN,WAAW,GAAGA,WAAW;IAC9B,IAAI,CAACD,OAAO,GAAGI,eAAe;IAC9B,IAAI,CAACI,MAAM,GAAG,IAAIb,qBAAqB,CAAC;MACtCI,WAAW;MACXC,OAAO,EAAEI,eAAe;MACxBH,WAAW;MACXC;IACF,CAAC,CAAC;EACJ;;EAEA;AACF;AACA;AACA;AACA;AACA;AACA;EACEO,gBAAgBA,CAAC;IAAEC,MAAM;IAAEC,KAAK;IAAEC,WAAW;IAAEC;EAAS,CAAC,EAAE;IACzD,IAAI,CAACL,MAAM,CAACM,MAAM,CAACJ,MAAM,EAAEC,KAAK,EAAEC,WAAW,EAAEC,QAAQ,CAAC;EAC1D;;EAEA;AACF;AACA;AACA;AACA;AACA;AACA;AACA;EACEE,0BAA0B,GAAGA,CAACC,GAAG,EAAEC,GAAG,EAAEC,IAAI,KAAK;IAC/C,IAAIF,GAAG,CAACN,MAAM,KAAK,SAAS,EAAE;MAC5BQ,IAAI,CAAC,CAAC;MACN;IACF;IAEA,MAAMC,KAAK,GAAGC,IAAI,CAACC,GAAG,CAAC,CAAC;IACxBJ,GAAG,CAACK,EAAE,CAAC,QAAQ,EAAE,MAAM;MACrB,MAAMX,KAAK,GAAGK,GAAG,CAACL,KAAK,EAAEY,IAAI,IAAIP,GAAG,CAACO,IAAI,IAAI,SAAS;MAEtD,IAAI,CAACd,gBAAgB,CAAC;QACpBC,MAAM,EAAEM,GAAG,CAACN,MAAM;QAClBC,KAAK;QACLC,WAAW,EAAEK,GAAG,CAACO,UAAU;QAC3BX,QAAQ,EAAEO,IAAI,CAACC,GAAG,CAAC,CAAC,GAAGF;MACzB,CAAC,CAAC;IACJ,CAAC,CAAC;IAEFD,IAAI,CAAC,CAAC;EACR,CAAC;AACH;AAEAO,MAAM,CAACC,OAAO,GAAG;EAAE7B;AAAyB,CAAC","ignoreList":[]}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
3
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
4
+ *
5
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. Each **field name** encodes
6
+ * `(method, route, status_code)`. Values: `:count` is HINCRBY 1 per request; `:dur` is summed ms.
7
+ */
8
+ export class HttpMetricsRedisStore {
9
+ /**
10
+ * @param {Object} opts
11
+ * @param {import('redis').RedisClient} opts.redisClient
12
+ * @param {string} opts.appName BUILD_APP_NAME (key segment)
13
+ * @param {string} opts.processType logical process for key (e.g. web)
14
+ * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)
15
+ */
16
+ constructor({ redisClient, appName, processType, ttlSec }: {
17
+ redisClient: import('redis').RedisClient;
18
+ appName: string;
19
+ processType: string;
20
+ ttlSec?: number | undefined;
21
+ });
22
+ _client: import("redis").RedisClient;
23
+ ttlSec: number;
24
+ countKey: string;
25
+ durKey: string;
26
+ /**
27
+ * @returns {import('redis').RedisClient}
28
+ * @private
29
+ */
30
+ private _ensureClient;
31
+ /**
32
+ * @param {string} method
33
+ * @param {string} route
34
+ * @param {number} statusCode
35
+ * @param {number} durationMs
36
+ */
37
+ record(method: string, route: string, statusCode: number, durationMs: number): void;
38
+ /**
39
+ * Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
40
+ *
41
+ * @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
42
+ */
43
+ drainRows(): Promise<{
44
+ ok: boolean;
45
+ rows: {
46
+ labels: Object;
47
+ count: number;
48
+ dur: number;
49
+ }[];
50
+ }>;
51
+ /**
52
+ * @param {(labels: Object, value: number) => void} applyCount
53
+ * @param {(labels: Object, value: number) => void} applyDuration
54
+ * @returns {Promise<boolean>}
55
+ */
56
+ flushToCounters(applyCount: (labels: Object, value: number) => void, applyDuration: (labels: Object, value: number) => void): Promise<boolean>;
57
+ }
58
+ /**
59
+ * @param {string} method
60
+ * @param {string} route
61
+ * @param {number} statusCode
62
+ * @returns {string}
63
+ */
64
+ export function buildFieldKey(method: string, route: string, statusCode: number): string;
65
+ /**
66
+ * Record separator for hash fields (avoids collisions when route contains "|").
67
+ * @type {string}
68
+ */
69
+ export const FIELD_SEP: string;
70
+ /**
71
+ * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).
72
+ * @type {number}
73
+ */
74
+ export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
75
+ /**
76
+ * @param {unknown} raw redis eval result [countPairs, durPairs]
77
+ * @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}
78
+ */
79
+ export function rowsFromDrainRaw(raw: unknown): {
80
+ labels: {
81
+ method: string;
82
+ route: string;
83
+ status_code: string;
84
+ };
85
+ count: number;
86
+ dur: number;
87
+ }[];
88
+ //# sourceMappingURL=httpMetricsRedisStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA6EA;;;;;;GAMG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACd,OAAO,EAApB,MAAM;QACO,WAAW,EAAxB,MAAM;QACQ,MAAM;OAgB9B;IAVC,qCAA0B;IAC1B,eAGwC;IAIxC,iBAAiD;IACjD,eAA6C;IAG/C;;;OAGG;IACH,sBAEC;IAED;;;;;OAKG;IACH,eALW,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QAqBhB;IAED;;;;OAIG;IACH,aAFa,QAAQ;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC,CAgC5F;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAe5B;CACF;AAlLD;;;;;GAKG;AACH,sCALW,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AA7BD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAExB;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AAgC9C;;;GAGG;AACH,sCAHW,OAAO,GACL;IAAE,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,CA+B5G"}
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Record separator for hash fields (avoids collisions when route contains "|").
5
+ * @type {string}
6
+ */
7
+ const FIELD_SEP = '\x1e';
8
+
9
+ /**
10
+ * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).
11
+ * @type {number}
12
+ */
13
+ const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120;
14
+ const DRAIN_LUA = `
15
+ local function drain(key)
16
+ local v = redis.call('HGETALL', key)
17
+ redis.call('DEL', key)
18
+ return v
19
+ end
20
+ return {drain(KEYS[1]), drain(KEYS[2])}
21
+ `;
22
+
23
+ /**
24
+ * @param {string} method
25
+ * @param {string} route
26
+ * @param {number} statusCode
27
+ * @returns {string}
28
+ */
29
+ function buildFieldKey(method, route, statusCode) {
30
+ return [method, route, String(statusCode)].join(FIELD_SEP);
31
+ }
32
+ function hgetallPairsToObject(pairs) {
33
+ const o = {};
34
+ if (!pairs || !pairs.length) {
35
+ return o;
36
+ }
37
+ for (let i = 0; i < pairs.length; i += 2) {
38
+ o[pairs[i]] = pairs[i + 1];
39
+ }
40
+ return o;
41
+ }
42
+
43
+ /**
44
+ * @param {unknown} raw redis eval result [countPairs, durPairs]
45
+ * @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}
46
+ */
47
+ function rowsFromDrainRaw(raw) {
48
+ const rows = [];
49
+ if (!raw || !Array.isArray(raw) || raw.length < 2) {
50
+ return rows;
51
+ }
52
+ const counts = hgetallPairsToObject(raw[0]);
53
+ const durs = hgetallPairsToObject(raw[1]);
54
+ const fieldKeys = Object.keys(counts);
55
+ for (const field of fieldKeys) {
56
+ const count = parseInt(counts[field], 10);
57
+ if (!count || count < 1) {
58
+ continue;
59
+ }
60
+ const dur = parseInt(durs[field] || '0', 10) || 0;
61
+ const parts = field.split(FIELD_SEP);
62
+ let labels = null;
63
+ if (parts.length === 3) {
64
+ const [m, route, statusStr] = parts;
65
+ labels = {
66
+ method: m,
67
+ route,
68
+ status_code: statusStr
69
+ };
70
+ } else if (parts.length === 5 || parts.length === 6) {
71
+ const [m, route, statusStr] = parts;
72
+ labels = {
73
+ method: m,
74
+ route,
75
+ status_code: statusStr
76
+ };
77
+ }
78
+ if (!labels) {
79
+ continue;
80
+ }
81
+ rows.push({
82
+ labels,
83
+ count,
84
+ dur
85
+ });
86
+ }
87
+ return rows;
88
+ }
89
+
90
+ /**
91
+ * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
92
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
93
+ *
94
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. Each **field name** encodes
95
+ * `(method, route, status_code)`. Values: `:count` is HINCRBY 1 per request; `:dur` is summed ms.
96
+ */
97
+ class HttpMetricsRedisStore {
98
+ /**
99
+ * @param {Object} opts
100
+ * @param {import('redis').RedisClient} opts.redisClient
101
+ * @param {string} opts.appName BUILD_APP_NAME (key segment)
102
+ * @param {string} opts.processType logical process for key (e.g. web)
103
+ * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)
104
+ */
105
+ constructor({
106
+ redisClient,
107
+ appName,
108
+ processType,
109
+ ttlSec
110
+ }) {
111
+ if (redisClient == null) {
112
+ throw new Error('HttpMetricsRedisStore: redisClient is required');
113
+ }
114
+ this._client = redisClient;
115
+ this.ttlSec = typeof ttlSec === 'number' && ttlSec > 0 ? ttlSec : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC;
116
+ const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
117
+ this.countKey = `metrics:http:v2:${keySeg}:count`;
118
+ this.durKey = `metrics:http:v2:${keySeg}:dur`;
119
+ }
120
+
121
+ /**
122
+ * @returns {import('redis').RedisClient}
123
+ * @private
124
+ */
125
+ _ensureClient() {
126
+ return this._client;
127
+ }
128
+
129
+ /**
130
+ * @param {string} method
131
+ * @param {string} route
132
+ * @param {number} statusCode
133
+ * @param {number} durationMs
134
+ */
135
+ record(method, route, statusCode, durationMs) {
136
+ try {
137
+ const client = this._ensureClient();
138
+ const field = buildFieldKey(method, route, statusCode);
139
+ const dur = Math.max(0, Math.round(Number(durationMs) || 0));
140
+ client.multi().hincrby(this.countKey, field, 1).hincrby(this.durKey, field, dur).expire(this.countKey, this.ttlSec).expire(this.durKey, this.ttlSec).exec(err => {
141
+ if (err) {
142
+ console.error('[HttpMetricsRedisStore] record failed:', err.message);
143
+ }
144
+ });
145
+ } catch (e) {
146
+ console.error('[HttpMetricsRedisStore] record:', e.message);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
152
+ *
153
+ * @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
154
+ */
155
+ drainRows() {
156
+ let client;
157
+ try {
158
+ client = this._ensureClient();
159
+ } catch (e) {
160
+ console.error('[HttpMetricsRedisStore] drainRows:', e.message);
161
+ return Promise.resolve({
162
+ ok: false,
163
+ rows: []
164
+ });
165
+ }
166
+ return new Promise(resolve => {
167
+ client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
168
+ if (evalErr) {
169
+ console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
170
+ resolve({
171
+ ok: false,
172
+ rows: []
173
+ });
174
+ return;
175
+ }
176
+ try {
177
+ const rows = rowsFromDrainRaw(raw);
178
+ resolve({
179
+ ok: true,
180
+ rows
181
+ });
182
+ } catch (e) {
183
+ console.error('[HttpMetricsRedisStore] drainRows parse failed:', e.message);
184
+ resolve({
185
+ ok: false,
186
+ rows: []
187
+ });
188
+ }
189
+ });
190
+ });
191
+ }
192
+
193
+ /**
194
+ * @param {(labels: Object, value: number) => void} applyCount
195
+ * @param {(labels: Object, value: number) => void} applyDuration
196
+ * @returns {Promise<boolean>}
197
+ */
198
+ flushToCounters(applyCount, applyDuration) {
199
+ return this.drainRows().then(({
200
+ ok,
201
+ rows
202
+ }) => {
203
+ if (!ok) {
204
+ return false;
205
+ }
206
+ for (const row of rows) {
207
+ applyCount(row.labels, row.count);
208
+ if (row.dur > 0) {
209
+ applyDuration(row.labels, row.dur);
210
+ }
211
+ }
212
+ return true;
213
+ });
214
+ }
215
+ }
216
+ module.exports = {
217
+ HttpMetricsRedisStore,
218
+ buildFieldKey,
219
+ FIELD_SEP,
220
+ DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
221
+ rowsFromDrainRaw
222
+ };
223
+ //# sourceMappingURL=httpMetricsRedisStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","DRAIN_LUA","buildFieldKey","method","route","statusCode","String","join","hgetallPairsToObject","pairs","o","length","i","rowsFromDrainRaw","raw","rows","Array","isArray","counts","durs","fieldKeys","Object","keys","field","count","parseInt","dur","parts","split","labels","m","statusStr","status_code","push","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","e","drainRows","Promise","resolve","ok","eval","evalErr","flushToCounters","applyCount","applyDuration","then","row","module","exports"],"sources":["../../src/metrics/httpMetricsRedisStore.js"],"sourcesContent":["/**\n * Record separator for hash fields (avoids collisions when route contains \"|\").\n * @type {string}\n */\nconst FIELD_SEP = '\\x1e'\n\n/**\n * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).\n * @type {number}\n */\nconst DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120\n\nconst DRAIN_LUA = `\nlocal function drain(key)\n local v = redis.call('HGETALL', key)\n redis.call('DEL', key)\n return v\nend\nreturn {drain(KEYS[1]), drain(KEYS[2])}\n`\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode) {\n return [method, route, String(statusCode)].join(FIELD_SEP)\n}\n\nfunction hgetallPairsToObject(pairs) {\n const o = {}\n if (!pairs || !pairs.length) {\n return o\n }\n for (let i = 0; i < pairs.length; i += 2) {\n o[pairs[i]] = pairs[i + 1]\n }\n return o\n}\n\n/**\n * @param {unknown} raw redis eval result [countPairs, durPairs]\n * @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}\n */\nfunction rowsFromDrainRaw(raw) {\n const rows = []\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n return rows\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n for (const field of fieldKeys) {\n const count = parseInt(counts[field], 10)\n if (!count || count < 1) {\n continue\n }\n const dur = parseInt(durs[field] || '0', 10) || 0\n const parts = field.split(FIELD_SEP)\n let labels = null\n if (parts.length === 3) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n } else if (parts.length === 5 || parts.length === 6) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n }\n if (!labels) {\n continue\n }\n rows.push({ labels, count, dur })\n }\n return rows\n}\n\n/**\n * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).\n * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.\n *\n * **Structure:** `countKey` / `durKey` are each **one Redis hash**. Each **field name** encodes\n * `(method, route, status_code)`. Values: `:count` is HINCRBY 1 per request; `:dur` is summed ms.\n */\nclass HttpMetricsRedisStore {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient\n * @param {string} opts.appName BUILD_APP_NAME (key segment)\n * @param {string} opts.processType logical process for key (e.g. web)\n * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisStore: redisClient is required')\n }\n this._client = redisClient\n this.ttlSec =\n typeof ttlSec === 'number' && ttlSec > 0\n ? ttlSec\n : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC\n const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(\n processType\n )}`\n this.countKey = `metrics:http:v2:${keySeg}:count`\n this.durKey = `metrics:http:v2:${keySeg}:dur`\n }\n\n /**\n * @returns {import('redis').RedisClient}\n * @private\n */\n _ensureClient() {\n return this._client\n }\n\n /**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {number} durationMs\n */\n record(method, route, statusCode, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n client\n .multi()\n .hincrby(this.countKey, field, 1)\n .hincrby(this.durKey, field, dur)\n .expire(this.countKey, this.ttlSec)\n .expire(this.durKey, this.ttlSec)\n .exec(err => {\n if (err) {\n console.error('[HttpMetricsRedisStore] record failed:', err.message)\n }\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.\n *\n * @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}\n */\n drainRows() {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] drainRows:', e.message)\n return Promise.resolve({ ok: false, rows: [] })\n }\n return new Promise(resolve => {\n client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n resolve({ ok: false, rows: [] })\n return\n }\n try {\n const rows = rowsFromDrainRaw(raw)\n resolve({ ok: true, rows })\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] drainRows parse failed:',\n e.message\n )\n resolve({ ok: false, rows: [] })\n }\n })\n })\n }\n\n /**\n * @param {(labels: Object, value: number) => void} applyCount\n * @param {(labels: Object, value: number) => void} applyDuration\n * @returns {Promise<boolean>}\n */\n flushToCounters(applyCount, applyDuration) {\n return this.drainRows().then(({ ok, rows }) => {\n if (!ok) {\n return false\n }\n for (const row of rows) {\n applyCount(row.labels, row.count)\n if (row.dur > 0) {\n applyDuration(row.labels, row.dur)\n }\n }\n return true\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n rowsFromDrainRaw,\n}\n"],"mappings":"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","ignoreList":[]}