@adalo/metrics 0.0.0-staging.29 → 0.0.0-staging.30
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 +139 -0
- package/docs/http-metrics-redis.md +1 -0
- package/lib/metrics/httpMetricsRedisCollector.d.ts +2 -0
- package/lib/metrics/httpMetricsRedisCollector.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisCollector.js +32 -2
- package/lib/metrics/httpMetricsRedisCollector.js.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.d.ts +26 -0
- package/lib/metrics/httpMetricsRedisStore.d.ts.map +1 -1
- package/lib/metrics/httpMetricsRedisStore.js +95 -49
- package/lib/metrics/httpMetricsRedisStore.js.map +1 -1
- package/package.json +1 -1
- package/src/metrics/httpMetricsRedisCollector.js +37 -6
- package/src/metrics/httpMetricsRedisStore.js +67 -41
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
const { HttpMetricsRedisCollector } = require('../src/metrics/httpMetricsRedisCollector')
|
|
2
2
|
const { buildFieldKey } = require('../src/metrics/httpMetricsRedisStore')
|
|
3
3
|
|
|
4
|
+
function appRequestTotalsByRoute(metricsJson) {
|
|
5
|
+
const reqTotal = metricsJson.find(m => m.name === 'app_requests_total')
|
|
6
|
+
if (!reqTotal || !reqTotal.values) {
|
|
7
|
+
return {}
|
|
8
|
+
}
|
|
9
|
+
return Object.fromEntries(reqTotal.values.map(v => [v.labels.route, v.value]))
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
function createRedisV3Mock() {
|
|
5
13
|
const multiChain = {
|
|
6
14
|
hincrby: jest.fn().mockReturnThis(),
|
|
@@ -52,13 +60,144 @@ describe('HttpMetricsRedisCollector', () => {
|
|
|
52
60
|
disablePushgateway: true,
|
|
53
61
|
})
|
|
54
62
|
|
|
63
|
+
const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
|
|
64
|
+
|
|
55
65
|
await collector.pushMetrics()
|
|
56
66
|
|
|
57
67
|
expect(redis.eval).toHaveBeenCalled()
|
|
68
|
+
expect(pushSpy).toHaveBeenCalledTimes(1)
|
|
58
69
|
const metrics = await collector._registry.getMetricsAsJSON()
|
|
59
70
|
const total = metrics.find(m => m.name === 'app_requests_total')
|
|
60
71
|
expect(total).toBeDefined()
|
|
61
72
|
const totalDur = metrics.find(m => m.name === 'app_requests_total_duration')
|
|
62
73
|
expect(totalDur).toBeDefined()
|
|
74
|
+
|
|
75
|
+
pushSpy.mockRestore()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('primes new label sets with 0 then applies count (extra push only when labels are new)', async () => {
|
|
79
|
+
const redis = createRedisV3Mock()
|
|
80
|
+
const field = buildFieldKey('GET', '/health', 200)
|
|
81
|
+
|
|
82
|
+
const collector = new HttpMetricsRedisCollector({
|
|
83
|
+
redisClient: redis,
|
|
84
|
+
appName: 'test-app',
|
|
85
|
+
dynoId: 'dyno-1',
|
|
86
|
+
processType: 'http-metrics',
|
|
87
|
+
redisProcessTypeForKeys: 'web',
|
|
88
|
+
disablePushgateway: true,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
|
|
92
|
+
|
|
93
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
94
|
+
cb(null, [
|
|
95
|
+
[field, '2'],
|
|
96
|
+
[field, '0'],
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
await collector.pushMetrics()
|
|
100
|
+
expect(pushSpy).toHaveBeenCalledTimes(1)
|
|
101
|
+
|
|
102
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
103
|
+
cb(null, [
|
|
104
|
+
[field, '1'],
|
|
105
|
+
[field, '0'],
|
|
106
|
+
])
|
|
107
|
+
})
|
|
108
|
+
await collector.pushMetrics()
|
|
109
|
+
expect(pushSpy).toHaveBeenCalledTimes(1)
|
|
110
|
+
|
|
111
|
+
pushSpy.mockRestore()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('prime push for new route a leaves existing route b unchanged (a=0, b=18), then a=1, b=38', async () => {
|
|
115
|
+
const redis = createRedisV3Mock()
|
|
116
|
+
const fieldB = buildFieldKey('GET', '/b', 200)
|
|
117
|
+
const fieldA = buildFieldKey('GET', '/a', 200)
|
|
118
|
+
|
|
119
|
+
const collector = new HttpMetricsRedisCollector({
|
|
120
|
+
redisClient: redis,
|
|
121
|
+
appName: 'test-app',
|
|
122
|
+
dynoId: 'dyno-1',
|
|
123
|
+
processType: 'http-metrics',
|
|
124
|
+
redisProcessTypeForKeys: 'web',
|
|
125
|
+
disablePushgateway: true,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
let primePushCount = 0
|
|
129
|
+
const pushSpy = jest.spyOn(collector, 'gatewayPush').mockImplementation(async () => {
|
|
130
|
+
primePushCount++
|
|
131
|
+
if (primePushCount === 2) {
|
|
132
|
+
const metrics = await collector._registry.getMetricsAsJSON()
|
|
133
|
+
const byRoute = appRequestTotalsByRoute(metrics)
|
|
134
|
+
expect(byRoute['/a']).toBe(0)
|
|
135
|
+
expect(byRoute['/b']).toBe(18)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
140
|
+
cb(null, [
|
|
141
|
+
[fieldB, '18'],
|
|
142
|
+
[fieldB, '0'],
|
|
143
|
+
])
|
|
144
|
+
})
|
|
145
|
+
await collector.pushMetrics()
|
|
146
|
+
|
|
147
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
148
|
+
cb(null, [
|
|
149
|
+
[fieldA, '1', fieldB, '20'],
|
|
150
|
+
[fieldA, '0', fieldB, '0'],
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
await collector.pushMetrics()
|
|
154
|
+
|
|
155
|
+
const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
|
|
156
|
+
expect(final['/a']).toBe(1)
|
|
157
|
+
expect(final['/b']).toBe(38)
|
|
158
|
+
expect(pushSpy).toHaveBeenCalledTimes(2)
|
|
159
|
+
|
|
160
|
+
pushSpy.mockRestore()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('does not prime POST when drain only has already-seen routes (was a:1 b:18, then a:1 b:20)', async () => {
|
|
164
|
+
const redis = createRedisV3Mock()
|
|
165
|
+
const fieldA = buildFieldKey('GET', '/a', 200)
|
|
166
|
+
const fieldB = buildFieldKey('GET', '/b', 200)
|
|
167
|
+
|
|
168
|
+
const collector = new HttpMetricsRedisCollector({
|
|
169
|
+
redisClient: redis,
|
|
170
|
+
appName: 'test-app',
|
|
171
|
+
dynoId: 'dyno-1',
|
|
172
|
+
processType: 'http-metrics',
|
|
173
|
+
redisProcessTypeForKeys: 'web',
|
|
174
|
+
disablePushgateway: true,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const pushSpy = jest.spyOn(collector, 'gatewayPush').mockResolvedValue()
|
|
178
|
+
|
|
179
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
180
|
+
cb(null, [
|
|
181
|
+
[fieldA, '1', fieldB, '18'],
|
|
182
|
+
[fieldA, '0', fieldB, '0'],
|
|
183
|
+
])
|
|
184
|
+
})
|
|
185
|
+
await collector.pushMetrics()
|
|
186
|
+
expect(pushSpy).toHaveBeenCalledTimes(1)
|
|
187
|
+
|
|
188
|
+
redis.eval.mockImplementation((lua, numKeys, k1, k2, cb) => {
|
|
189
|
+
cb(null, [
|
|
190
|
+
[fieldA, '1', fieldB, '20'],
|
|
191
|
+
[fieldA, '0', fieldB, '0'],
|
|
192
|
+
])
|
|
193
|
+
})
|
|
194
|
+
await collector.pushMetrics()
|
|
195
|
+
expect(pushSpy).toHaveBeenCalledTimes(1)
|
|
196
|
+
|
|
197
|
+
const final = appRequestTotalsByRoute(await collector._registry.getMetricsAsJSON())
|
|
198
|
+
expect(final['/a']).toBe(2)
|
|
199
|
+
expect(final['/b']).toBe(38)
|
|
200
|
+
|
|
201
|
+
pushSpy.mockRestore()
|
|
63
202
|
})
|
|
64
203
|
})
|
|
@@ -14,5 +14,6 @@ This file is a **quick entry point** for the Redis-backed HTTP aggregation path.
|
|
|
14
14
|
| **Hash field** | Three logical parts: method, route, HTTP status (joined with an internal `FIELD_SEP`). |
|
|
15
15
|
| **Prometheus labels** | `method`, `route`, `status_code` (plus default `app` / `process_type` without `dyno_id` on these counters). |
|
|
16
16
|
| **Backend** | Web: `backend/monitoring.ts` + `METRICS_HTTP_ENABLED`. Drain: `backend/http-metrics-collector.ts`, Procfile `http-metrics:`. |
|
|
17
|
+
| **Prime 0 on new routes** | The first time a `(method, route, status_code)` appears **in this process**, the collector `inc(..., 0)` for count+duration on **that label set only**, **POST**, then applies drained counts for **all** rows. Existing series are **not** touched in the prime loop — e.g. in-memory `b` was **18**, drain has new `a:1` and `b:20`: prime push exports **`a=0`, `b=18`**; after apply, final push has **`a=1`, `b=38`**. If **all** label sets were already seen, there is **no** extra prime `POST`. |
|
|
17
18
|
|
|
18
19
|
For in-process HTTP metrics **without** Redis, see **`MetricsClient`** + `METRICS_HTTP_ENABLED` in **[docs/METRICS.md](../../docs/METRICS.md#http-metrics-two-valid-designs)**.
|
|
@@ -42,6 +42,8 @@ export class HttpMetricsRedisCollector extends BaseMetricsClient {
|
|
|
42
42
|
ttlSec?: number | undefined;
|
|
43
43
|
} | undefined);
|
|
44
44
|
_store: HttpMetricsRedisStore;
|
|
45
|
+
/** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
|
|
46
|
+
_httpCounterPrimedKeys: Set<string>;
|
|
45
47
|
}
|
|
46
48
|
import { BaseMetricsClient } from "./baseMetricsClient";
|
|
47
49
|
import { HttpMetricsRedisStore } from "./httpMetricsRedisStore";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisCollector.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisCollector.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisCollector.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisCollector.js"],"names":[],"mappings":"AAMA;;;;;;;;GAQG;AACH;IACE;;;;;;;;;;;;;;;;OAgBG;IACH;qBAfW,OAAO,OAAO,EAAE,WAAW;;;;;;;;;;;;;;mBAgErC;IA/BC,8BAKE;IAEF,6FAA6F;IAC7F,wBADW,IAAI,MAAM,CAAC,CACiB;CAqE1C"}
|
|
@@ -4,7 +4,8 @@ const {
|
|
|
4
4
|
BaseMetricsClient
|
|
5
5
|
} = require('./baseMetricsClient');
|
|
6
6
|
const {
|
|
7
|
-
HttpMetricsRedisStore
|
|
7
|
+
HttpMetricsRedisStore,
|
|
8
|
+
buildFieldKey
|
|
8
9
|
} = require('./httpMetricsRedisStore');
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -56,6 +57,9 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
|
|
|
56
57
|
processType: keyProcessType,
|
|
57
58
|
ttlSec: config.ttlSec
|
|
58
59
|
});
|
|
60
|
+
|
|
61
|
+
/** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
|
|
62
|
+
this._httpCounterPrimedKeys = new Set();
|
|
59
63
|
this.createCounter({
|
|
60
64
|
name: 'app_requests_total',
|
|
61
65
|
help: 'Total number of HTTP requests',
|
|
@@ -76,7 +80,33 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
|
|
|
76
80
|
*/
|
|
77
81
|
pushMetrics = async () => {
|
|
78
82
|
if (this._store && this.countersFunctions?.app_requests_total && this.countersFunctions?.app_requests_total_duration) {
|
|
79
|
-
|
|
83
|
+
const {
|
|
84
|
+
ok,
|
|
85
|
+
rows
|
|
86
|
+
} = await this._store.drainRows();
|
|
87
|
+
if (ok && rows.length > 0) {
|
|
88
|
+
const applyCount = (labels, value) => this.countersFunctions.app_requests_total(labels, value);
|
|
89
|
+
const applyDur = (labels, value) => this.countersFunctions.app_requests_total_duration(labels, value);
|
|
90
|
+
let primedAny = false;
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const key = buildFieldKey(row.labels.method, row.labels.route, row.labels.status_code);
|
|
93
|
+
if (!this._httpCounterPrimedKeys.has(key)) {
|
|
94
|
+
this._httpCounterPrimedKeys.add(key);
|
|
95
|
+
applyCount(row.labels, 0);
|
|
96
|
+
applyDur(row.labels, 0);
|
|
97
|
+
primedAny = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (primedAny) {
|
|
101
|
+
await this.gatewayPush();
|
|
102
|
+
}
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
applyCount(row.labels, row.count);
|
|
105
|
+
if (row.dur > 0) {
|
|
106
|
+
applyDur(row.labels, row.dur);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
80
110
|
}
|
|
81
111
|
return this._pushMetrics();
|
|
82
112
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisCollector.js","names":["BaseMetricsClient","require","HttpMetricsRedisStore","HttpMetricsRedisCollector","constructor","config","redisClient","Error","blockNodeDefaultMetrics","keyProcessType","redisProcessTypeForKeys","defaultLabelsWithoutDynoId","app","appName","process_type","_store","processType","ttlSec","createCounter","name","help","labelNames","withDefaultLabelsWithoutDynoId","useLabelsWithoutDynoId","pushMetrics","countersFunctions","app_requests_total","app_requests_total_duration","
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisCollector.js","names":["BaseMetricsClient","require","HttpMetricsRedisStore","buildFieldKey","HttpMetricsRedisCollector","constructor","config","redisClient","Error","blockNodeDefaultMetrics","keyProcessType","redisProcessTypeForKeys","defaultLabelsWithoutDynoId","app","appName","process_type","_store","processType","ttlSec","_httpCounterPrimedKeys","Set","createCounter","name","help","labelNames","withDefaultLabelsWithoutDynoId","useLabelsWithoutDynoId","pushMetrics","countersFunctions","app_requests_total","app_requests_total_duration","ok","rows","drainRows","length","applyCount","labels","value","applyDur","primedAny","row","key","method","route","status_code","has","add","gatewayPush","count","dur","_pushMetrics","module","exports"],"sources":["../../src/metrics/httpMetricsRedisCollector.js"],"sourcesContent":["const { BaseMetricsClient } = require('./baseMetricsClient')\nconst {\n HttpMetricsRedisStore,\n buildFieldKey,\n} = require('./httpMetricsRedisStore')\n\n/**\n * Drain worker: reads HTTP aggregates from Redis (written by {@link HttpMetricsRedisRecorder}),\n * applies them to `app_requests_*` counters, then pushes the registry to the VM-agent (same as {@link BaseMetricsClient}).\n * **Minimal usage:** `{ redisClient }` only. Redis keys use segment **`web`** unless you pass **`redisProcessTypeForKeys`**.\n * `processType` / `appName` / `dynoId` follow {@link BaseMetricsClient} defaults (e.g. `BUILD_DYNO_PROCESS_TYPE`) and do **not** select Redis hash names.\n * Always passes `blockNodeDefaultMetrics: true` (HTTP-focused registry).\n *\n * @extends BaseMetricsClient\n */\nclass HttpMetricsRedisCollector extends BaseMetricsClient {\n /**\n * @param {Object} [config]\n * @param {import('redis').RedisClient} config.redisClient **Required.** Injected client (same pattern as {@link RedisMetricsClient}).\n * @param {string} [config.appName] Application name (defaults per {@link BaseMetricsClient})\n * @param {string} [config.dynoId] Dyno/instance ID\n * @param {string} [config.processType] Label `process_type` on push (default from env / base)\n * @param {boolean} [config.enabled] Enable collection and push\n * @param {boolean} [config.logValues] Log metric JSON to console\n * @param {string} [config.pushgatewayUrl] VM-agent import URL\n * @param {string} [config.pushgatewaySecret] Basic auth secret (Base64)\n * @param {number} [config.intervalSec] Push interval (seconds)\n * @param {boolean} [config.removeOldMetrics] Clear old series on shutdown where supported\n * @param {function} [config.startupValidation] Run before first push\n * @param {boolean} [config.disablePushgateway] Skip POST to VM-agent\n * @param {string} [config.redisProcessTypeForKeys] Segment in Redis keys for HTTP hashes (default **`web`**). Optional; only if writers use a non-`web` segment.\n * @param {number} [config.ttlSec] Passed to {@link HttpMetricsRedisStore} (should match writers)\n */\n constructor(config = {}) {\n const { redisClient } = config\n if (redisClient == null) {\n throw new Error('HttpMetricsRedisCollector: redisClient is required')\n }\n\n super({\n ...config,\n blockNodeDefaultMetrics: true,\n })\n\n const keyProcessType = config.redisProcessTypeForKeys || 'web'\n\n this.defaultLabelsWithoutDynoId = {\n app: this.appName,\n process_type: keyProcessType,\n }\n\n this._store = new HttpMetricsRedisStore({\n redisClient,\n appName: this.appName,\n processType: keyProcessType,\n ttlSec: config.ttlSec,\n })\n\n /** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */\n this._httpCounterPrimedKeys = new Set()\n\n this.createCounter({\n name: 'app_requests_total',\n help: 'Total number of HTTP requests',\n labelNames: this.withDefaultLabelsWithoutDynoId([\n 'method',\n 'route',\n 'status_code',\n ]),\n useLabelsWithoutDynoId: true,\n })\n\n this.createCounter({\n name: 'app_requests_total_duration',\n help: 'Total duration of HTTP requests in milliseconds',\n labelNames: this.withDefaultLabelsWithoutDynoId([\n 'method',\n 'route',\n 'status_code',\n ]),\n useLabelsWithoutDynoId: true,\n })\n }\n\n /**\n * Drains Redis into counters, then runs gauge updates and VM-agent push ({@link BaseMetricsClient#_pushMetrics}).\n * @returns {Promise<void>}\n */\n pushMetrics = async () => {\n if (\n this._store &&\n this.countersFunctions?.app_requests_total &&\n this.countersFunctions?.app_requests_total_duration\n ) {\n const { ok, rows } = await this._store.drainRows()\n if (ok && rows.length > 0) {\n const applyCount = (labels, value) =>\n this.countersFunctions.app_requests_total(labels, value)\n const applyDur = (labels, value) =>\n this.countersFunctions.app_requests_total_duration(labels, value)\n\n let primedAny = false\n for (const row of rows) {\n const key = buildFieldKey(\n row.labels.method,\n row.labels.route,\n row.labels.status_code\n )\n if (!this._httpCounterPrimedKeys.has(key)) {\n this._httpCounterPrimedKeys.add(key)\n applyCount(row.labels, 0)\n applyDur(row.labels, 0)\n primedAny = true\n }\n }\n if (primedAny) {\n await this.gatewayPush()\n }\n for (const row of rows) {\n applyCount(row.labels, row.count)\n if (row.dur > 0) {\n applyDur(row.labels, row.dur)\n }\n }\n }\n }\n return this._pushMetrics()\n }\n}\n\nmodule.exports = { HttpMetricsRedisCollector }\n"],"mappings":"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","ignoreList":[]}
|
|
@@ -35,6 +35,19 @@ export class HttpMetricsRedisStore {
|
|
|
35
35
|
* @param {number} durationMs
|
|
36
36
|
*/
|
|
37
37
|
record(method: string, route: string, statusCode: number, durationMs: number): void;
|
|
38
|
+
/**
|
|
39
|
+
* Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
|
|
42
|
+
*/
|
|
43
|
+
drainRows(): Promise<{
|
|
44
|
+
ok: boolean;
|
|
45
|
+
rows: {
|
|
46
|
+
labels: Object;
|
|
47
|
+
count: number;
|
|
48
|
+
dur: number;
|
|
49
|
+
}[];
|
|
50
|
+
}>;
|
|
38
51
|
/**
|
|
39
52
|
* @param {(labels: Object, value: number) => void} applyCount
|
|
40
53
|
* @param {(labels: Object, value: number) => void} applyDuration
|
|
@@ -59,4 +72,17 @@ export const FIELD_SEP: string;
|
|
|
59
72
|
* @type {number}
|
|
60
73
|
*/
|
|
61
74
|
export const DEFAULT_HTTP_METRICS_REDIS_TTL_SEC: number;
|
|
75
|
+
/**
|
|
76
|
+
* @param {unknown} raw redis eval result [countPairs, durPairs]
|
|
77
|
+
* @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}
|
|
78
|
+
*/
|
|
79
|
+
export function rowsFromDrainRaw(raw: unknown): {
|
|
80
|
+
labels: {
|
|
81
|
+
method: string;
|
|
82
|
+
route: string;
|
|
83
|
+
status_code: string;
|
|
84
|
+
};
|
|
85
|
+
count: number;
|
|
86
|
+
dur: number;
|
|
87
|
+
}[];
|
|
62
88
|
//# sourceMappingURL=httpMetricsRedisStore.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisStore.d.ts","sourceRoot":"","sources":["../../src/metrics/httpMetricsRedisStore.js"],"names":[],"mappings":"AA6EA;;;;;;GAMG;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;;;;;OAKG;IACH,eALW,MAAM,SACN,MAAM,cACN,MAAM,cACN,MAAM,QAqBhB;IAED;;;;OAIG;IACH,aAFa,QAAQ;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC,CAsC5F;IAED;;;;OAIG;IACH,qCAJoB,MAAM,SAAS,MAAM,KAAK,IAAI,0BAC9B,MAAM,SAAS,MAAM,KAAK,IAAI,GACrC,QAAQ,OAAO,CAAC,CAe5B;CACF;AAxLD;;;;;GAKG;AACH,sCALW,MAAM,SACN,MAAM,cACN,MAAM,GACJ,MAAM,CAIlB;AA7BD;;;GAGG;AACH,wBAFU,MAAM,CAEQ;AAExB;;;GAGG;AACH,iDAFU,MAAM,CAE8B;AAgC9C;;;GAGG;AACH,sCAHW,OAAO,GACL;IAAE,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EAAE,CA+B5G"}
|
|
@@ -40,6 +40,53 @@ function hgetallPairsToObject(pairs) {
|
|
|
40
40
|
return o;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {unknown} raw redis eval result [countPairs, durPairs]
|
|
45
|
+
* @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}
|
|
46
|
+
*/
|
|
47
|
+
function rowsFromDrainRaw(raw) {
|
|
48
|
+
const rows = [];
|
|
49
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
const counts = hgetallPairsToObject(raw[0]);
|
|
53
|
+
const durs = hgetallPairsToObject(raw[1]);
|
|
54
|
+
const fieldKeys = Object.keys(counts);
|
|
55
|
+
for (const field of fieldKeys) {
|
|
56
|
+
const count = parseInt(counts[field], 10);
|
|
57
|
+
if (!count || count < 1) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const dur = parseInt(durs[field] || '0', 10) || 0;
|
|
61
|
+
const parts = field.split(FIELD_SEP);
|
|
62
|
+
let labels = null;
|
|
63
|
+
if (parts.length === 3) {
|
|
64
|
+
const [m, route, statusStr] = parts;
|
|
65
|
+
labels = {
|
|
66
|
+
method: m,
|
|
67
|
+
route,
|
|
68
|
+
status_code: statusStr
|
|
69
|
+
};
|
|
70
|
+
} else if (parts.length === 5 || parts.length === 6) {
|
|
71
|
+
const [m, route, statusStr] = parts;
|
|
72
|
+
labels = {
|
|
73
|
+
method: m,
|
|
74
|
+
route,
|
|
75
|
+
status_code: statusStr
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (!labels) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
rows.push({
|
|
82
|
+
labels,
|
|
83
|
+
count,
|
|
84
|
+
dur
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
/**
|
|
44
91
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
45
92
|
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
@@ -101,77 +148,76 @@ class HttpMetricsRedisStore {
|
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* @returns {Promise<boolean>}
|
|
151
|
+
* Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
|
|
152
|
+
*
|
|
153
|
+
* @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
|
|
107
154
|
*/
|
|
108
|
-
|
|
155
|
+
drainRows() {
|
|
109
156
|
let client;
|
|
110
157
|
try {
|
|
111
158
|
client = this._ensureClient();
|
|
112
159
|
} catch (e) {
|
|
113
|
-
console.error('[HttpMetricsRedisStore]
|
|
114
|
-
return Promise.resolve(
|
|
160
|
+
console.error('[HttpMetricsRedisStore] drainRows:', e.message);
|
|
161
|
+
return Promise.resolve({
|
|
162
|
+
ok: false,
|
|
163
|
+
rows: []
|
|
164
|
+
});
|
|
115
165
|
}
|
|
116
166
|
return new Promise(resolve => {
|
|
117
167
|
client.eval(DRAIN_LUA, 2, this.countKey, this.durKey, (evalErr, raw) => {
|
|
118
168
|
if (evalErr) {
|
|
119
169
|
console.error('[HttpMetricsRedisStore] drain failed:', evalErr.message);
|
|
120
|
-
resolve(
|
|
170
|
+
resolve({
|
|
171
|
+
ok: false,
|
|
172
|
+
rows: []
|
|
173
|
+
});
|
|
121
174
|
return;
|
|
122
175
|
}
|
|
123
176
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const durs = hgetallPairsToObject(raw[1]);
|
|
130
|
-
const fieldKeys = Object.keys(counts);
|
|
131
|
-
for (const field of fieldKeys) {
|
|
132
|
-
const count = parseInt(counts[field], 10);
|
|
133
|
-
if (!count || count < 1) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const dur = parseInt(durs[field] || '0', 10) || 0;
|
|
137
|
-
const parts = field.split(FIELD_SEP);
|
|
138
|
-
let labels = null;
|
|
139
|
-
if (parts.length === 3) {
|
|
140
|
-
const [m, route, statusStr] = parts;
|
|
141
|
-
labels = {
|
|
142
|
-
method: m,
|
|
143
|
-
route,
|
|
144
|
-
status_code: statusStr
|
|
145
|
-
};
|
|
146
|
-
} else if (parts.length === 5 || parts.length === 6) {
|
|
147
|
-
const [m, route, statusStr] = parts;
|
|
148
|
-
labels = {
|
|
149
|
-
method: m,
|
|
150
|
-
route,
|
|
151
|
-
status_code: statusStr
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
if (!labels) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
applyCount(labels, count);
|
|
158
|
-
if (dur > 0) {
|
|
159
|
-
applyDuration(labels, dur);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
resolve(true);
|
|
177
|
+
const rows = rowsFromDrainRaw(raw);
|
|
178
|
+
resolve({
|
|
179
|
+
ok: true,
|
|
180
|
+
rows
|
|
181
|
+
});
|
|
163
182
|
} catch (e) {
|
|
164
|
-
console.error('[HttpMetricsRedisStore]
|
|
165
|
-
resolve(
|
|
183
|
+
console.error('[HttpMetricsRedisStore] drainRows parse failed:', e.message);
|
|
184
|
+
resolve({
|
|
185
|
+
ok: false,
|
|
186
|
+
rows: []
|
|
187
|
+
});
|
|
166
188
|
}
|
|
167
189
|
});
|
|
168
190
|
});
|
|
169
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {(labels: Object, value: number) => void} applyCount
|
|
195
|
+
* @param {(labels: Object, value: number) => void} applyDuration
|
|
196
|
+
* @returns {Promise<boolean>}
|
|
197
|
+
*/
|
|
198
|
+
flushToCounters(applyCount, applyDuration) {
|
|
199
|
+
return this.drainRows().then(({
|
|
200
|
+
ok,
|
|
201
|
+
rows
|
|
202
|
+
}) => {
|
|
203
|
+
if (!ok) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
for (const row of rows) {
|
|
207
|
+
applyCount(row.labels, row.count);
|
|
208
|
+
if (row.dur > 0) {
|
|
209
|
+
applyDuration(row.labels, row.dur);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
170
215
|
}
|
|
171
216
|
module.exports = {
|
|
172
217
|
HttpMetricsRedisStore,
|
|
173
218
|
buildFieldKey,
|
|
174
219
|
FIELD_SEP,
|
|
175
|
-
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC
|
|
220
|
+
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
|
|
221
|
+
rowsFromDrainRaw
|
|
176
222
|
};
|
|
177
223
|
//# sourceMappingURL=httpMetricsRedisStore.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","DRAIN_LUA","buildFieldKey","method","route","statusCode","String","join","hgetallPairsToObject","pairs","o","length","i","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","field","dur","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","e","flushToCounters","applyCount","applyDuration","Promise","resolve","eval","evalErr","raw","Array","isArray","counts","durs","fieldKeys","Object","keys","count","parseInt","parts","split","labels","m","statusStr","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 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 * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode) {\n return [method, route, String(statusCode)].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**. Each **field name** encodes\n * `(method, route, status_code)`. Values: `:count` is HINCRBY 1 per request; `:dur` is summed ms.\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 {number} durationMs\n */\n record(method, route, statusCode, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode)\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 }\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.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 resolve(true)\n return\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\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 let labels = null\n if (parts.length === 3) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n } else if (parts.length === 5 || parts.length === 6) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n }\n if (!labels) {\n continue\n }\n applyCount(labels, count)\n if (dur > 0) {\n applyDuration(labels, dur)\n }\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 DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n}\n"],"mappings":"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","ignoreList":[]}
|
|
1
|
+
{"version":3,"file":"httpMetricsRedisStore.js","names":["FIELD_SEP","DEFAULT_HTTP_METRICS_REDIS_TTL_SEC","DRAIN_LUA","buildFieldKey","method","route","statusCode","String","join","hgetallPairsToObject","pairs","o","length","i","rowsFromDrainRaw","raw","rows","Array","isArray","counts","durs","fieldKeys","Object","keys","field","count","parseInt","dur","parts","split","labels","m","statusStr","status_code","push","HttpMetricsRedisStore","constructor","redisClient","appName","processType","ttlSec","Error","_client","keySeg","encodeURIComponent","countKey","durKey","_ensureClient","record","durationMs","client","Math","max","round","Number","multi","hincrby","expire","exec","err","console","error","message","e","drainRows","Promise","resolve","ok","eval","evalErr","flushToCounters","applyCount","applyDuration","then","row","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 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 * @param {string} method\n * @param {string} route\n * @param {number} statusCode\n * @returns {string}\n */\nfunction buildFieldKey(method, route, statusCode) {\n return [method, route, String(statusCode)].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 * @param {unknown} raw redis eval result [countPairs, durPairs]\n * @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}\n */\nfunction rowsFromDrainRaw(raw) {\n const rows = []\n if (!raw || !Array.isArray(raw) || raw.length < 2) {\n return rows\n }\n const counts = hgetallPairsToObject(raw[0])\n const durs = hgetallPairsToObject(raw[1])\n const fieldKeys = Object.keys(counts)\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 let labels = null\n if (parts.length === 3) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n } else if (parts.length === 5 || parts.length === 6) {\n const [m, route, statusStr] = parts\n labels = { method: m, route, status_code: statusStr }\n }\n if (!labels) {\n continue\n }\n rows.push({ labels, count, dur })\n }\n return rows\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**. Each **field name** encodes\n * `(method, route, status_code)`. Values: `:count` is HINCRBY 1 per request; `:dur` is summed ms.\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 {number} durationMs\n */\n record(method, route, statusCode, durationMs) {\n try {\n const client = this._ensureClient()\n const field = buildFieldKey(method, route, statusCode)\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 }\n })\n } catch (e) {\n console.error('[HttpMetricsRedisStore] record:', e.message)\n }\n }\n\n /**\n * Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.\n *\n * @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}\n */\n drainRows() {\n let client\n try {\n client = this._ensureClient()\n } catch (e) {\n console.error('[HttpMetricsRedisStore] drainRows:', e.message)\n return Promise.resolve({ ok: false, rows: [] })\n }\n return new Promise(resolve => {\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({ ok: false, rows: [] })\n return\n }\n try {\n const rows = rowsFromDrainRaw(raw)\n resolve({ ok: true, rows })\n } catch (e) {\n console.error(\n '[HttpMetricsRedisStore] drainRows parse failed:',\n e.message\n )\n resolve({ ok: false, rows: [] })\n }\n }\n )\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 return this.drainRows().then(({ ok, rows }) => {\n if (!ok) {\n return false\n }\n for (const row of rows) {\n applyCount(row.labels, row.count)\n if (row.dur > 0) {\n applyDuration(row.labels, row.dur)\n }\n }\n return true\n })\n }\n}\n\nmodule.exports = {\n HttpMetricsRedisStore,\n buildFieldKey,\n FIELD_SEP,\n DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,\n rowsFromDrainRaw,\n}\n"],"mappings":"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","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const { BaseMetricsClient } = require('./baseMetricsClient')
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
HttpMetricsRedisStore,
|
|
4
|
+
buildFieldKey,
|
|
5
|
+
} = require('./httpMetricsRedisStore')
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Drain worker: reads HTTP aggregates from Redis (written by {@link HttpMetricsRedisRecorder}),
|
|
@@ -53,6 +56,9 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
|
|
|
53
56
|
ttlSec: config.ttlSec,
|
|
54
57
|
})
|
|
55
58
|
|
|
59
|
+
/** @type {Set<string>} Redis field keys already primed with inc(0) + push in this process */
|
|
60
|
+
this._httpCounterPrimedKeys = new Set()
|
|
61
|
+
|
|
56
62
|
this.createCounter({
|
|
57
63
|
name: 'app_requests_total',
|
|
58
64
|
help: 'Total number of HTTP requests',
|
|
@@ -86,12 +92,37 @@ class HttpMetricsRedisCollector extends BaseMetricsClient {
|
|
|
86
92
|
this.countersFunctions?.app_requests_total &&
|
|
87
93
|
this.countersFunctions?.app_requests_total_duration
|
|
88
94
|
) {
|
|
89
|
-
await this._store.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
const { ok, rows } = await this._store.drainRows()
|
|
96
|
+
if (ok && rows.length > 0) {
|
|
97
|
+
const applyCount = (labels, value) =>
|
|
98
|
+
this.countersFunctions.app_requests_total(labels, value)
|
|
99
|
+
const applyDur = (labels, value) =>
|
|
93
100
|
this.countersFunctions.app_requests_total_duration(labels, value)
|
|
94
|
-
|
|
101
|
+
|
|
102
|
+
let primedAny = false
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
const key = buildFieldKey(
|
|
105
|
+
row.labels.method,
|
|
106
|
+
row.labels.route,
|
|
107
|
+
row.labels.status_code
|
|
108
|
+
)
|
|
109
|
+
if (!this._httpCounterPrimedKeys.has(key)) {
|
|
110
|
+
this._httpCounterPrimedKeys.add(key)
|
|
111
|
+
applyCount(row.labels, 0)
|
|
112
|
+
applyDur(row.labels, 0)
|
|
113
|
+
primedAny = true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (primedAny) {
|
|
117
|
+
await this.gatewayPush()
|
|
118
|
+
}
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
applyCount(row.labels, row.count)
|
|
121
|
+
if (row.dur > 0) {
|
|
122
|
+
applyDur(row.labels, row.dur)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
95
126
|
}
|
|
96
127
|
return this._pushMetrics()
|
|
97
128
|
}
|
|
@@ -40,6 +40,41 @@ function hgetallPairsToObject(pairs) {
|
|
|
40
40
|
return o
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {unknown} raw redis eval result [countPairs, durPairs]
|
|
45
|
+
* @returns {{ labels: { method: string, route: string, status_code: string }, count: number, dur: number }[]}
|
|
46
|
+
*/
|
|
47
|
+
function rowsFromDrainRaw(raw) {
|
|
48
|
+
const rows = []
|
|
49
|
+
if (!raw || !Array.isArray(raw) || raw.length < 2) {
|
|
50
|
+
return rows
|
|
51
|
+
}
|
|
52
|
+
const counts = hgetallPairsToObject(raw[0])
|
|
53
|
+
const durs = hgetallPairsToObject(raw[1])
|
|
54
|
+
const fieldKeys = Object.keys(counts)
|
|
55
|
+
for (const field of fieldKeys) {
|
|
56
|
+
const count = parseInt(counts[field], 10)
|
|
57
|
+
if (!count || count < 1) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
const dur = parseInt(durs[field] || '0', 10) || 0
|
|
61
|
+
const parts = field.split(FIELD_SEP)
|
|
62
|
+
let labels = null
|
|
63
|
+
if (parts.length === 3) {
|
|
64
|
+
const [m, route, statusStr] = parts
|
|
65
|
+
labels = { method: m, route, status_code: statusStr }
|
|
66
|
+
} else if (parts.length === 5 || parts.length === 6) {
|
|
67
|
+
const [m, route, statusStr] = parts
|
|
68
|
+
labels = { method: m, route, status_code: statusStr }
|
|
69
|
+
}
|
|
70
|
+
if (!labels) {
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
rows.push({ labels, count, dur })
|
|
74
|
+
}
|
|
75
|
+
return rows
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/**
|
|
44
79
|
* Redis HTTP aggregate store. Uses an **injected** client (same pattern as {@link RedisMetricsClient}).
|
|
45
80
|
* Expects `redis` v3-style API: `multi().hincrby().expire().exec(cb)`, `eval`.
|
|
@@ -107,17 +142,17 @@ class HttpMetricsRedisStore {
|
|
|
107
142
|
}
|
|
108
143
|
|
|
109
144
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* @returns {Promise<boolean>}
|
|
145
|
+
* Atomically drain Redis hashes (same Lua as `flushToCounters`) and return parsed rows.
|
|
146
|
+
*
|
|
147
|
+
* @returns {Promise<{ ok: boolean, rows: { labels: Object, count: number, dur: number }[] }>}
|
|
113
148
|
*/
|
|
114
|
-
|
|
149
|
+
drainRows() {
|
|
115
150
|
let client
|
|
116
151
|
try {
|
|
117
152
|
client = this._ensureClient()
|
|
118
153
|
} catch (e) {
|
|
119
|
-
console.error('[HttpMetricsRedisStore]
|
|
120
|
-
return Promise.resolve(false)
|
|
154
|
+
console.error('[HttpMetricsRedisStore] drainRows:', e.message)
|
|
155
|
+
return Promise.resolve({ ok: false, rows: [] })
|
|
121
156
|
}
|
|
122
157
|
return new Promise(resolve => {
|
|
123
158
|
client.eval(
|
|
@@ -131,53 +166,43 @@ class HttpMetricsRedisStore {
|
|
|
131
166
|
'[HttpMetricsRedisStore] drain failed:',
|
|
132
167
|
evalErr.message
|
|
133
168
|
)
|
|
134
|
-
resolve(false)
|
|
169
|
+
resolve({ ok: false, rows: [] })
|
|
135
170
|
return
|
|
136
171
|
}
|
|
137
|
-
|
|
138
172
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
const counts = hgetallPairsToObject(raw[0])
|
|
144
|
-
const durs = hgetallPairsToObject(raw[1])
|
|
145
|
-
const fieldKeys = Object.keys(counts)
|
|
146
|
-
for (const field of fieldKeys) {
|
|
147
|
-
const count = parseInt(counts[field], 10)
|
|
148
|
-
if (!count || count < 1) {
|
|
149
|
-
continue
|
|
150
|
-
}
|
|
151
|
-
const dur = parseInt(durs[field] || '0', 10) || 0
|
|
152
|
-
const parts = field.split(FIELD_SEP)
|
|
153
|
-
let labels = null
|
|
154
|
-
if (parts.length === 3) {
|
|
155
|
-
const [m, route, statusStr] = parts
|
|
156
|
-
labels = { method: m, route, status_code: statusStr }
|
|
157
|
-
} else if (parts.length === 5 || parts.length === 6) {
|
|
158
|
-
const [m, route, statusStr] = parts
|
|
159
|
-
labels = { method: m, route, status_code: statusStr }
|
|
160
|
-
}
|
|
161
|
-
if (!labels) {
|
|
162
|
-
continue
|
|
163
|
-
}
|
|
164
|
-
applyCount(labels, count)
|
|
165
|
-
if (dur > 0) {
|
|
166
|
-
applyDuration(labels, dur)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
resolve(true)
|
|
173
|
+
const rows = rowsFromDrainRaw(raw)
|
|
174
|
+
resolve({ ok: true, rows })
|
|
170
175
|
} catch (e) {
|
|
171
176
|
console.error(
|
|
172
|
-
'[HttpMetricsRedisStore]
|
|
177
|
+
'[HttpMetricsRedisStore] drainRows parse failed:',
|
|
173
178
|
e.message
|
|
174
179
|
)
|
|
175
|
-
resolve(false)
|
|
180
|
+
resolve({ ok: false, rows: [] })
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
)
|
|
179
184
|
})
|
|
180
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {(labels: Object, value: number) => void} applyCount
|
|
189
|
+
* @param {(labels: Object, value: number) => void} applyDuration
|
|
190
|
+
* @returns {Promise<boolean>}
|
|
191
|
+
*/
|
|
192
|
+
flushToCounters(applyCount, applyDuration) {
|
|
193
|
+
return this.drainRows().then(({ ok, rows }) => {
|
|
194
|
+
if (!ok) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
applyCount(row.labels, row.count)
|
|
199
|
+
if (row.dur > 0) {
|
|
200
|
+
applyDuration(row.labels, row.dur)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
})
|
|
205
|
+
}
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
module.exports = {
|
|
@@ -185,4 +210,5 @@ module.exports = {
|
|
|
185
210
|
buildFieldKey,
|
|
186
211
|
FIELD_SEP,
|
|
187
212
|
DEFAULT_HTTP_METRICS_REDIS_TTL_SEC,
|
|
213
|
+
rowsFromDrainRaw,
|
|
188
214
|
}
|