@adalo/metrics 0.0.0-staging.24 → 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 +214 -42
- package/lib/metrics/httpMetricsRedisRecorder.d.ts +5 -5
- package/lib/metrics/httpMetricsRedisRecorder.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisRecorder.js +8 -7
- package/lib/metrics/httpMetricsRedisRecorder.js.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.d.ts +8 -7
- package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.js +64 -60
- package/lib/metrics/httpMetricsRedisStore.js.map +1 -1
- package/package.json +1 -1
- package/src/metrics/httpMetricsRedisRecorder.js +9 -7
- package/src/metrics/httpMetricsRedisStore.js +90 -85
|
@@ -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,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
|
|
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":"AAOA;;;;;GAKG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;
|
|
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
|
|
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
|
-
httpMetricsTraceLog(`
|
|
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","
|
|
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
|
|
3
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
4
4
|
*
|
|
5
|
-
* **
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,8 @@ export function isRedisPeerInstalled(): boolean;
|
|
|
71
71
|
*/
|
|
72
72
|
export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
|
|
73
73
|
/**
|
|
74
|
-
*
|
|
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.
|
|
75
76
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
76
77
|
*/
|
|
77
78
|
export function httpMetricsTraceLog(body: string): void;
|
|
@@ -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"}
|
|
@@ -29,13 +29,24 @@ 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
|
-
*
|
|
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.
|
|
35
40
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
36
41
|
*/
|
|
37
42
|
function httpMetricsTraceLog(body) {
|
|
38
|
-
|
|
43
|
+
const line = `${LOG} ${body}${clusterHint()}`;
|
|
44
|
+
console.log(line);
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`${line}\n`);
|
|
47
|
+
} catch (_) {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
51
|
const DRAIN_LUA = `
|
|
41
52
|
local function drain(key)
|
|
@@ -82,12 +93,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
82
93
|
|
|
83
94
|
/**
|
|
84
95
|
* 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
|
|
96
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
86
97
|
*
|
|
87
|
-
* **
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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.
|
|
91
103
|
*/
|
|
92
104
|
class HttpMetricsRedisStore {
|
|
93
105
|
/**
|
|
@@ -111,7 +123,6 @@ class HttpMetricsRedisStore {
|
|
|
111
123
|
const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
|
|
112
124
|
this.countKey = `metrics:http:v2:${keySeg}:count`;
|
|
113
125
|
this.durKey = `metrics:http:v2:${keySeg}:dur`;
|
|
114
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
/**
|
|
@@ -135,12 +146,13 @@ class HttpMetricsRedisStore {
|
|
|
135
146
|
const client = this._ensureClient();
|
|
136
147
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId);
|
|
137
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)`);
|
|
138
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 => {
|
|
139
151
|
if (err) {
|
|
140
152
|
console.error('[HttpMetricsRedisStore] record failed:', err.message);
|
|
141
153
|
return;
|
|
142
154
|
}
|
|
143
|
-
httpMetricsTraceLog(`
|
|
155
|
+
httpMetricsTraceLog(`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`);
|
|
144
156
|
});
|
|
145
157
|
} catch (e) {
|
|
146
158
|
console.error('[HttpMetricsRedisStore] record:', e.message);
|
|
@@ -161,65 +173,57 @@ class HttpMetricsRedisStore {
|
|
|
161
173
|
return Promise.resolve(false);
|
|
162
174
|
}
|
|
163
175
|
return new Promise(resolve => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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);
|
|
167
180
|
resolve(false);
|
|
168
181
|
return;
|
|
169
182
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (evalErr) {
|
|
175
|
-
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
176
|
-
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);
|
|
177
187
|
return;
|
|
178
188
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
198
|
}
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
}
|
|
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);
|
|
216
219
|
}
|
|
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
220
|
}
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
}
|
|
223
227
|
});
|
|
224
228
|
});
|
|
225
229
|
}
|
|
@@ -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":"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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
|
@@ -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
|
|
19
|
-
* @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}).
|
|
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 =
|
|
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
|
-
`
|
|
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,24 @@ 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
|
-
*
|
|
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.
|
|
34
40
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
35
41
|
*/
|
|
36
42
|
function httpMetricsTraceLog(body) {
|
|
37
|
-
|
|
43
|
+
const line = `${LOG} ${body}${clusterHint()}`
|
|
44
|
+
console.log(line)
|
|
45
|
+
try {
|
|
46
|
+
process.stderr.write(`${line}\n`)
|
|
47
|
+
} catch (_) {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
const DRAIN_LUA = `
|
|
@@ -83,12 +95,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
83
95
|
|
|
84
96
|
/**
|
|
85
97
|
* 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
|
|
98
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
87
99
|
*
|
|
88
|
-
* **
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
92
105
|
*/
|
|
93
106
|
class HttpMetricsRedisStore {
|
|
94
107
|
/**
|
|
@@ -112,7 +125,6 @@ class HttpMetricsRedisStore {
|
|
|
112
125
|
)}`
|
|
113
126
|
this.countKey = `metrics:http:v2:${keySeg}:count`
|
|
114
127
|
this.durKey = `metrics:http:v2:${keySeg}:dur`
|
|
115
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/**
|
|
@@ -136,6 +148,10 @@ class HttpMetricsRedisStore {
|
|
|
136
148
|
const client = this._ensureClient()
|
|
137
149
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId)
|
|
138
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
|
+
)
|
|
139
155
|
client
|
|
140
156
|
.multi()
|
|
141
157
|
.hincrby(this.countKey, field, 1)
|
|
@@ -148,7 +164,7 @@ class HttpMetricsRedisStore {
|
|
|
148
164
|
return
|
|
149
165
|
}
|
|
150
166
|
httpMetricsTraceLog(
|
|
151
|
-
`
|
|
167
|
+
`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`
|
|
152
168
|
)
|
|
153
169
|
})
|
|
154
170
|
} catch (e) {
|
|
@@ -170,90 +186,79 @@ class HttpMetricsRedisStore {
|
|
|
170
186
|
return Promise.resolve(false)
|
|
171
187
|
}
|
|
172
188
|
return new Promise(resolve => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
client.del(this.lockKey, () => resolve(true))
|
|
191
|
-
}
|
|
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
|
+
}
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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)`
|
|
197
211
|
)
|
|
198
|
-
|
|
212
|
+
resolve(true)
|
|
199
213
|
return
|
|
200
214
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
209
224
|
}
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
}
|
|
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)
|
|
241
245
|
}
|
|
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
246
|
}
|
|
253
|
-
|
|
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)
|
|
254
259
|
}
|
|
255
|
-
|
|
256
|
-
|
|
260
|
+
}
|
|
261
|
+
)
|
|
257
262
|
})
|
|
258
263
|
}
|
|
259
264
|
}
|