@adalo/metrics 0.0.0-staging.29 → 0.0.0-staging.30

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.
@@ -1,6 +1,14 @@
1
1
  const { HttpMetricsRedisCollector } = require('../src/metrics/httpMetricsRedisCollector')
2
2
  const { buildFieldKey } = require('../src/metrics/httpMetricsRedisStore')
3
3
 
4
+ function appRequestTotalsByRoute(metricsJson) {
5
+ const reqTotal = metricsJson.find(m => m.name === 'app_requests_total')
6
+ if (!reqTotal || !reqTotal.values) {
7
+ return {}
8
+ }
9
+ return Object.fromEntries(reqTotal.values.map(v => [v.labels.route, v.value]))
10
+ }
11
+
4
12
  function createRedisV3Mock() {
5
13
  const multiChain = {
6
14
  hincrby: jest.fn().mockReturnThis(),
@@ -52,13 +60,144 @@ describe('HttpMetricsRedisCollector', () => {
52
60
  disablePushgateway: true,
53
61
  })
54
62
 
63
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
64
+
55
65
  await collector.pushMetrics()
56
66
 
57
67
  expect(redis.eval).toHaveBeenCalled()
68
+ expect(pushSpy).toHaveBeenCalledTimes(1)
58
69
  const metrics = await collector._registry.getMetricsAsJSON()
59
70
  const total = metrics.find(m => m.name === 'app_requests_total')
60
71
  expect(total).toBeDefined()
61
72
  const totalDur = metrics.find(m => m.name === 'app_requests_total_duration')
62
73
  expect(totalDur).toBeDefined()
74
+
75
+ pushSpy.mockRestore()
76
+ })
77
+
78
+ it('primes new label sets with 0 then applies count (extra push only when labels are new)', async () => {
79
+ const redis = createRedisV3Mock()
80
+ const field = buildFieldKey('GET', '/health', 200)
81
+
82
+ const collector = new HttpMetricsRedisCollector({
83
+ redisClient: redis,
84
+ appName: 'test-app',
85
+ dynoId: 'dyno-1',
86
+ processType: 'http-metrics',
87
+ redisProcessTypeForKeys: 'web',
88
+ disablePushgateway: true,
89
+ })
90
+
91
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
92
+
93
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
94
+ cb(null, [
95
+ [field, '2'],
96
+ [field, '0'],
97
+ ])
98
+ })
99
+ await collector.pushMetrics()
100
+ expect(pushSpy).toHaveBeenCalledTimes(1)
101
+
102
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
103
+ cb(null, [
104
+ [field, '1'],
105
+ [field, '0'],
106
+ ])
107
+ })
108
+ await collector.pushMetrics()
109
+ expect(pushSpy).toHaveBeenCalledTimes(1)
110
+
111
+ pushSpy.mockRestore()
112
+ })
113
+
114
+ it('prime push for new route a leaves existing route b unchanged (a=0, b=18), then a=1, b=38', async () => {
115
+ const redis = createRedisV3Mock()
116
+ const fieldB = buildFieldKey('GET', '/b', 200)
117
+ const fieldA = buildFieldKey('GET', '/a', 200)
118
+
119
+ const collector = new HttpMetricsRedisCollector({
120
+ redisClient: redis,
121
+ appName: 'test-app',
122
+ dynoId: 'dyno-1',
123
+ processType: 'http-metrics',
124
+ redisProcessTypeForKeys: 'web',
125
+ disablePushgateway: true,
126
+ })
127
+
128
+ let primePushCount = 0
129
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockImplementation(async () => {
130
+ primePushCount++
131
+ if (primePushCount === 2) {
132
+ const metrics = await collector._registry.getMetricsAsJSON()
133
+ const byRoute = appRequestTotalsByRoute(metrics)
134
+ expect(byRoute['/a']).toBe(0)
135
+ expect(byRoute['/b']).toBe(18)
136
+ }
137
+ })
138
+
139
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
140
+ cb(null, [
141
+ [fieldB, '18'],
142
+ [fieldB, '0'],
143
+ ])
144
+ })
145
+ await collector.pushMetrics()
146
+
147
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
148
+ cb(null, [
149
+ [fieldA, '1', fieldB, '20'],
150
+ [fieldA, '0', fieldB, '0'],
151
+ ])
152
+ })
153
+ await collector.pushMetrics()
154
+
155
+ const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
156
+ expect(final['/a']).toBe(1)
157
+ expect(final['/b']).toBe(38)
158
+ expect(pushSpy).toHaveBeenCalledTimes(2)
159
+
160
+ pushSpy.mockRestore()
161
+ })
162
+
163
+ it('does not prime POST when drain only has already-seen routes (was a:1 b:18, then a:1 b:20)', async () => {
164
+ const redis = createRedisV3Mock()
165
+ const fieldA = buildFieldKey('GET', '/a', 200)
166
+ const fieldB = buildFieldKey('GET', '/b', 200)
167
+
168
+ const collector = new HttpMetricsRedisCollector({
169
+ redisClient: redis,
170
+ appName: 'test-app',
171
+ dynoId: 'dyno-1',
172
+ processType: 'http-metrics',
173
+ redisProcessTypeForKeys: 'web',
174
+ disablePushgateway: true,
175
+ })
176
+
177
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
178
+
179
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
180
+ cb(null, [
181
+ [fieldA, '1', fieldB, '18'],
182
+ [fieldA, '0', fieldB, '0'],
183
+ ])
184
+ })
185
+ await collector.pushMetrics()
186
+ expect(pushSpy).toHaveBeenCalledTimes(1)
187
+
188
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
189
+ cb(null, [
190
+ [fieldA, '1', fieldB, '20'],
191
+ [fieldA, '0', fieldB, '0'],
192
+ ])
193
+ })
194
+ await collector.pushMetrics()
195
+ expect(pushSpy).toHaveBeenCalledTimes(1)
196
+
197
+ const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
198
+ expect(final['/a']).toBe(2)
199
+ expect(final['/b']).toBe(38)
200
+
201
+ pushSpy.mockRestore()
63
202
  })
