@adalo/metrics 0.1.172 → 0.1.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +7 -0
  2. package/__tests__/httpMetricsRedisCollector.test.js +203 -0
  3. package/__tests__/httpMetricsRedisRecorder.test.js +60 -0
  4. package/__tests__/httpMetricsRedisStore.test.js +431 -0
  5. package/docs/http-metrics-redis.md +19 -0
  6. package/lib/health/healthCheckClient.js +1 -1
  7. package/lib/health/healthCheckClient.js.map +1 -1
  8. package/lib/health/healthCheckWorker.d.ts.map +1 -1
  9. package/lib/health/healthCheckWorker.js +15 -1
  10. package/lib/health/healthCheckWorker.js.map +1 -1
  11. package/lib/index.d.ts +4 -0
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +44 -0
  14. package/lib/index.js.map +1 -1
  15. package/lib/metrics/baseMetricsClient.d.ts +2 -0
  16. package/lib/metrics/baseMetricsClient.d.ts.map +1 -1
  17. package/lib/metrics/baseMetricsClient.js +6 -3
  18. package/lib/metrics/baseMetricsClient.js.map +1 -1
  19. package/lib/metrics/httpMetricsRedisCollector.d.ts +50 -0
  20. package/lib/metrics/httpMetricsRedisCollector.d.ts.map +1 -0
  21. package/lib/metrics/httpMetricsRedisCollector.js +117 -0
  22. package/lib/metrics/httpMetricsRedisCollector.js.map +1 -0
  23. package/lib/metrics/httpMetricsRedisRecorder.d.ts +48 -0
  24. package/lib/metrics/httpMetricsRedisRecorder.d.ts.map +1 -0
  25. package/lib/metrics/httpMetricsRedisRecorder.js +86 -0
  26. package/lib/metrics/httpMetricsRedisRecorder.js.map +1 -0
  27. package/lib/metrics/httpMetricsRedisStore.d.ts +88 -0
  28. package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -0
  29. package/lib/metrics/httpMetricsRedisStore.js +223 -0
  30. package/lib/metrics/httpMetricsRedisStore.js.map +1 -0
  31. package/lib/metrics/metricsClient.d.ts +34 -27
  32. package/lib/metrics/metricsClient.d.ts.map +1 -1
  33. package/lib/metrics/metricsClient.js +35 -37
  34. package/lib/metrics/metricsClient.js.map +1 -1
  35. package/lib/metrics/metricsDatabaseClient.d.ts.map +1 -1
  36. package/lib/metrics/metricsDatabaseClient.js +6 -1
  37. package/lib/metrics/metricsDatabaseClient.js.map +1 -1
  38. package/lib/metrics/metricsProcessTypeUtils.d.ts +58 -0
  39. package/lib/metrics/metricsProcessTypeUtils.d.ts.map +1 -0
  40. package/lib/metrics/metricsProcessTypeUtils.js +86 -0
  41. package/lib/metrics/metricsProcessTypeUtils.js.map +1 -0
  42. package/lib/metrics/metricsQueueRedisClient.d.ts.map +1 -1
  43. package/lib/metrics/metricsQueueRedisClient.js +5 -0
  44. package/lib/metrics/metricsQueueRedisClient.js.map +1 -1
  45. package/lib/metrics/metricsRedisClient.d.ts.map +1 -1
  46. package/lib/metrics/metricsRedisClient.js +7 -1
  47. package/lib/metrics/metricsRedisClient.js.map +1 -1
  48. package/package.json +5 -5
  49. package/src/health/healthCheckClient.js +1 -1
  50. package/src/health/healthCheckWorker.js +18 -1
  51. package/src/index.ts +4 -0
  52. package/src/metrics/baseMetricsClient.js +4 -1
  53. package/src/metrics/httpMetricsRedisCollector.js +131 -0
  54. package/src/metrics/httpMetricsRedisRecorder.js +74 -0
  55. package/src/metrics/httpMetricsRedisStore.js +208 -0
  56. package/src/metrics/metricsClient.js +34 -53
  57. package/src/metrics/metricsDatabaseClient.js +7 -1
  58. package/src/metrics/metricsProcessTypeUtils.js +98 -0
  59. package/src/metrics/metricsQueueRedisClient.js +6 -0
  60. package/src/metrics/metricsRedisClient.js +12 -1
