mm_metrics 0.0.0-stage → 1.0.1
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/README.md +36 -3
- package/index.js +25 -0
- package/lib/metrics.js +178 -0
- package/package.json +28 -5
- package/test.js +20 -0
- package/tests/metrics.test.js +72 -0
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# mm_metrics 指标采集模块
|
|
2
|
+
|
|
3
|
+
进程内指标采集:提供 counter(计数器)、gauge(仪表盘)、histogram(直方图)三种基础指标类型,支持标签维度与快照导出。数据保存在内存中,可由 mm_monitor 等模块定期拉取上报。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
const { metricsAdmin } = require('mm_metrics');
|
|
9
|
+
|
|
10
|
+
const metrics = metricsAdmin('os');
|
|
11
|
+
|
|
12
|
+
// 计数器:请求总数(按路由维度)
|
|
13
|
+
metrics.inc('http_requests_total', 1, { route: '/api/user', method: 'GET' });
|
|
14
|
+
|
|
15
|
+
// 仪表盘:当前在线连接数
|
|
16
|
+
metrics.gauge('ws_connections', 128);
|
|
17
|
+
|
|
18
|
+
// 直方图:接口耗时观测(毫秒),默认桶 [10,50,100,500,1000,5000]
|
|
19
|
+
metrics.observe('http_duration_ms', 87, { labels: { route: '/api/user' } });
|
|
20
|
+
|
|
21
|
+
// 导出快照
|
|
22
|
+
console.log(JSON.stringify(metrics.snapshot(), null, 2));
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## API
|
|
26
|
+
|
|
27
|
+
| 方法 | 说明 |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `inc(name, value?, labels?)` | 计数器累加,value 缺省 1 且须非负 |
|
|
30
|
+
| `gauge(name, value, labels?)` | 仪表盘设值,可增可减 |
|
|
31
|
+
| `observe(name, value, { labels, buckets })` | 直方图观测,返回 `{ count, sum, max }` |
|
|
32
|
+
| `snapshot()` | 导出全部指标(含标签与分桶明细) |
|
|
33
|
+
| `reset()` | 清空全部指标 |
|
|
34
|
+
|
|
35
|
+
`metricsAdmin(scope, config)` 按作用域缓存实例,相同 `scope` 返回同一实例。
|
|
36
|
+
|
package/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { Metrics } = require('./lib/metrics.js');
|
|
2
|
+
const Admin = require('mm_corejs').Admin;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 指标管理 Admin 工厂
|
|
6
|
+
* 支持 os.js 调用:require('mm_metrics').admin.setServer(os) -> admin.get(scope, config)
|
|
7
|
+
*/
|
|
8
|
+
let admin = new Admin();
|
|
9
|
+
admin.setClass(Metrics);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 获取(或创建)指定作用域的指标实例
|
|
13
|
+
* @param {string} [scope] 作用域,缺省 'os'
|
|
14
|
+
* @param {Object} [config] 配置参数
|
|
15
|
+
* @returns {Metrics} 指标实例
|
|
16
|
+
*/
|
|
17
|
+
function metricsAdmin(scope, config) {
|
|
18
|
+
return admin.get(scope || 'os', config);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
Metrics,
|
|
23
|
+
admin,
|
|
24
|
+
metricsAdmin
|
|
25
|
+
};
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Base } = require('mm_mod');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 指标采集类
|
|
7
|
+
*
|
|
8
|
+
* 提供计数器(counter)、仪表盘(gauge)、直方图(histogram)三种基础指标类型,
|
|
9
|
+
* 支持标签维度与快照导出,数据保存在进程内存中,可由 mm_monitor 等模块定期拉取。
|
|
10
|
+
*/
|
|
11
|
+
class Metrics extends Base {
|
|
12
|
+
/**
|
|
13
|
+
* 模块默认配置
|
|
14
|
+
*/
|
|
15
|
+
static config = {
|
|
16
|
+
name: 'metrics',
|
|
17
|
+
title: '指标采集模块',
|
|
18
|
+
description: '进程内指标采集:counter / gauge / histogram,支持标签与快照导出'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 指标注册表:name -> { type, help, values: Map<labelKey, value|state> }
|
|
23
|
+
* @type {Map<string, Object>}
|
|
24
|
+
*/
|
|
25
|
+
#metrics = new Map();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 声明并获取(或创建)一个指标
|
|
29
|
+
* @param {string} type 指标类型:counter / gauge / histogram
|
|
30
|
+
* @param {string} name 指标名称
|
|
31
|
+
* @param {Object} [options] 选项:{ help, buckets }
|
|
32
|
+
* @returns {Object} 指标记录
|
|
33
|
+
* @throws {TypeError} 类型未知或与既有同名指标类型冲突时
|
|
34
|
+
*/
|
|
35
|
+
#metric(type, name, options) {
|
|
36
|
+
if (!name || typeof name !== 'string') {
|
|
37
|
+
throw new TypeError(`${this._label()} metric name must be a non-empty string`);
|
|
38
|
+
}
|
|
39
|
+
let m = this.#metrics.get(name);
|
|
40
|
+
if (m) {
|
|
41
|
+
if (m.type !== type) {
|
|
42
|
+
throw new TypeError(`${this._label()} metric "${name}" already declared as ${m.type}, not ${type}`);
|
|
43
|
+
}
|
|
44
|
+
return m;
|
|
45
|
+
}
|
|
46
|
+
m = {
|
|
47
|
+
type,
|
|
48
|
+
help: (options && options.help) || '',
|
|
49
|
+
values: new Map(),
|
|
50
|
+
buckets: (options && options.buckets) || null
|
|
51
|
+
};
|
|
52
|
+
this.#metrics.set(name, m);
|
|
53
|
+
return m;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 标签序列化为映射键
|
|
58
|
+
* @param {Object} labels 标签对象
|
|
59
|
+
* @returns {string} 稳定的键
|
|
60
|
+
*/
|
|
61
|
+
#labelKey(labels) {
|
|
62
|
+
if (!labels || typeof labels !== 'object') {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
const keys = Object.keys(labels).sort();
|
|
66
|
+
return keys.map((k) => `${k}=${String(labels[k])}`).join(',');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 计数器累加(只增不减)
|
|
71
|
+
* @param {string} name 指标名称
|
|
72
|
+
* @param {number} [value] 增量,默认 1,须非负
|
|
73
|
+
* @param {Object} [labels] 标签对象
|
|
74
|
+
* @returns {number} 累加后的值
|
|
75
|
+
*/
|
|
76
|
+
inc(name, value, labels) {
|
|
77
|
+
const step = value === undefined ? 1 : value;
|
|
78
|
+
if (typeof step !== 'number' || !Number.isFinite(step) || step < 0) {
|
|
79
|
+
throw new TypeError(`${this._label()} counter increment must be a non-negative finite number`);
|
|
80
|
+
}
|
|
81
|
+
const m = this.#metric('counter', name);
|
|
82
|
+
const key = this.#labelKey(labels);
|
|
83
|
+
const now = m.values.get(key) || 0;
|
|
84
|
+
m.values.set(key, now + step);
|
|
85
|
+
return now + step;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 仪表盘设值(可增可减)
|
|
90
|
+
* @param {string} name 指标名称
|
|
91
|
+
* @param {number} value 当前值
|
|
92
|
+
* @param {Object} [labels] 标签对象
|
|
93
|
+
* @returns {number} 设置后的值
|
|
94
|
+
*/
|
|
95
|
+
gauge(name, value, labels) {
|
|
96
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
97
|
+
throw new TypeError(`${this._label()} gauge value must be a finite number`);
|
|
98
|
+
}
|
|
99
|
+
const m = this.#metric('gauge', name);
|
|
100
|
+
m.values.set(this.#labelKey(labels), value);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 直方图观测:记录一个样本并更新分桶与统计量
|
|
106
|
+
* @param {string} name 指标名称
|
|
107
|
+
* @param {number} value 样本值
|
|
108
|
+
* @param {Object} [options] 选项:{ labels, buckets }(buckets 首次声明后固定,缺省为默认桶)
|
|
109
|
+
* @returns {Object} 观测后的统计量 { count, sum, max }
|
|
110
|
+
*/
|
|
111
|
+
observe(name, value, options) {
|
|
112
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
113
|
+
throw new TypeError(`${this._label()} histogram observation must be a finite number`);
|
|
114
|
+
}
|
|
115
|
+
const buckets = (options && options.buckets) || [10, 50, 100, 500, 1000, 5000];
|
|
116
|
+
const m = this.#metric('histogram', name, { buckets: buckets });
|
|
117
|
+
const labels = (options && options.labels) || null;
|
|
118
|
+
const key = this.#labelKey(labels);
|
|
119
|
+
let state = m.values.get(key);
|
|
120
|
+
if (!state) {
|
|
121
|
+
state = { count: 0, sum: 0, max: -Infinity, buckets: new Map() };
|
|
122
|
+
m.values.set(key, state);
|
|
123
|
+
}
|
|
124
|
+
state.count += 1;
|
|
125
|
+
state.sum += value;
|
|
126
|
+
if (value > state.max) {
|
|
127
|
+
state.max = value;
|
|
128
|
+
}
|
|
129
|
+
for (const bound of m.buckets) {
|
|
130
|
+
if (value <= bound) {
|
|
131
|
+
state.buckets.set(bound, (state.buckets.get(bound) || 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { count: state.count, sum: state.sum, max: state.max };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 导出全部指标快照
|
|
139
|
+
* @returns {Array<Object>} 指标快照数组,每项含 name/type/help 与 values
|
|
140
|
+
*/
|
|
141
|
+
snapshot() {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const [name, m] of this.#metrics) {
|
|
144
|
+
const values = [];
|
|
145
|
+
for (const [key, value] of m.values) {
|
|
146
|
+
const item = { labels: {}, value };
|
|
147
|
+
if (key) {
|
|
148
|
+
for (const pair of key.split(',')) {
|
|
149
|
+
const idx = pair.indexOf('=');
|
|
150
|
+
item.labels[pair.slice(0, idx)] = pair.slice(idx + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (m.type === 'histogram' && value && value.buckets) {
|
|
154
|
+
item.value = {
|
|
155
|
+
count: value.count,
|
|
156
|
+
sum: value.sum,
|
|
157
|
+
max: value.max === -Infinity ? null : value.max,
|
|
158
|
+
buckets: Object.fromEntries(value.buckets)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
values.push(item);
|
|
162
|
+
}
|
|
163
|
+
out.push({ name, type: m.type, help: m.help, buckets: m.buckets, values });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 清空全部指标
|
|
170
|
+
* @returns {Metrics} 当前实例
|
|
171
|
+
*/
|
|
172
|
+
reset() {
|
|
173
|
+
this.#metrics.clear();
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { Metrics };
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "mm_metrics",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "超级美眉指标采集模块:counter / gauge / histogram 进程内指标,支持标签与快照导出",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test.js && node --test tests/metrics.test.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"mm_mod",
|
|
11
|
+
"mm_infra",
|
|
12
|
+
"observability",
|
|
13
|
+
"metrics",
|
|
14
|
+
"counter",
|
|
15
|
+
"gauge",
|
|
16
|
+
"histogram"
|
|
17
|
+
],
|
|
18
|
+
"layer": "infra",
|
|
19
|
+
"group": "observability",
|
|
20
|
+
"author": "邱文武",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"mm_corejs": "^1.1.4",
|
|
24
|
+
"mm_mod": "^1.0.1"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('assert');
|
|
3
|
+
const mod = require('./index.js');
|
|
4
|
+
|
|
5
|
+
assert.strictEqual(typeof mod.Metrics, 'function', '应导出 Metrics 类');
|
|
6
|
+
assert.strictEqual(typeof mod.metricsAdmin, 'function', '应导出 metricsAdmin 函数');
|
|
7
|
+
|
|
8
|
+
const m1 = mod.metricsAdmin('smoke');
|
|
9
|
+
const m2 = mod.metricsAdmin('smoke');
|
|
10
|
+
assert.strictEqual(m1, m2, '相同作用域应返回同一实例');
|
|
11
|
+
assert.ok(m1 instanceof mod.Metrics, '应返回 Metrics 实例');
|
|
12
|
+
|
|
13
|
+
m1.reset();
|
|
14
|
+
m1.inc('requests_total');
|
|
15
|
+
m1.gauge('active', 5);
|
|
16
|
+
m1.observe('duration_ms', 42);
|
|
17
|
+
const snap = m1.snapshot();
|
|
18
|
+
assert.strictEqual(snap.length, 3, '快照应包含 3 个指标');
|
|
19
|
+
|
|
20
|
+
console.log('mm_metrics 模块测试通过');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* mm_metrics 单元测试
|
|
4
|
+
*/
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
|
|
8
|
+
const { Metrics, metricsAdmin } = require('../index.js');
|
|
9
|
+
|
|
10
|
+
test('计数器:累加、标签维度与参数校验', () => {
|
|
11
|
+
const m = new Metrics();
|
|
12
|
+
assert.strictEqual(m.inc('req'), 1);
|
|
13
|
+
assert.strictEqual(m.inc('req'), 2);
|
|
14
|
+
assert.strictEqual(m.inc('req', 5), 7);
|
|
15
|
+
assert.strictEqual(m.inc('req', 1, { route: '/a' }), 1, '不同标签独立计数');
|
|
16
|
+
assert.strictEqual(m.inc('req', 2, { route: '/a' }), 3);
|
|
17
|
+
assert.throws(() => m.inc('req', -1), TypeError, '负增量应拒绝');
|
|
18
|
+
assert.throws(() => m.inc('req', NaN), TypeError);
|
|
19
|
+
assert.throws(() => m.inc(''), TypeError);
|
|
20
|
+
// 同名指标类型冲突
|
|
21
|
+
assert.throws(() => m.gauge('req', 1), TypeError);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('仪表盘:设值与读取', () => {
|
|
25
|
+
const m = new Metrics();
|
|
26
|
+
m.gauge('conns', 10);
|
|
27
|
+
m.gauge('conns', 8);
|
|
28
|
+
m.gauge('conns', 3, { region: 'cn' });
|
|
29
|
+
const snap = m.snapshot().find((x) => x.name === 'conns');
|
|
30
|
+
assert.strictEqual(snap.type, 'gauge');
|
|
31
|
+
const get = (labels) => snap.values.find((v) => JSON.stringify(v.labels) === JSON.stringify(labels || {})).value;
|
|
32
|
+
assert.strictEqual(get({}), 8, '同键重复设值应覆盖');
|
|
33
|
+
assert.strictEqual(get({ region: 'cn' }), 3);
|
|
34
|
+
assert.throws(() => m.gauge('x', 'abc'), TypeError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('直方图:分桶与统计量', () => {
|
|
38
|
+
const m = new Metrics();
|
|
39
|
+
for (const v of [5, 20, 80, 300, 2000]) {
|
|
40
|
+
m.observe('dur', v);
|
|
41
|
+
}
|
|
42
|
+
const snap = m.snapshot().find((x) => x.name === 'dur');
|
|
43
|
+
assert.strictEqual(snap.type, 'histogram');
|
|
44
|
+
const val = snap.values[0].value;
|
|
45
|
+
assert.strictEqual(val.count, 5);
|
|
46
|
+
assert.strictEqual(val.sum, 2405);
|
|
47
|
+
assert.strictEqual(val.max, 2000);
|
|
48
|
+
assert.strictEqual(val.buckets['10'], 1, '<=10 的样本 1 个');
|
|
49
|
+
assert.strictEqual(val.buckets['100'], 3, '<=100 的样本 3 个');
|
|
50
|
+
assert.strictEqual(val.buckets['5000'], 5);
|
|
51
|
+
// 自定义桶
|
|
52
|
+
m.observe('lat', 7, { buckets: [5, 10, 100] });
|
|
53
|
+
const lat = m.snapshot().find((x) => x.name === 'lat');
|
|
54
|
+
assert.deepStrictEqual(lat.buckets, [5, 10, 100]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('快照导出与 reset', () => {
|
|
58
|
+
const m = new Metrics();
|
|
59
|
+
m.inc('a');
|
|
60
|
+
m.gauge('b', 1);
|
|
61
|
+
m.observe('c', 1);
|
|
62
|
+
assert.strictEqual(m.snapshot().length, 3);
|
|
63
|
+
m.reset();
|
|
64
|
+
assert.strictEqual(m.snapshot().length, 0, 'reset 后应为空');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('工厂函数按作用域共享实例', () => {
|
|
68
|
+
const a = metricsAdmin('ut-metrics');
|
|
69
|
+
const b = metricsAdmin('ut-metrics');
|
|
70
|
+
assert.strictEqual(a, b);
|
|
71
|
+
assert.notStrictEqual(metricsAdmin('ut-metrics-2'), a, '不同作用域应返回不同实例');
|
|
72
|
+
});
|