64
203
  })
@@ -14,5 +14,6 @@ This file is a **quick entry point** for the Redis-backed HTTP aggregation path.
14
14
  | **Hash field** | Three logical parts: method, route, HTTP status (joined with an internal `FIELD_SEP`). |
15
15
  | **Prometheus labels** | `method`, `route`, `status_code` (plus default `app` / `process_type` without `dyno_id` on these counters). |
16
16
  | **Backend** | Web: `backend/monitoring.ts` + `METRICS_HTTP_ENABLED`. Drain: `backend/http-metrics-collector.ts`, Procfile `http-metrics:`. |
17
+ | **Prime 0 on new routes** | The first time a `(method, route, status_code)` appears **in this process**, the collector `inc(..., 0)` for count+duration on **that label set only**, **POST**, then applies drained counts for **all** rows. Existing series are **not** touched in the prime loop — e.g. in-memory `b` was **18**, drain has new `a:1` and `b:20`: prime push exports **`a=0`, `b=18`**; after apply, final push has **`a=1`, `b=38`**. If **all** label sets were already seen, there is **no** extra prime `POST`. |
17
18
 
18
19
  For in-process HTTP metrics **without** Redis, see **`MetricsClient`** + `METRICS_HTTP_ENABLED` in **[docs/METRICS.md](../../docs/METRICS.md#http-metrics-two-valid-designs)**.
@@ -42,6 +42,8 @@ export class HttpMetricsRedisCollector extends BaseMetricsClient {
42
42
  ttlSec?: number | undefined;
43
43
  } | undefined);
44
44
  _store: HttpMetricsRedisStore;
45
+ /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
46
+ _httpCounterPrimedKeys: Set<string>;
45
47
  }
46
48
  import { BaseMetricsClient } from "./baseMetricsClient";
47
49
  import { HttpMetricsRedisStore } from "./httpMetricsRedisStore";
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisCollector.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisCollector.js"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH;IACE;;;;;;;;;;;;;;;;OAgBG;IACH;qBAfW,OAAO,OAAO,EAAE,WAAW;;;;;;;;;;;;;;mBA6DrC;IA5BC,8BAKE;CA4CL"}
1
+ {"version":3,"file":"httpMetricsRedisCollector.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisCollector.js"],"names":[],"mappings":"AAMA;;;;;;;;GAQG;AACH;IACE;;;;;;;;;;;;;;;;OAgBG;IACH;qBAfW,OAAO,OAAO,EAAE,WAAW;;;;;;;;;;;;;;mBAgErC;IA/BC,8BAKE;IAEF,6FAA6F;IAC7F,wBADW,IAAI,MAAM,CAAC,CACiB;CAqE1C"}
@@ -4,7 +4,8 @@ const {
4
4
  BaseMetricsClient
5
5
  } = require('./baseMetricsClient');