package/README.md CHANGED
@@ -25,8 +25,15 @@ new MetricsClient({
25
25
  pushgatewayUrl, // defaults: process.env.METRICS_PUSHGATEWAY_URL || ''
26
26
  pushgatewaySecret, // defaults: process.env.METRICS_PUSHGATEWAY_SECRET || '' (Base64 of user:password)
27
27
  intervalSec, // defaults: process.env.METRICS_INTERVAL_SEC || 15
28
+ httpMetricsEnabled, // defaults: METRICS_HTTP_ENABLED === 'true' (in-process HTTP counters only)
28
29
  })
29
30
  ```
31
+
32
+ ### HTTP metrics: `MetricsClient` vs Redis
33
+
34
+ **`MetricsClient`** only registers **in-process** `app_requests_*` counters when `METRICS_HTTP_ENABLED` / `httpMetricsEnabled` is on. For **Redis-backed** HTTP aggregation (multi-web / cluster), use **`HttpMetricsRedisRecorder`** (writers) and **`HttpMetricsRedisCollector`** (drain + push) with an injected **`redisClient`** — same idea as `RedisMetricsClient`, not mixed into `MetricsClient`. See **[`docs/http-metrics-redis.md`](docs/http-metrics-redis.md)** (quick Redis reference) and the repo guide **[`../docs/METRICS.md`](../docs/METRICS.md)** (full architecture for devs and AI assistants).
35
+
36
+ `DatabaseMetricsClient` / `QueueRedisMetricsClient` / `RedisMetricsClient` call **`process.exit(0)` with no logs** if `BUILD_DYNO_PROCESS_TYPE` (or `processType`) does not match `database-metrics` / `queue-metrics` / (`redis-metrics` or `queue-metrics`) respectively.
30
37
  ## Example Usage
31
38
  ```ts
32
39
  import { MetricsClient } from '@adalo/metrics-js'
