@adalo/metrics 0.0.0-staging.23 → 0.0.0-staging.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/httpMetricsRedisCollector.test.js +0 -10
- package/__tests__/httpMetricsRedisRecorder.test.js +11 -5
- package/__tests__/httpMetricsRedisStore.test.js +248 -25
- package/lib/metrics/httpMetricsRedisRecorder.d.ts +5 -5
- package/lib/metrics/httpMetricsRedisRecorder.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisRecorder.js +10 -9
- package/lib/metrics/httpMetricsRedisRecorder.js.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.d.ts +12 -4
- package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.js +89 -57
- package/lib/metrics/httpMetricsRedisStore.js.map +1 -1
- package/package.json +5 -5
- package/src/metrics/httpMetricsRedisRecorder.js +11 -11
- package/src/metrics/httpMetricsRedisStore.js +115 -82
|
@@ -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({
|
|
28
|
-
'redisClient is required'
|
|
29
|
-
)
|
|
37
|
+
expect(() => new HttpMetricsRedisRecorder({})).toThrow('redisClient is required')
|
|
30
38
|
})
|
|
31
39
|
|
|
32
40
|
it('trackHttpRequest forwards to Redis multi/hincrby', async () => {
|
|
33
41
|
const redis = createRedisV3Mock()
|
|
34
42
|
const rec = new HttpMetricsRedisRecorder({
|
|
35
43
|
redisClient: redis,
|
|
36
|
-
appName: 'app',
|
|
37
|
-
processType: 'web',
|
|
38
44
|
})
|
|
39
45
|
const countKey = rec._store.countKey
|
|
40
46
|
const durKey = rec._store.durKey
|
|
@@ -21,13 +21,70 @@ function createRedisV3Mock() {
|
|
|
21
21
|
}
|
|
22
22
|
return {
|
|
23
23
|
multi: jest.fn(() => multiChain),
|
|
24
|
-
set: jest.fn(),
|
|
25
24
|
eval: jest.fn(),
|
|
26
|
-
del: jest.fn(),
|
|
27
25
|
_multiChain: multiChain,
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
28
|
|
|
29
|
+
/**
|
|
30
|
+
* In-memory redis@3 mock: MULTI/EXEC applies HINCRBY to shared hashes; EVAL returns
|
|
31
|
+
* [HGETALL+DEL countKey, HGETALL+DEL durKey] like the real drain Lua. Use this to
|
|
32
|
+
* simulate many writers (same client) without Redis.
|
|
33
|
+
*/
|
|
34
|
+
function createRedisV3InMemoryMock() {
|
|
35
|
+
/** @type {Record<string, Record<string, string>>} */
|
|
36
|
+
const hashes = {}
|
|
37
|
+
|
|
38
|
+
function hgetallFlat(key) {
|
|
39
|
+
const h = hashes[key]
|
|
40
|
+
if (!h) return []
|
|
41
|
+
const flat = []
|
|
42
|
+
for (const [f, v] of Object.entries(h)) {
|
|
43
|
+
flat.push(f, v)
|
|
44
|
+
}
|
|
45
|
+
return flat
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function drainKey(key) {
|
|
49
|
+
const flat = hgetallFlat(key)
|
|
50
|
+
delete hashes[key]
|
|
51
|
+
return flat
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const client = {
|
|
55
|
+
multi: jest.fn(() => {
|
|
56
|
+
const ops = []
|
|
57
|
+
const chain = {}
|
|
58
|
+
chain.hincrby = jest.fn((key, field, inc) => {
|
|
59
|
+
ops.push({ key, field, inc: Number(inc) })
|
|
60
|
+
return chain
|
|
61
|
+
})
|
|
62
|
+
chain.expire = jest.fn().mockReturnValue(chain)
|
|
63
|
+
chain.exec = jest.fn(cb => {
|
|
64
|
+
for (const op of ops) {
|
|
65
|
+
if (!hashes[op.key]) hashes[op.key] = {}
|
|
66
|
+
const cur = parseInt(hashes[op.key][op.field] || '0', 10)
|
|
67
|
+
hashes[op.key][op.field] = String(cur + op.inc)
|
|
68
|
+
}
|
|
69
|
+
if (cb) cb(null)
|
|
70
|
+
})
|
|
71
|
+
return chain
|
|
72
|
+
}),
|
|
73
|
+
eval: jest.fn((lua, numKeys, k1, k2, cb) => {
|
|
74
|
+
const out = [drainKey(k1), drainKey(k2)]
|
|
75
|
+
if (cb) cb(null, out)
|
|
76
|
+
}),
|
|
77
|
+
/** @param {string} key */
|
|
78
|
+
_fieldCount(key) {
|
|
79
|
+
return Object.keys(hashes[key] || {}).length
|
|
80
|
+
},
|
|
81
|
+
_peek(key) {
|
|
82
|
+
return { ...(hashes[key] || {}) }
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
return client
|
|
86
|
+
}
|
|
87
|
+
|
|
31
88
|
describe('HttpMetricsRedisStore', () => {
|
|
32
89
|
describe('buildFieldKey', () => {
|
|
33
90
|
it('joins parts with FIELD_SEP', () => {
|
|
@@ -54,7 +111,6 @@ describe('HttpMetricsRedisStore', () => {
|
|
|
54
111
|
const seg = `${encodeURIComponent('my app')}:${encodeURIComponent('web')}`
|
|
55
112
|
expect(store.countKey).toBe(`metrics:http:v2:${seg}:count`)
|
|
56
113
|
expect(store.durKey).toBe(`metrics:http:v2:${seg}:dur`)
|
|
57
|
-
expect(store.lockKey).toBe(`metrics:http:v2:${seg}:drain_lock`)
|
|
58
114
|
})
|
|
59
115
|
})
|
|
60
116
|
|
|
@@ -82,26 +138,51 @@ describe('HttpMetricsRedisStore', () => {
|
|
|
82
138
|
})
|
|
83
139
|
|
|
84
140
|
describe('flushToCounters', () => {
|
|
85
|
-
it('
|
|
141
|
+
it('applies aggregated count and summed duration for one route (many requests → one field)', async () => {
|
|
86
142
|
const redis = createRedisV3Mock()
|
|
87
|
-
|
|
88
|
-
|
|
143
|
+
const field = buildFieldKey('GET', '/api/items', 200, '', '')
|
|
144
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
145
|
+
cb(null, [
|
|
146
|
+
[field, '100'],
|
|
147
|
+
[field, '4500'],
|
|
148
|
+
])
|
|
89
149
|
})
|
|
150
|
+
|
|
90
151
|
const store = new HttpMetricsRedisStore({
|
|
91
152
|
redisClient: redis,
|
|
92
153
|
appName: 'app',
|
|
93
154
|
processType: 'web',
|
|
94
155
|
})
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
const applyCount = jest.fn()
|
|
157
|
+
const applyDuration = jest.fn()
|
|
158
|
+
|
|
159
|
+
const ok = await store.flushToCounters(applyCount, applyDuration)
|
|
160
|
+
|
|
161
|
+
expect(ok).toBe(true)
|
|
162
|
+
expect(applyCount).toHaveBeenCalledWith(
|
|
163
|
+
{
|
|
164
|
+
method: 'GET',
|
|
165
|
+
route: '/api/items',
|
|
166
|
+
status_code: '200',
|
|
167
|
+
appId: '',
|
|
168
|
+
databaseId: '',
|
|
169
|
+
},
|
|
170
|
+
100
|
|
171
|
+
)
|
|
172
|
+
expect(applyDuration).toHaveBeenCalledWith(
|
|
173
|
+
{
|
|
174
|
+
method: 'GET',
|
|
175
|
+
route: '/api/items',
|
|
176
|
+
status_code: '200',
|
|
177
|
+
appId: '',
|
|
178
|
+
databaseId: '',
|
|
179
|
+
},
|
|
180
|
+
4500
|
|
181
|
+
)
|
|
98
182
|
})
|
|
99
183
|
|
|
100
184
|
it('drains hashes and applies count and duration', async () => {
|
|
101
185
|
const redis = createRedisV3Mock()
|
|
102
|
-
redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
|
|
103
|
-
cb(null, 'OK')
|
|
104
|
-
})
|
|
105
186
|
const field = buildFieldKey('POST', '/r', 201, 'a1', 'd1')
|
|
106
187
|
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
107
188
|
cb(null, [
|
|
@@ -109,11 +190,6 @@ describe('HttpMetricsRedisStore', () => {
|
|
|
109
190
|
[field, '50'],
|
|
110
191
|
])
|
|
111
192
|
})
|
|
112
|
-
redis.del.mockImplementation((key, cb) => {
|
|
113
|
-
if (cb) {
|
|
114
|
-
cb()
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
193
|
|
|
118
194
|
const store = new HttpMetricsRedisStore({
|
|
119
195
|
redisClient: redis,
|
|
@@ -157,17 +233,9 @@ describe('HttpMetricsRedisStore', () => {
|
|
|
157
233
|
|
|
158
234
|
it('resolves true with no applies when eval returns short array', async () => {
|
|
159
235
|
const redis = createRedisV3Mock()
|
|
160
|
-
redis.set.mockImplementation((key, val, mode, ttl, nx, cb) => {
|
|
161
|
-
cb(null, 'OK')
|
|
162
|
-
})
|
|
163
236
|
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
164
237
|
cb(null, [])
|
|
165
238
|
})
|
|
166
|
-
redis.del.mockImplementation((key, cb) => {
|
|
167
|
-
if (cb) {
|
|
168
|
-
cb()
|
|
169
|
-
}
|
|
170
|
-
})
|
|
171
239
|
const store = new HttpMetricsRedisStore({
|
|
172
240
|
redisClient: redis,
|
|
173
241
|
appName: 'app',
|
|
@@ -179,4 +247,159 @@ describe('HttpMetricsRedisStore', () => {
|
|
|
179
247
|
expect(applyCount).not.toHaveBeenCalled()
|
|
180
248
|
})
|
|
181
249
|
})
|
|
250
|
+
|
|
251
|
+
describe('in-memory integration (many fields, shared writers)', () => {
|
|
252
|
+
it('flush applies many distinct hash fields (many routes / labels)', async () => {
|
|
253
|
+
const redis = createRedisV3InMemoryMock()
|
|
254
|
+
const store = new HttpMetricsRedisStore({
|
|
255
|
+
redisClient: redis,
|
|
256
|
+
appName: 'svc',
|
|
257
|
+
processType: 'web',
|
|
258
|
+
})
|
|
259
|
+
const routes = Array.from({ length: 24 }, (_, i) => `/api/v${i % 3}/item/${i}`)
|
|
260
|
+
for (let i = 0; i < routes.length; i++) {
|
|
261
|
+
const method = i % 2 === 0 ? 'GET' : 'POST'
|
|
262
|
+
const status = i % 5 === 0 ? 500 : 200
|
|
263
|
+
store.record(method, routes[i], status, `app-${i % 4}`, `db-${i % 3}`, 10 + i)
|
|
264
|
+
}
|
|
265
|
+
expect(redis._fieldCount(store.countKey)).toBe(routes.length)
|
|
266
|
+
|
|
267
|
+
const applyCount = jest.fn()
|
|
268
|
+
const applyDuration = jest.fn()
|
|
269
|
+
const ok = await store.flushToCounters(applyCount, applyDuration)
|
|
270
|
+
|
|
271
|
+
expect(ok).toBe(true)
|
|
272
|
+
expect(applyCount).toHaveBeenCalledTimes(routes.length)
|
|
273
|
+
let sumCount = 0
|
|
274
|
+
let sumDur = 0
|
|
275
|
+
applyCount.mock.calls.forEach(([, c]) => {
|
|
276
|
+
sumCount += c
|
|
277
|
+
})
|
|
278
|
+
applyDuration.mock.calls.forEach(([, d]) => {
|
|
279
|
+
sumDur += d
|
|
280
|
+
})
|
|
281
|
+
expect(sumCount).toBe(routes.length)
|
|
282
|
+
expect(sumDur).toBe(routes.reduce((acc, _, i) => acc + (10 + i), 0))
|
|
283
|
+
expect(redis._peek(store.countKey)).toEqual({})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('many requests for the same field aggregate to one apply (like one dyno hot route)', async () => {
|
|
287
|
+
const redis = createRedisV3InMemoryMock()
|
|
288
|
+
const store = new HttpMetricsRedisStore({
|
|
289
|
+
redisClient: redis,
|
|
290
|
+
appName: 'app',
|
|
291
|
+
processType: 'web',
|
|
292
|
+
})
|
|
293
|
+
const n = 80
|
|
294
|
+
for (let i = 0; i < n; i++) {
|
|
295
|
+
store.record('GET', '/hot', 200, 'a1', 'd1', 5)
|
|
296
|
+
}
|
|
297
|
+
expect(redis._fieldCount(store.countKey)).toBe(1)
|
|
298
|
+
|
|
299
|
+
const applyCount = jest.fn()
|
|
300
|
+
const applyDuration = jest.fn()
|
|
301
|
+
await store.flushToCounters(applyCount, applyDuration)
|
|
302
|
+
|
|
303
|
+
expect(applyCount).toHaveBeenCalledTimes(1)
|
|
304
|
+
expect(applyCount).toHaveBeenCalledWith(
|
|
305
|
+
expect.objectContaining({
|
|
306
|
+
method: 'GET',
|
|
307
|
+
route: '/hot',
|
|
308
|
+
status_code: '200',
|
|
309
|
+
appId: 'a1',
|
|
310
|
+
databaseId: 'd1',
|
|
311
|
+
}),
|
|
312
|
+
n
|
|
313
|
+
)
|
|
314
|
+
expect(applyDuration).toHaveBeenCalledWith(
|
|
315
|
+
expect.objectContaining({ route: '/hot' }),
|
|
316
|
+
n * 5
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('multiple store instances on the same redis client combine like multiple dynos', async () => {
|
|
321
|
+
const redis = createRedisV3InMemoryMock()
|
|
322
|
+
const opts = { redisClient: redis, appName: 'prod', processType: 'web' }
|
|
323
|
+
const dynoA = new HttpMetricsRedisStore(opts)
|
|
324
|
+
const dynoB = new HttpMetricsRedisStore(opts)
|
|
325
|
+
|
|
326
|
+
dynoA.record('GET', '/a', 200, '', '', 3)
|
|
327
|
+
dynoB.record('GET', '/b', 304, '', '', 7)
|
|
328
|
+
dynoA.record('POST', '/c', 201, 'x', 'y', 11)
|
|
329
|
+
|
|
330
|
+
expect(redis._fieldCount(dynoA.countKey)).toBe(3)
|
|
331
|
+
|
|
332
|
+
const applyCount = jest.fn()
|
|
333
|
+
const applyDuration = jest.fn()
|
|
334
|
+
const ok = await dynoB.flushToCounters(applyCount, applyDuration)
|
|
335
|
+
|
|
336
|
+
expect(ok).toBe(true)
|
|
337
|
+
expect(applyCount).toHaveBeenCalledTimes(3)
|
|
338
|
+
expect(applyDuration).toHaveBeenCalledTimes(3)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('interleaved record() calls from parallel async work combine correctly', async () => {
|
|
342
|
+
const redis = createRedisV3InMemoryMock()
|
|
343
|
+
const store = new HttpMetricsRedisStore({
|
|
344
|
+
redisClient: redis,
|
|
345
|
+
appName: 'app',
|
|
346
|
+
processType: 'web',
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
await Promise.all(
|
|
350
|
+
[0, 1, 2, 3, 4].map(worker =>
|
|
351
|
+
Promise.all(
|
|
352
|
+
Array.from({ length: 15 }, (_, j) => {
|
|
353
|
+
const route = `/w${worker}/r${j}`
|
|
354
|
+
store.record('GET', route, 200, `app${worker}`, 'db0', 2)
|
|
355
|
+
return Promise.resolve()
|
|
356
|
+
})
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
expect(redis._fieldCount(store.countKey)).toBe(5 * 15)
|
|
362
|
+
|
|
363
|
+
const applyCount = jest.fn()
|
|
364
|
+
const applyDuration = jest.fn()
|
|
365
|
+
await store.flushToCounters(applyCount, applyDuration)
|
|
366
|
+
|
|
367
|
+
expect(applyCount).toHaveBeenCalledTimes(75)
|
|
368
|
+
let total = 0
|
|
369
|
+
applyCount.mock.calls.forEach(([, c]) => {
|
|
370
|
+
total += c
|
|
371
|
+
})
|
|
372
|
+
expect(total).toBe(75)
|
|
373
|
+
expect(
|
|
374
|
+
applyDuration.mock.calls.reduce((acc, [, d]) => acc + d, 0)
|
|
375
|
+
).toBe(75 * 2)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('mixed methods, statuses, and apps in one drain', async () => {
|
|
379
|
+
const redis = createRedisV3InMemoryMock()
|
|
380
|
+
const store = new HttpMetricsRedisStore({
|
|
381
|
+
redisClient: redis,
|
|
382
|
+
appName: 'mixed',
|
|
383
|
+
processType: 'api',
|
|
384
|
+
})
|
|
385
|
+
const samples = [
|
|
386
|
+
['GET', '/x', 200, 'a', 'd', 1],
|
|
387
|
+
['GET', '/x', 404, 'a', 'd', 2],
|
|
388
|
+
['DELETE', '/x|y', 204, 'b', 'd', 3],
|
|
389
|
+
]
|
|
390
|
+
for (const [m, r, s, a, d, dur] of samples) {
|
|
391
|
+
store.record(m, r, s, a, d, dur)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const applyCount = jest.fn()
|
|
395
|
+
const applyDuration = jest.fn()
|
|
396
|
+
await store.flushToCounters(applyCount, applyDuration)
|
|
397
|
+
|
|
398
|
+
expect(applyCount).toHaveBeenCalledTimes(3)
|
|
399
|
+
expect(applyCount.mock.calls.map(([{ method, route, status_code }]) => `${method} ${route} ${status_code}`).sort()).toEqual(
|
|
400
|
+
['DELETE /x|y 204', 'GET /x 200', 'GET /x 404'].sort()
|
|
401
|
+
)
|
|
402
|
+
expect(applyDuration.mock.calls.reduce((acc, [, v]) => acc + v, 0)).toBe(6)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
182
405
|
})
|
|
@@ -8,14 +8,14 @@ export class HttpMetricsRedisRecorder {
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {Object} opts
|
|
10
10
|
* @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
|
|
11
|
-
* @param {string} opts.appName
|
|
12
|
-
* @param {string} opts.processType
|
|
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
|
|
18
|
-
processType
|
|
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":"
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisRecorder.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACb,OAAO;QACP,WAAW;QACX,MAAM;OAe9B;IARC,oBAA8B;IAC9B,gBAA8B;IAC9B,8BAKE;IAGJ;;;;;;;;OAQG;IACH;QAP0B,MAAM,EAArB,MAAM;QACS,KAAK,EAApB,MAAM;QACS,WAAW,EAA1B,MAAM;QACU,KAAK;QACL,UAAU;QACX,QAAQ,EAAvB,MAAM;aAiBhB;IAED;;;;;;;OAOG;IACH,kCAJW,OAAO,MAAM,EAAE,eAAe,OAC9B,OAAO,MAAM,EAAE,cAAc,0BAkCvC;CACF"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
|
-
HttpMetricsRedisStore
|
|
4
|
+
HttpMetricsRedisStore,
|
|
5
|
+
httpMetricsTraceLog
|
|
5
6
|
} = require('./httpMetricsRedisStore');
|
|
6
|
-
const LOG = '[http-metrics-redis]';
|
|
7
7
|
function trunc(s, max = 120) {
|
|
8
8
|
const t = String(s);
|
|
9
9
|
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
@@ -19,24 +19,25 @@ class HttpMetricsRedisRecorder {
|
|
|
19
19
|
/**
|
|
20
20
|
* @param {Object} opts
|
|
21
21
|
* @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
|
|
22
|
-
* @param {string} opts.appName
|
|
23
|
-
* @param {string} opts.processType
|
|
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 =
|
|
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
|
-
|
|
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","
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisRecorder.js","names":["HttpMetricsRedisStore","httpMetricsTraceLog","require","trunc","s","max","t","String","length","slice","HttpMetricsRedisRecorder","constructor","redisClient","appName","processType","ttlSec","Error","resolvedAppName","process","env","BUILD_APP_NAME","_store","trackHttpRequest","method","route","status_code","appId","databaseId","duration","pid","record","trackHttpRequestMiddleware","req","res","next","start","Date","now","on","path","params","body","query","datasourceId","statusCode","module","exports"],"sources":["../../src/metrics/httpMetricsRedisRecorder.js"],"sourcesContent":["const { HttpMetricsRedisStore, httpMetricsTraceLog } = require('./httpMetricsRedisStore')\n\nfunction trunc(s, max = 120) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\n\n/**\n * Records HTTP request aggregates only to Redis (no in-process Prometheus counters on this path).\n * Pair with {@link HttpMetricsRedisCollector} on a drain process to flush into counters and push to the VM-agent.\n *\n * @see HttpMetricsRedisStore\n */\nclass HttpMetricsRedisRecorder {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).\n * @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.\n * @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).\n * @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).\n */\n constructor({ redisClient, appName, processType = 'web', ttlSec } = {}) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisRecorder: redisClient is required')\n }\n const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app'\n this.processType = processType\n this.appName = resolvedAppName\n this._store = new HttpMetricsRedisStore({\n redisClient,\n appName: resolvedAppName,\n processType,\n ttlSec,\n })\n }\n\n /**\n * @param {Object} params\n * @param {string} params.method\n * @param {string} params.route\n * @param {number} params.status_code\n * @param {string} [params.appId]\n * @param {string} [params.databaseId]\n * @param {number} params.duration\n */\n trackHttpRequest({\n method,\n route,\n status_code,\n appId = '',\n databaseId = '',\n duration,\n }) {\n httpMetricsTraceLog(\n `track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` +\n `method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +\n `appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)} ` +\n `(then save_to_redis lines from store)`\n )\n this._store.record(method, route, status_code, appId, databaseId, duration)\n }\n\n /**\n * Express middleware: appends a `finish` listener and writes one aggregate row per request to Redis.\n * Does not check `METRICS_ENABLED`; the app should only mount this when HTTP Redis metrics should run.\n *\n * @param {import('http').IncomingMessage} req\n * @param {import('http').ServerResponse} res\n * @param {function} next\n */\n trackHttpRequestMiddleware = (req, res, next) => {\n if (req.method === 'OPTIONS') {\n next()\n return\n }\n\n const start = Date.now()\n res.on('finish', () => {\n const route = req.route?.path || req.path || 'unknown'\n const appId =\n req.params?.appId || req.body?.appId || req.query?.appId || ''\n const databaseId =\n req.params?.databaseId ||\n req.body?.databaseId ||\n req.query?.databaseId ||\n req.params?.datasourceId ||\n req.body?.datasourceId ||\n req.query?.datasourceId ||\n ''\n\n this.trackHttpRequest({\n method: req.method,\n route,\n status_code: res.statusCode,\n appId,\n databaseId,\n duration: Date.now() - start,\n })\n })\n\n next()\n }\n}\n\nmodule.exports = { HttpMetricsRedisRecorder }\n"],"mappings":"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","ignoreList":[]}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
3
|
-
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval
|
|
3
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
4
4
|
*
|
|
5
|
-
* **
|
|
6
|
-
*
|
|
5
|
+
* **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
|
|
6
|
+
* one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
|
|
7
|
+
* appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
|
|
8
|
+
* it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
|
|
9
|
+
* Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
|
|
7
10
|
*/
|
|
8
11
|
export class HttpMetricsRedisStore {
|
|
9
12
|
/**
|
|
@@ -23,7 +26,6 @@ export class HttpMetricsRedisStore {
|
|
|
23
26
|
ttlSec: number;
|
|
24
27
|
countKey: string;
|
|
25
28
|
durKey: string;
|
|
26
|
-
lockKey: string;
|
|
27
29
|
/**
|
|
28
30
|
* @returns {import('redis').RedisClient}
|
|
29
31
|
* @private
|
|
@@ -68,4 +70,10 @@ export function isRedisPeerInstalled(): boolean;
|
|
|
68
70
|
* @type {number}
|
|
69
71
|
*/
|
|
70
72
|
export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
|
|
73
|
+
/**
|
|
74
|
+
* Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
|
|
75
|
+
* so hosts that treat streams differently still show something.
|
|
76
|
+
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
77
|
+
*/
|
|
78
|
+
export function httpMetricsTraceLog(body: string): void;
|
|
71
79
|
//# sourceMappingURL=httpMetricsRedisStore.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA+FA;;;;;;;;;GASG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACd,OAAO,EAApB,MAAM;QACO,WAAW,EAAxB,MAAM;QACQ,MAAM;OAgB9B;IAVC,qCAA0B;IAC1B,eAGwC;IAIxC,iBAAiD;IACjD,eAA6C;IAG/C;;;OAGG;IACH,sBAEC;IAED;;;;;;;OAOG;IACH,eAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QA6BhB;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAqF5B;CACF;AA/LD;;;;;;;GAOG;AACH,sCAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AAlFD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAwDxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AAhED;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AA0B9C;;;;GAIG;AACH,0CAFW,MAAM,QAUhB"}
|
|
@@ -12,6 +12,42 @@ const FIELD_SEP = '\x1e';
|
|
|
12
12
|
*/
|
|
13
13
|
const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120;
|
|
14
14
|
const LOG = '[http-metrics-redis]';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function clusterHint() {
|
|
21
|
+
try {
|
|
22
|
+
// eslint-disable-next-line global-require
|
|
23
|
+
const c = require('cluster');
|
|
24
|
+
if (c.isWorker && c.worker != null) {
|
|
25
|
+
return ` cluster_worker=${c.worker.id}`;
|
|
26
|
+
}
|
|
27
|
+
} catch (_) {
|
|
28
|
+
/* cluster not in use */
|
|
29
|
+
}
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
function truncField(s, max = 96) {
|
|
33
|
+
const t = String(s);
|
|
34
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
|
|
39
|
+
* so hosts that treat streams differently still show something.
|
|
40
|
+
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
41
|
+
*/
|
|
42
|
+
function httpMetricsTraceLog(body) {
|
|
43
|
+
const line = `${LOG} ${body}${clusterHint()}`;
|
|
44
|
+
console.log(line);
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`${line}\n`);
|
|
47
|
+
} catch (_) {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
15
51
|
const DRAIN_LUA = `
|
|
16
52
|
local function drain(key)
|
|
17
53
|
local v = redis.call('HGETALL', key)
|
|
@@ -57,10 +93,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
57
93
|
|
|
58
94
|
/**
|
|
59
95
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
60
|
-
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval
|
|
96
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
61
97
|
*
|
|
62
|
-
* **
|
|
63
|
-
*
|
|
98
|
+
* **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
|
|
99
|
+
* one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
|
|
100
|
+
* appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
|
|
101
|
+
* it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
|
|
102
|
+
* Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
|
|
64
103
|
*/
|
|
65
104
|
class HttpMetricsRedisStore {
|
|
66
105
|
/**
|
|
@@ -84,7 +123,6 @@ class HttpMetricsRedisStore {
|
|
|
84
123
|
const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
|
|
85
124
|
this.countKey = `metrics:http:v2:${keySeg}:count`;
|
|
86
125
|
this.durKey = `metrics:http:v2:${keySeg}:dur`;
|
|
87
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`;
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
/**
|
|
@@ -108,12 +146,13 @@ class HttpMetricsRedisStore {
|
|
|
108
146
|
const client = this._ensureClient();
|
|
109
147
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId);
|
|
110
148
|
const dur = Math.max(0, Math.round(Number(durationMs) || 0));
|
|
149
|
+
httpMetricsTraceLog(`save_to_redis pid=${process.pid} countKey=${this.countKey} ` + `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`);
|
|
111
150
|
client.multi().hincrby(this.countKey, field, 1).hincrby(this.durKey, field, dur).expire(this.countKey, this.ttlSec).expire(this.durKey, this.ttlSec).exec(err => {
|
|
112
151
|
if (err) {
|
|
113
152
|
console.error('[HttpMetricsRedisStore] record failed:', err.message);
|
|
114
153
|
return;
|
|
115
154
|
}
|
|
116
|
-
|
|
155
|
+
httpMetricsTraceLog(`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`);
|
|
117
156
|
});
|
|
118
157
|
} catch (e) {
|
|
119
158
|
console.error('[HttpMetricsRedisStore] record:', e.message);
|
|
@@ -134,65 +173,57 @@ class HttpMetricsRedisStore {
|
|
|
134
173
|
return Promise.resolve(false);
|
|
135
174
|
}
|
|
136
175
|
return new Promise(resolve => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
176
|
+
httpMetricsTraceLog(`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`);
|
|
177
|
+
client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
|
|
178
|
+
if (evalErr) {
|
|
179
|
+
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
140
180
|
resolve(false);
|
|
141
181
|
return;
|
|
142
182
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (evalErr) {
|
|
148
|
-
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
149
|
-
finish();
|
|
183
|
+
try {
|
|
184
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
185
|
+
httpMetricsTraceLog(`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`);
|
|
186
|
+
resolve(true);
|
|
150
187
|
return;
|
|
151
188
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
189
|
+
const counts = hgetallPairsToObject(raw[0]);
|
|
190
|
+
const durs = hgetallPairsToObject(raw[1]);
|
|
191
|
+
const fieldKeys = Object.keys(counts);
|
|
192
|
+
let sumRequests = 0;
|
|
193
|
+
const samples = [];
|
|
194
|
+
for (const field of fieldKeys) {
|
|
195
|
+
const count = parseInt(counts[field], 10);
|
|
196
|
+
if (!count || count < 1) {
|
|
197
|
+
continue;
|
|
157
198
|
}
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const labels = {
|
|
179
|
-
method: m,
|
|
180
|
-
route,
|
|
181
|
-
status_code: statusStr,
|
|
182
|
-
appId: aid,
|
|
183
|
-
databaseId: did
|
|
184
|
-
};
|
|
185
|
-
applyCount(labels, count);
|
|
186
|
-
if (dur > 0) {
|
|
187
|
-
applyDuration(labels, dur);
|
|
188
|
-
}
|
|
199
|
+
const dur = parseInt(durs[field] || '0', 10) || 0;
|
|
200
|
+
const parts = field.split(FIELD_SEP);
|
|
201
|
+
if (parts.length !== 5) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const [m, route, statusStr, aid, did] = parts;
|
|
205
|
+
sumRequests += count;
|
|
206
|
+
if (samples.length < 3) {
|
|
207
|
+
samples.push(`${m} ${route} ${statusStr} x${count}`);
|
|
208
|
+
}
|
|
209
|
+
const labels = {
|
|
210
|
+
method: m,
|
|
211
|
+
route,
|
|
212
|
+
status_code: statusStr,
|
|
213
|
+
appId: aid,
|
|
214
|
+
databaseId: did
|
|
215
|
+
};
|
|
216
|
+
applyCount(labels, count);
|
|
217
|
+
if (dur > 0) {
|
|
218
|
+
applyDuration(labels, dur);
|
|
189
219
|
}
|
|
190
|
-
console.warn(`${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
|
|
193
220
|
}
|
|
194
|
-
|
|
195
|
-
|
|
221
|
+
httpMetricsTraceLog(`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
|
|
222
|
+
resolve(true);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
|
|
225
|
+
resolve(false);
|
|
226
|
+
}
|
|
196
227
|
});
|
|
197
228
|
});
|
|
198
229
|
}
|
|
@@ -202,6 +233,7 @@ module.exports = {
|
|
|
202
233
|
buildFieldKey,
|
|
203
234
|
FIELD_SEP,
|
|
204
235
|
isRedisPeerInstalled,
|
|
205
|
-
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC
|
|
236
|
+
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
|
|
237
|
+
httpMetricsTraceLog
|
|
206
238
|
};
|
|
207
239
|
//# sourceMappingURL=httpMetricsRedisStore.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","LOG","DRAIN_LUA","isRedisPeerInstalled","require","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","String","join","hgetallPairsToObject","pairs","o","length","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","lockKey","_ensureClient","record","durationMs","client","field","dur","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","warn","process","pid","e","flushToCounters","applyCount","applyDuration","Promise","set","setErr","ok","eval","evalErr","raw","finish","del","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","status_code","module","exports"],"sources":["../../src/metrics/httpMetricsRedisStore.js"],"sourcesContent":["/**\n * Record separator for hash fields (avoids collisions when route contains \"|\").\n * @type {string}\n */\nconst FIELD_SEP = '\\x1e'\n\n/**\n * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).\n * @type {number}\n */\nconst DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120\n\nconst LOG = '[http-metrics-redis]'\n\nconst DRAIN_LUA = `\nlocal function drain(key)\n local v = redis.call('HGETALL', key)\n redis.call('DEL', key)\n return v\nend\nreturn {drain(KEYS[1]), drain(KEYS[2])}\n`\n\n/**\n * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].join(FIELD_SEP)\n}\n\nfunction hgetallPairsToObject(pairs) {\n const o = {}\n if (!pairs || !pairs.length) {\n return o\n }\n for (let i = 0; i < pairs.length; i += 2) {\n o[pairs[i]] = pairs[i + 1]\n }\n return o\n}\n\n/**\n * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).\n * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.\n *\n * **Stored metrics:** two hashes per app/segment — `:count` (HINCRBY per route group) and `:dur`\n * (sum of duration ms per same field). Hash field = `method\\\\x1eroute\\\\x1estatus\\\\x1eappId\\\\x1edatabaseId`.\n */\nclass HttpMetricsRedisStore {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient\n * @param {string} opts.appName BUILD_APP_NAME (key segment)\n * @param {string} opts.processType logical process for key (e.g. web)\n * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisStore: redisClient is required')\n }\n this._client = redisClient\n this.ttlSec =\n typeof ttlSec === 'number' && ttlSec > 0\n ? ttlSec\n : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC\n const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(\n processType\n )}`\n this.countKey = `metrics:http:v2:${keySeg}:count`\n this.durKey = `metrics:http:v2:${keySeg}:dur`\n this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`\n }\n\n /**\n * @returns {import('redis').RedisClient}\n * @private\n */\n _ensureClient() {\n return this._client\n }\n\n /**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n client\n .multi()\n .hincrby(this.countKey, field, 1)\n .hincrby(this.durKey, field, dur)\n .expire(this.countKey, this.ttlSec)\n .expire(this.durKey, this.ttlSec)\n .exec(err => {\n if (err) {\n console.error('[HttpMetricsRedisStore] record failed:', err.message)\n return\n }\n console.warn(\n `${LOG} 2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`\n )\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * @param {(labels: Object, value: number) => void} applyCount\n * @param {(labels: Object, value: number) => void} applyDuration\n * @returns {Promise<boolean>}\n */\n flushToCounters(applyCount, applyDuration) {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] flush:', e.message)\n return Promise.resolve(false)\n }\n return new Promise(resolve => {\n client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {\n if (setErr || ok !== 'OK') {\n console.warn(\n `${LOG} 3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${\n setErr ? `error:${setErr.message}` : 'lock_not_acquired'\n }`\n )\n resolve(false)\n return\n }\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n const finish = () => {\n client.del(this.lockKey, () => resolve(true))\n }\n\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n finish()\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n console.warn(\n `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`\n )\n finish()\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n let sumRequests = 0\n const samples = []\n for (const field of fieldKeys) {\n const count = parseInt(counts[field], 10)\n if (!count || count < 1) {\n continue\n }\n const dur = parseInt(durs[field] || '0', 10) || 0\n const parts = field.split(FIELD_SEP)\n if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n console.warn(\n `${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\n )\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n }\n finish()\n }\n )\n })\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n isRedisPeerInstalled,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n}\n"],"mappings":"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","ignoreList":[]}
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","LOG","clusterHint","c","require","isWorker","worker","id","_","truncField","s","max","t","String","length","slice","httpMetricsTraceLog","body","line","console","log","process","stderr","write","DRAIN_LUA","isRedisPeerInstalled","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","join","hgetallPairsToObject","pairs","o","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","field","dur","Math","round","Number","pid","multi","hincrby","expire","exec","err","error","message","e","flushToCounters","applyCount","applyDuration","Promise","eval","evalErr","raw","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","status_code","module","exports"],"sources":["../../src/metrics/httpMetricsRedisStore.js"],"sourcesContent":["/**\n * Record separator for hash fields (avoids collisions when route contains \"|\").\n * @type {string}\n */\nconst FIELD_SEP = '\\x1e'\n\n/**\n * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).\n * @type {number}\n */\nconst DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120\n\nconst LOG = '[http-metrics-redis]'\n\n/**\n * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.\n * @returns {string}\n */\nfunction clusterHint() {\n try {\n // eslint-disable-next-line global-require\n const c = require('cluster')\n if (c.isWorker && c.worker != null) {\n return ` cluster_worker=${c.worker.id}`\n }\n } catch (_) {\n /* cluster not in use */\n }\n return ''\n}\n\nfunction truncField(s, max = 96) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\n\n/**\n * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**\n * so hosts that treat streams differently still show something.\n * @param {string} body - Line body after `[http-metrics-redis]` prefix.\n */\nfunction httpMetricsTraceLog(body) {\n const line = `${LOG} ${body}${clusterHint()}`\n console.log(line)\n try {\n process.stderr.write(`${line}\\n`)\n } catch (_) {\n /* ignore */\n }\n}\n\nconst DRAIN_LUA = `\nlocal function drain(key)\n local v = redis.call('HGETALL', key)\n redis.call('DEL', key)\n return v\nend\nreturn {drain(KEYS[1]), drain(KEYS[2])}\n`\n\n/**\n * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].join(FIELD_SEP)\n}\n\nfunction hgetallPairsToObject(pairs) {\n const o = {}\n if (!pairs || !pairs.length) {\n return o\n }\n for (let i = 0; i < pairs.length; i += 2) {\n o[pairs[i]] = pairs[i + 1]\n }\n return o\n}\n\n/**\n * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).\n * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.\n *\n * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not\n * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,\n * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`\n * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).\n * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.\n */\nclass HttpMetricsRedisStore {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient\n * @param {string} opts.appName BUILD_APP_NAME (key segment)\n * @param {string} opts.processType logical process for key (e.g. web)\n * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisStore: redisClient is required')\n }\n this._client = redisClient\n this.ttlSec =\n typeof ttlSec === 'number' && ttlSec > 0\n ? ttlSec\n : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC\n const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(\n processType\n )}`\n this.countKey = `metrics:http:v2:${keySeg}:count`\n this.durKey = `metrics:http:v2:${keySeg}:dur`\n }\n\n /**\n * @returns {import('redis').RedisClient}\n * @private\n */\n _ensureClient() {\n return this._client\n }\n\n /**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n httpMetricsTraceLog(\n `save_to_redis pid=${process.pid} countKey=${this.countKey} ` +\n `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`\n )\n client\n .multi()\n .hincrby(this.countKey, field, 1)\n .hincrby(this.durKey, field, dur)\n .expire(this.countKey, this.ttlSec)\n .expire(this.durKey, this.ttlSec)\n .exec(err => {\n if (err) {\n console.error('[HttpMetricsRedisStore] record failed:', err.message)\n return\n }\n httpMetricsTraceLog(\n `save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`\n )\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * @param {(labels: Object, value: number) => void} applyCount\n * @param {(labels: Object, value: number) => void} applyDuration\n * @returns {Promise<boolean>}\n */\n flushToCounters(applyCount, applyDuration) {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] flush:', e.message)\n return Promise.resolve(false)\n }\n return new Promise(resolve => {\n httpMetricsTraceLog(\n `get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`\n )\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n resolve(false)\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n httpMetricsTraceLog(\n `get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`\n )\n resolve(true)\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n let sumRequests = 0\n const samples = []\n for (const field of fieldKeys) {\n const count = parseInt(counts[field], 10)\n if (!count || count < 1) {\n continue\n }\n const dur = parseInt(durs[field] || '0', 10) || 0\n const parts = field.split(FIELD_SEP)\n if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n httpMetricsTraceLog(\n `get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\n )\n resolve(true)\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n resolve(false)\n }\n }\n )\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n isRedisPeerInstalled,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n httpMetricsTraceLog,\n}\n"],"mappings":"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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.
|
|
3
|
+
"version": "0.0.0-staging.26",
|
|
4
4
|
"description": "Reusable metrics utilities for Node.js and Laravel apps",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@types/pg": "^8.15.6"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@babel/cli": "7.
|
|
35
|
-
"@babel/core": "7.
|
|
36
|
-
"@babel/preset-env": "7.
|
|
37
|
-
"@babel/preset-typescript": "7.
|
|
34
|
+
"@babel/cli": "7.24.7",
|
|
35
|
+
"@babel/core": "7.24.7",
|
|
36
|
+
"@babel/preset-env": "7.24.7",
|
|
37
|
+
"@babel/preset-typescript": "7.24.7",
|
|
38
38
|
"@types/ioredis": "^5.0.0",
|
|
39
39
|
"@types/jest": "28.1.6",
|
|
40
40
|
"@types/node": "17.0.38",
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
const { HttpMetricsRedisStore } = require('./httpMetricsRedisStore')
|
|
2
|
-
|
|
3
|
-
const LOG = '[http-metrics-redis]'
|
|
1
|
+
const { HttpMetricsRedisStore, httpMetricsTraceLog } = require('./httpMetricsRedisStore')
|
|
4
2
|
|
|
5
3
|
function trunc(s, max = 120) {
|
|
6
4
|
const t = String(s)
|
|
@@ -17,19 +15,20 @@ class HttpMetricsRedisRecorder {
|
|
|
17
15
|
/**
|
|
18
16
|
* @param {Object} opts
|
|
19
17
|
* @param {import('redis').RedisClient} opts.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).
|
|
20
|
-
* @param {string} opts.appName
|
|
21
|
-
* @param {string} opts.processType
|
|
18
|
+
* @param {string} [opts.appName] Optional override; default same as {@link BaseMetricsClient}: `BUILD_APP_NAME` or `unknown-app`.
|
|
19
|
+
* @param {string} [opts.processType] Redis key segment (default **`web`**, same as {@link HttpMetricsRedisCollector}).
|
|
22
20
|
* @param {number} [opts.ttlSec] Redis hash key TTL in seconds (sliding; default 120).
|
|
23
21
|
*/
|
|
24
|
-
constructor({ redisClient, appName, processType, ttlSec }) {
|
|
22
|
+
constructor({ redisClient, appName, processType = 'web', ttlSec } = {}) {
|
|
25
23
|
if (redisClient == null) {
|
|
26
24
|
throw new Error('HttpMetricsRedisRecorder: redisClient is required')
|
|
27
25
|
}
|
|
26
|
+
const resolvedAppName = appName || process.env.BUILD_APP_NAME || 'unknown-app'
|
|
28
27
|
this.processType = processType
|
|
29
|
-
this.appName =
|
|
28
|
+
this.appName = resolvedAppName
|
|
30
29
|
this._store = new HttpMetricsRedisStore({
|
|
31
30
|
redisClient,
|
|
32
|
-
appName,
|
|
31
|
+
appName: resolvedAppName,
|
|
33
32
|
processType,
|
|
34
33
|
ttlSec,
|
|
35
34
|
})
|
|
@@ -52,10 +51,11 @@ class HttpMetricsRedisRecorder {
|
|
|
52
51
|
databaseId = '',
|
|
53
52
|
duration,
|
|
54
53
|
}) {
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
httpMetricsTraceLog(
|
|
55
|
+
`track_request pid=${process.pid} app=${this.appName} segment=${this.processType} ` +
|
|
57
56
|
`method=${method} route=${trunc(route)} status=${status_code} durationMs=${duration} ` +
|
|
58
|
-
`appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)}`
|
|
57
|
+
`appId=${trunc(appId || '—', 40)} databaseId=${trunc(databaseId || '—', 40)} ` +
|
|
58
|
+
`(then save_to_redis lines from store)`
|
|
59
59
|
)
|
|
60
60
|
this._store.record(method, route, status_code, appId, databaseId, duration)
|
|
61
61
|
}
|
|
@@ -12,6 +12,43 @@ const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120
|
|
|
12
12
|
|
|
13
13
|
const LOG = '[http-metrics-redis]'
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function clusterHint() {
|
|
20
|
+
try {
|
|
21
|
+
// eslint-disable-next-line global-require
|
|
22
|
+
const c = require('cluster')
|
|
23
|
+
if (c.isWorker && c.worker != null) {
|
|
24
|
+
return ` cluster_worker=${c.worker.id}`
|
|
25
|
+
}
|
|
26
|
+
} catch (_) {
|
|
27
|
+
/* cluster not in use */
|
|
28
|
+
}
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function truncField(s, max = 96) {
|
|
33
|
+
const t = String(s)
|
|
34
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis). Emits **stdout + stderr**
|
|
39
|
+
* so hosts that treat streams differently still show something.
|
|
40
|
+
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
41
|
+
*/
|
|
42
|
+
function httpMetricsTraceLog(body) {
|
|
43
|
+
const line = `${LOG} ${body}${clusterHint()}`
|
|
44
|
+
console.log(line)
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`${line}\n`)
|
|
47
|
+
} catch (_) {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
15
52
|
const DRAIN_LUA = `
|
|
16
53
|
local function drain(key)
|
|
17
54
|
local v = redis.call('HGETALL', key)
|
|
@@ -58,10 +95,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
58
95
|
|
|
59
96
|
/**
|
|
60
97
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
61
|
-
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval
|
|
98
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
62
99
|
*
|
|
63
|
-
* **
|
|
64
|
-
*
|
|
100
|
+
* **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
|
|
101
|
+
* one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
|
|
102
|
+
* appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
|
|
103
|
+
* it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
|
|
104
|
+
* Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
|
|
65
105
|
*/
|
|
66
106
|
class HttpMetricsRedisStore {
|
|
67
107
|
/**
|
|
@@ -85,7 +125,6 @@ class HttpMetricsRedisStore {
|
|
|
85
125
|
)}`
|
|
86
126
|
this.countKey = `metrics:http:v2:${keySeg}:count`
|
|
87
127
|
this.durKey = `metrics:http:v2:${keySeg}:dur`
|
|
88
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`
|
|
89
128
|
}
|
|
90
129
|
|
|
91
130
|
/**
|
|
@@ -109,6 +148,10 @@ class HttpMetricsRedisStore {
|
|
|
109
148
|
const client = this._ensureClient()
|
|
110
149
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId)
|
|
111
150
|
const dur = Math.max(0, Math.round(Number(durationMs) || 0))
|
|
151
|
+
httpMetricsTraceLog(
|
|
152
|
+
`save_to_redis pid=${process.pid} countKey=${this.countKey} ` +
|
|
153
|
+
`durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`
|
|
154
|
+
)
|
|
112
155
|
client
|
|
113
156
|
.multi()
|
|
114
157
|
.hincrby(this.countKey, field, 1)
|
|
@@ -120,8 +163,8 @@ class HttpMetricsRedisStore {
|
|
|
120
163
|
console.error('[HttpMetricsRedisStore] record failed:', err.message)
|
|
121
164
|
return
|
|
122
165
|
}
|
|
123
|
-
|
|
124
|
-
|
|
166
|
+
httpMetricsTraceLog(
|
|
167
|
+
`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`
|
|
125
168
|
)
|
|
126
169
|
})
|
|
127
170
|
} catch (e) {
|
|
@@ -143,90 +186,79 @@ class HttpMetricsRedisStore {
|
|
|
143
186
|
return Promise.resolve(false)
|
|
144
187
|
}
|
|
145
188
|
return new Promise(resolve => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
client.del(this.lockKey, () => resolve(true))
|
|
164
|
-
}
|
|
189
|
+
httpMetricsTraceLog(
|
|
190
|
+
`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`
|
|
191
|
+
)
|
|
192
|
+
client.eval(
|
|
193
|
+
DRAIN_LUA,
|
|
194
|
+
2,
|
|
195
|
+
this.countKey,
|
|
196
|
+
this.durKey,
|
|
197
|
+
(evalErr, raw) => {
|
|
198
|
+
if (evalErr) {
|
|
199
|
+
console.error(
|
|
200
|
+
'[HttpMetricsRedisStore] drain failed:',
|
|
201
|
+
evalErr.message
|
|
202
|
+
)
|
|
203
|
+
resolve(false)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
165
206
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
207
|
+
try {
|
|
208
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
209
|
+
httpMetricsTraceLog(
|
|
210
|
+
`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`
|
|
170
211
|
)
|
|
171
|
-
|
|
212
|
+
resolve(true)
|
|
172
213
|
return
|
|
173
214
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
215
|
+
const counts = hgetallPairsToObject(raw[0])
|
|
216
|
+
const durs = hgetallPairsToObject(raw[1])
|
|
217
|
+
const fieldKeys = Object.keys(counts)
|
|
218
|
+
let sumRequests = 0
|
|
219
|
+
const samples = []
|
|
220
|
+
for (const field of fieldKeys) {
|
|
221
|
+
const count = parseInt(counts[field], 10)
|
|
222
|
+
if (!count || count < 1) {
|
|
223
|
+
continue
|
|
182
224
|
}
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const labels = {
|
|
204
|
-
method: m,
|
|
205
|
-
route,
|
|
206
|
-
status_code: statusStr,
|
|
207
|
-
appId: aid,
|
|
208
|
-
databaseId: did,
|
|
209
|
-
}
|
|
210
|
-
applyCount(labels, count)
|
|
211
|
-
if (dur > 0) {
|
|
212
|
-
applyDuration(labels, dur)
|
|
213
|
-
}
|
|
225
|
+
const dur = parseInt(durs[field] || '0', 10) || 0
|
|
226
|
+
const parts = field.split(FIELD_SEP)
|
|
227
|
+
if (parts.length !== 5) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
const [m, route, statusStr, aid, did] = parts
|
|
231
|
+
sumRequests += count
|
|
232
|
+
if (samples.length < 3) {
|
|
233
|
+
samples.push(`${m} ${route} ${statusStr} x${count}`)
|
|
234
|
+
}
|
|
235
|
+
const labels = {
|
|
236
|
+
method: m,
|
|
237
|
+
route,
|
|
238
|
+
status_code: statusStr,
|
|
239
|
+
appId: aid,
|
|
240
|
+
databaseId: did,
|
|
241
|
+
}
|
|
242
|
+
applyCount(labels, count)
|
|
243
|
+
if (dur > 0) {
|
|
244
|
+
applyDuration(labels, dur)
|
|
214
245
|
}
|
|
215
|
-
console.warn(
|
|
216
|
-
`${LOG} 3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${
|
|
217
|
-
fieldKeys.length
|
|
218
|
-
} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
|
|
219
|
-
)
|
|
220
|
-
} catch (e) {
|
|
221
|
-
console.error(
|
|
222
|
-
'[HttpMetricsRedisStore] flush apply failed:',
|
|
223
|
-
e.message
|
|
224
|
-
)
|
|
225
246
|
}
|
|
226
|
-
|
|
247
|
+
httpMetricsTraceLog(
|
|
248
|
+
`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${
|
|
249
|
+
fieldKeys.length
|
|
250
|
+
} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
|
|
251
|
+
)
|
|
252
|
+
resolve(true)
|
|
253
|
+
} catch (e) {
|
|
254
|
+
console.error(
|
|
255
|
+
'[HttpMetricsRedisStore] flush apply failed:',
|
|
256
|
+
e.message
|
|
257
|
+
)
|
|
258
|
+
resolve(false)
|
|
227
259
|
}
|
|
228
|
-
|
|
229
|
-
|
|
260
|
+
}
|
|
261
|
+
)
|
|
230
262
|
})
|
|
231
263
|
}
|
|
232
264
|
}
|
|
@@ -237,4 +269,5 @@ module.exports = {
|
|
|
237
269
|
FIELD_SEP,
|
|
238
270
|
isRedisPeerInstalled,
|
|
239
271
|
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
|
|
272
|
+
httpMetricsTraceLog,
|
|
240
273
|
}
|