6
6
  const {
7
- HttpMetricsRedisStore
7
+ HttpMetricsRedisStore,
8
+ buildFieldKey
8
9
  } = require('./httpMetricsRedisStore');
9
10
 
10
11
  /**
@@ -56,6 +57,9 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
56
57
  processType: keyProcessType,
57
58
  ttlSec: config.ttlSec
58
59
  });
60
+
61
+ /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
62
+ this._httpCounterPrimedKeys = new Set();
59
63
  this.createCounter({
60
64
  name: 'app_requests_total',
61
65
  help: 'Total number of HTTP requests',
@@ -76,7 +80,33 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
76
80
  */
77
81
  pushMetrics = async () => {
78
82
  if (this._store && this.countersFunctions?.app_requests_total && this.countersFunctions?.app_requests_total_duration) {
79
- await this._store.flushToCounters((labels, value) => this.countersFunctions.app_requests_total(labels, value), (labels, value) => this.countersFunctions.app_requests_total_duration(labels, value));
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
+ }
80
110
  }
81
111
  return this._pushMetrics();
82
112
  };
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisCollector.js","names":["BaseMetricsClient","require","HttpMetricsRedisStore","HttpMetricsRedisCollector","constructor","config","redisClient","Error","blockNodeDefaultMetrics","keyProcessType","redisProcessTypeForKeys","defaultLabelsWithoutDynoId","app","appName","process_type","_store","processType","ttlSec","createCounter","name","help","labelNames","withDefaultLabelsWithoutDynoId","useLabelsWithoutDynoId","pushMetrics","countersFunctions","app_requests_total","app_requests_total_duration","flushToCounters","labels","value","_pushMetrics","module","exports"],"sources":["../../src/metrics/httpMetricsRedisCollector.js"],"sourcesContent":["const { BaseMetricsClient } = require('./baseMetricsClient')\nconst { HttpMetricsRedisStore } = 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 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 await this._store.flushToCounters(\n (labels, value) =>\n this.countersFunctions.app_requests_total(labels, value),\n (labels, value) =>\n this.countersFunctions.app_requests_total_duration(labels, value)\n )\n }\n return this._pushMetrics()\n }\n}\n\nmodule.exports = { HttpMetricsRedisCollector }\n"],"mappings":";;AAAA,MAAM;EAAEA;AAAkB,CAAC,GAAGC,OAAO,CAAC,qBAAqB,CAAC;AAC5D,MAAM;EAAEC;AAAsB,CAAC,GAAGD,OAAO,CAAC,yBAAyB,CAAC;;AAEpE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAME,yBAAyB,SAASH,iBAAiB,CAAC;EACxD;AACF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;EACEI,WAAWA,CAACC,MAAM,GAAG,CAAC,CAAC,EAAE;IACvB,MAAM;MAAEC;IAAY,CAAC,GAAGD,MAAM;IAC9B,IAAIC,WAAW,IAAI,IAAI,EAAE;MACvB,MAAM,IAAIC,KAAK,CAAC,oDAAoD,CAAC;IACvE;IAEA,KAAK,CAAC;MACJ,GAAGF,MAAM;MACTG,uBAAuB,EAAE;IAC3B,CAAC,CAAC;IAEF,MAAMC,cAAc,GAAGJ,MAAM,CAACK,uBAAuB,IAAI,KAAK;IAE9D,IAAI,CAACC,0BAA0B,GAAG;MAChCC,GAAG,EAAE,IAAI,CAACC,OAAO;MACjBC,YAAY,EAAEL;IAChB,CAAC;IAED,IAAI,CAACM,MAAM,GAAG,IAAIb,qBAAqB,CAAC;MACtCI,WAAW;MACXO,OAAO,EAAE,IAAI,CAACA,OAAO;MACrBG,WAAW,EAAEP,cAAc;MAC3BQ,MAAM,EAAEZ,MAAM,CAACY;IACjB,CAAC,CAAC;IAEF,IAAI,CAACC,aAAa,CAAC;MACjBC,IAAI,EAAE,oBAAoB;MAC1BC,IAAI,EAAE,+BAA+B;MACrCC,UAAU,EAAE,IAAI,CAACC,8BAA8B,CAAC,CAC9C,QAAQ,EACR,OAAO,EACP,aAAa,CACd,CAAC;MACFC,sBAAsB,EAAE;IAC1B,CAAC,CAAC;IAEF,IAAI,CAACL,aAAa,CAAC;MACjBC,IAAI,EAAE,6BAA6B;MACnCC,IAAI,EAAE,iDAAiD;MACvDC,UAAU,EAAE,IAAI,CAACC,8BAA8B,CAAC,CAC9C,QAAQ,EACR,OAAO,EACP,aAAa,CACd,CAAC;MACFC,sBAAsB,EAAE;IAC1B,CAAC,CAAC;EACJ;;EAEA;AACF;AACA;AACA;EACEC,WAAW,GAAG,MAAAA,CAAA,KAAY;IACxB,IACE,IAAI,CAACT,MAAM,IACX,IAAI,CAACU,iBAAiB,EAAEC,kBAAkB,IAC1C,IAAI,CAACD,iBAAiB,EAAEE,2BAA2B,EACnD;MACA,MAAM,IAAI,CAACZ,MAAM,CAACa,eAAe,CAC/B,CAACC,MAAM,EAAEC,KAAK,KACZ,IAAI,CAACL,iBAAiB,CAACC,kBAAkB,CAACG,MAAM,EAAEC,KAAK,CAAC,EAC1D,CAACD,MAAM,EAAEC,KAAK,KACZ,IAAI,CAACL,iBAAiB,CAACE,2BAA2B,CAACE,MAAM,EAAEC,KAAK,CACpE,CAAC;IACH;IACA,OAAO,IAAI,CAACC,YAAY,CAAC,CAAC;EAC5B,CAAC;AACH;AAEAC,MAAM,CAACC,OAAO,GAAG;EAAE9B;AAA0B,CAAC","ignoreList":[]}
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":[]}
@@ -35,6 +35,19 @@ export class HttpMetricsRedisStore {
35
35
  * @param {number} durationMs
36
36
  */
37
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
+ }>;
38
51
  /**
39
52
  * @param {(labels: Object, value: number) => void} applyCount
40
53
  * @param {(labels: Object, value: number) => void} applyDuration
@@ -59,4 +72,17 @@ export const FIELD_SEP: string;
59
72
  * @type {number}
60
73
  */