@@ -0,0 +1,203 @@
1
+ const { HttpMetricsRedisCollector } = require('../src/metrics/httpMetricsRedisCollector')
2
+ const { buildFieldKey } = require('../src/metrics/httpMetricsRedisStore')
3
+
4
+ function appRequestTotalsByRoute(metricsJson) {
5
+ const reqTotal = metricsJson.find(m => m.name === 'app_requests_total')
6
+ if (!reqTotal || !reqTotal.values) {
7
+ return {}
8
+ }
9
+ return Object.fromEntries(reqTotal.values.map(v => [v.labels.route, v.value]))
10
+ }
11
+
12
+ function createRedisV3Mock() {
13
+ const multiChain = {
14
+ hincrby: jest.fn().mockReturnThis(),
15
+ expire: jest.fn().mockReturnThis(),
16
+ exec: jest.fn(cb => {
17
+ if (cb) {
18
+ setImmediate(() => cb(null))
19
+ }
20
+ }),
21
+ }
22
+ return {
23
+ multi: jest.fn(() => multiChain),
24
+ eval: jest.fn(),
25
+ _multiChain: multiChain,
26
+ }
27
+ }
28
+
29
+ describe('HttpMetricsRedisCollector', () => {
30
+ const originalEnv = process.env
31
+
32
+ beforeEach(() => {
33
+ process.env = { ...originalEnv, METRICS_DISABLE_PUSHGATEWAY: 'true' }
34
+ })
35
+
36
+ afterEach(() => {
37
+ process.env = originalEnv
38
+ })
39
+
40
+ it('throws without redisClient', () => {
41
+ expect(() => new HttpMetricsRedisCollector({ appName: 'a' })).toThrow('redisClient is required')
42
+ })
43
+
44
+ it('pushMetrics drains Redis then completes without network push', async () => {
45
+ const redis = createRedisV3Mock()
46
+ const field = buildFieldKey('GET', '/health', 200)
47
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
48
+ cb(null, [
49
+ [field, '1'],
50
+ [field, '10'],
51
+ ])
52
+ })
53
+
54
+ const collector = new HttpMetricsRedisCollector({
55
+ redisClient: redis,
56
+ appName: 'test-app',
57
+ dynoId: 'dyno-1',
58
+ processType: 'http-metrics',
59
+ redisProcessTypeForKeys: 'web',
60
+ disablePushgateway: true,
61
+ })
62
+
63
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
64
+
65
+ await collector.pushMetrics()
66
+
67
+ expect(redis.eval).toHaveBeenCalled()
68
+ expect(pushSpy).toHaveBeenCalledTimes(1)
69
+ const metrics = await collector._registry.getMetricsAsJSON()
70
+ const total = metrics.find(m => m.name === 'app_requests_total')
71
+ expect(total).toBeDefined()
72
+ const totalDur = metrics.find(m => m.name === 'app_requests_total_duration')
73
+ expect(totalDur).toBeDefined()
74
+
75
+ pushSpy.mockRestore()
76
+ })
77
+
78
+ it('primes new label sets with 0 then applies count (extra push only when labels are new)', async () => {
79
+ const redis = createRedisV3Mock()
80
+ const field = buildFieldKey('GET', '/health', 200)
81
+
82
+ const collector = new HttpMetricsRedisCollector({
83
+ redisClient: redis,
84
+ appName: 'test-app',
85
+ dynoId: 'dyno-1',
86
+ processType: 'http-metrics',
87
+ redisProcessTypeForKeys: 'web',
88
+ disablePushgateway: true,
89
+ })
90
+
91
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
92
+
93
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
94
+ cb(null, [
95
+ [field, '2'],
96
+ [field, '0'],
97
+ ])
98
+ })
99
+ await collector.pushMetrics()
100
+ expect(pushSpy).toHaveBeenCalledTimes(1)
101
+
102
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
103
+ cb(null, [
104
+ [field, '1'],
105
+ [field, '0'],
106
+ ])
107
+ })
108
+ await collector.pushMetrics()
109
+ expect(pushSpy).toHaveBeenCalledTimes(1)
110
+
111
+ pushSpy.mockRestore()
112
+ })
113
+
114
+ it('prime push for new route a leaves existing route b unchanged (a=0, b=18), then a=1, b=38', async () => {
115
+ const redis = createRedisV3Mock()
116
+ const fieldB = buildFieldKey('GET', '/b', 200)
117
+ const fieldA = buildFieldKey('GET', '/a', 200)
118
+
119
+ const collector = new HttpMetricsRedisCollector({
120
+ redisClient: redis,
121
+ appName: 'test-app',
122
+ dynoId: 'dyno-1',
123
+ processType: 'http-metrics',
124
+ redisProcessTypeForKeys: 'web',
125
+ disablePushgateway: true,
126
+ })
127
+
128
+ let primePushCount = 0
129
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockImplementation(async () => {
130
+ primePushCount++
131
+ if (primePushCount === 2) {
132
+ const metrics = await collector._registry.getMetricsAsJSON()
133
+ const byRoute = appRequestTotalsByRoute(metrics)
134
+ expect(byRoute['/a']).toBe(0)
135
+ expect(byRoute['/b']).toBe(18)
136
+ }
137
+ })
138
+
139
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
140
+ cb(null, [
141
+ [fieldB, '18'],
142
+ [fieldB, '0'],
143
+ ])
144
+ })
145
+ await collector.pushMetrics()
146
+
147
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
148
+ cb(null, [
149
+ [fieldA, '1', fieldB, '20'],
150
+ [fieldA, '0', fieldB, '0'],
151
+ ])
152
+ })
153
+ await collector.pushMetrics()
154
+
155
+ const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
156
+ expect(final['/a']).toBe(1)
157
+ expect(final['/b']).toBe(38)
158
+ expect(pushSpy).toHaveBeenCalledTimes(2)
159
+
160
+ pushSpy.mockRestore()
161
+ })
162
+
163
+ it('does not prime POST when drain only has already-seen routes (was a:1 b:18, then a:1 b:20)', async () => {
164
+ const redis = createRedisV3Mock()
165
+ const fieldA = buildFieldKey('GET', '/a', 200)
166
+ const fieldB = buildFieldKey('GET', '/b', 200)
167
+
168
+ const collector = new HttpMetricsRedisCollector({
169
+ redisClient: redis,
170
+ appName: 'test-app',
171
+ dynoId: 'dyno-1',
172
+ processType: 'http-metrics',
173
+ redisProcessTypeForKeys: 'web',
174
+ disablePushgateway: true,
175
+ })
176
+
177
+ const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
178
+
179
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
180
+ cb(null, [
181
+ [fieldA, '1', fieldB, '18'],
182
+ [fieldA, '0', fieldB, '0'],
183
+ ])
184
+ })
185
+ await collector.pushMetrics()
186
+ expect(pushSpy).toHaveBeenCalledTimes(1)
187
+
188
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
189
+ cb(null, [
190
+ [fieldA, '1', fieldB, '20'],
191
+ [fieldA, '0', fieldB, '0'],
192
+ ])
193
+ })
194
+ await collector.pushMetrics()
195
+ expect(pushSpy).toHaveBeenCalledTimes(1)
196
+
197
+ const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
198
+ expect(final['/a']).toBe(2)
199
+ expect(final['/b']).toBe(38)
200
+
201
+ pushSpy.mockRestore()
202
+ })
203
+ })
@@ -0,0 +1,60 @@
1
+ const { HttpMetricsRedisRecorder } = require('../src/metrics/httpMetricsRedisRecorder')
2
+ const { buildFieldKey } = require('../src/metrics/httpMetricsRedisStore')
3
+
4
+ const flushMicrotasks = () => new Promise(resolve => setImmediate(resolve))
5
+
6
+ function createRedisV3Mock() {
7
+ const multiChain = {
8
+ hincrby: jest.fn().mockReturnThis(),
9
+ expire: jest.fn().mockReturnThis(),
10
+ exec: jest.fn(cb => {
11
+ if (cb) {
12
+ setImmediate(() => cb(null))
13
+ }
14
+ }),
15
+ }
16
+ return {
17
+ multi: jest.fn(() => multiChain),
18
+ set: jest.fn(),
19
+ eval: jest.fn(),
20
+ del: jest.fn(),
21
+ _multiChain: multiChain,
22
+ }
23
+ }
24
+
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
+
36
+ it('throws without redisClient', () => {
37
+ expect(() => new HttpMetricsRedisRecorder({})).toThrow('redisClient is required')
38
+ })
39
+
40
+ it('trackHttpRequest forwards to Redis multi/hincrby', async () => {
41
+ const redis = createRedisV3Mock()
42
+ const rec = new HttpMetricsRedisRecorder({
43
+ redisClient: redis,
44
+ })
45
+ const countKey = rec._store.countKey
46
+ const durKey = rec._store.durKey
47
+ const field = buildFieldKey('GET', '/p', 404)
48
+
49
+ rec.trackHttpRequest({
50
+ method: 'GET',
51
+ route: '/p',
52
+ status_code: 404,
53
+ duration: 5,
54
+ })
55
+ await flushMicrotasks()
56
+
57
+ expect(redis._multiChain.hincrby).toHaveBeenCalledWith(countKey, field, 1)
58
+ expect(redis._multiChain.hincrby).toHaveBeenCalledWith(durKey, field, 5)
59
+ })
60
+ })
@@ -0,0 +1,431 @@
1
+ const {
2
+ HttpMetricsRedisStore,
3
+ buildFieldKey,
4
+ FIELD_SEP,
5
+ } = require('../src/metrics/httpMetricsRedisStore')
6
+
7
+ const flushMicrotasks = () => new Promise(resolve => setImmediate(resolve))
8
+
9
+ /**
10
+ * redis@3-style mock: multi().hincrby().expire().exec(cb)
11
+ */
12
+ function createRedisV3Mock() {
13
+ const multiChain = {
14
+ hincrby: jest.fn().mockReturnThis(),
15
+ expire: jest.fn().mockReturnThis(),
16
+ exec: jest.fn(cb => {
17
+ if (cb) {
18
+ setImmediate(() => cb(null))
19
+ }
20
+ }),
21
+ }
22
+ return {
23
+ multi: jest.fn(() => multiChain),
24
+ eval: jest.fn(),
25
+ _multiChain: multiChain,
26
+ }
27
+ }
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
+
88
+ describe('HttpMetricsRedisStore', () => {
89
+ describe('buildFieldKey', () => {
90
+ it('joins method, route, status with FIELD_SEP', () => {
91
+ expect(buildFieldKey('GET', '/api', 200)).toBe(
92
+ ['GET', '/api', '200'].join(FIELD_SEP)
93
+ )
94
+ })
95
+ })
96
+
97
+ describe('constructor', () => {
98
+ it('throws without redisClient', () => {
99
+ expect(() => new HttpMetricsRedisStore({ appName: 'a', processType: 'web' })).toThrow(
100
+ 'redisClient is required'
101
+ )
102
+ })
103
+
104
+ it('builds encoded v2 keys', () => {
105
+ const redis = createRedisV3Mock()
106
+ const store = new HttpMetricsRedisStore({
107
+ redisClient: redis,
108
+ appName: 'my app',
109
+ processType: 'web',
110
+ })
111
+ const seg = `${encodeURIComponent('my app')}:${encodeURIComponent('web')}`
112
+ expect(store.countKey).toBe(`metrics:http:v2:${seg}:count`)
113
+ expect(store.durKey).toBe(`metrics:http:v2:${seg}:dur`)
114
+ })
115
+ })
116
+
117
+ describe('record', () => {
118
+ it('increments count and duration hashes with TTL', async () => {
119
+ const redis = createRedisV3Mock()
120
+ const store = new HttpMetricsRedisStore({
121
+ redisClient: redis,
122
+ appName: 'app',
123
+ processType: 'web',
124
+ ttlSec: 90,
125
+ })
126
+ const field = buildFieldKey('GET', '/x', 200)
127
+
128
+ store.record('GET', '/x', 200, 12)
129
+ await flushMicrotasks()
130
+
131
+ expect(redis.multi).toHaveBeenCalled()
132
+ expect(redis._multiChain.hincrby).toHaveBeenCalledWith(store.countKey, field, 1)
133
+ expect(redis._multiChain.hincrby).toHaveBeenCalledWith(store.durKey, field, 12)
134
+ expect(redis._multiChain.expire).toHaveBeenCalledWith(store.countKey, 90)
135
+ expect(redis._multiChain.expire).toHaveBeenCalledWith(store.durKey, 90)
136
+ expect(redis._multiChain.exec).toHaveBeenCalled()
137
+ })
138
+ })
139
+
140
+ describe('flushToCounters', () => {
141
+ it('applies aggregated count and summed duration for one route (many requests → one field)', async () => {
142
+ const redis = createRedisV3Mock()
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
+ ])
149
+ })
150
+
151
+ const store = new HttpMetricsRedisStore({
152
+ redisClient: redis,
153
+ appName: 'app',
154
+ processType: 'web',
155
+ })
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
+ },
168
+ 100
169
+ )
170
+ expect(applyDuration).toHaveBeenCalledWith(
171
+ {
172
+ method: 'GET',
173
+ route: '/api/items',
174
+ status_code: '200',
175
+ },
176
+ 4500
177
+ )
178
+ })
179
+
180
+ it('drains hashes and applies count and duration', async () => {
181
+ const redis = createRedisV3Mock()
182
+ const field = buildFieldKey('POST', '/r', 201)
183
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
184
+ cb(null, [
185
+ [field, '2'],
186
+ [field, '50'],
187
+ ])
188
+ })
189
+
190
+ const store = new HttpMetricsRedisStore({
191
+ redisClient: redis,
192
+ appName: 'app',
193
+ processType: 'web',
194
+ })
195
+ const applyCount = jest.fn()
196
+ const applyDuration = jest.fn()
197
+
198
+ const ok = await store.flushToCounters(applyCount, applyDuration)
199
+
200
+ expect(ok).toBe(true)
201
+ expect(redis.eval).toHaveBeenCalledWith(
202
+ expect.stringContaining('HGETALL'),
203
+ 2,
204
+ store.countKey,
205
+ store.durKey,
206
+ expect.any(Function)
207
+ )
208
+ expect(applyCount).toHaveBeenCalledWith(
209
+ {
210
+ method: 'POST',
211
+ route: '/r',
212
+ status_code: '201',
213
+ },
214
+ 2
215
+ )
216
+ expect(applyDuration).toHaveBeenCalledWith(
217
+ {
218
+ method: 'POST',
219
+ route: '/r',
220
+ status_code: '201',
221
+ },
222
+ 50
223
+ )
224
+ })
225
+
226
+ it('legacy 6-part hash fields still drain (labels from first three segments)', async () => {
227
+ const redis = createRedisV3Mock()
228
+ const legacyField = ['GET', '/old', '200', 'a', 'd', '999'].join(FIELD_SEP)
229
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
230
+ cb(null, [
231
+ [legacyField, '3'],
232
+ [legacyField, '9'],
233
+ ])
234
+ })
235
+ const store = new HttpMetricsRedisStore({
236
+ redisClient: redis,
237
+ appName: 'app',
238
+ processType: 'web',
239
+ })
240
+ const applyCount = jest.fn()
241
+ const applyDuration = jest.fn()
242
+ const ok = await store.flushToCounters(applyCount, applyDuration)
243
+ expect(ok).toBe(true)
244
+ expect(applyCount).toHaveBeenCalledWith(
245
+ {
246
+ method: 'GET',
247
+ route: '/old',
248
+ status_code: '200',
249
+ },
250
+ 3
251
+ )
252
+ expect(applyDuration).toHaveBeenCalledWith(
253
+ {
254
+ method: 'GET',
255
+ route: '/old',
256
+ status_code: '200',
257
+ },
258
+ 9
259
+ )
260
+ })
261
+
262
+ it('resolves true with no applies when eval returns short array', async () => {
263
+ const redis = createRedisV3Mock()
264
+ redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
265
+ cb(null, [])
266
+ })
267
+ const store = new HttpMetricsRedisStore({
268
+ redisClient: redis,
269
+ appName: 'app',
270
+ processType: 'web',
271
+ })
272
+ const applyCount = jest.fn()
273
+ const ok = await store.flushToCounters(applyCount, jest.fn())
274
+ expect(ok).toBe(true)
275
+ expect(applyCount).not.toHaveBeenCalled()
276
+ })
277
+ })
278
+
279
+ describe('in-memory integration (many fields, shared writers)', () => {
280
+ it('flush applies many distinct hash fields (many routes / labels)', async () => {
281
+ const redis = createRedisV3InMemoryMock()
282
+ const store = new HttpMetricsRedisStore({
283
+ redisClient: redis,
284
+ appName: 'svc',
285
+ processType: 'web',
286
+ })
287
+ const routes = Array.from({ length: 24 }, (_, i) => `/api/v${i % 3}/item/${i}`)
288
+ for (let i = 0; i < routes.length; i++) {
289
+ const method = i % 2 === 0 ? 'GET' : 'POST'
290
+ const status = i % 5 === 0 ? 500 : 200
291
+ store.record(method, routes[i], status, 10 + i)
292
+ }
293
+ expect(redis._fieldCount(store.countKey)).toBe(routes.length)
294
+
295
+ const applyCount = jest.fn()
296
+ const applyDuration = jest.fn()
297
+ const ok = await store.flushToCounters(applyCount, applyDuration)
298
+
299
+ expect(ok).toBe(true)
300
+ expect(applyCount).toHaveBeenCalledTimes(routes.length)
301
+ let sumCount = 0
302
+ let sumDur = 0
303
+ applyCount.mock.calls.forEach(([, c]) => {
304
+ sumCount += c
305
+ })
306
+ applyDuration.mock.calls.forEach(([, d]) => {
307
+ sumDur += d
308
+ })
309
+ expect(sumCount).toBe(routes.length)
310
+ expect(sumDur).toBe(routes.reduce((acc, _, i) => acc + (10 + i), 0))
311
+ expect(redis._peek(store.countKey)).toEqual({})
312
+ })
313
+
314
+ it('many requests for the same field aggregate to one apply (like one dyno hot route)', async () => {
315
+ const redis = createRedisV3InMemoryMock()
316
+ const store = new HttpMetricsRedisStore({
317
+ redisClient: redis,
318
+ appName: 'app',
319
+ processType: 'web',
320
+ })
321
+ const n = 80
322
+ for (let i = 0; i < n; i++) {
323
+ store.record('GET', '/hot', 200, 5)
324
+ }
325
+ expect(redis._fieldCount(store.countKey)).toBe(1)
326
+
327
+ const applyCount = jest.fn()
328
+ const applyDuration = jest.fn()
329
+ await store.flushToCounters(applyCount, applyDuration)
330
+
331
+ expect(applyCount).toHaveBeenCalledTimes(1)
332
+ expect(applyCount).toHaveBeenCalledWith(
333
+ expect.objectContaining({
334
+ method: 'GET',
335
+ route: '/hot',
336
+ status_code: '200',
337
+ }),
338
+ n
339
+ )
340
+ expect(applyDuration).toHaveBeenCalledWith(
341
+ expect.objectContaining({ route: '/hot' }),
342
+ n * 5
343
+ )
344
+ })
345
+
346
+ it('multiple store instances on the same redis client combine like multiple dynos', async () => {
347
+ const redis = createRedisV3InMemoryMock()
348
+ const opts = { redisClient: redis, appName: 'prod', processType: 'web' }
349
+ const dynoA = new HttpMetricsRedisStore(opts)
350
+ const dynoB = new HttpMetricsRedisStore(opts)
351
+
352
+ dynoA.record('GET', '/a', 200, 3)
353
+ dynoB.record('GET', '/b', 304, 7)
354
+ dynoA.record('POST', '/c', 201, 11)
355
+
356
+ expect(redis._fieldCount(dynoA.countKey)).toBe(3)
357
+
358
+ const applyCount = jest.fn()
359
+ const applyDuration = jest.fn()
360
+ const ok = await dynoB.flushToCounters(applyCount, applyDuration)
361
+
362
+ expect(ok).toBe(true)
363
+ expect(applyCount).toHaveBeenCalledTimes(3)
364
+ expect(applyDuration).toHaveBeenCalledTimes(3)
365
+ })
366
+
367
+ it('interleaved record() calls from parallel async work combine correctly', async () => {
368
+ const redis = createRedisV3InMemoryMock()
369
+ const store = new HttpMetricsRedisStore({
370
+ redisClient: redis,
371
+ appName: 'app',
372
+ processType: 'web',
373
+ })
374
+
375
+ await Promise.all(
376
+ [0, 1, 2, 3, 4].map(worker =>
377
+ Promise.all(
378
+ Array.from({ length: 15 }, (_, j) => {
379
+ const route = `/w${worker}/r${j}`
380
+ store.record('GET', route, 200, 2)
381
+ return Promise.resolve()
382
+ })
383
+ )
384
+ )
385
+ )
386
+
387
+ expect(redis._fieldCount(store.countKey)).toBe(5 * 15)
388
+
389
+ const applyCount = jest.fn()
390
+ const applyDuration = jest.fn()
391
+ await store.flushToCounters(applyCount, applyDuration)
392
+
393
+ expect(applyCount).toHaveBeenCalledTimes(75)
394
+ let total = 0
395
+ applyCount.mock.calls.forEach(([, c]) => {
396
+ total += c
397
+ })
398
+ expect(total).toBe(75)
399
+ expect(
400
+ applyDuration.mock.calls.reduce((acc, [, d]) => acc + d, 0)
401
+ ).toBe(75 * 2)
402
+ })
403
+
404
+ it('mixed methods, statuses, and apps in one drain', async () => {
405
+ const redis = createRedisV3InMemoryMock()
406
+ const store = new HttpMetricsRedisStore({
407
+ redisClient: redis,
408
+ appName: 'mixed',
409
+ processType: 'api',
410
+ })
411
+ const samples = [
412
+ ['GET', '/x', 200, 1],
413
+ ['GET', '/x', 404, 2],
414
+ ['DELETE', '/x|y', 204, 3],
415
+ ]
416
+ for (const [m, r, s, dur] of samples) {
417
+ store.record(m, r, s, dur)
418
+ }
419
+
420
+ const applyCount = jest.fn()
421
+ const applyDuration = jest.fn()
422
+ await store.flushToCounters(applyCount, applyDuration)
423
+
424
+ expect(applyCount).toHaveBeenCalledTimes(3)
425
+ expect(applyCount.mock.calls.map(([{ method, route, status_code }]) => `${method} ${route} ${status_code}`).sort()).toEqual(
426
+ ['DELETE /x|y 204', 'GET /x 200', 'GET /x 404'].sort()
427
+ )
428
+ expect(applyDuration.mock.calls.reduce((acc, [, v]) => acc + v, 0)).toBe(6)
429
+ })
430
+ })
431
+ })