@adalo/metrics 0.0.0-staging.24 → 0.0.0-staging.27

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,8 @@ describe('HttpMetricsRedisStore', () => {
82
138
  })
83
139
 
84
140
  describe('flushToCounters', () => {
85
- it('returns false when lock is not acquired', async () => {
86
- const redis = createRedisV3Mock()
87
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
88
- cb(null, null)
89
- })
90
- const store = new HttpMetricsRedisStore({
91
- redisClient: redis,
92
- appName: 'app',
93
- processType: 'web',
94
- })
95
- const ok = await store.flushToCounters(jest.fn(), jest.fn())
96
- expect(ok).toBe(false)
97
- expect(redis.eval).not.toHaveBeenCalled()
98
- })
99
-
100
141
  it('applies aggregated count and summed duration for one route (many requests → one field)', async () => {
101
142
  const redis = createRedisV3Mock()
102
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
103
- cb(null, 'OK')
104
- })
105
143
  const field = buildFieldKey('GET', '/api/items', 200, '', '')
106
144
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
107
145
  cb(null, [
@@ -109,11 +147,6 @@ describe('HttpMetricsRedisStore', () => {
109
147
  [field, '4500'],
110
148
  ])
111
149
  })
112
- redis.del.mockImplementation((key, cb) => {
113
- if (cb) {
114
- cb()
115
- }
116
- })
117
150
 