61
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
+ }[];
62
88
  //# sourceMappingURL=httpMetricsRedisStore.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA0CA;;;;;;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,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAoE5B;CACF;AA/JD;;;;;GAKG;AACH,sCALW,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AA7BD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAExB;;;GAGG;AACH,iDAFU,MAAM,CAE8B"}
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,CAsC5F;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAe5B;CACF;AAxLD;;;;;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"}
@@ -40,6 +40,53 @@ function hgetallPairsToObject(pairs) {
40
40
  return o;
41
41
  }
42
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
+
43
90
  /**
44
91
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
45
92
  * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
@@ -101,77 +148,76 @@ class HttpMetricsRedisStore {
101
148
  }
102
149
 
103
150
  /**
104
- * @param {(labels: Object, value: number) => void} applyCount
105
- * @param {(labels: Object, value: number) => void} applyDuration
106
- * @returns {Promise<boolean>}
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 }[] }>}
107
154
  */
108
- flushToCounters(applyCount, applyDuration) {
155
+ drainRows() {
109
156
  let client;
110
157
  try {
111
158
  client = this._ensureClient();
112
159
  } catch (e) {
113
- console.error('[HttpMetricsRedisStore] flush:', e.message);
114
- return Promise.resolve(false);
160
+ console.error('[HttpMetricsRedisStore] drainRows:', e.message);
161
+ return Promise.resolve({
162
+ ok: false,
163
+ rows: []
164
+ });
115
165
  }
116
166
  return new Promise(resolve => {
117
167
  client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
118
168
  if (evalErr) {
119
169
  console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
120
- resolve(false);
170
+ resolve({
171
+ ok: false,
172
+ rows: []
173
+ });
121
174
  return;
122
175
  }
123
176
  try {
124
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
125
- resolve(true);
126
- return;
127
- }
128
- const counts = hgetallPairsToObject(raw[0]);
129
- const durs = hgetallPairsToObject(raw[1]);
130
- const fieldKeys = Object.keys(counts);
131
- for (const field of fieldKeys) {
132
- const count = parseInt(counts[field], 10);
133
- if (!count || count < 1) {
134
- continue;
135
- }
136
- const dur = parseInt(durs[field] || '0', 10) || 0;
137
- const parts = field.split(FIELD_SEP);
138
- let labels = null;
139
- if (parts.length === 3) {
140
- const [m, route, statusStr] = parts;
141
- labels = {
142
- method: m,
143
- route,
144
- status_code: statusStr
145
- };
146
- } else if (parts.length === 5 || parts.length === 6) {
147
- const [m, route, statusStr] = parts;
148
- labels = {
149
- method: m,
150
- route,
151
- status_code: statusStr
152
- };
153
- }
154
- if (!labels) {
155
- continue;
156
- }
157
- applyCount(labels, count);
158
- if (dur > 0) {
159
- applyDuration(labels, dur);
160
- }
161
- }
162
- resolve(true);
177
+ const rows = rowsFromDrainRaw(raw);
178
+ resolve({
179
+ ok: true,
180
+ rows
181
+ });
163
182
  } catch (e) {
164
- console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
165
- resolve(false);
183
+ console.error('[HttpMetricsRedisStore] drainRows parse failed:', e.message);
184
+ resolve({
185
+ ok: false,
186
+ rows: []
187
+ });
166
188
  }
167
189
  });
168
190
  });
