@adalo/metrics 0.0.0-staging.23 → 0.0.0-staging.26

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.
@@ -13,9 +13,7 @@ function createRedisV3Mock() {
13
13
  }
14
14
  return {
15
15
  multi: jest.fn(() => multiChain),
16
- set: jest.fn(),
17
16
  eval: jest.fn(),
18
- del: jest.fn(),
19
17
  _multiChain: multiChain,
20
18
  }
21
19
  }
@@ -37,9 +35,6 @@ describe('HttpMetricsRedisCollector', () => {
37
35
 
38
36
  it('pushMetrics drains Redis then completes without network push', async () => {
39
37
  const redis = createRedisV3Mock()
40
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
41
- cb(null, 'OK')
42
- })
43
38
  const field = buildFieldKey('GET', '/health', 200, '', '')
44
39
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
45
40
  cb(null, [
@@ -47,11 +42,6 @@ describe('HttpMetricsRedisCollector', () => {
47
42
  [field, '10'],
48
43
  ])
49
44
  })
50
- redis.del.mockImplementation((key, cb) => {
51
- if (cb) {
52
- cb()
53
- }
54
- })
55
45
 
56
46
  const collector = new HttpMetricsRedisCollector({
57
47
  redisClient: redis,
@@ -23,18 +23,24 @@ function createRedisV3Mock() {
23
23
  }
24
24
 
25
25
  describe('HttpMetricsRedisRecorder', () => {
26
+ const originalEnv = process.env
27
+
28
+ beforeEach(() => {
29
+ process.env = { ...originalEnv, BUILD_APP_NAME: 'app' }
30
+ })
31
+
32
+ afterEach(() => {
33
+ process.env = originalEnv
34
+ })
35
+
26
36
  it('throws without redisClient', () => {
27
- expect(() => new HttpMetricsRedisRecorder({ appName: 'a', processType: 'web' })).toThrow(
28
- 'redisClient is required'
29
- )
37
+ expect(() => new HttpMetricsRedisRecorder({})).toThrow('redisClient is required')
30
38
  })
31
39
 
32
40
  it('trackHttpRequest forwards to Redis multi/hincrby', async () => {
33
41
  const redis = createRedisV3Mock()
34
42
  const rec = new HttpMetricsRedisRecorder({
35
43
  redisClient: redis,
36
- appName: 'app',
37
- processType: 'web',
38
44
  })
39
45
  const countKey = rec._store.countKey
40
46
  const durKey = rec._store.durKey
@@ -21,13 +21,70 @@ function createRedisV3Mock() {
21
21
  }
22
22
  return {
23
23
  multi: jest.fn(() => multiChain),
24
- set: jest.fn(),
25
24
  eval: jest.fn(),
26
- del: jest.fn(),
27
25
  _multiChain: multiChain,
28
26
  }
29
27
  }
30
28
 
29
+ /**
30
+ * In-memory redis@3 mock: MULTI/EXEC applies HINCRBY to shared hashes; EVAL returns
31
+ * [HGETALL+DEL countKey, HGETALL+DEL durKey] like the real drain Lua. Use this to
32
+ * simulate many writers (same client) without Redis.
33
+ */
34
+ function createRedisV3InMemoryMock() {
35
+ /** @type {Record<string, Record<string, string>>} */
36
+ const hashes = {}
37
+
38
+ function hgetallFlat(key) {
39
+ const h = hashes[key]
40
+ if (!h) return []
41
+ const flat = []
42
+ for (const [f, v] of Object.entries(h)) {
43
+ flat.push(f, v)
44
+ }
45
+ return flat
46
+ }
47
+
48
+ function drainKey(key) {
49
+ const flat = hgetallFlat(key)
50
+ delete hashes[key]
51
+ return flat
52
+ }
53
+
54
+ const client = {
55
+ multi: jest.fn(() => {
56
+ const ops = []
57
+ const chain = {}
58
+ chain.hincrby = jest.fn((key, field, inc) => {
59
+ ops.push({ key, field, inc: Number(inc) })
60
+ return chain
61
+ })
62
+ chain.expire = jest.fn().mockReturnValue(chain)
63
+ chain.exec = jest.fn(cb => {
64
+ for (const op of ops) {
65
+ if (!hashes[op.key]) hashes[op.key] = {}
66
+ const cur = parseInt(hashes[op.key][op.field] || '0', 10)
67
+ hashes[op.key][op.field] = String(cur + op.inc)
68
+ }
69
+ if (cb) cb(null)
70
+ })
71
+ return chain
72
+ }),
73
+ eval: jest.fn((lua, numKeys, k1, k2, cb) => {
74
+ const out = [drainKey(k1), drainKey(k2)]
75
+ if (cb) cb(null, out)
76
+ }),
77
+ /** @param {string} key */
78
+ _fieldCount(key) {
79
+ return Object.keys(hashes[key] || {}).length
80
+ },
81
+ _peek(key) {
82
+ return { ...(hashes[key] || {}) }
83
+ },
84
+ }
85
+ return client
86
+ }
87
+
31
88
  describe('HttpMetricsRedisStore', () => {
32
89
  describe('buildFieldKey', () => {
33
90
  it('joins parts with FIELD_SEP', () => {
@@ -54,7 +111,6 @@ describe('HttpMetricsRedisStore', () => {
54
111
  const seg = `${encodeURIComponent('my app')}:${encodeURIComponent('web')}`
55
112
  expect(store.countKey).toBe(`metrics:http:v2:${seg}:count`)
56
113
  expect(store.durKey).toBe(`metrics:http:v2:${seg}:dur`)
57
- expect(store.lockKey).toBe(`metrics:http:v2:${seg}:drain_lock`)
58
114
  })
59
115
  })
60
116
 
@@ -82,26 +138,51 @@ describe('HttpMetricsRedisStore', () => {
82
138
  })
83
139
 
84
140
  describe('flushToCounters', () => {
85
- it('returns false when lock is not acquired', async () => {
141
+ it('applies aggregated count and summed duration for one route (many requests → one field)', async () => {
86
142
  const redis = createRedisV3Mock()
87
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
88
- cb(null, null)
143
+ const field = buildFieldKey('GET', '/api/items', 200, '', '')
144
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
145
+ cb(null, [
146
+ [field, '100'],
147
+ [field, '4500'],
148
+ ])
89
149
  })
150
+
90
151
  const store = new HttpMetricsRedisStore({
91
152
  redisClient: redis,
92
153
  appName: 'app',
93
154
  processType: 'web',
94
155
  })
95
- const ok = await store.flushToCounters(jest.fn(), jest.fn())
96
- expect(ok).toBe(false)
97
- expect(redis.eval).not.toHaveBeenCalled()
156
+ const applyCount = jest.fn()
157
+ const applyDuration = jest.fn()
158
+
159
+ const ok = await store.flushToCounters(applyCount, applyDuration)
160
+
161
+ expect(ok).toBe(true)
162
+ expect(applyCount).toHaveBeenCalledWith(
163
+ {
164
+ method: 'GET',
165
+ route: '/api/items',
166
+ status_code: '200',
167
+ appId: '',
168
+ databaseId: '',
169
+ },
170
+ 100
171
+ )
172
+ expect(applyDuration).toHaveBeenCalledWith(
173
+ {
174
+ method: 'GET',
175
+ route: '/api/items',
176
+ status_code: '200',
177
+ appId: '',
178
+ databaseId: '',
179
+ },
180
+ 4500
181
+ )
98
182
  })
99
183
 
100
184
  it('drains hashes and applies count and duration', async () => {
101
185
  const redis = createRedisV3Mock()
102
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
103
- cb(null, 'OK')
104
- })
105
186
  const field = buildFieldKey('POST', '/r', 201, 'a1', 'd1')
106
187
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
107
188
  cb(null, [
@@ -109,11 +190,6 @@ describe('HttpMetricsRedisStore', () => {
109
190
  [field, '50'],
110
191
  ])
111
192
  })
112
- redis.del.mockImplementation((key, cb) => {
113
- if (cb) {
114
- cb()
115
- }
116
- })
117
193
 
118
194
  const store = new HttpMetricsRedisStore({
119
195
  redisClient: redis,
@@ -157,17 +233,9 @@ describe('HttpMetricsRedisStore', () => {
157
233
 
158
234
  it('resolves true with no applies when eval returns short array', async () => {
159
235
  const redis = createRedisV3Mock()
160
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
161
- cb(null, 'OK')
162
- })
163
236
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
164
237
  cb(null, [])
165
238
  })
166
- redis.del.mockImplementation((key, cb) => {
167
- if (cb) {
168
- cb()
169
- }
170
- })
171
239
  const store = new HttpMetricsRedisStore({
172
240
  redisClient: redis,
173
241
  appName: 'app',
@@ -179,4 +247,159 @@ describe('HttpMetricsRedisStore', () => {
179
247
  expect(applyCount).not.toHaveBeenCalled()
180
248
  })
181
249
  })
250
+
251
+ describe('in-memory integration (many fields, shared writers)', () => {
252
+ it('flush applies many distinct hash fields (many routes / labels)', async () => {
253
+ const redis = createRedisV3InMemoryMock()
254
+ const store = new HttpMetricsRedisStore({
255
+ redisClient: redis,
256
+ appName: 'svc',
257
+ processType: 'web',
258
+ })
259
+ const routes = Array.from({ length: 24 }, (_, i) => `/api/v${i % 3}/item/${i}`)
260
+ for (let i = 0; i < routes.length; i++) {
261
+ const method = i % 2 === 0 ? 'GET' : 'POST'
262
+ const status = i % 5 === 0 ? 500 : 200
263
+ store.record(method, routes[i], status, `app-${i % 4}`, `db-${i % 3}`, 10 + i)
264
+ }
265
+ expect(redis._fieldCount(store.countKey)).toBe(routes.length)
266
+
267
+ const applyCount = jest.fn()
268
+ const applyDuration = jest.fn()
269
+ const ok = await store.flushToCounters(applyCount, applyDuration)
270
+
271
+ expect(ok).toBe(true)
272
+ expect(applyCount).toHaveBeenCalledTimes(routes.length)
273
+ let sumCount = 0
274
+ let sumDur = 0
275
+ applyCount.mock.calls.forEach(([, c]) => {
276
+ sumCount += c
277
+ })
278
+ applyDuration.mock.calls.forEach(([, d]) => {
279
+ sumDur += d
280
+ })
281
+ expect(sumCount).toBe(routes.length)
282
+ expect(sumDur).toBe(routes.reduce((acc, _, i) => acc + (10 + i), 0))
283
+ expect(redis._peek(store.countKey)).toEqual({})
284
+ })
285
+
286
+ it('many requests for the same field aggregate to one apply (like one dyno hot route)', async () => {
287
+ const redis = createRedisV3InMemoryMock()
288
+ const store = new HttpMetricsRedisStore({
289
+ redisClient: redis,
290
+ appName: 'app',
291
+ processType: 'web',
292
+ })
293
+ const n = 80
294
+ for (let i = 0; i < n; i++) {
295
+ store.record('GET', '/hot', 200, 'a1', 'd1', 5)
296
+ }
297
+ expect(redis._fieldCount(store.countKey)).toBe(1)
298
+
299
+ const applyCount = jest.fn()
300
+ const applyDuration = jest.fn()
301
+ await store.flushToCounters(applyCount, applyDuration)
302
+
303
+ expect(applyCount).toHaveBeenCalledTimes(1)
304
+ expect(applyCount).toHaveBeenCalledWith(
305
+ expect.objectContaining({
306
+ method: 'GET',
307
+ route: '/hot',
308
+ status_code: '200',
309
+ appId: 'a1',
310
+ databaseId: 'd1',
311
+ }),
312
+ n
313
+ )
314
+ expect(applyDuration).toHaveBeenCalledWith(
315
+ expect.objectContaining({ route: '/hot' }),
316
+ n * 5
317
+ )
318
+ })
319
+
320
+ it('multiple store instances on the same redis client combine like multiple dynos', async () => {
321
+ const redis = createRedisV3InMemoryMock()
322
+ const opts = { redisClient: redis, appName: 'prod', processType: 'web' }
323
+ const dynoA = new HttpMetricsRedisStore(opts)
324
+ const dynoB = new HttpMetricsRedisStore(opts)
325
+
326
+ dynoA.record('GET', '/a', 200, '', '', 3)
327
+ dynoB.record('GET', '/b', 304, '', '', 7)
328
+ dynoA.record('POST', '/c', 201, 'x', 'y', 11)
329
+
330
+ expect(redis._fieldCount(dynoA.countKey)).toBe(3)
331
+
332
+ const applyCount = jest.fn()
333
+ const applyDuration = jest.fn()
334
+ const ok = await dynoB.flushToCounters(applyCount, applyDuration)
335
+
336
+ expect(ok).toBe(true)
337
+ expect(applyCount).toHaveBeenCalledTimes(3)
338
+ expect(applyDuration).toHaveBeenCalledTimes(3)
339
+ })
340
+
341
+ it('interleaved record() calls from parallel async work combine correctly', async () => {
342
+ const redis = createRedisV3InMemoryMock()
343
+ const store = new HttpMetricsRedisStore({
344
+ redisClient: redis,
345
+ appName: 'app',
346
+ processType: 'web',
347
+ })
348
+
349
+ await Promise.all(
350
+ [0, 1, 2, 3, 4].map(worker =>
351
+ Promise.all(
352
+ Array.from({ length: 15 }, (_, j) => {
353
+ const route = `/w${worker}/r${j}`
354
+ store.record('GET', route, 200, `app${worker}`, 'db0', 2)
355
+ return Promise.resolve()
356
+ })
357
+ )
358
+ )
359
+ )
360
+
361
+ expect(redis._fieldCount(store.countKey)).toBe(5 * 15)
362
+
363
+ const applyCount = jest.fn()
364
+ const applyDuration = jest.fn()
365
+ await store.flushToCounters(applyCount, applyDuration)
366
+
367
+ expect(applyCount).toHaveBeenCalledTimes(75)
368
+ let total = 0
369
+ applyCount.mock.calls.forEach(([, c]) => {
370
+ total += c
371
+ })
372
+ expect(total).toBe(75)
373
+ expect(
374
+ applyDuration.mock.calls.reduce((acc, [, d]) => acc + d, 0)
375
+ ).toBe(75 * 2)
376
+ })
377
+
378
+ it('mixed methods, statuses, and apps in one drain', async () => {
379
+ const redis = createRedisV3InMemoryMock()
380
+ const store = new HttpMetricsRedisStore({
381
+ redisClient: redis,
382
+ appName: 'mixed',
383
+ processType: 'api',
384
+ })
385
+ const samples = [
386
+ ['GET', '/x', 200, 'a', 'd', 1],
387
+ ['GET', '/x', 404, 'a', 'd', 2],
388
+ ['DELETE', '/x|y', 204, 'b', 'd', 3],
389
+ ]
390
+ for (const [m, r, s, a, d, dur] of samples) {
391
+ store.record(m, r, s, a, d, dur)
392
+ }
393
+
394
+ const applyCount = jest.fn()
395
+ const applyDuration = jest.fn()
396
+ await store.flushToCounters(applyCount, applyDuration)
397
+
398
+ expect(applyCount).toHaveBeenCalledTimes(3)
399
+ expect(applyCount.mock.calls.map(([{ method, route, status_code }]) => `${method} ${route} ${status_code}`).sort()).toEqual(
400
+ ['DELETE /x|y 204', 'GET /x 200', 'GET /x 404'].sort()
401
+ )
402
+ expect(applyDuration.mock.calls.reduce((acc, [, v]) => acc + v, 0)).toBe(6)
403
+ })
404
+ })
182
405
  })
@@ -8,14 +8,14 @@ export class HttpMetricsRedisRecorder {
8
8
  /**
9
9
  * @param {Object} opts
10
10
  * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
11
- * @param {string} opts.appName Application name (must match collector; typically `BUILD_APP_NAME`).
12
- * @param {string} opts.processType Logical process segment in Redis keys (e.g. `web`; must match collector’s key segment).
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
13
  * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
14
14
  */
15
- constructor({ redisClient, appName, processType, ttlSec }: {
15
+ constructor({ redisClient, appName, processType, ttlSec }?: {
16
16
  redisClient: import('redis').RedisClient;
17
- appName: string;
18
- processType: string;
17
+ appName?: string | undefined;
18
+ processType?: string | undefined;
19
19
  ttlSec?: number | undefined;
20
20
  });
21
21
  processType: string;
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisRecorder.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"names":[],"mappings":"AASA;;;;;GAKG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACd,OAAO,EAApB,MAAM;QACO,WAAW,EAAxB,MAAM;QACQ,MAAM;OAc9B;IARC,oBAA8B;IAC9B,gBAAsB;IACtB,8BAKE;IAGJ;;;;;;;;OAQG;IACH;QAP0B,MAAM,EAArB,MAAM;QACS,KAAK,EAApB,MAAM;QACS,WAAW,EAA1B,MAAM;QACU,KAAK;QACL,UAAU;QACX,QAAQ,EAAvB,MAAM;aAgBhB;IAED;;;;;;;OAOG;IACH,kCAJW,OAAO,MAAM,EAAE,eAAe,OAC9B,OAAO,MAAM,EAAE,cAAc,0BAkCvC;CACF"}
1
+ {"version":3,"file":"httpMetricsRedisRecorder.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACb,OAAO;QACP,WAAW;QACX,MAAM;OAe9B;IARC,oBAA8B;IAC9B,gBAA8B;IAC9B,8BAKE;IAGJ;;;;;;;;OAQG;IACH;QAP0B,MAAM,EAArB,MAAM;QACS,KAAK,EAApB,MAAM;QACS,WAAW,EAA1B,MAAM;QACU,KAAK;QACL,UAAU;QACX,QAAQ,EAAvB,MAAM;aAiBhB;IAED;;;;;;;OAOG;IACH,kCAJW,OAAO,MAAM,EAAE,eAAe,OAC9B,OAAO,MAAM,EAAE,cAAc,0BAkCvC;CACF"}
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
 
3
3
  const {
4
- HttpMetricsRedisStore
4
+ HttpMetricsRedisStore,
5
+ httpMetricsTraceLog
5
6
  } = require('./httpMetricsRedisStore');
6
- const LOG = '[http-metrics-redis]';
7
7
  function trunc(s, max = 120) {
8
8
  const t = String(s);
9
9
  return t.length > max ? `${t.slice(0, max - 3)}...` : t;
@@ -19,24 +19,25 @@ class HttpMetricsRedisRecorder {
19
19
  /**
20
20
  * @param {Object} opts
21
21
  * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
22
- * @param {string} opts.appName Application name (must match collector; typically `BUILD_APP_NAME`).
23
- * @param {string} opts.processType Logical process segment in Redis keys (e.g. `web`; must match collector’s key segment).
22
+ * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.
23
+ * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).
24
24
  * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
25
25
  */
26
26
  constructor({
27
27
  redisClient,
28
28
  appName,
29
- processType,
29
+ processType = 'web',
30
30
  ttlSec
31
- }) {
31
+ } = {}) {
32
32
  if (redisClient == null) {
33
33
  throw new Error('HttpMetricsRedisRecorder: redisClient is required');
34
34
  }
35
+ const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app';
35
36
  this.processType = processType;
36
- this.appName = appName;
37
+ this.appName = resolvedAppName;
37
38
  this._store = new HttpMetricsRedisStore({
38
39
  redisClient,
39
- appName,
40
+ appName: resolvedAppName,
40
41
  processType,
41
42
  ttlSec
42
43
  });
@@ -59,7 +60,7 @@ class HttpMetricsRedisRecorder {
59
60
  databaseId = '',
60
61
  duration
61
62
  }) {
62
- console.warn(`${LOG} 1_track pid=${process.pid} app=${this.appName} segment=${this.processType} ` + `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` + `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)}`);
63
+ httpMetricsTraceLog(`track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` + `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` + `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)} ` + `(then save_to_redis lines from store)`);
63
64
  this._store.record(method, route, status_code, appId, databaseId, duration);
64
65
  }
65
66
 
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisRecorder.js","names":["HttpMetricsRedisStore","require","LOG","trunc","s","max","t","String","length","slice","HttpMetricsRedisRecorder","constructor","redisClient","appName","processType","ttlSec","Error","_store","trackHttpRequest","method","route","status_code","appId","databaseId","duration","console","warn","process","pid","record","trackHttpRequestMiddleware","req","res","next","start","Date","now","on","path","params","body","query","datasourceId","statusCode","module","exports"],"sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"sourcesContent":["const { HttpMetricsRedisStore } = require('./httpMetricsRedisStore')\n\nconst LOG = '[http-metrics-redis]'\n\nfunction trunc(s, max = 120) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\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 Application name (must match collector; typically `BUILD_APP_NAME`).\n * @param {string} opts.processType Logical process segment in Redis keys (e.g. `web`; must match collector’s key segment).\n * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisRecorder: redisClient is required')\n }\n this.processType = processType\n this.appName = appName\n this._store = new HttpMetricsRedisStore({\n redisClient,\n appName,\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 {string} [params.appId]\n * @param {string} [params.databaseId]\n * @param {number} params.duration\n */\n trackHttpRequest({\n method,\n route,\n status_code,\n appId = '',\n databaseId = '',\n duration,\n }) {\n console.warn(\n `${LOG} 1_track pid=${process.pid} app=${this.appName} segment=${this.processType} ` +\n `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +\n `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)}`\n )\n this._store.record(method, route, status_code, appId, databaseId, 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 const appId =\n req.params?.appId || req.body?.appId || req.query?.appId || ''\n const databaseId =\n req.params?.databaseId ||\n req.body?.databaseId ||\n req.query?.databaseId ||\n req.params?.datasourceId ||\n req.body?.datasourceId ||\n req.query?.datasourceId ||\n ''\n\n this.trackHttpRequest({\n method: req.method,\n route,\n status_code: res.statusCode,\n appId,\n databaseId,\n duration: Date.now() - start,\n })\n })\n\n next()\n }\n}\n\nmodule.exports = { HttpMetricsRedisRecorder }\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"file":"httpMetricsRedisRecorder.js","names":["HttpMetricsRedisStore","httpMetricsTraceLog","require","trunc","s","max","t","String","length","slice","HttpMetricsRedisRecorder","constructor","redisClient","appName","processType","ttlSec","Error","resolvedAppName","process","env","BUILD_APP_NAME","_store","trackHttpRequest","method","route","status_code","appId","databaseId","duration","pid","record","trackHttpRequestMiddleware","req","res","next","start","Date","now","on","path","params","body","query","datasourceId","statusCode","module","exports"],"sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"sourcesContent":["const { HttpMetricsRedisStore, httpMetricsTraceLog } = require('./httpMetricsRedisStore')\n\nfunction trunc(s, max = 120) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\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 = 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 {string} [params.appId]\n * @param {string} [params.databaseId]\n * @param {number} params.duration\n */\n trackHttpRequest({\n method,\n route,\n status_code,\n appId = '',\n databaseId = '',\n duration,\n }) {\n httpMetricsTraceLog(\n `track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` +\n `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +\n `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)} ` +\n `(then save_to_redis lines from store)`\n )\n this._store.record(method, route, status_code, appId, databaseId, 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 const appId =\n req.params?.appId || req.body?.appId || req.query?.appId || ''\n const databaseId =\n req.params?.databaseId ||\n req.body?.databaseId ||\n req.query?.databaseId ||\n req.params?.datasourceId ||\n req.body?.datasourceId ||\n req.query?.datasourceId ||\n ''\n\n this.trackHttpRequest({\n method: req.method,\n route,\n status_code: res.statusCode,\n appId,\n databaseId,\n duration: Date.now() - start,\n })\n })\n\n next()\n }\n}\n\nmodule.exports = { HttpMetricsRedisRecorder }\n"],"mappings":"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","ignoreList":[]}
@@ -1,9 +1,12 @@
1
1
  /**
2
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`, `set`, `del`.
3
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
4
4
  *
5
- * **Stored metrics:** two hashes per app/segment `:count` (HINCRBY per route group) and `:dur`
6
- * (sum of duration ms per same field). Hash field = `method\\x1eroute\\x1estatus\\x1eappId\\x1edatabaseId`.
5
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
6
+ * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
7
+ * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
8
+ * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
9
+ * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
7
10
  */
8
11
  export class HttpMetricsRedisStore {
9
12
  /**
@@ -23,7 +26,6 @@ export class HttpMetricsRedisStore {
23
26
  ttlSec: number;
24
27
  countKey: string;
25
28
  durKey: string;
26
- lockKey: string;
27
29
  /**
28
30
  * @returns {import('redis').RedisClient}
29
31
  * @private
@@ -68,4 +70,10 @@ export function isRedisPeerInstalled(): boolean;
68
70
  * @type {number}
69
71
  */
70
72
  export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
73
+ /**
74
+ * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
75
+ * so hosts that treat streams differently still show something.
76
+ * @param {string} body - Line body after `[http-metrics-redis]` prefix.
77
+ */
78
+ export function httpMetricsTraceLog(body: string): void;
71
79
  //# sourceMappingURL=httpMetricsRedisStore.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA0DA;;;;;;GAMG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACd,OAAO,EAApB,MAAM;QACO,WAAW,EAAxB,MAAM;QACQ,MAAM;OAiB9B;IAXC,qCAA0B;IAC1B,eAGwC;IAIxC,iBAAiD;IACjD,eAA6C;IAC7C,gBAAqD;IAGvD;;;OAGG;IACH,sBAEC;IAED;;;;;;;OAOG;IACH,eAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QAyBhB;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAgG5B;CACF;AApMD;;;;;;;GAOG;AACH,sCAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AA7CD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAmBxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AA3BD;;;GAGG;AACH,iDAFU,MAAM,CAE8B"}
1
+ {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA+FA;;;;;;;;;GASG;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;;;;;;;OAOG;IACH,eAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QA6BhB;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAqF5B;CACF;AA/LD;;;;;;;GAOG;AACH,sCAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AAlFD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAwDxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AAhED;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AA0B9C;;;;GAIG;AACH,0CAFW,MAAM,QAUhB"}
@@ -12,6 +12,42 @@ const FIELD_SEP = '\x1e';
12
12
  */
13
13
  const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120;
14
14
  const LOG = '[http-metrics-redis]';
15
+
16
+ /**
17
+ * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.
18
+ * @returns {string}
19
+ */
20
+ function clusterHint() {
21
+ try {
22
+ // eslint-disable-next-line global-require
23
+ const c = require('cluster');
24
+ if (c.isWorker && c.worker != null) {
25
+ return ` cluster_worker=${c.worker.id}`;
26
+ }
27
+ } catch (_) {
28
+ /* cluster not in use */
29
+ }
30
+ return '';
31
+ }
32
+ function truncField(s, max = 96) {
33
+ const t = String(s);
34
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t;
35
+ }
36
+
37
+ /**
38
+ * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
39
+ * so hosts that treat streams differently still show something.
40
+ * @param {string} body - Line body after `[http-metrics-redis]` prefix.
41
+ */
42
+ function httpMetricsTraceLog(body) {
43
+ const line = `${LOG} ${body}${clusterHint()}`;
44
+ console.log(line);
45
+ try {
46
+ process.stderr.write(`${line}\n`);
47
+ } catch (_) {
48
+ /* ignore */
49
+ }
50
+ }
15
51
  const DRAIN_LUA = `
16
52
  local function drain(key)
17
53
  local v = redis.call('HGETALL', key)
@@ -57,10 +93,13 @@ function hgetallPairsToObject(pairs) {
57
93
 
58
94
  /**
59
95
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
60
- * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.
96
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
61
97
  *
62
- * **Stored metrics:** two hashes per app/segment `:count` (HINCRBY per route group) and `:dur`
63
- * (sum of duration ms per same field). Hash field = `method\\x1eroute\\x1estatus\\x1eappId\\x1edatabaseId`.
98
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
99
+ * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
100
+ * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
101
+ * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
102
+ * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
64
103
  */
65
104
  class HttpMetricsRedisStore {
66
105
  /**
@@ -84,7 +123,6 @@ class HttpMetricsRedisStore {
84
123
  const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
85
124
  this.countKey = `metrics:http:v2:${keySeg}:count`;
86
125
  this.durKey = `metrics:http:v2:${keySeg}:dur`;
87
- this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`;
88
126
  }
89
127
 
90
128
  /**
@@ -108,12 +146,13 @@ class HttpMetricsRedisStore {
108
146
  const client = this._ensureClient();
109
147
  const field = buildFieldKey(method, route, statusCode, appId, databaseId);
110
148
  const dur = Math.max(0, Math.round(Number(durationMs) || 0));
149
+ httpMetricsTraceLog(`save_to_redis pid=${process.pid} countKey=${this.countKey} ` + `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`);
111
150
  client.multi().hincrby(this.countKey, field, 1).hincrby(this.durKey, field, dur).expire(this.countKey, this.ttlSec).expire(this.durKey, this.ttlSec).exec(err => {
112
151
  if (err) {
113
152
  console.error('[HttpMetricsRedisStore] record failed:', err.message);
114
153
  return;
115
154
  }
116
- console.warn(`${LOG} 2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`);
155
+ httpMetricsTraceLog(`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`);
117
156
  });
118
157
  } catch (e) {
119
158
  console.error('[HttpMetricsRedisStore] record:', e.message);
@@ -134,65 +173,57 @@ class HttpMetricsRedisStore {
134
173
  return Promise.resolve(false);
135
174
  }
136
175
  return new Promise(resolve => {
137
- client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {
138
- if (setErr || ok !== 'OK') {
139
- console.warn(`${LOG} 3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${setErr ? `error:${setErr.message}` : 'lock_not_acquired'}`);
176
+ httpMetricsTraceLog(`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`);
177
+ client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
178
+ if (evalErr) {
179
+ console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
140
180
  resolve(false);
141
181
  return;
142
182
  }
143
- client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
144
- const finish = () => {
145
- client.del(this.lockKey, () => resolve(true));
146
- };
147
- if (evalErr) {
148
- console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
149
- finish();
183
+ try {
184
+ if (!raw || !Array.isArray(raw) || raw.length < 2) {
185
+ httpMetricsTraceLog(`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`);
186
+ resolve(true);
150
187
  return;
151
188
  }
152
- try {
153
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
154
- console.warn(`${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`);
155
- finish();
156
- return;
189
+ const counts = hgetallPairsToObject(raw[0]);
190
+ const durs = hgetallPairsToObject(raw[1]);
191
+ const fieldKeys = Object.keys(counts);
192
+ let sumRequests = 0;
193
+ const samples = [];
194
+ for (const field of fieldKeys) {
195
+ const count = parseInt(counts[field], 10);
196
+ if (!count || count < 1) {
197
+ continue;
157
198
  }
158
- const counts = hgetallPairsToObject(raw[0]);
159
- const durs = hgetallPairsToObject(raw[1]);
160
- const fieldKeys = Object.keys(counts);
161
- let sumRequests = 0;
162
- const samples = [];
163
- for (const field of fieldKeys) {
164
- const count = parseInt(counts[field], 10);
165
- if (!count || count < 1) {
166
- continue;
167
- }
168
- const dur = parseInt(durs[field] || '0', 10) || 0;
169
- const parts = field.split(FIELD_SEP);
170
- if (parts.length !== 5) {
171
- continue;
172
- }
173
- const [m, route, statusStr, aid, did] = parts;
174
- sumRequests += count;
175
- if (samples.length < 3) {
176
- samples.push(`${m} ${route} ${statusStr} x${count}`);
177
- }
178
- const labels = {
179
- method: m,
180
- route,
181
- status_code: statusStr,
182
- appId: aid,
183
- databaseId: did
184
- };
185
- applyCount(labels, count);
186
- if (dur > 0) {
187
- applyDuration(labels, dur);
188
- }
199
+ const dur = parseInt(durs[field] || '0', 10) || 0;
200
+ const parts = field.split(FIELD_SEP);
201
+ if (parts.length !== 5) {
202
+ continue;
203
+ }
204
+ const [m, route, statusStr, aid, did] = parts;
205
+ sumRequests += count;
206
+ if (samples.length < 3) {
207
+ samples.push(`${m} ${route} ${statusStr} x${count}`);
208
+ }
209
+ const labels = {
210
+ method: m,
211
+ route,
212
+ status_code: statusStr,
213
+ appId: aid,
214
+ databaseId: did
215
+ };
216
+ applyCount(labels, count);
217
+ if (dur > 0) {
218
+ applyDuration(labels, dur);
189
219
  }
190
- console.warn(`${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
191
- } catch (e) {
192
- console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
193
220
  }
194
- finish();
195
- });
221
+ httpMetricsTraceLog(`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
222
+ resolve(true);
223
+ } catch (e) {
224
+ console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
225
+ resolve(false);
226
+ }
196
227
  });
197
228
  });
198
229
  }
@@ -202,6 +233,7 @@ module.exports = {
202
233
  buildFieldKey,
203
234
  FIELD_SEP,
204
235
  isRedisPeerInstalled,
205
- DEFAULT_HTTP_METRICS_REDIS_TTL_SEC
236
+ DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
237
+ httpMetricsTraceLog
206
238
  };
207
239
  //# sourceMappingURL=httpMetricsRedisStore.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","LOG","DRAIN_LUA","isRedisPeerInstalled","require","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","String","join","hgetallPairsToObject","pairs","o","length","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","lockKey","_ensureClient","record","durationMs","client","field","dur","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","warn","process","pid","e","flushToCounters","applyCount","applyDuration","Promise","set","setErr","ok","eval","evalErr","raw","finish","del","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","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 LOG = '[http-metrics-redis]'\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 * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].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`, `set`, `del`.\n *\n * **Stored metrics:** two hashes per app/segment — `:count` (HINCRBY per route group) and `:dur`\n * (sum of duration ms per same field). Hash field = `method\\\\x1eroute\\\\x1estatus\\\\x1eappId\\\\x1edatabaseId`.\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 this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`\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 {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\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 return\n }\n console.warn(\n `${LOG} 2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`\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.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {\n if (setErr || ok !== 'OK') {\n console.warn(\n `${LOG} 3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${\n setErr ? `error:${setErr.message}` : 'lock_not_acquired'\n }`\n )\n resolve(false)\n return\n }\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n const finish = () => {\n client.del(this.lockKey, () => resolve(true))\n }\n\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n finish()\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n console.warn(\n `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`\n )\n finish()\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n let sumRequests = 0\n const samples = []\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 if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n console.warn(\n `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\n )\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n }\n finish()\n }\n )\n })\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n isRedisPeerInstalled,\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","LOG","clusterHint","c","require","isWorker","worker","id","_","truncField","s","max","t","String","length","slice","httpMetricsTraceLog","body","line","console","log","process","stderr","write","DRAIN_LUA","isRedisPeerInstalled","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","join","hgetallPairsToObject","pairs","o","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","field","dur","Math","round","Number","pid","multi","hincrby","expire","exec","err","error","message","e","flushToCounters","applyCount","applyDuration","Promise","eval","evalErr","raw","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","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 LOG = '[http-metrics-redis]'\n\n/**\n * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.\n * @returns {string}\n */\nfunction clusterHint() {\n try {\n // eslint-disable-next-line global-require\n const c = require('cluster')\n if (c.isWorker && c.worker != null) {\n return ` cluster_worker=${c.worker.id}`\n }\n } catch (_) {\n /* cluster not in use */\n }\n return ''\n}\n\nfunction truncField(s, max = 96) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\n\n/**\n * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**\n * so hosts that treat streams differently still show something.\n * @param {string} body - Line body after `[http-metrics-redis]` prefix.\n */\nfunction httpMetricsTraceLog(body) {\n const line = `${LOG} ${body}${clusterHint()}`\n console.log(line)\n try {\n process.stderr.write(`${line}\\n`)\n } catch (_) {\n /* ignore */\n }\n}\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 * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].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**. The hash has **many fields** — not\n * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,\n * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`\n * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).\n * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.\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 {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n httpMetricsTraceLog(\n `save_to_redis pid=${process.pid} countKey=${this.countKey} ` +\n `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`\n )\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 return\n }\n httpMetricsTraceLog(\n `save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`\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 httpMetricsTraceLog(\n `get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`\n )\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 httpMetricsTraceLog(\n `get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`\n )\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 let sumRequests = 0\n const samples = []\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 if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n httpMetricsTraceLog(\n `get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\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 isRedisPeerInstalled,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n httpMetricsTraceLog,\n}\n"],"mappings":";;AAAA;AACA;AACA;AACA;AACA,MAAMA,SAAS,GAAG,MAAM;;AAExB;AACA;AACA;AACA;AACA,MAAMC,kCAAkC,GAAG,GAAG;AAE9C,MAAMC,GAAG,GAAG,sBAAsB;;AAElC;AACA;AACA;AACA;AACA,SAASC,WAAWA,CAAA,EAAG;EACrB,IAAI;IACF;IACA,MAAMC,CAAC,GAAGC,OAAO,CAAC,SAAS,CAAC;IAC5B,IAAID,CAAC,CAACE,QAAQ,IAAIF,CAAC,CAACG,MAAM,IAAI,IAAI,EAAE;MAClC,OAAO,mBAAmBH,CAAC,CAACG,MAAM,CAACC,EAAE,EAAE;IACzC;EACF,CAAC,CAAC,OAAOC,CAAC,EAAE;IACV;EAAA;EAEF,OAAO,EAAE;AACX;AAEA,SAASC,UAAUA,CAACC,CAAC,EAAEC,GAAG,GAAG,EAAE,EAAE;EAC/B,MAAMC,CAAC,GAAGC,MAAM,CAACH,CAAC,CAAC;EACnB,OAAOE,CAAC,CAACE,MAAM,GAAGH,GAAG,GAAG,GAAGC,CAAC,CAACG,KAAK,CAAC,CAAC,EAAEJ,GAAG,GAAG,CAAC,CAAC,KAAK,GAAGC,CAAC;AACzD;;AAEA;AACA;AACA;AACA;AACA;AACA,SAASI,mBAAmBA,CAACC,IAAI,EAAE;EACjC,MAAMC,IAAI,GAAG,GAAGjB,GAAG,IAAIgB,IAAI,GAAGf,WAAW,CAAC,CAAC,EAAE;EAC7CiB,OAAO,CAACC,GAAG,CAACF,IAAI,CAAC;EACjB,IAAI;IACFG,OAAO,CAACC,MAAM,CAACC,KAAK,CAAC,GAAGL,IAAI,IAAI,CAAC;EACnC,CAAC,CAAC,OAAOV,CAAC,EAAE;IACV;EAAA;AAEJ;AAEA,MAAMgB,SAAS,GAAG;AAClB;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;;AAED;AACA;AACA;AACA,SAASC,oBAAoBA,CAAA,EAAG;EAC9B,IAAI;IACFrB,OAAO,CAACsB,OAAO,CAAC,OAAO,CAAC;IACxB,OAAO,IAAI;EACb,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;AACF;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASC,aAAaA,CAACC,MAAM,EAAEC,KAAK,EAAEC,UAAU,EAAEC,KAAK,EAAEC,UAAU,EAAE;EACnE,OAAO,CAACJ,MAAM,EAAEC,KAAK,EAAEhB,MAAM,CAACiB,UAAU,CAAC,EAAEC,KAAK,EAAEC,UAAU,CAAC,CAACC,IAAI,CAAClC,SAAS,CAAC;AAC/E;AAEA,SAASmC,oBAAoBA,CAACC,KAAK,EAAE;EACnC,MAAMC,CAAC,GAAG,CAAC,CAAC;EACZ,IAAI,CAACD,KAAK,IAAI,CAACA,KAAK,CAACrB,MAAM,EAAE;IAC3B,OAAOsB,CAAC;EACV;EACA,KAAK,IAAIC,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGF,KAAK,CAACrB,MAAM,EAAEuB,CAAC,IAAI,CAAC,EAAE;IACxCD,CAAC,CAACD,KAAK,CAACE,CAAC,CAAC,CAAC,GAAGF,KAAK,CAACE,CAAC,GAAG,CAAC,CAAC;EAC5B;EACA,OAAOD,CAAC;AACV;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAME,qBAAqB,CAAC;EAC1B;AACF;AACA;AACA;AACA;AACA;AACA;EACEC,WAAWA,CAAC;IAAEC,WAAW;IAAEC,OAAO;IAAEC,WAAW;IAAEC;EAAO,CAAC,EAAE;IACzD,IAAIH,WAAW,IAAI,IAAI,EAAE;MACvB,MAAM,IAAII,KAAK,CAAC,gDAAgD,CAAC;IACnE;IACA,IAAI,CAACC,OAAO,GAAGL,WAAW;IAC1B,IAAI,CAACG,MAAM,GACT,OAAOA,MAAM,KAAK,QAAQ,IAAIA,MAAM,GAAG,CAAC,GACpCA,MAAM,GACN3C,kCAAkC;IACxC,MAAM8C,MAAM,GAAG,GAAGC,kBAAkB,CAACN,OAAO,CAAC,IAAIM,kBAAkB,CACjEL,WACF,CAAC,EAAE;IACH,IAAI,CAACM,QAAQ,GAAG,mBAAmBF,MAAM,QAAQ;IACjD,IAAI,CAACG,MAAM,GAAG,mBAAmBH,MAAM,MAAM;EAC/C;;EAEA;AACF;AACA;AACA;EACEI,aAAaA,CAAA,EAAG;IACd,OAAO,IAAI,CAACL,OAAO;EACrB;;EAEA;AACF;AACA;AACA;AACA;AACA;AACA;AACA;EACEM,MAAMA,CAACvB,MAAM,EAAEC,KAAK,EAAEC,UAAU,EAAEC,KAAK,EAAEC,UAAU,EAAEoB,UAAU,EAAE;IAC/D,IAAI;MACF,MAAMC,MAAM,GAAG,IAAI,CAACH,aAAa,CAAC,CAAC;MACnC,MAAMI,KAAK,GAAG3B,aAAa,CAACC,MAAM,EAAEC,KAAK,EAAEC,UAAU,EAAEC,KAAK,EAAEC,UAAU,CAAC;MACzE,MAAMuB,GAAG,GAAGC,IAAI,CAAC7C,GAAG,CAAC,CAAC,EAAE6C,IAAI,CAACC,KAAK,CAACC,MAAM,CAACN,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;MAC5DpC,mBAAmB,CACjB,qBAAqBK,OAAO,CAACsC,GAAG,aAAa,IAAI,CAACX,QAAQ,GAAG,GAC3D,UAAU,IAAI,CAACC,MAAM,UAAUxC,UAAU,CAAC6C,KAAK,CAAC,eAAeC,GAAG,sBACtE,CAAC;MACDF,MAAM,CACHO,KAAK,CAAC,CAAC,CACPC,OAAO,CAAC,IAAI,CAACb,QAAQ,EAAEM,KAAK,EAAE,CAAC,CAAC,CAChCO,OAAO,CAAC,IAAI,CAACZ,MAAM,EAAEK,KAAK,EAAEC,GAAG,CAAC,CAChCO,MAAM,CAAC,IAAI,CAACd,QAAQ,EAAE,IAAI,CAACL,MAAM,CAAC,CAClCmB,MAAM,CAAC,IAAI,CAACb,MAAM,EAAE,IAAI,CAACN,MAAM,CAAC,CAChCoB,IAAI,CAACC,GAAG,IAAI;QACX,IAAIA,GAAG,EAAE;UACP7C,OAAO,CAAC8C,KAAK,CAAC,wCAAwC,EAAED,GAAG,CAACE,OAAO,CAAC;UACpE;QACF;QACAlD,mBAAmB,CACjB,wBAAwBK,OAAO,CAACsC,GAAG,aAAa,IAAI,CAACX,QAAQ,WAAW,IAAI,CAACC,MAAM,qBACrF,CAAC;MACH,CAAC,CAAC;IACN,CAAC,CAAC,OAAOkB,CAAC,EAAE;MACVhD,OAAO,CAAC8C,KAAK,CAAC,iCAAiC,EAAEE,CAAC,CAACD,OAAO,CAAC;IAC7D;EACF;;EAEA;AACF;AACA;AACA;AACA;EACEE,eAAeA,CAACC,UAAU,EAAEC,aAAa,EAAE;IACzC,IAAIjB,MAAM;IACV,IAAI;MACFA,MAAM,GAAG,IAAI,CAACH,aAAa,CAAC,CAAC;IAC/B,CAAC,CAAC,OAAOiB,CAAC,EAAE;MACVhD,OAAO,CAAC8C,KAAK,CAAC,gCAAgC,EAAEE,CAAC,CAACD,OAAO,CAAC;MAC1D,OAAOK,OAAO,CAAC7C,OAAO,CAAC,KAAK,CAAC;IAC/B;IACA,OAAO,IAAI6C,OAAO,CAAC7C,OAAO,IAAI;MAC5BV,mBAAmB,CACjB,0BAA0BK,OAAO,CAACsC,GAAG,aAAa,IAAI,CAACX,QAAQ,eACjE,CAAC;MACDK,MAAM,CAACmB,IAAI,CACThD,SAAS,EACT,CAAC,EACD,IAAI,CAACwB,QAAQ,EACb,IAAI,CAACC,MAAM,EACX,CAACwB,OAAO,EAAEC,GAAG,KAAK;QAChB,IAAID,OAAO,EAAE;UACXtD,OAAO,CAAC8C,KAAK,CACX,uCAAuC,EACvCQ,OAAO,CAACP,OACV,CAAC;UACDxC,OAAO,CAAC,KAAK,CAAC;UACd;QACF;QAEA,IAAI;UACF,IAAI,CAACgD,GAAG,IAAI,CAACC,KAAK,CAACC,OAAO,CAACF,GAAG,CAAC,IAAIA,GAAG,CAAC5D,MAAM,GAAG,CAAC,EAAE;YACjDE,mBAAmB,CACjB,4BAA4BK,OAAO,CAACsC,GAAG,aAAa,IAAI,CAACX,QAAQ,uDACnE,CAAC;YACDtB,OAAO,CAAC,IAAI,CAAC;YACb;UACF;UACA,MAAMmD,MAAM,GAAG3C,oBAAoB,CAACwC,GAAG,CAAC,CAAC,CAAC,CAAC;UAC3C,MAAMI,IAAI,GAAG5C,oBAAoB,CAACwC,GAAG,CAAC,CAAC,CAAC,CAAC;UACzC,MAAMK,SAAS,GAAGC,MAAM,CAACC,IAAI,CAACJ,MAAM,CAAC;UACrC,IAAIK,WAAW,GAAG,CAAC;UACnB,MAAMC,OAAO,GAAG,EAAE;UAClB,KAAK,MAAM7B,KAAK,IAAIyB,SAAS,EAAE;YAC7B,MAAMK,KAAK,GAAGC,QAAQ,CAACR,MAAM,CAACvB,KAAK,CAAC,EAAE,EAAE,CAAC;YACzC,IAAI,CAAC8B,KAAK,IAAIA,KAAK,GAAG,CAAC,EAAE;cACvB;YACF;YACA,MAAM7B,GAAG,GAAG8B,QAAQ,CAACP,IAAI,CAACxB,KAAK,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC;YACjD,MAAMgC,KAAK,GAAGhC,KAAK,CAACiC,KAAK,CAACxF,SAAS,CAAC;YACpC,IAAIuF,KAAK,CAACxE,MAAM,KAAK,CAAC,EAAE;cACtB;YACF;YACA,MAAM,CAAC0E,CAAC,EAAE3D,KAAK,EAAE4D,SAAS,EAAEC,GAAG,EAAEC,GAAG,CAAC,GAAGL,KAAK;YAC7CJ,WAAW,IAAIE,KAAK;YACpB,IAAID,OAAO,CAACrE,MAAM,GAAG,CAAC,EAAE;cACtBqE,OAAO,CAACS,IAAI,CAAC,GAAGJ,CAAC,IAAI3D,KAAK,IAAI4D,SAAS,KAAKL,KAAK,EAAE,CAAC;YACtD;YACA,MAAMS,MAAM,GAAG;cACbjE,MAAM,EAAE4D,CAAC;cACT3D,KAAK;cACLiE,WAAW,EAAEL,SAAS;cACtB1D,KAAK,EAAE2D,GAAG;cACV1D,UAAU,EAAE2D;YACd,CAAC;YACDtB,UAAU,CAACwB,MAAM,EAAET,KAAK,CAAC;YACzB,IAAI7B,GAAG,GAAG,CAAC,EAAE;cACXe,aAAa,CAACuB,MAAM,EAAEtC,GAAG,CAAC;YAC5B;UACF;UACAvC,mBAAmB,CACjB,yBAAyBK,OAAO,CAACsC,GAAG,aAAa,IAAI,CAACX,QAAQ,gBAC5D+B,SAAS,CAACjE,MAAM,iBACDoE,WAAW,WAAWC,OAAO,CAAClD,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EACnE,CAAC;UACDP,OAAO,CAAC,IAAI,CAAC;QACf,CAAC,CAAC,OAAOyC,CAAC,EAAE;UACVhD,OAAO,CAAC8C,KAAK,CACX,6CAA6C,EAC7CE,CAAC,CAACD,OACJ,CAAC;UACDxC,OAAO,CAAC,KAAK,CAAC;QAChB;MACF,CACF,CAAC;IACH,CAAC,CAAC;EACJ;AACF;AAEAqE,MAAM,CAACC,OAAO,GAAG;EACf1D,qBAAqB;EACrBX,aAAa;EACb5B,SAAS;EACT0B,oBAAoB;EACpBzB,kCAAkC;EAClCgB;AACF,CAAC","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adalo/metrics",
3
- "version": "0.0.0-staging.23",
3
+ "version": "0.0.0-staging.26",
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",
@@ -31,10 +31,10 @@
31
31
  "@types/pg": "^8.15.6"
32
32
  },
33
33
  "devDependencies": {
34
- "@babel/cli": "7.18.6",
35
- "@babel/core": "7.18.6",
36
- "@babel/preset-env": "7.18.6",
37
- "@babel/preset-typescript": "7.18.6",
34
+ "@babel/cli": "7.24.7",
35
+ "@babel/core": "7.24.7",
36
+ "@babel/preset-env": "7.24.7",
37
+ "@babel/preset-typescript": "7.24.7",
38
38
  "@types/ioredis": "^5.0.0",
39
39
  "@types/jest": "28.1.6",
40
40
  "@types/node": "17.0.38",
@@ -1,6 +1,4 @@
1
- const { HttpMetricsRedisStore } = require('./httpMetricsRedisStore')
2
-
3
- const LOG = '[http-metrics-redis]'
1
+ const { HttpMetricsRedisStore, httpMetricsTraceLog } = require('./httpMetricsRedisStore')
4
2
 
5
3
  function trunc(s, max = 120) {
6
4
  const t = String(s)
@@ -17,19 +15,20 @@ class HttpMetricsRedisRecorder {
17
15
  /**
18
16
  * @param {Object} opts
19
17
  * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
20
- * @param {string} opts.appName Application name (must match collector; typically `BUILD_APP_NAME`).
21
- * @param {string} opts.processType Logical process segment in Redis keys (e.g. `web`; must match collector’s key segment).
18
+ * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.
19
+ * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).
22
20
  * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
23
21
  */
24
- constructor({ redisClient, appName, processType, ttlSec }) {
22
+ constructor({ redisClient, appName, processType = 'web', ttlSec } = {}) {
25
23
  if (redisClient == null) {
26
24
  throw new Error('HttpMetricsRedisRecorder: redisClient is required')
27
25
  }
26
+ const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app'
28
27
  this.processType = processType
29
- this.appName = appName
28
+ this.appName = resolvedAppName
30
29
  this._store = new HttpMetricsRedisStore({
31
30
  redisClient,
32
- appName,
31
+ appName: resolvedAppName,
33
32
  processType,
34
33
  ttlSec,
35
34
  })
@@ -52,10 +51,11 @@ class HttpMetricsRedisRecorder {
52
51
  databaseId = '',
53
52
  duration,
54
53
  }) {
55
- console.warn(
56
- `${LOG} 1_track pid=${process.pid} app=${this.appName} segment=${this.processType} ` +
54
+ httpMetricsTraceLog(
55
+ `track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` +
57
56
  `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +
58
- `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)}`
57
+ `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)} ` +
58
+ `(then save_to_redis lines from store)`
59
59
  )
60
60
  this._store.record(method, route, status_code, appId, databaseId, duration)
61
61
  }
@@ -12,6 +12,43 @@ const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120
12
12
 
13
13
  const LOG = '[http-metrics-redis]'
14
14
 
15
+ /**
16
+ * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.
17
+ * @returns {string}
18
+ */
19
+ function clusterHint() {
20
+ try {
21
+ // eslint-disable-next-line global-require
22
+ const c = require('cluster')
23
+ if (c.isWorker && c.worker != null) {
24
+ return ` cluster_worker=${c.worker.id}`
25
+ }
26
+ } catch (_) {
27
+ /* cluster not in use */
28
+ }
29
+ return ''
30
+ }
31
+
32
+ function truncField(s, max = 96) {
33
+ const t = String(s)
34
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t
35
+ }
36
+
37
+ /**
38
+ * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
39
+ * so hosts that treat streams differently still show something.
40
+ * @param {string} body - Line body after `[http-metrics-redis]` prefix.
41
+ */
42
+ function httpMetricsTraceLog(body) {
43
+ const line = `${LOG} ${body}${clusterHint()}`
44
+ console.log(line)
45
+ try {
46
+ process.stderr.write(`${line}\n`)
47
+ } catch (_) {
48
+ /* ignore */
49
+ }
50
+ }
51
+
15
52
  const DRAIN_LUA = `
16
53
  local function drain(key)
17
54
  local v = redis.call('HGETALL', key)
@@ -58,10 +95,13 @@ function hgetallPairsToObject(pairs) {
58
95
 
59
96
  /**
60
97
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
61
- * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.
98
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
62
99
  *
63
- * **Stored metrics:** two hashes per app/segment `:count` (HINCRBY per route group) and `:dur`
64
- * (sum of duration ms per same field). Hash field = `method\\x1eroute\\x1estatus\\x1eappId\\x1edatabaseId`.
100
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
101
+ * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
102
+ * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
103
+ * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
104
+ * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
65
105
  */
66
106
  class HttpMetricsRedisStore {
67
107
  /**
@@ -85,7 +125,6 @@ class HttpMetricsRedisStore {
85
125
  )}`
86
126
  this.countKey = `metrics:http:v2:${keySeg}:count`
87
127
  this.durKey = `metrics:http:v2:${keySeg}:dur`
88
- this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`
89
128
  }
90
129
 
91
130
  /**
@@ -109,6 +148,10 @@ class HttpMetricsRedisStore {
109
148
  const client = this._ensureClient()
110
149
  const field = buildFieldKey(method, route, statusCode, appId, databaseId)
111
150
  const dur = Math.max(0, Math.round(Number(durationMs) || 0))
151
+ httpMetricsTraceLog(
152
+ `save_to_redis pid=${process.pid} countKey=${this.countKey} ` +
153
+ `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`
154
+ )
112
155
  client
113
156
  .multi()
114
157
  .hincrby(this.countKey, field, 1)
@@ -120,8 +163,8 @@ class HttpMetricsRedisStore {
120
163
  console.error('[HttpMetricsRedisStore] record failed:', err.message)
121
164
  return
122
165
  }
123
- console.warn(
124
- `${LOG} 2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`
166
+ httpMetricsTraceLog(
167
+ `save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`
125
168
  )
126
169
  })
127
170
  } catch (e) {
@@ -143,90 +186,79 @@ class HttpMetricsRedisStore {
143
186
  return Promise.resolve(false)
144
187
  }
145
188
  return new Promise(resolve => {
146
- client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {
147
- if (setErr || ok !== 'OK') {
148
- console.warn(
149
- `${LOG} 3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${
150
- setErr ? `error:${setErr.message}` : 'lock_not_acquired'
151
- }`
152
- )
153
- resolve(false)
154
- return
155
- }
156
- client.eval(
157
- DRAIN_LUA,
158
- 2,
159
- this.countKey,
160
- this.durKey,
161
- (evalErr, raw) => {
162
- const finish = () => {
163
- client.del(this.lockKey, () => resolve(true))
164
- }
189
+ httpMetricsTraceLog(
190
+ `get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`
191
+ )
192
+ client.eval(
193
+ DRAIN_LUA,
194
+ 2,
195
+ this.countKey,
196
+ this.durKey,
197
+ (evalErr, raw) => {
198
+ if (evalErr) {
199
+ console.error(
200
+ '[HttpMetricsRedisStore] drain failed:',
201
+ evalErr.message
202
+ )
203
+ resolve(false)
204
+ return
205
+ }
165
206
 
166
- if (evalErr) {
167
- console.error(
168
- '[HttpMetricsRedisStore] drain failed:',
169
- evalErr.message
207
+ try {
208
+ if (!raw || !Array.isArray(raw) || raw.length < 2) {
209
+ httpMetricsTraceLog(
210
+ `get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`
170
211
  )
171
- finish()
212
+ resolve(true)
172
213
  return
173
214
  }
174
-
175
- try {
176
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
177
- console.warn(
178
- `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`
179
- )
180
- finish()
181
- return
215
+ const counts = hgetallPairsToObject(raw[0])
216
+ const durs = hgetallPairsToObject(raw[1])
217
+ const fieldKeys = Object.keys(counts)
218
+ let sumRequests = 0
219
+ const samples = []
220
+ for (const field of fieldKeys) {
221
+ const count = parseInt(counts[field], 10)
222
+ if (!count || count < 1) {
223
+ continue
182
224
  }
183
- const counts = hgetallPairsToObject(raw[0])
184
- const durs = hgetallPairsToObject(raw[1])
185
- const fieldKeys = Object.keys(counts)
186
- let sumRequests = 0
187
- const samples = []
188
- for (const field of fieldKeys) {
189
- const count = parseInt(counts[field], 10)
190
- if (!count || count < 1) {
191
- continue
192
- }
193
- const dur = parseInt(durs[field] || '0', 10) || 0
194
- const parts = field.split(FIELD_SEP)
195
- if (parts.length !== 5) {
196
- continue
197
- }
198
- const [m, route, statusStr, aid, did] = parts
199
- sumRequests += count
200
- if (samples.length < 3) {
201
- samples.push(`${m} ${route} ${statusStr} x${count}`)
202
- }
203
- const labels = {
204
- method: m,
205
- route,
206
- status_code: statusStr,
207
- appId: aid,
208
- databaseId: did,
209
- }
210
- applyCount(labels, count)
211
- if (dur > 0) {
212
- applyDuration(labels, dur)
213
- }
225
+ const dur = parseInt(durs[field] || '0', 10) || 0
226
+ const parts = field.split(FIELD_SEP)
227
+ if (parts.length !== 5) {
228
+ continue
229
+ }
230
+ const [m, route, statusStr, aid, did] = parts
231
+ sumRequests += count
232
+ if (samples.length < 3) {
233
+ samples.push(`${m} ${route} ${statusStr} x${count}`)
234
+ }
235
+ const labels = {
236
+ method: m,
237
+ route,
238
+ status_code: statusStr,
239
+ appId: aid,
240
+ databaseId: did,
241
+ }
242
+ applyCount(labels, count)
243
+ if (dur > 0) {
244
+ applyDuration(labels, dur)
214
245
  }
215
- console.warn(
216
- `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${
217
- fieldKeys.length
218
- } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
219
- )
220
- } catch (e) {
221
- console.error(
222
- '[HttpMetricsRedisStore] flush apply failed:',
223
- e.message
224
- )
225
246
  }
226
- finish()
247
+ httpMetricsTraceLog(
248
+ `get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${
249
+ fieldKeys.length
250
+ } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
251
+ )
252
+ resolve(true)
253
+ } catch (e) {
254
+ console.error(
255
+ '[HttpMetricsRedisStore] flush apply failed:',
256
+ e.message
257
+ )
258
+ resolve(false)
227
259
  }
228
- )
229
- })
260
+ }
261
+ )
230
262
  })
231
263
  }
232
264
  }
@@ -237,4 +269,5 @@ module.exports = {
237
269
  FIELD_SEP,
238
270
  isRedisPeerInstalled,
239
271
  DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
272
+ httpMetricsTraceLog,
240
273
  }