118
151
  const store = new HttpMetricsRedisStore({
119
152
  redisClient: redis,
@@ -150,9 +183,6 @@ describe('HttpMetricsRedisStore', () => {
150
183
 
151
184
  it('drains hashes and applies count and duration', async () => {
152
185
  const redis = createRedisV3Mock()
153
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
154
- cb(null, 'OK')
155
- })
156
186
  const field = buildFieldKey('POST', '/r', 201, 'a1', 'd1')
157
187
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
158
188
  cb(null, [
@@ -160,11 +190,6 @@ describe('HttpMetricsRedisStore', () => {
160
190
  [field, '50'],
161
191
  ])
162
192
  })
163
- redis.del.mockImplementation((key, cb) => {
164
- if (cb) {
165
- cb()
166
- }
167
- })
168
193
 
169
194
  const store = new HttpMetricsRedisStore({
170
195
  redisClient: redis,
@@ -208,17 +233,9 @@ describe('HttpMetricsRedisStore', () => {
208
233
 
209
234
  it('resolves true with no applies when eval returns short array', async () => {
210
235
  const redis = createRedisV3Mock()
211
- redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
212
- cb(null, 'OK')
213
- })
214
236
  redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
215
237
  cb(null, [])
216
238
  })
217
- redis.del.mockImplementation((key, cb) => {
218
- if (cb) {
219
- cb()
220
- }
221
- })
222
239
  const store = new HttpMetricsRedisStore({
223
240
  redisClient: redis,
224
241
  appName: 'app',
@@ -230,4 +247,159 @@ describe('HttpMetricsRedisStore', () => {
230
247
  expect(applyCount).not.toHaveBeenCalled()
231
248
  })
232
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
+ })
233
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":"AAOA;;;;;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"}
@@ -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
- httpMetricsTraceLog(`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","httpMetricsTraceLog","require","trunc","s","max","t","String","length","slice","HttpMetricsRedisRecorder","constructor","redisClient","appName","processType","ttlSec","Error","_store","trackHttpRequest","method","route","status_code","appId","databaseId","duration","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, 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 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 httpMetricsTraceLog(\n `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,11 +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). Same `(method, route, status, appId, databaseId)` → same hash field;
7
- * many requests in an interval **add** to count and **sum** durations (Redis `HINCRBY`). Drain applies those
8
- * totals to `app_requests_total` / `app_requests_total_duration`.
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.
9
10
  */
10
11
  export class HttpMetricsRedisStore {
11
12
  /**
@@ -25,7 +26,6 @@ export class HttpMetricsRedisStore {
25
26
  ttlSec: number;
26
27
  countKey: string;
27
28
  durKey: string;
28
- lockKey: string;
29
29
  /**
30
30
  * @returns {import('redis').RedisClient}
31
31
  * @private
@@ -71,7 +71,9 @@ export function isRedisPeerInstalled(): boolean;
71
71
  */
72
72
  export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
73
73
  /**
74
- * Stdout trace (uses **console.log**, not warn same as typical app request logs).
74
+ * Trace line for SAVE (web Redis) and GET/drain (collector Redis).
75
+ * Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
76
+ * show duplicate lines if we also wrote the same text to stderr.
75
77
  * @param {string} body - Line body after `[http-metrics-redis]` prefix.
76
78
  */
77
79
  export function httpMetricsTraceLog(body: string): void;
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AAmFA;;;;;;;;GAQG;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;AAtMD;;;;;;;GAOG;AACH,sCAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AAtED;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AA4CxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AApDD;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AAqB9C;;;GAGG;AACH,0CAFW,MAAM,QAIhB"}
1
+ {"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA2FA;;;;;;;;;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;AA9ED;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAoDxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AA5DD;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AA0B9C;;;;;GAKG;AACH,0CAFW,MAAM,QAKhB"}
@@ -29,13 +29,20 @@ function clusterHint() {
29
29
  }
30
30
  return '';
31
31
  }
32
+ function truncField(s, max = 96) {
33
+ const t = String(s);
34
+ return t.length > max ? `${t.slice(0, max - 3)}...` : t;
35
+ }
32
36
 
33
37
  /**
34
- * Stdout trace (uses **console.log**, not warn same as typical app request logs).
38
+ * Trace line for SAVE (web Redis) and GET/drain (collector Redis).
39
+ * Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
40
+ * show duplicate lines if we also wrote the same text to stderr.
35
41
  * @param {string} body - Line body after `[http-metrics-redis]` prefix.
36
42
  */
37
43
  function httpMetricsTraceLog(body) {
38
- console.log(`${LOG} ${body}${clusterHint()}`);
44
+ const line = `${LOG} ${body}${clusterHint()}`;
45
+ console.log(line);
39
46
  }
40
47
  const DRAIN_LUA = `
41
48
  local function drain(key)
@@ -82,12 +89,13 @@ function hgetallPairsToObject(pairs) {
82
89
 
83
90
  /**
84
91
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
85
- * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.
92
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
86
93
  *
87
- * **Stored metrics:** two hashes per app/segment `:count` (HINCRBY per route group) and `:dur`
88
- * (sum of duration ms per same field). Same `(method, route, status, appId, databaseId)` → same hash field;
89
- * many requests in an interval **add** to count and **sum** durations (Redis `HINCRBY`). Drain applies those
90
- * totals to `app_requests_total` / `app_requests_total_duration`.
94
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
95
+ * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
96
+ * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
97
+ * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
98
+ * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
91
99
  */
92
100
  class HttpMetricsRedisStore {
93
101
  /**
@@ -111,7 +119,6 @@ class HttpMetricsRedisStore {
111
119
  const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
112
120
  this.countKey = `metrics:http:v2:${keySeg}:count`;
113
121
  this.durKey = `metrics:http:v2:${keySeg}:dur`;
114
- this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`;
115
122
  }
116
123
 
117
124
  /**
@@ -135,12 +142,13 @@ class HttpMetricsRedisStore {
135
142
  const client = this._ensureClient();
136
143
  const field = buildFieldKey(method, route, statusCode, appId, databaseId);
137
144
  const dur = Math.max(0, Math.round(Number(durationMs) || 0));
145
+ httpMetricsTraceLog(`save_to_redis pid=${process.pid} countKey=${this.countKey} ` + `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`);
138
146
  client.multi().hincrby(this.countKey, field, 1).hincrby(this.durKey, field, dur).expire(this.countKey, this.ttlSec).expire(this.durKey, this.ttlSec).exec(err => {
139
147
  if (err) {
140
148
  console.error('[HttpMetricsRedisStore] record failed:', err.message);
141
149
  return;
142
150
  }
143
- httpMetricsTraceLog(`2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`);
151
+ httpMetricsTraceLog(`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`);
144
152
  });
145
153
  } catch (e) {
146
154
  console.error('[HttpMetricsRedisStore] record:', e.message);
@@ -161,65 +169,57 @@ class HttpMetricsRedisStore {
161
169
  return Promise.resolve(false);
162
170
  }
163
171
  return new Promise(resolve => {
164
- client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {
165
- if (setErr || ok !== 'OK') {
166
- httpMetricsTraceLog(`3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${setErr ? `error:${setErr.message}` : 'lock_not_acquired'}`);
172
+ httpMetricsTraceLog(`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`);
173
+ client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
174
+ if (evalErr) {
175
+ console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
167
176
  resolve(false);
168
177
  return;
169
178
  }
170
- client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
171
- const finish = () => {
172
- client.del(this.lockKey, () => resolve(true));
173
- };
174
- if (evalErr) {
175
- console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
176
- finish();
179
+ try {
180
+ if (!raw || !Array.isArray(raw) || raw.length < 2) {
181
+ httpMetricsTraceLog(`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`);
182
+ resolve(true);
177
183
  return;
178
184
  }
179
- try {
180
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
181
- httpMetricsTraceLog(`3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`);
182
- finish();
183
- return;
185
+ const counts = hgetallPairsToObject(raw[0]);
186
+ const durs = hgetallPairsToObject(raw[1]);
187
+ const fieldKeys = Object.keys(counts);
188
+ let sumRequests = 0;
189
+ const samples = [];
190
+ for (const field of fieldKeys) {
191
+ const count = parseInt(counts[field], 10);
192
+ if (!count || count < 1) {
193
+ continue;
194
+ }
195
+ const dur = parseInt(durs[field] || '0', 10) || 0;
196
+ const parts = field.split(FIELD_SEP);
197
+ if (parts.length !== 5) {
198
+ continue;
199
+ }
200
+ const [m, route, statusStr, aid, did] = parts;
201
+ sumRequests += count;
202
+ if (samples.length < 3) {
203
+ samples.push(`${m} ${route} ${statusStr} x${count}`);
184
204
  }
185
- const counts = hgetallPairsToObject(raw[0]);
186
- const durs = hgetallPairsToObject(raw[1]);
187
- const fieldKeys = Object.keys(counts);
188
- let sumRequests = 0;
189
- const samples = [];
190
- for (const field of fieldKeys) {
191
- const count = parseInt(counts[field], 10);
192
- if (!count || count < 1) {
193
- continue;
194
- }
195
- const dur = parseInt(durs[field] || '0', 10) || 0;
196
- const parts = field.split(FIELD_SEP);
197
- if (parts.length !== 5) {
198
- continue;
199
- }
200
- const [m, route, statusStr, aid, did] = parts;
201
- sumRequests += count;
202
- if (samples.length < 3) {
203
- samples.push(`${m} ${route} ${statusStr} x${count}`);
204
- }
205
- const labels = {
206
- method: m,
207
- route,
208
- status_code: statusStr,
209
- appId: aid,
210
- databaseId: did
211
- };
212
- applyCount(labels, count);
213
- if (dur > 0) {
214
- applyDuration(labels, dur);
215
- }
205
+ const labels = {
206
+ method: m,
207
+ route,
208
+ status_code: statusStr,
209
+ appId: aid,
210
+ databaseId: did
211
+ };
212
+ applyCount(labels, count);
213
+ if (dur > 0) {
214
+ applyDuration(labels, dur);
216
215
  }
217
- httpMetricsTraceLog(`3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
218
- } catch (e) {
219
- console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
220
216
  }
221
- finish();
222
- });
217
+ httpMetricsTraceLog(`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
218
+ resolve(true);
219
+ } catch (e) {
220
+ console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
221
+ resolve(false);
222
+ }
223
223
  });
224
224
  });
225
225
  }
@@ -1 +1 @@
1
- {"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","LOG","clusterHint","c","require","isWorker","worker","id","_","httpMetricsTraceLog","body","console","log","DRAIN_LUA","isRedisPeerInstalled","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","error","message","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\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\n/**\n * Stdout trace (uses **console.log**, not warn — same as typical app request logs).\n * @param {string} body - Line body after `[http-metrics-redis]` prefix.\n */\nfunction httpMetricsTraceLog(body) {\n console.log(`${LOG} ${body}${clusterHint()}`)\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`, `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). Same `(method, route, status, appId, databaseId)` → same hash field;\n * many requests in an interval **add** to count and **sum** durations (Redis `HINCRBY`). Drain applies those\n * totals to `app_requests_total` / `app_requests_total_duration`.\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 httpMetricsTraceLog(\n `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 httpMetricsTraceLog(\n `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 httpMetricsTraceLog(\n `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 httpMetricsTraceLog(\n `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 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;AACA;AACA;AACA;AACA,SAASC,mBAAmBA,CAACC,IAAI,EAAE;EACjCC,OAAO,CAACC,GAAG,CAAC,GAAGX,GAAG,IAAIS,IAAI,GAAGR,WAAW,CAAC,CAAC,EAAE,CAAC;AAC/C;AAEA,MAAMW,SAAS,GAAG;AAClB;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;;AAED;AACA;AACA;AACA,SAASC,oBAAoBA,CAAA,EAAG;EAC9B,IAAI;IACFV,OAAO,CAACW,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,EAAEI,MAAM,CAACH,UAAU,CAAC,EAAEC,KAAK,EAAEC,UAAU,CAAC,CAACE,IAAI,CAACxB,SAAS,CAAC;AAC/E;AAEA,SAASyB,oBAAoBA,CAACC,KAAK,EAAE;EACnC,MAAMC,CAAC,GAAG,CAAC,CAAC;EACZ,IAAI,CAACD,KAAK,IAAI,CAACA,KAAK,CAACE,MAAM,EAAE;IAC3B,OAAOD,CAAC;EACV;EACA,KAAK,IAAIE,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGH,KAAK,CAACE,MAAM,EAAEC,CAAC,IAAI,CAAC,EAAE;IACxCF,CAAC,CAACD,KAAK,CAACG,CAAC,CAAC,CAAC,GAAGH,KAAK,CAACG,CAAC,GAAG,CAAC,CAAC;EAC5B;EACA,OAAOF,CAAC;AACV;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,MAAMG,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,GACNlC,kCAAkC;IACxC,MAAMqC,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;IAC7C,IAAI,CAACI,OAAO,GAAG,mBAAmBJ,MAAM,aAAa;EACvD;;EAEA;AACF;AACA;AACA;EACEK,aAAaA,CAAA,EAAG;IACd,OAAO,IAAI,CAACN,OAAO;EACrB;;EAEA;AACF;AACA;AACA;AACA;AACA;AACA;AACA;EACEO,MAAMA,CAAC1B,MAAM,EAAEC,KAAK,EAAEC,UAAU,EAAEC,KAAK,EAAEC,UAAU,EAAEuB,UAAU,EAAE;IAC/D,IAAI;MACF,MAAMC,MAAM,GAAG,IAAI,CAACH,aAAa,CAAC,CAAC;MACnC,MAAMI,KAAK,GAAG9B,aAAa,CAACC,MAAM,EAAEC,KAAK,EAAEC,UAAU,EAAEC,KAAK,EAAEC,UAAU,CAAC;MACzE,MAAM0B,GAAG,GAAGC,IAAI,CAACC,GAAG,CAAC,CAAC,EAAED,IAAI,CAACE,KAAK,CAACC,MAAM,CAACP,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;MAC5DC,MAAM,CACHO,KAAK,CAAC,CAAC,CACPC,OAAO,CAAC,IAAI,CAACd,QAAQ,EAAEO,KAAK,EAAE,CAAC,CAAC,CAChCO,OAAO,CAAC,IAAI,CAACb,MAAM,EAAEM,KAAK,EAAEC,GAAG,CAAC,CAChCO,MAAM,CAAC,IAAI,CAACf,QAAQ,EAAE,IAAI,CAACL,MAAM,CAAC,CAClCoB,MAAM,CAAC,IAAI,CAACd,MAAM,EAAE,IAAI,CAACN,MAAM,CAAC,CAChCqB,IAAI,CAACC,GAAG,IAAI;QACX,IAAIA,GAAG,EAAE;UACP7C,OAAO,CAAC8C,KAAK,CAAC,wCAAwC,EAAED,GAAG,CAACE,OAAO,CAAC;UACpE;QACF;QACAjD,mBAAmB,CACjB,qBAAqBkD,OAAO,CAACC,GAAG,aAAa,IAAI,CAACrB,QAAQ,WAAW,IAAI,CAACC,MAAM,KAClF,CAAC;MACH,CAAC,CAAC;IACN,CAAC,CAAC,OAAOqB,CAAC,EAAE;MACVlD,OAAO,CAAC8C,KAAK,CAAC,iCAAiC,EAAEI,CAAC,CAACH,OAAO,CAAC;IAC7D;EACF;;EAEA;AACF;AACA;AACA;AACA;EACEI,eAAeA,CAACC,UAAU,EAAEC,aAAa,EAAE;IACzC,IAAInB,MAAM;IACV,IAAI;MACFA,MAAM,GAAG,IAAI,CAACH,aAAa,CAAC,CAAC;IAC/B,CAAC,CAAC,OAAOmB,CAAC,EAAE;MACVlD,OAAO,CAAC8C,KAAK,CAAC,gCAAgC,EAAEI,CAAC,CAACH,OAAO,CAAC;MAC1D,OAAOO,OAAO,CAAClD,OAAO,CAAC,KAAK,CAAC;IAC/B;IACA,OAAO,IAAIkD,OAAO,CAAClD,OAAO,IAAI;MAC5B8B,MAAM,CAACqB,GAAG,CAAC,IAAI,CAACzB,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC0B,MAAM,EAAEC,EAAE,KAAK;QAC5D,IAAID,MAAM,IAAIC,EAAE,KAAK,IAAI,EAAE;UACzB3D,mBAAmB,CACjB,0BAA0BkD,OAAO,CAACC,GAAG,aAAa,IAAI,CAACrB,QAAQ,WAC7D4B,MAAM,GAAG,SAASA,MAAM,CAACT,OAAO,EAAE,GAAG,mBAAmB,EAE5D,CAAC;UACD3C,OAAO,CAAC,KAAK,CAAC;UACd;QACF;QACA8B,MAAM,CAACwB,IAAI,CACTxD,SAAS,EACT,CAAC,EACD,IAAI,CAAC0B,QAAQ,EACb,IAAI,CAACC,MAAM,EACX,CAAC8B,OAAO,EAAEC,GAAG,KAAK;UAChB,MAAMC,MAAM,GAAGA,CAAA,KAAM;YACnB3B,MAAM,CAAC4B,GAAG,CAAC,IAAI,CAAChC,OAAO,EAAE,MAAM1B,OAAO,CAAC,IAAI,CAAC,CAAC;UAC/C,CAAC;UAED,IAAIuD,OAAO,EAAE;YACX3D,OAAO,CAAC8C,KAAK,CACX,uCAAuC,EACvCa,OAAO,CAACZ,OACV,CAAC;YACDc,MAAM,CAAC,CAAC;YACR;UACF;UAEA,IAAI;YACF,IAAI,CAACD,GAAG,IAAI,CAACG,KAAK,CAACC,OAAO,CAACJ,GAAG,CAAC,IAAIA,GAAG,CAAC5C,MAAM,GAAG,CAAC,EAAE;cACjDlB,mBAAmB,CACjB,qBAAqBkD,OAAO,CAACC,GAAG,aAAa,IAAI,CAACrB,QAAQ,iDAC5D,CAAC;cACDiC,MAAM,CAAC,CAAC;cACR;YACF;YACA,MAAMI,MAAM,GAAGpD,oBAAoB,CAAC+C,GAAG,CAAC,CAAC,CAAC,CAAC;YAC3C,MAAMM,IAAI,GAAGrD,oBAAoB,CAAC+C,GAAG,CAAC,CAAC,CAAC,CAAC;YACzC,MAAMO,SAAS,GAAGC,MAAM,CAACC,IAAI,CAACJ,MAAM,CAAC;YACrC,IAAIK,WAAW,GAAG,CAAC;YACnB,MAAMC,OAAO,GAAG,EAAE;YAClB,KAAK,MAAMpC,KAAK,IAAIgC,SAAS,EAAE;cAC7B,MAAMK,KAAK,GAAGC,QAAQ,CAACR,MAAM,CAAC9B,KAAK,CAAC,EAAE,EAAE,CAAC;cACzC,IAAI,CAACqC,KAAK,IAAIA,KAAK,GAAG,CAAC,EAAE;gBACvB;cACF;cACA,MAAMpC,GAAG,GAAGqC,QAAQ,CAACP,IAAI,CAAC/B,KAAK,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC;cACjD,MAAMuC,KAAK,GAAGvC,KAAK,CAACwC,KAAK,CAACvF,SAAS,CAAC;cACpC,IAAIsF,KAAK,CAAC1D,MAAM,KAAK,CAAC,EAAE;gBACtB;cACF;cACA,MAAM,CAAC4D,CAAC,EAAErE,KAAK,EAAEsE,SAAS,EAAEC,GAAG,EAAEC,GAAG,CAAC,GAAGL,KAAK;cAC7CJ,WAAW,IAAIE,KAAK;cACpB,IAAID,OAAO,CAACvD,MAAM,GAAG,CAAC,EAAE;gBACtBuD,OAAO,CAACS,IAAI,CAAC,GAAGJ,CAAC,IAAIrE,KAAK,IAAIsE,SAAS,KAAKL,KAAK,EAAE,CAAC;cACtD;cACA,MAAMS,MAAM,GAAG;gBACb3E,MAAM,EAAEsE,CAAC;gBACTrE,KAAK;gBACL2E,WAAW,EAAEL,SAAS;gBACtBpE,KAAK,EAAEqE,GAAG;gBACVpE,UAAU,EAAEqE;cACd,CAAC;cACD3B,UAAU,CAAC6B,MAAM,EAAET,KAAK,CAAC;cACzB,IAAIpC,GAAG,GAAG,CAAC,EAAE;gBACXiB,aAAa,CAAC4B,MAAM,EAAE7C,GAAG,CAAC;cAC5B;YACF;YACAtC,mBAAmB,CACjB,qBAAqBkD,OAAO,CAACC,GAAG,aAAa,IAAI,CAACrB,QAAQ,gBACxDuC,SAAS,CAACnD,MAAM,iBACDsD,WAAW,WAAWC,OAAO,CAAC3D,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EACnE,CAAC;UACH,CAAC,CAAC,OAAOsC,CAAC,EAAE;YACVlD,OAAO,CAAC8C,KAAK,CACX,6CAA6C,EAC7CI,CAAC,CAACH,OACJ,CAAC;UACH;UACAc,MAAM,CAAC,CAAC;QACV,CACF,CAAC;MACH,CAAC,CAAC;IACJ,CAAC,CAAC;EACJ;AACF;AAEAsB,MAAM,CAACC,OAAO,GAAG;EACflE,qBAAqB;EACrBb,aAAa;EACbjB,SAAS;EACTe,oBAAoB;EACpBd,kCAAkC;EAClCS;AACF,CAAC","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","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","process","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).\n * Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would\n * show duplicate lines if we also wrote the same text to stderr.\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}\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":"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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.24",
3
+ "version": "0.0.0-staging.27",
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",
@@ -15,19 +15,20 @@ class HttpMetricsRedisRecorder {
15
15
  /**
16
16
  * @param {Object} opts
17
17
  * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
18
- * @param {string} opts.appName Application name (must match collector; typically `BUILD_APP_NAME`).
19
- * @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}).
20
20
  * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
21
21
  */
22
- constructor({ redisClient, appName, processType, ttlSec }) {
22
+ constructor({ redisClient, appName, processType = 'web', ttlSec } = {}) {
23
23
  if (redisClient == null) {
24
24
  throw new Error('HttpMetricsRedisRecorder: redisClient is required')
25
25
  }
26
+ const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app'
26
27
  this.processType = processType
27
- this.appName = appName
28
+ this.appName = resolvedAppName
28
29
  this._store = new HttpMetricsRedisStore({
29
30
  redisClient,
30
- appName,
31
+ appName: resolvedAppName,
31
32
  processType,
32
33
  ttlSec,
33
34
  })
@@ -51,9 +52,10 @@ class HttpMetricsRedisRecorder {
51
52
  duration,
52
53
  }) {
53
54
  httpMetricsTraceLog(
54
- `1_track pid=${process.pid} app=${this.appName} segment=${this.processType} ` +
55
+ `track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` +
55
56
  `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +
56
- `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)`
57
59
  )
58
60
  this._store.record(method, route, status_code, appId, databaseId, duration)
59
61
  }
@@ -29,12 +29,20 @@ function clusterHint() {
29
29
  return ''
30
30
  }
31
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
+
32
37
  /**
33
- * Stdout trace (uses **console.log**, not warn same as typical app request logs).
38
+ * Trace line for SAVE (web Redis) and GET/drain (collector Redis).
39
+ * Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
40
+ * show duplicate lines if we also wrote the same text to stderr.
34
41
  * @param {string} body - Line body after `[http-metrics-redis]` prefix.
35
42
  */
36
43
  function httpMetricsTraceLog(body) {
37
- console.log(`${LOG} ${body}${clusterHint()}`)
44
+ const line = `${LOG} ${body}${clusterHint()}`
45
+ console.log(line)
38
46
  }
39
47
 
40
48
  const DRAIN_LUA = `
@@ -83,12 +91,13 @@ function hgetallPairsToObject(pairs) {
83
91
 
84
92
  /**
85
93
  * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
86
- * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.
94
+ * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
87
95
  *
88
- * **Stored metrics:** two hashes per app/segment `:count` (HINCRBY per route group) and `:dur`
89
- * (sum of duration ms per same field). Same `(method, route, status, appId, databaseId)` → same hash field;
90
- * many requests in an interval **add** to count and **sum** durations (Redis `HINCRBY`). Drain applies those
91
- * totals to `app_requests_total` / `app_requests_total_duration`.
96
+ * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
97
+ * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
98
+ * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
99
+ * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
100
+ * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
92
101
  */
93
102
  class HttpMetricsRedisStore {
94
103
  /**
@@ -112,7 +121,6 @@ class HttpMetricsRedisStore {
112
121
  )}`
113
122
  this.countKey = `metrics:http:v2:${keySeg}:count`
114
123
  this.durKey = `metrics:http:v2:${keySeg}:dur`
115
- this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`
116
124
  }
117
125
 
118
126
  /**
@@ -136,6 +144,10 @@ class HttpMetricsRedisStore {
136
144
  const client = this._ensureClient()
137
145
  const field = buildFieldKey(method, route, statusCode, appId, databaseId)
138
146
  const dur = Math.max(0, Math.round(Number(durationMs) || 0))
147
+ httpMetricsTraceLog(
148
+ `save_to_redis pid=${process.pid} countKey=${this.countKey} ` +
149
+ `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`
150
+ )
139
151
  client
140
152
  .multi()
141
153
  .hincrby(this.countKey, field, 1)
@@ -148,7 +160,7 @@ class HttpMetricsRedisStore {
148
160
  return
149
161
  }
150
162
  httpMetricsTraceLog(
151
- `2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`
163
+ `save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`
152
164
  )
153
165
  })
154
166
  } catch (e) {
@@ -170,90 +182,79 @@ class HttpMetricsRedisStore {
170
182
  return Promise.resolve(false)
171
183
  }
172
184
  return new Promise(resolve => {
173
- client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {
174
- if (setErr || ok !== 'OK') {
175
- httpMetricsTraceLog(
176
- `3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${
177
- setErr ? `error:${setErr.message}` : 'lock_not_acquired'
178
- }`
179
- )
180
- resolve(false)
181
- return
182
- }
183
- client.eval(
184
- DRAIN_LUA,
185
- 2,
186
- this.countKey,
187
- this.durKey,
188
- (evalErr, raw) => {
189
- const finish = () => {
190
- client.del(this.lockKey, () => resolve(true))
191
- }
185
+ httpMetricsTraceLog(
186
+ `get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`
187
+ )
188
+ client.eval(
189
+ DRAIN_LUA,
190
+ 2,
191
+ this.countKey,
192
+ this.durKey,
193
+ (evalErr, raw) => {
194
+ if (evalErr) {
195
+ console.error(
196
+ '[HttpMetricsRedisStore] drain failed:',
197
+ evalErr.message
198
+ )
199
+ resolve(false)
200
+ return
201
+ }
192
202
 
193
- if (evalErr) {
194
- console.error(
195
- '[HttpMetricsRedisStore] drain failed:',
196
- evalErr.message
203
+ try {
204
+ if (!raw || !Array.isArray(raw) || raw.length < 2) {
205
+ httpMetricsTraceLog(
206
+ `get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`
197
207
  )
198
- finish()
208
+ resolve(true)
199
209
  return
200
210
  }
201
-
202
- try {
203
- if (!raw || !Array.isArray(raw) || raw.length < 2) {
204
- httpMetricsTraceLog(
205
- `3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`
206
- )
207
- finish()
208
- return
211
+ const counts = hgetallPairsToObject(raw[0])
212
+ const durs = hgetallPairsToObject(raw[1])
213
+ const fieldKeys = Object.keys(counts)
214
+ let sumRequests = 0
215
+ const samples = []
216
+ for (const field of fieldKeys) {
217
+ const count = parseInt(counts[field], 10)
218
+ if (!count || count < 1) {
219
+ continue
209
220
  }
210
- const counts = hgetallPairsToObject(raw[0])
211
- const durs = hgetallPairsToObject(raw[1])
212
- const fieldKeys = Object.keys(counts)
213
- let sumRequests = 0
214
- const samples = []
215
- for (const field of fieldKeys) {
216
- const count = parseInt(counts[field], 10)
217
- if (!count || count < 1) {
218
- continue
219
- }
220
- const dur = parseInt(durs[field] || '0', 10) || 0
221
- const parts = field.split(FIELD_SEP)
222
- if (parts.length !== 5) {
223
- continue
224
- }
225
- const [m, route, statusStr, aid, did] = parts
226
- sumRequests += count
227
- if (samples.length < 3) {
228
- samples.push(`${m} ${route} ${statusStr} x${count}`)
229
- }
230
- const labels = {
231
- method: m,
232
- route,
233
- status_code: statusStr,
234
- appId: aid,
235
- databaseId: did,
236
- }
237
- applyCount(labels, count)
238
- if (dur > 0) {
239
- applyDuration(labels, dur)
240
- }
221
+ const dur = parseInt(durs[field] || '0', 10) || 0
222
+ const parts = field.split(FIELD_SEP)
223
+ if (parts.length !== 5) {
224
+ continue
225
+ }
226
+ const [m, route, statusStr, aid, did] = parts
227
+ sumRequests += count
228
+ if (samples.length < 3) {
229
+ samples.push(`${m} ${route} ${statusStr} x${count}`)
230
+ }
231
+ const labels = {
232
+ method: m,
233
+ route,
234
+ status_code: statusStr,
235
+ appId: aid,
236
+ databaseId: did,
237
+ }
238
+ applyCount(labels, count)
239
+ if (dur > 0) {
240
+ applyDuration(labels, dur)
241
241
  }
242
- httpMetricsTraceLog(
243
- `3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${
244
- fieldKeys.length
245
- } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
246
- )
247
- } catch (e) {
248
- console.error(
249
- '[HttpMetricsRedisStore] flush apply failed:',
250
- e.message
251
- )
252
242
  }
253
- finish()
243
+ httpMetricsTraceLog(
244
+ `get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${
245
+ fieldKeys.length
246
+ } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
247
+ )
248
+ resolve(true)
249
+ } catch (e) {
250
+ console.error(
251
+ '[HttpMetricsRedisStore] flush apply failed:',
252
+ e.message
253
+ )
254
+ resolve(false)
254
255
  }
255
- )
256
- })
256
+ }
257
+ )
257
258
  })
258
259
  }
259
260
  }