@wardx/core 0.1.0
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 +289 -0
- package/defaults.json +14 -0
- package/package.json +19 -0
- package/src/WardxCore.js +198 -0
- package/src/buffers/EventBuffer.js +28 -0
- package/src/buffers/LogBuffer.js +48 -0
- package/src/config/ConfigStore.js +31 -0
- package/src/config/ExperimentResolver.js +118 -0
- package/src/config/hash.js +27 -0
- package/src/frame/FrameBuilder.js +132 -0
- package/src/ids.js +30 -0
- package/src/index.js +14 -0
- package/src/internal/InternalMetrics.js +53 -0
- package/src/metrics/Counter.js +23 -0
- package/src/metrics/Gauge.js +22 -0
- package/src/metrics/Histogram.js +85 -0
- package/src/metrics/MetricsRegistry.js +172 -0
- package/src/metrics/Timer.js +6 -0
- package/src/metrics/dimensions.js +42 -0
- package/src/protocol.js +53 -0
- package/src/settings.js +97 -0
- package/src/trace/emit.js +5 -0
- package/src/trace/wrap.js +46 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { assignmentHash, hashToUnitInterval, subjectHash } from './hash.js';
|
|
2
|
+
|
|
3
|
+
export function assignVariant(experiment, subjectId) {
|
|
4
|
+
if (!experiment.enabled) return null;
|
|
5
|
+
const hash = assignmentHash(experiment.id, subjectId, experiment.salt);
|
|
6
|
+
const bucket = hashToUnitInterval(hash);
|
|
7
|
+
if (bucket >= experiment.allocation) return null;
|
|
8
|
+
const variants = experiment.variants;
|
|
9
|
+
if (!Array.isArray(variants) || variants.length === 0) {
|
|
10
|
+
throw new Error(`experiment ${experiment.id} has no variants`);
|
|
11
|
+
}
|
|
12
|
+
let totalWeight = 0;
|
|
13
|
+
for (let i = 0; i < variants.length; i++) {
|
|
14
|
+
const weight = variants[i].weight;
|
|
15
|
+
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) {
|
|
16
|
+
throw new Error(`experiment ${experiment.id} has invalid variant weight`);
|
|
17
|
+
}
|
|
18
|
+
totalWeight += weight;
|
|
19
|
+
}
|
|
20
|
+
if (totalWeight <= 0) {
|
|
21
|
+
throw new Error(`experiment ${experiment.id} variant weights must sum to > 0`);
|
|
22
|
+
}
|
|
23
|
+
let threshold = 0;
|
|
24
|
+
for (let i = 0; i < variants.length; i++) {
|
|
25
|
+
threshold += (variants[i].weight / totalWeight) * experiment.allocation;
|
|
26
|
+
if (bucket < threshold) return variants[i];
|
|
27
|
+
}
|
|
28
|
+
return variants[variants.length - 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function indexExperimentsByKey(experiments) {
|
|
32
|
+
const map = new Map();
|
|
33
|
+
if (!Array.isArray(experiments)) return map;
|
|
34
|
+
for (const experiment of experiments) {
|
|
35
|
+
if (!experiment.enabled) continue;
|
|
36
|
+
for (const variant of experiment.variants) {
|
|
37
|
+
for (const key of Object.keys(variant.values)) {
|
|
38
|
+
let list = map.get(key);
|
|
39
|
+
if (!list) {
|
|
40
|
+
list = [];
|
|
41
|
+
map.set(key, list);
|
|
42
|
+
}
|
|
43
|
+
if (!list.includes(experiment)) list.push(experiment);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ExperimentResolver {
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.privacySalt = options.privacySalt;
|
|
53
|
+
this.onExposure = options.onExposure;
|
|
54
|
+
this.exposureKeys = new Set();
|
|
55
|
+
this.assignmentsBySubject = new Map();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
hashSubject(subjectId) {
|
|
59
|
+
return subjectHash(this.privacySalt, subjectId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
recordAssignment(subjectId, experiment, variant) {
|
|
63
|
+
let list = this.assignmentsBySubject.get(subjectId);
|
|
64
|
+
if (!list) {
|
|
65
|
+
list = [];
|
|
66
|
+
this.assignmentsBySubject.set(subjectId, list);
|
|
67
|
+
}
|
|
68
|
+
for (let i = 0; i < list.length; i++) {
|
|
69
|
+
if (list[i].experiment === experiment.id) return;
|
|
70
|
+
}
|
|
71
|
+
list.push({ experiment: experiment.id, variant: variant.key });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
assignmentsFor(subjectId) {
|
|
75
|
+
return this.assignmentsBySubject.get(subjectId) || [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
resolve(key, remoteValue, subjectId, experimentsByKey) {
|
|
79
|
+
if (subjectId === undefined || subjectId === null) return remoteValue;
|
|
80
|
+
const list = experimentsByKey.get(key);
|
|
81
|
+
if (!list) return remoteValue;
|
|
82
|
+
for (const experiment of list) {
|
|
83
|
+
const variant = assignVariant(experiment, subjectId);
|
|
84
|
+
if (!variant) continue;
|
|
85
|
+
if (!Object.prototype.hasOwnProperty.call(variant.values, key)) continue;
|
|
86
|
+
this.recordAssignment(subjectId, experiment, variant);
|
|
87
|
+
this._expose(experiment, variant, subjectId);
|
|
88
|
+
return variant.values[key];
|
|
89
|
+
}
|
|
90
|
+
return remoteValue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_expose(experiment, variant, subjectId) {
|
|
94
|
+
const hashed = this.hashSubject(subjectId);
|
|
95
|
+
const exposureKey = experiment.id + '\0' + hashed;
|
|
96
|
+
if (this.exposureKeys.has(exposureKey)) return;
|
|
97
|
+
this.exposureKeys.add(exposureKey);
|
|
98
|
+
this.onExposure({
|
|
99
|
+
experiment: experiment.id,
|
|
100
|
+
variant: variant.key,
|
|
101
|
+
subject: hashed
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
relevantExperiments(subjectId, experiments) {
|
|
106
|
+
const known = this.assignmentsFor(subjectId);
|
|
107
|
+
if (known.length > 0) return known;
|
|
108
|
+
const attached = [];
|
|
109
|
+
if (!Array.isArray(experiments)) return attached;
|
|
110
|
+
for (const experiment of experiments) {
|
|
111
|
+
if (!experiment.enabled) continue;
|
|
112
|
+
const variant = assignVariant(experiment, subjectId);
|
|
113
|
+
if (!variant) continue;
|
|
114
|
+
attached.push({ experiment: experiment.id, variant: variant.key });
|
|
115
|
+
}
|
|
116
|
+
return attached;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
export const FNV_OFFSET_32 = 0x811c9dc5;
|
|
4
|
+
export const FNV_PRIME_32 = 0x01000193;
|
|
5
|
+
export const UINT32 = 4294967296;
|
|
6
|
+
|
|
7
|
+
export function fnv1a32(input) {
|
|
8
|
+
const bytes = typeof input === 'string' ? encoder.encode(input) : input;
|
|
9
|
+
let hash = FNV_OFFSET_32;
|
|
10
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
11
|
+
hash ^= bytes[i];
|
|
12
|
+
hash = Math.imul(hash, FNV_PRIME_32) >>> 0;
|
|
13
|
+
}
|
|
14
|
+
return hash;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hashToUnitInterval(hash) {
|
|
18
|
+
return hash / UINT32;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assignmentHash(experimentId, subjectId, salt) {
|
|
22
|
+
return fnv1a32(`${experimentId}:${subjectId}:${salt}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function subjectHash(projectSalt, subjectId) {
|
|
26
|
+
return (fnv1a32(`${projectSalt}:${subjectId}`) >>> 0).toString(16).padStart(8, '0');
|
|
27
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { INTERNAL } from '../protocol.js';
|
|
2
|
+
import { LOG_RANK } from '../protocol.js';
|
|
3
|
+
|
|
4
|
+
function measure(frame) {
|
|
5
|
+
const json = JSON.stringify(frame);
|
|
6
|
+
return { json, bytes: Buffer.byteLength(json, 'utf8') };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function logDropOrder(logs) {
|
|
10
|
+
return logs
|
|
11
|
+
.map((_, index) => index)
|
|
12
|
+
.sort((a, b) => {
|
|
13
|
+
const rankA = LOG_RANK[logs[a][1]];
|
|
14
|
+
const rankB = LOG_RANK[logs[b][1]];
|
|
15
|
+
const aKey = rankA === undefined ? Infinity : rankA;
|
|
16
|
+
const bKey = rankB === undefined ? Infinity : rankB;
|
|
17
|
+
if (aKey !== bKey) return aKey - bKey;
|
|
18
|
+
return a - b;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function logsWithoutFirstK(logs, dropOrder, k) {
|
|
23
|
+
if (k <= 0) return logs;
|
|
24
|
+
if (k >= logs.length) return [];
|
|
25
|
+
const drop = new Set(dropOrder.slice(0, k));
|
|
26
|
+
return logs.filter((_, index) => !drop.has(index));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function leastDrops(maxDrop, fits) {
|
|
30
|
+
if (maxDrop === 0) return 0;
|
|
31
|
+
if (!fits(maxDrop)) return maxDrop;
|
|
32
|
+
let lo = 0;
|
|
33
|
+
let hi = maxDrop;
|
|
34
|
+
while (lo < hi) {
|
|
35
|
+
const mid = (lo + hi) >> 1;
|
|
36
|
+
if (fits(mid)) hi = mid;
|
|
37
|
+
else lo = mid + 1;
|
|
38
|
+
}
|
|
39
|
+
return lo;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isInternalGauge(row) {
|
|
43
|
+
return typeof row[0] === 'string' && row[0].startsWith('wardx.internal.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class FrameBuilder {
|
|
47
|
+
static build({ seq, from, to, metrics, events, logs, internal }) {
|
|
48
|
+
const counters = metrics.counters.slice();
|
|
49
|
+
const gauges = metrics.gauges.slice();
|
|
50
|
+
const histograms = metrics.histograms.slice();
|
|
51
|
+
FrameBuilder.mergeInternal(counters, gauges, internal);
|
|
52
|
+
return {
|
|
53
|
+
seq,
|
|
54
|
+
from,
|
|
55
|
+
to,
|
|
56
|
+
metrics: {
|
|
57
|
+
counters,
|
|
58
|
+
gauges,
|
|
59
|
+
histograms
|
|
60
|
+
},
|
|
61
|
+
events,
|
|
62
|
+
logs
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static mergeInternal(counters, gauges, internal) {
|
|
67
|
+
if (internal.eventsDropped) counters.push([INTERNAL.eventsDropped, null, internal.eventsDropped]);
|
|
68
|
+
if (internal.logsDropped) counters.push([INTERNAL.logsDropped, null, internal.logsDropped]);
|
|
69
|
+
if (internal.cardinalityDropped) {
|
|
70
|
+
counters.push([INTERNAL.cardinalityDropped, null, internal.cardinalityDropped]);
|
|
71
|
+
}
|
|
72
|
+
if (internal.framesSent) counters.push([INTERNAL.framesSent, null, internal.framesSent]);
|
|
73
|
+
if (internal.framesFailed) counters.push([INTERNAL.framesFailed, null, internal.framesFailed]);
|
|
74
|
+
if (internal.bytesUncompressed) {
|
|
75
|
+
counters.push([INTERNAL.bytesUncompressed, null, internal.bytesUncompressed]);
|
|
76
|
+
}
|
|
77
|
+
if (internal.bytesCompressed) {
|
|
78
|
+
counters.push([INTERNAL.bytesCompressed, null, internal.bytesCompressed]);
|
|
79
|
+
}
|
|
80
|
+
gauges.push([INTERNAL.eventsBuffered, null, internal.eventsBuffered, Date.now()]);
|
|
81
|
+
gauges.push([INTERNAL.logsBuffered, null, internal.logsBuffered, Date.now()]);
|
|
82
|
+
if (internal.lastSyncMs) {
|
|
83
|
+
gauges.push([INTERNAL.lastSyncMs, null, internal.lastSyncMs, Date.now()]);
|
|
84
|
+
}
|
|
85
|
+
gauges.push([INTERNAL.configVersion, null, internal.configVersion, Date.now()]);
|
|
86
|
+
if (internal.processRssBytes) {
|
|
87
|
+
gauges.push([INTERNAL.processRssBytes, null, internal.processRssBytes, Date.now()]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static fitToMaxBytes(frame, maxFrameBytes) {
|
|
92
|
+
let { json, bytes } = measure(frame);
|
|
93
|
+
if (bytes <= maxFrameBytes) return { frame, json, droppedLogs: 0, droppedEvents: 0 };
|
|
94
|
+
|
|
95
|
+
let droppedLogs = 0;
|
|
96
|
+
let droppedEvents = 0;
|
|
97
|
+
|
|
98
|
+
if (frame.logs.length > 0) {
|
|
99
|
+
const dropOrder = logDropOrder(frame.logs);
|
|
100
|
+
const k = leastDrops(dropOrder.length, (mid) => {
|
|
101
|
+
const probe = { ...frame, logs: logsWithoutFirstK(frame.logs, dropOrder, mid) };
|
|
102
|
+
return measure(probe).bytes <= maxFrameBytes;
|
|
103
|
+
});
|
|
104
|
+
frame.logs = logsWithoutFirstK(frame.logs, dropOrder, k);
|
|
105
|
+
droppedLogs = k;
|
|
106
|
+
({ json, bytes } = measure(frame));
|
|
107
|
+
if (bytes <= maxFrameBytes) return { frame, json, droppedLogs, droppedEvents };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (frame.events.length > 0) {
|
|
111
|
+
const original = frame.events;
|
|
112
|
+
const k = leastDrops(original.length, (mid) => {
|
|
113
|
+
const probe = { ...frame, events: original.slice(0, original.length - mid) };
|
|
114
|
+
return measure(probe).bytes <= maxFrameBytes;
|
|
115
|
+
});
|
|
116
|
+
frame.events = original.slice(0, original.length - k);
|
|
117
|
+
droppedEvents = k;
|
|
118
|
+
({ json, bytes } = measure(frame));
|
|
119
|
+
if (bytes <= maxFrameBytes) return { frame, json, droppedLogs, droppedEvents };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (bytes > maxFrameBytes && frame.metrics.histograms.length > 0) {
|
|
123
|
+
frame.metrics.histograms = [];
|
|
124
|
+
({ json, bytes } = measure(frame));
|
|
125
|
+
}
|
|
126
|
+
if (bytes > maxFrameBytes) {
|
|
127
|
+
frame.metrics.gauges = frame.metrics.gauges.filter(isInternalGauge);
|
|
128
|
+
({ json } = measure(frame));
|
|
129
|
+
}
|
|
130
|
+
return { frame, json, droppedLogs, droppedEvents };
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/ids.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
4
|
+
|
|
5
|
+
export function ulid(now = Date.now()) {
|
|
6
|
+
const chars = new Array(26);
|
|
7
|
+
let time = now;
|
|
8
|
+
for (let i = 9; i >= 0; i--) {
|
|
9
|
+
chars[i] = ENCODING[time & 31];
|
|
10
|
+
time = Math.floor(time / 32);
|
|
11
|
+
}
|
|
12
|
+
const rand = randomBytes(10);
|
|
13
|
+
chars[10] = ENCODING[(rand[0] & 224) >> 5];
|
|
14
|
+
chars[11] = ENCODING[rand[0] & 31];
|
|
15
|
+
chars[12] = ENCODING[(rand[1] & 248) >> 3];
|
|
16
|
+
chars[13] = ENCODING[((rand[1] & 7) << 2) | ((rand[2] & 192) >> 6)];
|
|
17
|
+
chars[14] = ENCODING[(rand[2] & 62) >> 1];
|
|
18
|
+
chars[15] = ENCODING[((rand[2] & 1) << 4) | ((rand[3] & 240) >> 4)];
|
|
19
|
+
chars[16] = ENCODING[((rand[3] & 15) << 1) | ((rand[4] & 128) >> 7)];
|
|
20
|
+
chars[17] = ENCODING[(rand[4] & 124) >> 2];
|
|
21
|
+
chars[18] = ENCODING[((rand[4] & 3) << 3) | ((rand[5] & 224) >> 5)];
|
|
22
|
+
chars[19] = ENCODING[rand[5] & 31];
|
|
23
|
+
chars[20] = ENCODING[(rand[6] & 248) >> 3];
|
|
24
|
+
chars[21] = ENCODING[((rand[6] & 7) << 2) | ((rand[7] & 192) >> 6)];
|
|
25
|
+
chars[22] = ENCODING[(rand[7] & 62) >> 1];
|
|
26
|
+
chars[23] = ENCODING[((rand[7] & 1) << 4) | ((rand[8] & 240) >> 4)];
|
|
27
|
+
chars[24] = ENCODING[((rand[8] & 15) << 1) | ((rand[9] & 128) >> 7)];
|
|
28
|
+
chars[25] = ENCODING[(rand[9] & 124) >> 2];
|
|
29
|
+
return chars.join('');
|
|
30
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { PROTOCOL_VERSION, SDK_NAME, PLATFORM, INTERNAL } from './protocol.js';
|
|
2
|
+
export { fnv1a32, assignmentHash, hashToUnitInterval, subjectHash } from './config/hash.js';
|
|
3
|
+
export { assignVariant, ExperimentResolver } from './config/ExperimentResolver.js';
|
|
4
|
+
export { ConfigStore } from './config/ConfigStore.js';
|
|
5
|
+
export { Counter } from './metrics/Counter.js';
|
|
6
|
+
export { Gauge } from './metrics/Gauge.js';
|
|
7
|
+
export { Histogram } from './metrics/Histogram.js';
|
|
8
|
+
export { MetricsRegistry } from './metrics/MetricsRegistry.js';
|
|
9
|
+
export { EventBuffer } from './buffers/EventBuffer.js';
|
|
10
|
+
export { LogBuffer } from './buffers/LogBuffer.js';
|
|
11
|
+
export { FrameBuilder } from './frame/FrameBuilder.js';
|
|
12
|
+
export { WardxCore } from './WardxCore.js';
|
|
13
|
+
export { resolveSettings, loadSdkDefaults, nextSyncDelayMs } from './settings.js';
|
|
14
|
+
export { ulid } from './ids.js';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class InternalMetrics {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.eventsDropped = 0;
|
|
4
|
+
this.logsDropped = 0;
|
|
5
|
+
this.cardinalityDropped = 0;
|
|
6
|
+
this.framesSent = 0;
|
|
7
|
+
this.framesFailed = 0;
|
|
8
|
+
this.bytesUncompressed = 0;
|
|
9
|
+
this.bytesCompressed = 0;
|
|
10
|
+
this.eventsBuffered = 0;
|
|
11
|
+
this.logsBuffered = 0;
|
|
12
|
+
this.lastSyncMs = 0;
|
|
13
|
+
this.configVersion = 0;
|
|
14
|
+
this.processRssBytes = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
hasCounterActivity() {
|
|
18
|
+
return (
|
|
19
|
+
this.eventsDropped !== 0 ||
|
|
20
|
+
this.logsDropped !== 0 ||
|
|
21
|
+
this.cardinalityDropped !== 0 ||
|
|
22
|
+
this.framesSent !== 0 ||
|
|
23
|
+
this.framesFailed !== 0 ||
|
|
24
|
+
this.bytesUncompressed !== 0 ||
|
|
25
|
+
this.bytesCompressed !== 0
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
snapshotAndReset() {
|
|
30
|
+
const snap = {
|
|
31
|
+
eventsDropped: this.eventsDropped,
|
|
32
|
+
logsDropped: this.logsDropped,
|
|
33
|
+
cardinalityDropped: this.cardinalityDropped,
|
|
34
|
+
framesSent: this.framesSent,
|
|
35
|
+
framesFailed: this.framesFailed,
|
|
36
|
+
bytesUncompressed: this.bytesUncompressed,
|
|
37
|
+
bytesCompressed: this.bytesCompressed,
|
|
38
|
+
eventsBuffered: this.eventsBuffered,
|
|
39
|
+
logsBuffered: this.logsBuffered,
|
|
40
|
+
lastSyncMs: this.lastSyncMs,
|
|
41
|
+
configVersion: this.configVersion,
|
|
42
|
+
processRssBytes: this.processRssBytes
|
|
43
|
+
};
|
|
44
|
+
this.eventsDropped = 0;
|
|
45
|
+
this.logsDropped = 0;
|
|
46
|
+
this.cardinalityDropped = 0;
|
|
47
|
+
this.framesSent = 0;
|
|
48
|
+
this.framesFailed = 0;
|
|
49
|
+
this.bytesUncompressed = 0;
|
|
50
|
+
this.bytesCompressed = 0;
|
|
51
|
+
return snap;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class Counter {
|
|
2
|
+
constructor(name, dims) {
|
|
3
|
+
this.name = name;
|
|
4
|
+
this.dims = dims;
|
|
5
|
+
this.value = 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
inc() {
|
|
9
|
+
this.value += 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
add(n) {
|
|
13
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
14
|
+
throw new Error('counter.add requires a finite number');
|
|
15
|
+
}
|
|
16
|
+
this.value += n;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NOOP_COUNTER = {
|
|
21
|
+
inc() {},
|
|
22
|
+
add() {}
|
|
23
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class Gauge {
|
|
2
|
+
constructor(name, dims) {
|
|
3
|
+
this.name = name;
|
|
4
|
+
this.dims = dims;
|
|
5
|
+
this.value = 0;
|
|
6
|
+
this.timestamp = 0;
|
|
7
|
+
this.dirty = false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
set(value) {
|
|
11
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
12
|
+
throw new Error('gauge.set requires a finite number');
|
|
13
|
+
}
|
|
14
|
+
this.value = value;
|
|
15
|
+
this.timestamp = Date.now();
|
|
16
|
+
this.dirty = true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NOOP_GAUGE = {
|
|
21
|
+
set() {}
|
|
22
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { validateDimensions } from './dimensions.js';
|
|
2
|
+
|
|
3
|
+
export class Histogram {
|
|
4
|
+
constructor(name, dims, bounds, limits) {
|
|
5
|
+
this.name = name;
|
|
6
|
+
this.dims = dims;
|
|
7
|
+
this.bounds = bounds;
|
|
8
|
+
this.maxDimensionKeys = limits ? limits.maxDimensionKeys : null;
|
|
9
|
+
this.maxDimensionValueLength = limits ? limits.maxDimensionValueLength : null;
|
|
10
|
+
this.counts = new Array(bounds.length).fill(0);
|
|
11
|
+
this.count = 0;
|
|
12
|
+
this.sum = 0;
|
|
13
|
+
this.min = Infinity;
|
|
14
|
+
this.max = -Infinity;
|
|
15
|
+
this.exemplar = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
observe(value, attrs) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
20
|
+
throw new Error('histogram.observe requires a finite number');
|
|
21
|
+
}
|
|
22
|
+
const isMax = value >= this.max;
|
|
23
|
+
this.count += 1;
|
|
24
|
+
this.sum += value;
|
|
25
|
+
if (value < this.min) this.min = value;
|
|
26
|
+
if (isMax) {
|
|
27
|
+
this.max = value;
|
|
28
|
+
this.exemplar = this._exemplarFrom(value, attrs);
|
|
29
|
+
}
|
|
30
|
+
const bounds = this.bounds;
|
|
31
|
+
const counts = this.counts;
|
|
32
|
+
for (let i = 0; i < bounds.length; i++) {
|
|
33
|
+
if (value <= bounds[i]) {
|
|
34
|
+
counts[i] += 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_exemplarFrom(value, attrs) {
|
|
41
|
+
if (attrs == null) return null;
|
|
42
|
+
if (
|
|
43
|
+
!Number.isInteger(this.maxDimensionKeys) ||
|
|
44
|
+
!Number.isInteger(this.maxDimensionValueLength)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error('histogram.observe attrs require dimension limits');
|
|
47
|
+
}
|
|
48
|
+
const checked = validateDimensions(
|
|
49
|
+
attrs,
|
|
50
|
+
this.maxDimensionKeys,
|
|
51
|
+
this.maxDimensionValueLength
|
|
52
|
+
);
|
|
53
|
+
if (!checked.ok || checked.dims == null) return null;
|
|
54
|
+
return { value, attrs: { ...checked.dims } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot() {
|
|
58
|
+
const buckets = new Array(this.bounds.length);
|
|
59
|
+
for (let i = 0; i < this.bounds.length; i++) {
|
|
60
|
+
buckets[i] = [this.bounds[i], this.counts[i]];
|
|
61
|
+
}
|
|
62
|
+
const body = {
|
|
63
|
+
count: this.count,
|
|
64
|
+
sum: this.sum,
|
|
65
|
+
min: this.min,
|
|
66
|
+
max: this.max,
|
|
67
|
+
buckets
|
|
68
|
+
};
|
|
69
|
+
if (this.exemplar) body.exemplar = this.exemplar;
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
reset() {
|
|
74
|
+
this.counts.fill(0);
|
|
75
|
+
this.count = 0;
|
|
76
|
+
this.sum = 0;
|
|
77
|
+
this.min = Infinity;
|
|
78
|
+
this.max = -Infinity;
|
|
79
|
+
this.exemplar = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const NOOP_HISTOGRAM = {
|
|
84
|
+
observe() {}
|
|
85
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Counter, NOOP_COUNTER } from './Counter.js';
|
|
2
|
+
import { Gauge, NOOP_GAUGE } from './Gauge.js';
|
|
3
|
+
import { Histogram, NOOP_HISTOGRAM } from './Histogram.js';
|
|
4
|
+
import { startTimer } from './Timer.js';
|
|
5
|
+
import { assertMetricName, dimKey, validateDimensions } from './dimensions.js';
|
|
6
|
+
|
|
7
|
+
function boundsEqual(a, b) {
|
|
8
|
+
if (a.length !== b.length) return false;
|
|
9
|
+
for (let i = 0; i < a.length; i++) {
|
|
10
|
+
if (a[i] !== b[i]) return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseHistogramArgs(a, b, defaultBuckets) {
|
|
16
|
+
let buckets = defaultBuckets;
|
|
17
|
+
let dims = null;
|
|
18
|
+
if (a != null) {
|
|
19
|
+
if (Object.prototype.hasOwnProperty.call(a, 'buckets')) {
|
|
20
|
+
buckets = a.buckets;
|
|
21
|
+
const rest = { ...a };
|
|
22
|
+
delete rest.buckets;
|
|
23
|
+
dims = Object.keys(rest).length > 0 ? rest : null;
|
|
24
|
+
if (b != null) dims = dims ? { ...dims, ...b } : b;
|
|
25
|
+
} else {
|
|
26
|
+
dims = a;
|
|
27
|
+
if (b != null && Object.prototype.hasOwnProperty.call(b, 'buckets')) {
|
|
28
|
+
buckets = b.buckets;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(buckets) || buckets.length === 0) {
|
|
33
|
+
throw new Error('histogram buckets must be a non-empty array');
|
|
34
|
+
}
|
|
35
|
+
let prev = -Infinity;
|
|
36
|
+
for (const bound of buckets) {
|
|
37
|
+
if (typeof bound !== 'number' || !Number.isFinite(bound) || bound <= prev) {
|
|
38
|
+
throw new Error('histogram buckets must be strictly increasing finite numbers');
|
|
39
|
+
}
|
|
40
|
+
prev = bound;
|
|
41
|
+
}
|
|
42
|
+
return { buckets, dims };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class MetricsRegistry {
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.maxSeriesPerMetric = options.maxSeriesPerMetric;
|
|
48
|
+
this.maxDimensionKeys = options.maxDimensionKeys;
|
|
49
|
+
this.maxDimensionValueLength = options.maxDimensionValueLength;
|
|
50
|
+
this.defaultHistogramBuckets = options.defaultHistogramBuckets;
|
|
51
|
+
this.onCardinalityDropped = options.onCardinalityDropped;
|
|
52
|
+
this.countersByName = new Map();
|
|
53
|
+
this.gaugesByName = new Map();
|
|
54
|
+
this.histogramsByName = new Map();
|
|
55
|
+
this.rejected = new Set();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_series(kindMap, Ctor, name, dims, extra) {
|
|
59
|
+
assertMetricName(name);
|
|
60
|
+
const kindPrefix = kindMap === this.histogramsByName ? 'h' : kindMap === this.gaugesByName ? 'g' : 'c';
|
|
61
|
+
const checked = validateDimensions(dims, this.maxDimensionKeys, this.maxDimensionValueLength);
|
|
62
|
+
const key = dimKey(checked.ok ? checked.dims : dims);
|
|
63
|
+
const rejectKey = kindPrefix + '\0' + name + '\0' + key;
|
|
64
|
+
if (this.rejected.has(rejectKey)) return null;
|
|
65
|
+
if (!checked.ok) {
|
|
66
|
+
this.rejected.add(rejectKey);
|
|
67
|
+
this.onCardinalityDropped();
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
let byKey = kindMap.get(name);
|
|
71
|
+
if (!byKey) {
|
|
72
|
+
byKey = new Map();
|
|
73
|
+
kindMap.set(name, byKey);
|
|
74
|
+
}
|
|
75
|
+
let series = byKey.get(key);
|
|
76
|
+
if (series) return series;
|
|
77
|
+
if (byKey.size >= this.maxSeriesPerMetric) {
|
|
78
|
+
this.rejected.add(rejectKey);
|
|
79
|
+
this.onCardinalityDropped();
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
series = extra ? extra(checked.dims) : new Ctor(name, checked.dims);
|
|
83
|
+
byKey.set(key, series);
|
|
84
|
+
return series;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
counter(name, dims) {
|
|
88
|
+
const series = this._series(this.countersByName, Counter, name, dims);
|
|
89
|
+
return series || NOOP_COUNTER;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
gauge(name, dims) {
|
|
93
|
+
const series = this._series(this.gaugesByName, Gauge, name, dims);
|
|
94
|
+
return series || NOOP_GAUGE;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
histogram(name, a, b) {
|
|
98
|
+
const { buckets, dims } = parseHistogramArgs(a, b, this.defaultHistogramBuckets);
|
|
99
|
+
const series = this._series(this.histogramsByName, Histogram, name, dims, (resolvedDims) => {
|
|
100
|
+
return new Histogram(name, resolvedDims, buckets, {
|
|
101
|
+
maxDimensionKeys: this.maxDimensionKeys,
|
|
102
|
+
maxDimensionValueLength: this.maxDimensionValueLength
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
if (!series) return NOOP_HISTOGRAM;
|
|
106
|
+
if (!boundsEqual(series.bounds, buckets)) {
|
|
107
|
+
throw new Error(`histogram ${name} buckets cannot change for an existing series`);
|
|
108
|
+
}
|
|
109
|
+
return series;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
timer(name, dims) {
|
|
113
|
+
const histogram = this.histogram(name, dims);
|
|
114
|
+
return startTimer((duration, endDims) => {
|
|
115
|
+
if (endDims) {
|
|
116
|
+
this.histogram(name, { ...(dims || {}), ...endDims }).observe(duration);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
histogram.observe(duration);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
snapshotAndReset() {
|
|
124
|
+
const counters = [];
|
|
125
|
+
for (const byKey of this.countersByName.values()) {
|
|
126
|
+
for (const series of byKey.values()) {
|
|
127
|
+
if (series.value !== 0) {
|
|
128
|
+
counters.push([series.name, series.dims, series.value]);
|
|
129
|
+
series.value = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const gauges = [];
|
|
134
|
+
for (const byKey of this.gaugesByName.values()) {
|
|
135
|
+
for (const series of byKey.values()) {
|
|
136
|
+
if (series.dirty) {
|
|
137
|
+
gauges.push([series.name, series.dims, series.value, series.timestamp]);
|
|
138
|
+
series.dirty = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const histograms = [];
|
|
143
|
+
for (const byKey of this.histogramsByName.values()) {
|
|
144
|
+
for (const series of byKey.values()) {
|
|
145
|
+
if (series.count > 0) {
|
|
146
|
+
histograms.push([series.name, series.dims, series.snapshot()]);
|
|
147
|
+
series.reset();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { counters, gauges, histograms };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
isDirty() {
|
|
155
|
+
for (const byKey of this.countersByName.values()) {
|
|
156
|
+
for (const series of byKey.values()) {
|
|
157
|
+
if (series.value !== 0) return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const byKey of this.gaugesByName.values()) {
|
|
161
|
+
for (const series of byKey.values()) {
|
|
162
|
+
if (series.dirty) return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const byKey of this.histogramsByName.values()) {
|
|
166
|
+
for (const series of byKey.values()) {
|
|
167
|
+
if (series.count > 0) return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|