@adalo/metrics 0.0.0-staging.24 → 0.0.0-staging.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +9 -7
- package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.js +60 -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 +86 -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,9 @@ 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).
|
|
75
|
+
* Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
|
|
76
|
+
* show duplicate lines if we also wrote the same text to stderr.
|
|
75
77
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
76
78
|
*/
|
|
77
79
|
export function httpMetricsTraceLog(body: string): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA2FA;;;;;;;;;GASG;AACH;IACE;;;;;;OAMG;IACH;QAL6C,WAAW,EAA7C,OAAO,OAAO,EAAE,WAAW;QACd,OAAO,EAApB,MAAM;QACO,WAAW,EAAxB,MAAM;QACQ,MAAM;OAgB9B;IAVC,qCAA0B;IAC1B,eAGwC;IAIxC,iBAAiD;IACjD,eAA6C;IAG/C;;;OAGG;IACH,sBAEC;IAED;;;;;;;OAOG;IACH,eAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QA6BhB;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAqF5B;CACF;AA/LD;;;;;;;GAOG;AACH,sCAPW,MAAM,SACN,MAAM,cACN,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AA9ED;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAoDxB;;GAEG;AACH,wCAFa,OAAO,CASnB;AA5DD;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AA0B9C;;;;;GAKG;AACH,0CAFW,MAAM,QAKhB"}
|
|
@@ -29,13 +29,20 @@ function clusterHint() {
|
|
|
29
29
|
}
|
|
30
30
|
return '';
|
|
31
31
|
}
|
|
32
|
+
function truncField(s, max = 96) {
|
|
33
|
+
const t = String(s);
|
|
34
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
35
|
+
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
38
|
+
* Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis).
|
|
39
|
+
* Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
|
|
40
|
+
* show duplicate lines if we also wrote the same text to stderr.
|
|
35
41
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
36
42
|
*/
|
|
37
43
|
function httpMetricsTraceLog(body) {
|
|
38
|
-
|
|
44
|
+
const line = `${LOG} ${body}${clusterHint()}`;
|
|
45
|
+
console.log(line);
|
|
39
46
|
}
|
|
40
47
|
const DRAIN_LUA = `
|
|
41
48
|
local function drain(key)
|
|
@@ -82,12 +89,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
82
89
|
|
|
83
90
|
/**
|
|
84
91
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
85
|
-
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval
|
|
92
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
86
93
|
*
|
|
87
|
-
* **
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
94
|
+
* **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
|
|
95
|
+
* one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
|
|
96
|
+
* appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
|
|
97
|
+
* it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
|
|
98
|
+
* Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
|
|
91
99
|
*/
|
|
92
100
|
class HttpMetricsRedisStore {
|
|
93
101
|
/**
|
|
@@ -111,7 +119,6 @@ class HttpMetricsRedisStore {
|
|
|
111
119
|
const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(processType)}`;
|
|
112
120
|
this.countKey = `metrics:http:v2:${keySeg}:count`;
|
|
113
121
|
this.durKey = `metrics:http:v2:${keySeg}:dur`;
|
|
114
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`;
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
/**
|
|
@@ -135,12 +142,13 @@ class HttpMetricsRedisStore {
|
|
|
135
142
|
const client = this._ensureClient();
|
|
136
143
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId);
|
|
137
144
|
const dur = Math.max(0, Math.round(Number(durationMs) || 0));
|
|
145
|
+
httpMetricsTraceLog(`save_to_redis pid=${process.pid} countKey=${this.countKey} ` + `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`);
|
|
138
146
|
client.multi().hincrby(this.countKey, field, 1).hincrby(this.durKey, field, dur).expire(this.countKey, this.ttlSec).expire(this.durKey, this.ttlSec).exec(err => {
|
|
139
147
|
if (err) {
|
|
140
148
|
console.error('[HttpMetricsRedisStore] record failed:', err.message);
|
|
141
149
|
return;
|
|
142
150
|
}
|
|
143
|
-
httpMetricsTraceLog(`
|
|
151
|
+
httpMetricsTraceLog(`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`);
|
|
144
152
|
});
|
|
145
153
|
} catch (e) {
|
|
146
154
|
console.error('[HttpMetricsRedisStore] record:', e.message);
|
|
@@ -161,65 +169,57 @@ class HttpMetricsRedisStore {
|
|
|
161
169
|
return Promise.resolve(false);
|
|
162
170
|
}
|
|
163
171
|
return new Promise(resolve => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
httpMetricsTraceLog(`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`);
|
|
173
|
+
client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
|
|
174
|
+
if (evalErr) {
|
|
175
|
+
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
167
176
|
resolve(false);
|
|
168
177
|
return;
|
|
169
178
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (evalErr) {
|
|
175
|
-
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
176
|
-
finish();
|
|
179
|
+
try {
|
|
180
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
181
|
+
httpMetricsTraceLog(`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`);
|
|
182
|
+
resolve(true);
|
|
177
183
|
return;
|
|
178
184
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
const counts = hgetallPairsToObject(raw[0]);
|
|
186
|
+
const durs = hgetallPairsToObject(raw[1]);
|
|
187
|
+
const fieldKeys = Object.keys(counts);
|
|
188
|
+
let sumRequests = 0;
|
|
189
|
+
const samples = [];
|
|
190
|
+
for (const field of fieldKeys) {
|
|
191
|
+
const count = parseInt(counts[field], 10);
|
|
192
|
+
if (!count || count < 1) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const dur = parseInt(durs[field] || '0', 10) || 0;
|
|
196
|
+
const parts = field.split(FIELD_SEP);
|
|
197
|
+
if (parts.length !== 5) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const [m, route, statusStr, aid, did] = parts;
|
|
201
|
+
sumRequests += count;
|
|
202
|
+
if (samples.length < 3) {
|
|
203
|
+
samples.push(`${m} ${route} ${statusStr} x${count}`);
|
|
184
204
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const dur = parseInt(durs[field] || '0', 10) || 0;
|
|
196
|
-
const parts = field.split(FIELD_SEP);
|
|
197
|
-
if (parts.length !== 5) {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
const [m, route, statusStr, aid, did] = parts;
|
|
201
|
-
sumRequests += count;
|
|
202
|
-
if (samples.length < 3) {
|
|
203
|
-
samples.push(`${m} ${route} ${statusStr} x${count}`);
|
|
204
|
-
}
|
|
205
|
-
const labels = {
|
|
206
|
-
method: m,
|
|
207
|
-
route,
|
|
208
|
-
status_code: statusStr,
|
|
209
|
-
appId: aid,
|
|
210
|
-
databaseId: did
|
|
211
|
-
};
|
|
212
|
-
applyCount(labels, count);
|
|
213
|
-
if (dur > 0) {
|
|
214
|
-
applyDuration(labels, dur);
|
|
215
|
-
}
|
|
205
|
+
const labels = {
|
|
206
|
+
method: m,
|
|
207
|
+
route,
|
|
208
|
+
status_code: statusStr,
|
|
209
|
+
appId: aid,
|
|
210
|
+
databaseId: did
|
|
211
|
+
};
|
|
212
|
+
applyCount(labels, count);
|
|
213
|
+
if (dur > 0) {
|
|
214
|
+
applyDuration(labels, dur);
|
|
216
215
|
}
|
|
217
|
-
httpMetricsTraceLog(`3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
|
|
220
216
|
}
|
|
221
|
-
|
|
222
|
-
|
|
217
|
+
httpMetricsTraceLog(`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${fieldKeys.length} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`);
|
|
218
|
+
resolve(true);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
console.error('[HttpMetricsRedisStore] flush apply failed:', e.message);
|
|
221
|
+
resolve(false);
|
|
222
|
+
}
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","LOG","clusterHint","c","require","isWorker","worker","id","_","httpMetricsTraceLog","body","console","log","DRAIN_LUA","isRedisPeerInstalled","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","String","join","hgetallPairsToObject","pairs","o","length","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","lockKey","_ensureClient","record","durationMs","client","field","dur","Math","max","round","Number","multi","hincrby","expire","exec","err","error","message","process","pid","e","flushToCounters","applyCount","applyDuration","Promise","set","setErr","ok","eval","evalErr","raw","finish","del","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","status_code","module","exports"],"sources":["../../src/metrics/httpMetricsRedisStore.js"],"sourcesContent":["/**\n * Record separator for hash fields (avoids collisions when route contains \"|\").\n * @type {string}\n */\nconst FIELD_SEP = '\\x1e'\n\n/**\n * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).\n * @type {number}\n */\nconst DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120\n\nconst LOG = '[http-metrics-redis]'\n\n/**\n * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.\n * @returns {string}\n */\nfunction clusterHint() {\n try {\n // eslint-disable-next-line global-require\n const c = require('cluster')\n if (c.isWorker && c.worker != null) {\n return ` cluster_worker=${c.worker.id}`\n }\n } catch (_) {\n /* cluster not in use */\n }\n return ''\n}\n\n/**\n * Stdout trace (uses **console.log**, not warn — same as typical app request logs).\n * @param {string} body - Line body after `[http-metrics-redis]` prefix.\n */\nfunction httpMetricsTraceLog(body) {\n console.log(`${LOG} ${body}${clusterHint()}`)\n}\n\nconst DRAIN_LUA = `\nlocal function drain(key)\n local v = redis.call('HGETALL', key)\n redis.call('DEL', key)\n return v\nend\nreturn {drain(KEYS[1]), drain(KEYS[2])}\n`\n\n/**\n * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].join(FIELD_SEP)\n}\n\nfunction hgetallPairsToObject(pairs) {\n const o = {}\n if (!pairs || !pairs.length) {\n return o\n }\n for (let i = 0; i < pairs.length; i += 2) {\n o[pairs[i]] = pairs[i + 1]\n }\n return o\n}\n\n/**\n * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).\n * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`, `set`, `del`.\n *\n * **Stored metrics:** two hashes per app/segment — `:count` (HINCRBY per route group) and `:dur`\n * (sum of duration ms per same field). Same `(method, route, status, appId, databaseId)` → same hash field;\n * many requests in an interval **add** to count and **sum** durations (Redis `HINCRBY`). Drain applies those\n * totals to `app_requests_total` / `app_requests_total_duration`.\n */\nclass HttpMetricsRedisStore {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient\n * @param {string} opts.appName BUILD_APP_NAME (key segment)\n * @param {string} opts.processType logical process for key (e.g. web)\n * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisStore: redisClient is required')\n }\n this._client = redisClient\n this.ttlSec =\n typeof ttlSec === 'number' && ttlSec > 0\n ? ttlSec\n : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC\n const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(\n processType\n )}`\n this.countKey = `metrics:http:v2:${keySeg}:count`\n this.durKey = `metrics:http:v2:${keySeg}:dur`\n this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`\n }\n\n /**\n * @returns {import('redis').RedisClient}\n * @private\n */\n _ensureClient() {\n return this._client\n }\n\n /**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n client\n .multi()\n .hincrby(this.countKey, field, 1)\n .hincrby(this.durKey, field, dur)\n .expire(this.countKey, this.ttlSec)\n .expire(this.durKey, this.ttlSec)\n .exec(err => {\n if (err) {\n console.error('[HttpMetricsRedisStore] record failed:', err.message)\n return\n }\n httpMetricsTraceLog(\n `2_redis_write pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} ok`\n )\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * @param {(labels: Object, value: number) => void} applyCount\n * @param {(labels: Object, value: number) => void} applyDuration\n * @returns {Promise<boolean>}\n */\n flushToCounters(applyCount, applyDuration) {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] flush:', e.message)\n return Promise.resolve(false)\n }\n return new Promise(resolve => {\n client.set(this.lockKey, '1', 'EX', 25, 'NX', (setErr, ok) => {\n if (setErr || ok !== 'OK') {\n httpMetricsTraceLog(\n `3_redis_drain_skip pid=${process.pid} countKey=${this.countKey} reason=${\n setErr ? `error:${setErr.message}` : 'lock_not_acquired'\n }`\n )\n resolve(false)\n return\n }\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n const finish = () => {\n client.del(this.lockKey, () => resolve(true))\n }\n\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n finish()\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n httpMetricsTraceLog(\n `3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (empty_after_lua)`\n )\n finish()\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n let sumRequests = 0\n const samples = []\n for (const field of fieldKeys) {\n const count = parseInt(counts[field], 10)\n if (!count || count < 1) {\n continue\n }\n const dur = parseInt(durs[field] || '0', 10) || 0\n const parts = field.split(FIELD_SEP)\n if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n httpMetricsTraceLog(\n `3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\n )\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n }\n finish()\n }\n )\n })\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n isRedisPeerInstalled,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n httpMetricsTraceLog,\n}\n"],"mappings":"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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","DRAIN_LUA","isRedisPeerInstalled","resolve","buildFieldKey","method","route","statusCode","appId","databaseId","join","hgetallPairsToObject","pairs","o","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","field","dur","Math","round","Number","process","pid","multi","hincrby","expire","exec","err","error","message","e","flushToCounters","applyCount","applyDuration","Promise","eval","evalErr","raw","Array","isArray","counts","durs","fieldKeys","Object","keys","sumRequests","samples","count","parseInt","parts","split","m","statusStr","aid","did","push","labels","status_code","module","exports"],"sources":["../../src/metrics/httpMetricsRedisStore.js"],"sourcesContent":["/**\n * Record separator for hash fields (avoids collisions when route contains \"|\").\n * @type {string}\n */\nconst FIELD_SEP = '\\x1e'\n\n/**\n * Default Redis key TTL in seconds (sliding: refreshed on each `record` write).\n * @type {number}\n */\nconst DEFAULT_HTTP_METRICS_REDIS_TTL_SEC = 120\n\nconst LOG = '[http-metrics-redis]'\n\n/**\n * When Node runs behind `cluster`, HTTP is usually served from workers — include worker id in traces.\n * @returns {string}\n */\nfunction clusterHint() {\n try {\n // eslint-disable-next-line global-require\n const c = require('cluster')\n if (c.isWorker && c.worker != null) {\n return ` cluster_worker=${c.worker.id}`\n }\n } catch (_) {\n /* cluster not in use */\n }\n return ''\n}\n\nfunction truncField(s, max = 96) {\n const t = String(s)\n return t.length > max ? `${t.slice(0, max - 3)}...` : t\n}\n\n/**\n * Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis).\n * Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would\n * show duplicate lines if we also wrote the same text to stderr.\n * @param {string} body - Line body after `[http-metrics-redis]` prefix.\n */\nfunction httpMetricsTraceLog(body) {\n const line = `${LOG} ${body}${clusterHint()}`\n console.log(line)\n}\n\nconst DRAIN_LUA = `\nlocal function drain(key)\n local v = redis.call('HGETALL', key)\n redis.call('DEL', key)\n return v\nend\nreturn {drain(KEYS[1]), drain(KEYS[2])}\n`\n\n/**\n * @returns {boolean} Whether the `redis` npm package is resolvable (optional peer).\n */\nfunction isRedisPeerInstalled() {\n try {\n require.resolve('redis')\n return true\n } catch {\n return false\n }\n}\n\n/**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode, appId, databaseId) {\n return [method, route, String(statusCode), appId, databaseId].join(FIELD_SEP)\n}\n\nfunction hgetallPairsToObject(pairs) {\n const o = {}\n if (!pairs || !pairs.length) {\n return o\n }\n for (let i = 0; i < pairs.length; i += 2) {\n o[pairs[i]] = pairs[i + 1]\n }\n return o\n}\n\n/**\n * Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).\n * Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.\n *\n * **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not\n * one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,\n * appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`\n * it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).\n * Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.\n */\nclass HttpMetricsRedisStore {\n /**\n * @param {Object} opts\n * @param {import('redis').RedisClient} opts.redisClient\n * @param {string} opts.appName BUILD_APP_NAME (key segment)\n * @param {string} opts.processType logical process for key (e.g. web)\n * @param {number} [opts.ttlSec] Expire keys after this many seconds (default 120)\n */\n constructor({ redisClient, appName, processType, ttlSec }) {\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisStore: redisClient is required')\n }\n this._client = redisClient\n this.ttlSec =\n typeof ttlSec === 'number' && ttlSec > 0\n ? ttlSec\n : DEFAULT_HTTP_METRICS_REDIS_TTL_SEC\n const keySeg = `${encodeURIComponent(appName)}:${encodeURIComponent(\n processType\n )}`\n this.countKey = `metrics:http:v2:${keySeg}:count`\n this.durKey = `metrics:http:v2:${keySeg}:dur`\n }\n\n /**\n * @returns {import('redis').RedisClient}\n * @private\n */\n _ensureClient() {\n return this._client\n }\n\n /**\n * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @param {string} appId\n * @param {string} databaseId\n * @param {number} durationMs\n */\n record(method, route, statusCode, appId, databaseId, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode, appId, databaseId)\n const dur = Math.max(0, Math.round(Number(durationMs) || 0))\n httpMetricsTraceLog(\n `save_to_redis pid=${process.pid} countKey=${this.countKey} ` +\n `durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`\n )\n client\n .multi()\n .hincrby(this.countKey, field, 1)\n .hincrby(this.durKey, field, dur)\n .expire(this.countKey, this.ttlSec)\n .expire(this.durKey, this.ttlSec)\n .exec(err => {\n if (err) {\n console.error('[HttpMetricsRedisStore] record failed:', err.message)\n return\n }\n httpMetricsTraceLog(\n `save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`\n )\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * @param {(labels: Object, value: number) => void} applyCount\n * @param {(labels: Object, value: number) => void} applyDuration\n * @returns {Promise<boolean>}\n */\n flushToCounters(applyCount, applyDuration) {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] flush:', e.message)\n return Promise.resolve(false)\n }\n return new Promise(resolve => {\n httpMetricsTraceLog(\n `get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`\n )\n client.eval(\n DRAIN_LUA,\n 2,\n this.countKey,\n this.durKey,\n (evalErr, raw) => {\n if (evalErr) {\n console.error(\n '[HttpMetricsRedisStore] drain failed:',\n evalErr.message\n )\n resolve(false)\n return\n }\n\n try {\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n httpMetricsTraceLog(\n `get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`\n )\n resolve(true)\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\n let sumRequests = 0\n const samples = []\n for (const field of fieldKeys) {\n const count = parseInt(counts[field], 10)\n if (!count || count < 1) {\n continue\n }\n const dur = parseInt(durs[field] || '0', 10) || 0\n const parts = field.split(FIELD_SEP)\n if (parts.length !== 5) {\n continue\n }\n const [m, route, statusStr, aid, did] = parts\n sumRequests += count\n if (samples.length < 3) {\n samples.push(`${m} ${route} ${statusStr} x${count}`)\n }\n const labels = {\n method: m,\n route,\n status_code: statusStr,\n appId: aid,\n databaseId: did,\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\n }\n httpMetricsTraceLog(\n `get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${\n fieldKeys.length\n } sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`\n )\n resolve(true)\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] flush apply failed:',\n e.message\n )\n resolve(false)\n }\n }\n )\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n isRedisPeerInstalled,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n httpMetricsTraceLog,\n}\n"],"mappings":"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","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -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,20 @@ function clusterHint() {
|
|
|
29
29
|
return ''
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function truncField(s, max = 96) {
|
|
33
|
+
const t = String(s)
|
|
34
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
/**
|
|
33
|
-
*
|
|
38
|
+
* Trace line for SAVE (web → Redis) and GET/drain (collector ← Redis).
|
|
39
|
+
* Single `console.log` only: many hosts (e.g. Heroku) merge stdout+stderr into one stream and would
|
|
40
|
+
* show duplicate lines if we also wrote the same text to stderr.
|
|
34
41
|
* @param {string} body - Line body after `[http-metrics-redis]` prefix.
|
|
35
42
|
*/
|
|
36
43
|
function httpMetricsTraceLog(body) {
|
|
37
|
-
|
|
44
|
+
const line = `${LOG} ${body}${clusterHint()}`
|
|
45
|
+
console.log(line)
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const DRAIN_LUA = `
|
|
@@ -83,12 +91,13 @@ function hgetallPairsToObject(pairs) {
|
|
|
83
91
|
|
|
84
92
|
/**
|
|
85
93
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
86
|
-
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval
|
|
94
|
+
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
87
95
|
*
|
|
88
|
-
* **
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
96
|
+
* **Structure:** `countKey` / `durKey` are each **one Redis hash**. The hash has **many fields** — not
|
|
97
|
+
* one bucket for all traffic. Each **field name** encodes one label group `(method, route, status_code,
|
|
98
|
+
* appId, databaseId)`. The **value** in `:count` is **HINCRBY 1** per request for that group; in `:dur`
|
|
99
|
+
* it is **HINCRBY** of that request’s **duration ms** (so many requests → summed ms per same field).
|
|
100
|
+
* Different routes → different hash fields. Drain runs atomic Lua (HGETALL + DEL) per tick.
|
|
92
101
|
*/
|
|
93
102
|
class HttpMetricsRedisStore {
|
|
94
103
|
/**
|
|
@@ -112,7 +121,6 @@ class HttpMetricsRedisStore {
|
|
|
112
121
|
)}`
|
|
113
122
|
this.countKey = `metrics:http:v2:${keySeg}:count`
|
|
114
123
|
this.durKey = `metrics:http:v2:${keySeg}:dur`
|
|
115
|
-
this.lockKey = `metrics:http:v2:${keySeg}:drain_lock`
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
/**
|
|
@@ -136,6 +144,10 @@ class HttpMetricsRedisStore {
|
|
|
136
144
|
const client = this._ensureClient()
|
|
137
145
|
const field = buildFieldKey(method, route, statusCode, appId, databaseId)
|
|
138
146
|
const dur = Math.max(0, Math.round(Number(durationMs) || 0))
|
|
147
|
+
httpMetricsTraceLog(
|
|
148
|
+
`save_to_redis pid=${process.pid} countKey=${this.countKey} ` +
|
|
149
|
+
`durKey=${this.durKey} field=${truncField(field)} durationMs=${dur} (before MULTI/EXEC)`
|
|
150
|
+
)
|
|
139
151
|
client
|
|
140
152
|
.multi()
|
|
141
153
|
.hincrby(this.countKey, field, 1)
|
|
@@ -148,7 +160,7 @@ class HttpMetricsRedisStore {
|
|
|
148
160
|
return
|
|
149
161
|
}
|
|
150
162
|
httpMetricsTraceLog(
|
|
151
|
-
`
|
|
163
|
+
`save_to_redis_ok pid=${process.pid} countKey=${this.countKey} durKey=${this.durKey} (after MULTI/EXEC)`
|
|
152
164
|
)
|
|
153
165
|
})
|
|
154
166
|
} catch (e) {
|
|
@@ -170,90 +182,79 @@ class HttpMetricsRedisStore {
|
|
|
170
182
|
return Promise.resolve(false)
|
|
171
183
|
}
|
|
172
184
|
return new Promise(resolve => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
client.del(this.lockKey, () => resolve(true))
|
|
191
|
-
}
|
|
185
|
+
httpMetricsTraceLog(
|
|
186
|
+
`get_from_redis_try pid=${process.pid} countKey=${this.countKey} (EVAL drain)`
|
|
187
|
+
)
|
|
188
|
+
client.eval(
|
|
189
|
+
DRAIN_LUA,
|
|
190
|
+
2,
|
|
191
|
+
this.countKey,
|
|
192
|
+
this.durKey,
|
|
193
|
+
(evalErr, raw) => {
|
|
194
|
+
if (evalErr) {
|
|
195
|
+
console.error(
|
|
196
|
+
'[HttpMetricsRedisStore] drain failed:',
|
|
197
|
+
evalErr.message
|
|
198
|
+
)
|
|
199
|
+
resolve(false)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
192
202
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
try {
|
|
204
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
205
|
+
httpMetricsTraceLog(
|
|
206
|
+
`get_from_redis_empty pid=${process.pid} countKey=${this.countKey} hash_fields=0 sum_requests=0 (lua returned no pairs)`
|
|
197
207
|
)
|
|
198
|
-
|
|
208
|
+
resolve(true)
|
|
199
209
|
return
|
|
200
210
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
const counts = hgetallPairsToObject(raw[0])
|
|
212
|
+
const durs = hgetallPairsToObject(raw[1])
|
|
213
|
+
const fieldKeys = Object.keys(counts)
|
|
214
|
+
let sumRequests = 0
|
|
215
|
+
const samples = []
|
|
216
|
+
for (const field of fieldKeys) {
|
|
217
|
+
const count = parseInt(counts[field], 10)
|
|
218
|
+
if (!count || count < 1) {
|
|
219
|
+
continue
|
|
209
220
|
}
|
|
210
|
-
const
|
|
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
|
-
}
|
|
221
|
+
const dur = parseInt(durs[field] || '0', 10) || 0
|
|
222
|
+
const parts = field.split(FIELD_SEP)
|
|
223
|
+
if (parts.length !== 5) {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
const [m, route, statusStr, aid, did] = parts
|
|
227
|
+
sumRequests += count
|
|
228
|
+
if (samples.length < 3) {
|
|
229
|
+
samples.push(`${m} ${route} ${statusStr} x${count}`)
|
|
230
|
+
}
|
|
231
|
+
const labels = {
|
|
232
|
+
method: m,
|
|
233
|
+
route,
|
|
234
|
+
status_code: statusStr,
|
|
235
|
+
appId: aid,
|
|
236
|
+
databaseId: did,
|
|
237
|
+
}
|
|
238
|
+
applyCount(labels, count)
|
|
239
|
+
if (dur > 0) {
|
|
240
|
+
applyDuration(labels, dur)
|
|
241
241
|
}
|
|
242
|
-
httpMetricsTraceLog(
|
|
243
|
-
`3_redis_drain pid=${process.pid} countKey=${this.countKey} hash_fields=${
|
|
244
|
-
fieldKeys.length
|
|
245
|
-
} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
|
|
246
|
-
)
|
|
247
|
-
} catch (e) {
|
|
248
|
-
console.error(
|
|
249
|
-
'[HttpMetricsRedisStore] flush apply failed:',
|
|
250
|
-
e.message
|
|
251
|
-
)
|
|
252
242
|
}
|
|
253
|
-
|
|
243
|
+
httpMetricsTraceLog(
|
|
244
|
+
`get_from_redis_ok pid=${process.pid} countKey=${this.countKey} hash_fields=${
|
|
245
|
+
fieldKeys.length
|
|
246
|
+
} sum_requests=${sumRequests} sample=${samples.join(' | ') || '—'}`
|
|
247
|
+
)
|
|
248
|
+
resolve(true)
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(
|
|
251
|
+
'[HttpMetricsRedisStore] flush apply failed:',
|
|
252
|
+
e.message
|
|
253
|
+
)
|
|
254
|
+
resolve(false)
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
}
|
|
257
|
+
)
|
|
257
258
|
})
|
|
258
259
|
}
|
|
259
260
|
}
|