169
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
+ }
170
215
  }
171
216
  module.exports = {
172
217
  HttpMetricsRedisStore,
173
218
  buildFieldKey,
174
219
  FIELD_SEP,
175
- DEFAULT_HTTP_METRICS_REDIS_TTL_SEC
220
+ DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
221
+ rowsFromDrainRaw
176
222
  };
177
223
  //# sourceMappingURL=httpMetricsRedisStore.js.map
@@ -1 +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","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","field","dur","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","e","flushToCounters","applyCount","applyDuration","Promise","resolve","eval","evalErr","raw","Array","isArray","counts","durs","fieldKeys","Object","keys","count","parseInt","parts","split","labels","m","statusStr","status_code","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 * 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 * @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 let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] flush:', e.message)\n return Promise.resolve(false)\n }\n return new Promise(resolve => {\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n resolve(false)\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n resolve(true)\n return\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 applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n resolve(true)\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n resolve(false)\n }\n }\n )\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n}\n"],"mappings":"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","ignoreList":[]}
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(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (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 /**\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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adalo/metrics",
3
- "version": "0.0.0-staging.29",
3
+ "version": "0.0.0-staging.30",
4
4
  "description": "Reusable metrics utilities for Node.js and Laravel apps",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,5 +1,8 @@
1
1
  const { BaseMetricsClient } = require('./baseMetricsClient')
2
- const { HttpMetricsRedisStore } = require('./httpMetricsRedisStore')
2
+ const {
3
+ HttpMetricsRedisStore,
4
+ buildFieldKey,
5
+ } = require('./httpMetricsRedisStore')
3
6
 
4
7
  /**
5
8
  * Drain worker: reads HTTP aggregates from Redis (written by {@link HttpMetricsRedisRecorder}),
@@ -53,6 +56,9 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
53
56
  ttlSec: config.ttlSec,
54
57
  })
55
58
 
59
+ /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
60
+ this._httpCounterPrimedKeys = new Set()
61
+
56
62
  this.createCounter({
57
63
  name: 'app_requests_total',
58
64
  help: 'Total number of HTTP requests',
@@ -86,12 +92,37 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
86
92
  this.countersFunctions?.app_requests_total &&
87
93
  this.countersFunctions?.app_requests_total_duration
88
94
  ) {
89
- await this._store.flushToCounters(
90
- (labels, value) =>
91
- this.countersFunctions.app_requests_total(labels, value),
92
- (labels, value) =>
95
+ const { ok, rows } = await this._store.drainRows()
96
+ if (ok && rows.length > 0) {
97
+ const applyCount = (labels, value) =>
98
+ this.countersFunctions.app_requests_total(labels, value)
99
+ const applyDur = (labels, value) =>
93
100
  this.countersFunctions.app_requests_total_duration(labels, value)
94
- )
101
+
102
+ let primedAny = false
103
+ for (const row of rows) {
104
+ const key = buildFieldKey(
105
+ row.labels.method,
106
+ row.labels.route,
107
+ row.labels.status_code
108
+ )
109
+ if (!this._httpCounterPrimedKeys.has(key)) {
110
+ this._httpCounterPrimedKeys.add(key)
111
+ applyCount(row.labels, 0)
112
+ applyDur(row.labels, 0)
113
+ primedAny = true
114
+ }
115
+ }
116
+ if (primedAny) {
117
+ await this.gatewayPush()
118
+ }
119
+ for (const row of rows) {
120
+ applyCount(row.labels, row.count)
121
+ if (row.dur > 0) {
122
+ applyDur(row.labels, row.dur)
123
+ }
124
+ }
125
+ }
95
126
  }
96
127
  return this._pushMetrics()
97
128
  }
@@ -40,6 +40,41 @@ function hgetallPairsToObject(pairs) {
40
40
  return o
41
41
  }
42
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 = { method: m, route, status_code: statusStr }
66
+ } else if (parts.length === 5 || parts.length === 6) {
67
+ const [m, route, statusStr] = parts
68
+ labels = { method: m, route, status_code: statusStr }
69
+ }
70
+ if (!labels) {
71
+ continue
72
+ }
73
+ rows.push({ labels, count, dur })
74
+ }
75
+ return rows
76
+ }
77
+
43
78
  /**
44
79
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
45
80
  * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
@@ -107,17 +142,17 @@ class HttpMetricsRedisStore {
107
142
  }
108
143
 
109
144
  /**
110
- * @param {(labels: Object, value: number) => void} applyCount
111
- * @param {(labels: Object, value: number) => void} applyDuration
112
- * @returns {Promise<boolean>}
145
+ * Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
146
+ *
147
+ * @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
113
148
  */
114
- flushToCounters(applyCount, applyDuration) {
149
+ drainRows() {
115
150
  let client
116
151
  try {
117
152
  client = this._ensureClient()
118
153
  } catch (e) {
119
- console.error('[HttpMetricsRedisStore] flush:', e.message)
120
- return Promise.resolve(false)
154
+ console.error('[HttpMetricsRedisStore] drainRows:', e.message)
155
+ return Promise.resolve({ ok: false, rows: [] })
121
156
  }
122
157
  return new Promise(resolve => {
123
158
  client.eval(
@@ -131,53 +166,43 @@ class HttpMetricsRedisStore {
131
166
  '[HttpMetricsRedisStore] drain failed:',
132
167
  evalErr.message
133
168
  )
134
- resolve(false)
169
+ resolve({ ok: false, rows: [] })
135
170
  return
136
171
  }
137
-
138
172
  try {
139
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
140
- resolve(true)
141
- return
142
- }
143
- const counts = hgetallPairsToObject(raw[0])
144
- const durs = hgetallPairsToObject(raw[1])
145
- const fieldKeys = Object.keys(counts)
146
- for (const field of fieldKeys) {
147
- const count = parseInt(counts[field], 10)
148
- if (!count || count < 1) {
149
- continue
150
- }
151
- const dur = parseInt(durs[field] || '0', 10) || 0
152
- const parts = field.split(FIELD_SEP)
153
- let labels = null
154
- if (parts.length === 3) {
155
- const [m, route, statusStr] = parts
156
- labels = { method: m, route, status_code: statusStr }
157
- } else if (parts.length === 5 || parts.length === 6) {
158
- const [m, route, statusStr] = parts
159
- labels = { method: m, route, status_code: statusStr }
160
- }
161
- if (!labels) {
162
- continue
163
- }
164
- applyCount(labels, count)
165
- if (dur > 0) {
166
- applyDuration(labels, dur)
167
- }
168
- }
169
- resolve(true)
173
+ const rows = rowsFromDrainRaw(raw)
174
+ resolve({ ok: true, rows })
170
175
  } catch (e) {
171
176
  console.error(
172
- '[HttpMetricsRedisStore] flush apply failed:',
177
+ '[HttpMetricsRedisStore] drainRows parse failed:',
173
178
  e.message
174
179
  )
175
- resolve(false)
180
+ resolve({ ok: false, rows: [] })
176
181
  }
177
182
  }
178
183
  )
179
184
  })
180
185
  }
186
+
187
+ /**
188
+ * @param {(labels: Object, value: number) => void} applyCount
189
+ * @param {(labels: Object, value: number) => void} applyDuration
190
+ * @returns {Promise<boolean>}
191
+ */
192
+ flushToCounters(applyCount, applyDuration) {
193
+ return this.drainRows().then(({ ok, rows }) => {
194
+ if (!ok) {
195
+ return false
196
+ }
197
+ for (const row of rows) {
198
+ applyCount(row.labels, row.count)
199
+ if (row.dur > 0) {
200
+ applyDuration(row.labels, row.dur)
201
+ }
202
+ }
203
+ return true
204
+ })
205
+ }
181
206
  }
182
207
 
183
208
  module.exports = {
@@ -185,4 +210,5 @@ module.exports = {
185
210
  buildFieldKey,
186
211
  FIELD_SEP,
187
212
  DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
213
+ rowsFromDrainRaw,
188
214
  }