@wardx/core 0.1.7 → 0.4.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 +24 -18
- package/defaults.json +1 -0
- package/package.json +2 -2
- package/src/WardxCore.js +36 -24
- package/src/config/ConfigStore.js +7 -1
- package/src/config/ExperimentResolver.js +67 -29
- package/src/config/hash.js +7 -1
- package/src/frame/FrameBuilder.js +104 -70
- package/src/index.d.ts +61 -11
- package/src/index.js +9 -0
- package/src/metrics/HyperLogLog.js +106 -0
- package/src/metrics/MetricsRegistry.js +32 -2
- package/src/protocol.js +5 -2
- package/src/settings.js +9 -2
- package/src/trace/wrap.js +11 -0
package/README.md
CHANGED
|
@@ -37,30 +37,31 @@ import { WardxCore, assignVariant, loadSdkDefaults } from '@wardx/core';
|
|
|
37
37
|
| Key | Description |
|
|
38
38
|
| --- | --- |
|
|
39
39
|
| `endpoint` | Sync URL. The core does not use this key. Runtimes use this key. |
|
|
40
|
-
| `projectKey` | Project credential. Runtimes send this key.
|
|
40
|
+
| `projectKey` | Project credential. Runtimes send this key. It is never reused as the subject-hash salt. |
|
|
41
41
|
| `project` | Project name. |
|
|
42
42
|
| `role` | Runtime identity inside the project. Runtimes send this name. The core does not use this key. |
|
|
43
43
|
| `appVersion` | Application version. |
|
|
44
44
|
| `environment` | Environment name, for example `production`. |
|
|
45
|
-
| `privacySalt` |
|
|
45
|
+
| `privacySalt` | Required stable, non-empty, project-specific salt for one-way subject hashes. |
|
|
46
46
|
| `aggregateIntervalMs` | Default `1000`. Interval to snapshot dirty data. |
|
|
47
47
|
| `syncIntervalMs` | Default `15000`. Interval for the runtime sync. |
|
|
48
48
|
| `maxBufferedEvents` | Default `5000`. |
|
|
49
49
|
| `maxBufferedLogs` | Default `2000`. |
|
|
50
|
-
| `maxFrameBytes` | Default `524288`. |
|
|
50
|
+
| `maxFrameBytes` | Default `524288`; must be at least `1024`. |
|
|
51
51
|
| `maxSeriesPerMetric` | Default `1000`. |
|
|
52
52
|
| `maxDimensionKeys` | Default `8`. |
|
|
53
53
|
| `maxDimensionValueLength` | Default `64`. |
|
|
54
|
+
| `experimentStateMaxSubjects` | Default `100000`. Maximum subjects retained for assignment/exposure state in this SDK instance. |
|
|
54
55
|
| `histogramBuckets` | Default `[10, 25, 50, 100, 250, 500, 1000]`. |
|
|
55
56
|
| `tracer` | Optional. Duck-typed local hook with any of `measure`, `event`, `log`, `frame`. The core does not print. Runtimes may also call `sync`. |
|
|
56
57
|
|
|
57
|
-
The defaults live in `defaults.json`. Do not omit a required key. The loader does not add a fallback for a missing key. `tracer` is not a default key. Omit it to keep the measure path unchanged.
|
|
58
|
+
The defaults live in `defaults.json`. Do not omit a required key. The loader does not add a fallback for a missing key. `tracer` is not a default key. Omit it to keep the measure path unchanged. The project key authenticates only the project; `role` is client-selected routing metadata, not authorization. Never put secrets in Remote Config.
|
|
58
59
|
|
|
59
60
|
## Use case 1: Record metrics in a custom runtime
|
|
60
61
|
|
|
61
62
|
**When:** You write a runtime that is not Node.js, or you test the engine without HTTP.
|
|
62
63
|
|
|
63
|
-
**Objective:** Record counters, gauges, histograms, and timers. Then make a frame.
|
|
64
|
+
**Objective:** Record counters, gauges, histograms, distinct estimates, and timers. Then make a frame.
|
|
64
65
|
|
|
65
66
|
```js
|
|
66
67
|
import { WardxCore, loadSdkDefaults } from '@wardx/core';
|
|
@@ -73,7 +74,7 @@ const settings = {
|
|
|
73
74
|
role: 'client',
|
|
74
75
|
appVersion: '0.1.0',
|
|
75
76
|
environment: 'development',
|
|
76
|
-
privacySalt: '
|
|
77
|
+
privacySalt: 'demo-subject-hash-v1'
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
const core = new WardxCore(settings);
|
|
@@ -82,11 +83,12 @@ core.counter('match.completed', { mode: 'ranked' }).inc();
|
|
|
82
83
|
core.counter('coins.awarded').add(25);
|
|
83
84
|
core.gauge('players.online').set(12921);
|
|
84
85
|
core.histogram('request.duration').observe(42);
|
|
86
|
+
core.distinct('shot.traffic.hids', { result: 'violating' }).add(hid);
|
|
85
87
|
|
|
86
88
|
const endTimer = core.timer('matchmaking.duration');
|
|
87
89
|
endTimer({ result: 'success' });
|
|
88
90
|
|
|
89
|
-
const
|
|
91
|
+
const batch = core.snapshotFrame();
|
|
90
92
|
const frames = core.takePendingFrames();
|
|
91
93
|
```
|
|
92
94
|
|
|
@@ -95,7 +97,7 @@ const frames = core.takePendingFrames();
|
|
|
95
97
|
1. Load the SDK defaults.
|
|
96
98
|
2. Add the identity fields.
|
|
97
99
|
3. Construct `WardxCore`.
|
|
98
|
-
4. Call `counter`, `gauge`, `histogram`, or `timer`.
|
|
100
|
+
4. Call `counter`, `gauge`, `histogram`, `distinct`, or `timer`.
|
|
99
101
|
5. Call `snapshotFrame` when you need a frame.
|
|
100
102
|
6. Call `takePendingFrames` to get the pending frames.
|
|
101
103
|
|
|
@@ -114,6 +116,12 @@ core.histogram('coins.award_size').observe(80, { grantId: 'g-80' });
|
|
|
114
116
|
|
|
115
117
|
`timer(name, dims)` starts a timer. The returned function records the duration in milliseconds into a histogram. You can add dimensions when you stop the timer.
|
|
116
118
|
|
|
119
|
+
`distinct(name, dims).add(identifier)` updates a fixed HyperLogLog sketch. The
|
|
120
|
+
engine hashes the identifier as `SHA-256(privacySalt || 0x00 || identifier)` and
|
|
121
|
+
discards it immediately; the frame contains only 512 HLL registers (`p=9`,
|
|
122
|
+
about 4.6% standard error). Keep `privacySalt` stable across workers and time
|
|
123
|
+
windows so equal identifiers map to equal registers.
|
|
124
|
+
|
|
117
125
|
If a series is above `maxSeriesPerMetric`, or a dimension is not valid, the engine returns a no-op object. The engine increments `wardx.internal.cardinality_dropped`.
|
|
118
126
|
|
|
119
127
|
A dimension value must be a string, a number, or a boolean.
|
|
@@ -165,6 +173,7 @@ core.applyConfig(13, {
|
|
|
165
173
|
allocation: 1,
|
|
166
174
|
salt: '3ad8f9',
|
|
167
175
|
primaryMetric: 'message.sent',
|
|
176
|
+
goalMetric: 'message.sent',
|
|
168
177
|
variants: [
|
|
169
178
|
{ key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
|
|
170
179
|
{ key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
|
|
@@ -218,7 +227,7 @@ const experiment = {
|
|
|
218
227
|
const variant = assignVariant(experiment, 'user-1');
|
|
219
228
|
const hash = assignmentHash(experiment.id, 'user-1', experiment.salt);
|
|
220
229
|
const bucket = hashToUnitInterval(hash);
|
|
221
|
-
const hashedSubject = subjectHash('
|
|
230
|
+
const hashedSubject = subjectHash('demo-subject-hash-v1', 'user-1');
|
|
222
231
|
```
|
|
223
232
|
|
|
224
233
|
Hash input:
|
|
@@ -230,7 +239,7 @@ bucket = hash / 2^32
|
|
|
230
239
|
|
|
231
240
|
If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
232
241
|
|
|
233
|
-
`subjectHash` returns
|
|
242
|
+
`subjectHash` returns 64 lowercase hex digits of `SHA-256(UTF8(privacySalt) || 0x00 || UTF8(subjectId))`.
|
|
234
243
|
|
|
235
244
|
## Use case 5: Build a frame for a custom transport
|
|
236
245
|
|
|
@@ -242,8 +251,8 @@ If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
|
242
251
|
import { FrameBuilder, PROTOCOL_VERSION, SDK_NAME, PLATFORM } from '@wardx/core';
|
|
243
252
|
|
|
244
253
|
core.counter('match.completed').inc();
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
254
|
+
const batch = core.snapshotIfDirty();
|
|
255
|
+
if (batch) {
|
|
247
256
|
const frames = core.takePendingFrames();
|
|
248
257
|
const envelope = {
|
|
249
258
|
protocol: PROTOCOL_VERSION,
|
|
@@ -265,12 +274,9 @@ if (fitted) {
|
|
|
265
274
|
|
|
266
275
|
`snapshotIfDirty` returns `null` when there is no new data.
|
|
267
276
|
|
|
268
|
-
|
|
277
|
+
`snapshotFrame` uses `FrameBuilder.splitToMaxBytes`. It measures the serialized UTF-8 JSON, preserves row order within counters, gauges, histograms, events, and logs, and emits as many physical frames as needed with consecutive `seq` values. Every emitted frame is at most `maxFrameBytes`.
|
|
269
278
|
|
|
270
|
-
|
|
271
|
-
2. Events from the end of the buffer.
|
|
272
|
-
3. Application histograms.
|
|
273
|
-
4. Application gauges. Internal gauges stay.
|
|
279
|
+
An individual row that cannot fit in an otherwise empty frame is dropped. The batch reports dropped counts by collection and emits their total as `wardx.internal.frame_rows_dropped`. If even that internal row cannot fit, splitting throws. `maxFrameBytes` must be at least `1024`.
|
|
274
280
|
|
|
275
281
|
Internal series use the prefix `wardx.internal.`.
|
|
276
282
|
|
|
@@ -285,7 +291,7 @@ Internal series use the prefix `wardx.internal.`.
|
|
|
285
291
|
| `fnv1a32`, `assignmentHash`, `hashToUnitInterval`, `subjectHash` | Hash helpers. |
|
|
286
292
|
| `Counter`, `Gauge`, `Histogram`, `MetricsRegistry` | Metric types. |
|
|
287
293
|
| `EventBuffer`, `LogBuffer` | In-memory buffers. |
|
|
288
|
-
| `FrameBuilder` | Builds and
|
|
294
|
+
| `FrameBuilder` | Builds and splits frames to the serialized byte limit. |
|
|
289
295
|
| `resolveSettings`, `loadSdkDefaults`, `nextSyncDelayMs` | Settings helpers. |
|
|
290
296
|
| `PROTOCOL_VERSION`, `SDK_NAME`, `PLATFORM`, `INTERNAL` | Protocol constants. |
|
|
291
297
|
| `ulid` | Identifier helper. |
|
package/defaults.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wardx/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Runtime-agnostic Wardx engine for metrics, events, logs, Remote Config, and experiments.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wardx",
|
|
@@ -41,4 +41,4 @@
|
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
|
43
43
|
}
|
|
44
|
-
}
|
|
44
|
+
}
|
package/src/WardxCore.js
CHANGED
|
@@ -8,9 +8,10 @@ import { InternalMetrics } from './internal/InternalMetrics.js';
|
|
|
8
8
|
import { NOOP_COUNTER } from './metrics/Counter.js';
|
|
9
9
|
import { NOOP_GAUGE } from './metrics/Gauge.js';
|
|
10
10
|
import { NOOP_HISTOGRAM } from './metrics/Histogram.js';
|
|
11
|
+
import { NOOP_DISTINCT } from './metrics/HyperLogLog.js';
|
|
11
12
|
import { startTimer } from './metrics/Timer.js';
|
|
12
13
|
import { emit } from './trace/emit.js';
|
|
13
|
-
import { wrapCounter, wrapGauge, wrapHistogram } from './trace/wrap.js';
|
|
14
|
+
import { wrapCounter, wrapDistinct, wrapGauge, wrapHistogram } from './trace/wrap.js';
|
|
14
15
|
|
|
15
16
|
export class WardxCore {
|
|
16
17
|
constructor(settings) {
|
|
@@ -24,6 +25,7 @@ export class WardxCore {
|
|
|
24
25
|
maxDimensionKeys: settings.maxDimensionKeys,
|
|
25
26
|
maxDimensionValueLength: settings.maxDimensionValueLength,
|
|
26
27
|
defaultHistogramBuckets: settings.histogramBuckets,
|
|
28
|
+
privacySalt: settings.privacySalt,
|
|
27
29
|
onCardinalityDropped: () => {
|
|
28
30
|
this.internal.cardinalityDropped += 1;
|
|
29
31
|
}
|
|
@@ -33,6 +35,7 @@ export class WardxCore {
|
|
|
33
35
|
this.configStore = new ConfigStore();
|
|
34
36
|
this.experiments = new ExperimentResolver({
|
|
35
37
|
privacySalt: settings.privacySalt,
|
|
38
|
+
stateMaxSubjects: settings.experimentStateMaxSubjects,
|
|
36
39
|
onExposure: (payload) => {
|
|
37
40
|
this.event('experiment.exposure', payload);
|
|
38
41
|
}
|
|
@@ -67,6 +70,12 @@ export class WardxCore {
|
|
|
67
70
|
);
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
distinct(name, dims) {
|
|
74
|
+
return this._wrap(this.metrics.distinct(name, dims), NOOP_DISTINCT, (series, noop) =>
|
|
75
|
+
wrapDistinct(this._tracer, series, noop, name, dims)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
timer(name, dims) {
|
|
71
80
|
if (this._tracer === null) return this.metrics.timer(name, dims);
|
|
72
81
|
return startTimer((duration, endDims) => {
|
|
@@ -137,14 +146,12 @@ export class WardxCore {
|
|
|
137
146
|
throw new Error('experiment.goal requires a metric name');
|
|
138
147
|
}
|
|
139
148
|
const subject = this.experiments.hashSubject(subjectId);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
this.configStore.experiments
|
|
143
|
-
);
|
|
149
|
+
const assignment = this.experiments.exposedAssignmentForGoal(subjectId, name);
|
|
150
|
+
if (assignment === null) return;
|
|
144
151
|
const payload = {
|
|
145
152
|
metric: name,
|
|
146
153
|
subject,
|
|
147
|
-
experiments
|
|
154
|
+
experiments: [{ experiment: assignment.experiment, variant: assignment.variant }]
|
|
148
155
|
};
|
|
149
156
|
if (context && context.value !== undefined) payload.value = context.value;
|
|
150
157
|
this.event('experiment.goal', payload);
|
|
@@ -156,6 +163,7 @@ export class WardxCore {
|
|
|
156
163
|
values: config.values,
|
|
157
164
|
experiments: config.experiments
|
|
158
165
|
});
|
|
166
|
+
this.experiments.applySnapshot(this.configStore.experiments);
|
|
159
167
|
this.internal.configVersion = version;
|
|
160
168
|
}
|
|
161
169
|
|
|
@@ -182,7 +190,7 @@ export class WardxCore {
|
|
|
182
190
|
const logs = this.logs.swap();
|
|
183
191
|
const internal = this.internal.snapshotAndReset();
|
|
184
192
|
const frame = FrameBuilder.build({
|
|
185
|
-
seq:
|
|
193
|
+
seq: this.seq + 1,
|
|
186
194
|
from,
|
|
187
195
|
to,
|
|
188
196
|
metrics,
|
|
@@ -190,23 +198,27 @@ export class WardxCore {
|
|
|
190
198
|
logs,
|
|
191
199
|
internal
|
|
192
200
|
});
|
|
193
|
-
const
|
|
194
|
-
this.
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
const batch = FrameBuilder.splitToMaxBytes(frame, this.settings.maxFrameBytes);
|
|
202
|
+
this.seq = batch.frames.at(-1).seq;
|
|
203
|
+
this.pendingFrames.push(...batch.frames);
|
|
204
|
+
for (let i = 0; i < batch.frames.length; i++) {
|
|
205
|
+
const physical = batch.frames[i];
|
|
206
|
+
emit(this._tracer, 'frame', {
|
|
207
|
+
seq: physical.seq,
|
|
208
|
+
from: physical.from,
|
|
209
|
+
to: physical.to,
|
|
210
|
+
counters: physical.metrics.counters.length,
|
|
211
|
+
gauges: physical.metrics.gauges.length,
|
|
212
|
+
histograms: physical.metrics.histograms.length,
|
|
213
|
+
distincts: physical.metrics.distincts?.length || 0,
|
|
214
|
+
events: physical.events.length,
|
|
215
|
+
logs: physical.logs.length,
|
|
216
|
+
droppedLogs: i === 0 ? batch.droppedLogs : 0,
|
|
217
|
+
droppedEvents: i === 0 ? batch.droppedEvents : 0,
|
|
218
|
+
droppedRows: i === 0 ? batch.droppedRows : 0
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return batch;
|
|
210
222
|
}
|
|
211
223
|
|
|
212
224
|
takePendingFrames() {
|
|
@@ -15,9 +15,15 @@ export class ConfigStore {
|
|
|
15
15
|
if (typeof snapshot.version !== 'number' || !Number.isFinite(snapshot.version)) {
|
|
16
16
|
throw new Error('config snapshot version must be a finite number');
|
|
17
17
|
}
|
|
18
|
+
const experiments = Array.isArray(snapshot.experiments) ? snapshot.experiments : [];
|
|
19
|
+
for (const experiment of experiments) {
|
|
20
|
+
if (typeof experiment.goalMetric !== 'string' || experiment.goalMetric.length === 0) {
|
|
21
|
+
throw new Error(`experiment ${experiment.id} requires a non-empty goalMetric`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
18
24
|
this.version = snapshot.version;
|
|
19
25
|
this.values = snapshot.values && typeof snapshot.values === 'object' ? snapshot.values : Object.create(null);
|
|
20
|
-
this.experiments =
|
|
26
|
+
this.experiments = experiments;
|
|
21
27
|
this.experimentsByKey = indexExperimentsByKey(this.experiments);
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { assignmentHash, hashToUnitInterval, subjectHash } from './hash.js';
|
|
2
2
|
|
|
3
|
+
function canonicalize(value) {
|
|
4
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
const canonical = {};
|
|
7
|
+
for (const key of Object.keys(value).sort()) canonical[key] = canonicalize(value[key]);
|
|
8
|
+
return canonical;
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function experimentFingerprint(experiment) {
|
|
14
|
+
return subjectHash('wardx.experiment.snapshot', JSON.stringify(canonicalize(experiment)));
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
export function assignVariant(experiment, subjectId) {
|
|
4
18
|
if (!experiment.enabled) return null;
|
|
5
19
|
const hash = assignmentHash(experiment.id, subjectId, experiment.salt);
|
|
@@ -51,8 +65,9 @@ export class ExperimentResolver {
|
|
|
51
65
|
constructor(options) {
|
|
52
66
|
this.privacySalt = options.privacySalt;
|
|
53
67
|
this.onExposure = options.onExposure;
|
|
54
|
-
this.
|
|
55
|
-
this.
|
|
68
|
+
this.stateMaxSubjects = options.stateMaxSubjects;
|
|
69
|
+
this.stateBySubject = new Map();
|
|
70
|
+
this.activeFingerprints = new Map();
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
hashSubject(subjectId) {
|
|
@@ -60,19 +75,31 @@ export class ExperimentResolver {
|
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
recordAssignment(subjectId, experiment, variant) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
const subject = this.hashSubject(subjectId);
|
|
79
|
+
let state = this.stateBySubject.get(subject);
|
|
80
|
+
if (!state) {
|
|
81
|
+
while (this.stateBySubject.size >= this.stateMaxSubjects) {
|
|
82
|
+
const oldest = this.stateBySubject.keys().next().value;
|
|
83
|
+
this.stateBySubject.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
state = { assignments: new Map() };
|
|
86
|
+
this.stateBySubject.set(subject, state);
|
|
70
87
|
}
|
|
71
|
-
|
|
88
|
+
if (state.assignments.has(experiment.id)) return state.assignments.get(experiment.id);
|
|
89
|
+
const assignment = {
|
|
90
|
+
experiment: experiment.id,
|
|
91
|
+
variant: variant.key,
|
|
92
|
+
goalMetric: experiment.goalMetric,
|
|
93
|
+
fingerprint: experimentFingerprint(experiment),
|
|
94
|
+
exposed: false
|
|
95
|
+
};
|
|
96
|
+
state.assignments.set(experiment.id, assignment);
|
|
97
|
+
return assignment;
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
assignmentsFor(subjectId) {
|
|
75
|
-
|
|
101
|
+
const state = this.stateBySubject.get(this.hashSubject(subjectId));
|
|
102
|
+
return state ? Array.from(state.assignments.values()) : [];
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
resolve(key, remoteValue, subjectId, experimentsByKey) {
|
|
@@ -83,36 +110,47 @@ export class ExperimentResolver {
|
|
|
83
110
|
const variant = assignVariant(experiment, subjectId);
|
|
84
111
|
if (!variant) continue;
|
|
85
112
|
if (!Object.prototype.hasOwnProperty.call(variant.values, key)) continue;
|
|
86
|
-
this.recordAssignment(subjectId, experiment, variant);
|
|
87
|
-
this._expose(
|
|
113
|
+
const assignment = this.recordAssignment(subjectId, experiment, variant);
|
|
114
|
+
this._expose(assignment, subjectId);
|
|
88
115
|
return variant.values[key];
|
|
89
116
|
}
|
|
90
117
|
return remoteValue;
|
|
91
118
|
}
|
|
92
119
|
|
|
93
|
-
_expose(
|
|
120
|
+
_expose(assignment, subjectId) {
|
|
121
|
+
if (assignment.exposed) return;
|
|
122
|
+
assignment.exposed = true;
|
|
94
123
|
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
124
|
this.onExposure({
|
|
99
|
-
experiment: experiment
|
|
100
|
-
variant: variant
|
|
125
|
+
experiment: assignment.experiment,
|
|
126
|
+
variant: assignment.variant,
|
|
101
127
|
subject: hashed
|
|
102
128
|
});
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
131
|
+
exposedAssignmentForGoal(subjectId, goalMetric) {
|
|
132
|
+
const matches = this.assignmentsFor(subjectId).filter(
|
|
133
|
+
(assignment) => assignment.exposed && assignment.goalMetric === goalMetric
|
|
134
|
+
);
|
|
135
|
+
if (matches.length > 1) {
|
|
136
|
+
throw new Error(`goal metric ${goalMetric} matches multiple exposed experiments`);
|
|
137
|
+
}
|
|
138
|
+
return matches[0] || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
applySnapshot(experiments) {
|
|
142
|
+
const active = new Map();
|
|
110
143
|
for (const experiment of experiments) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
if (experiment.enabled) active.set(experiment.id, experimentFingerprint(experiment));
|
|
145
|
+
}
|
|
146
|
+
this.activeFingerprints = active;
|
|
147
|
+
for (const [subject, state] of this.stateBySubject) {
|
|
148
|
+
for (const [experimentId, assignment] of state.assignments) {
|
|
149
|
+
if (active.get(experimentId) !== assignment.fingerprint) {
|
|
150
|
+
state.assignments.delete(experimentId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (state.assignments.size === 0) this.stateBySubject.delete(subject);
|
|
115
154
|
}
|
|
116
|
-
return attached;
|
|
117
155
|
}
|
|
118
156
|
}
|
package/src/config/hash.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
const encoder = new TextEncoder();
|
|
2
4
|
|
|
3
5
|
export const FNV_OFFSET_32 = 0x811c9dc5;
|
|
@@ -23,5 +25,9 @@ export function assignmentHash(experimentId, subjectId, salt) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function subjectHash(projectSalt, subjectId) {
|
|
26
|
-
return (
|
|
28
|
+
return createHash('sha256')
|
|
29
|
+
.update(projectSalt, 'utf8')
|
|
30
|
+
.update('\0', 'utf8')
|
|
31
|
+
.update(subjectId, 'utf8')
|
|
32
|
+
.digest('hex');
|
|
27
33
|
}
|
|
@@ -1,46 +1,30 @@
|
|
|
1
1
|
import { INTERNAL } from '../protocol.js';
|
|
2
|
-
import { LOG_RANK } from '../protocol.js';
|
|
3
2
|
|
|
4
3
|
function measure(frame) {
|
|
5
4
|
const json = JSON.stringify(frame);
|
|
6
5
|
return { json, bytes: Buffer.byteLength(json, 'utf8') };
|
|
7
6
|
}
|
|
8
7
|
|
|
9
|
-
function
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return a - b;
|
|
19
|
-
});
|
|
8
|
+
function emptyFrame(seq, from, to) {
|
|
9
|
+
return {
|
|
10
|
+
seq,
|
|
11
|
+
from,
|
|
12
|
+
to,
|
|
13
|
+
metrics: { counters: [], gauges: [], histograms: [] },
|
|
14
|
+
events: [],
|
|
15
|
+
logs: []
|
|
16
|
+
};
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.');
|
|
19
|
+
function rowCount(frame) {
|
|
20
|
+
return (
|
|
21
|
+
frame.metrics.counters.length +
|
|
22
|
+
frame.metrics.gauges.length +
|
|
23
|
+
frame.metrics.histograms.length +
|
|
24
|
+
(frame.metrics.distincts?.length || 0) +
|
|
25
|
+
frame.events.length +
|
|
26
|
+
frame.logs.length
|
|
27
|
+
);
|
|
44
28
|
}
|
|
45
29
|
|
|
46
30
|
export class FrameBuilder {
|
|
@@ -56,7 +40,8 @@ export class FrameBuilder {
|
|
|
56
40
|
metrics: {
|
|
57
41
|
counters,
|
|
58
42
|
gauges,
|
|
59
|
-
histograms
|
|
43
|
+
histograms,
|
|
44
|
+
...(metrics.distincts?.length > 0 ? { distincts: metrics.distincts.slice() } : {})
|
|
60
45
|
},
|
|
61
46
|
events,
|
|
62
47
|
logs
|
|
@@ -88,45 +73,94 @@ export class FrameBuilder {
|
|
|
88
73
|
}
|
|
89
74
|
}
|
|
90
75
|
|
|
91
|
-
static
|
|
92
|
-
|
|
93
|
-
|
|
76
|
+
static splitToMaxBytes(frame, maxFrameBytes) {
|
|
77
|
+
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1024) {
|
|
78
|
+
throw new Error('maxFrameBytes must be an integer at least 1024');
|
|
79
|
+
}
|
|
80
|
+
const frames = [];
|
|
81
|
+
const jsons = [];
|
|
82
|
+
const dropped = {
|
|
83
|
+
counters: 0,
|
|
84
|
+
gauges: 0,
|
|
85
|
+
histograms: 0,
|
|
86
|
+
distincts: 0,
|
|
87
|
+
events: 0,
|
|
88
|
+
logs: 0
|
|
89
|
+
};
|
|
90
|
+
let current = emptyFrame(frame.seq, frame.from, frame.to);
|
|
94
91
|
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
const finishCurrent = () => {
|
|
93
|
+
const measured = measure(current);
|
|
94
|
+
if (measured.bytes > maxFrameBytes) {
|
|
95
|
+
throw new Error('frame splitter produced an oversized frame');
|
|
96
|
+
}
|
|
97
|
+
frames.push(current);
|
|
98
|
+
jsons.push(measured.json);
|
|
99
|
+
current = emptyFrame(frame.seq + frames.length, frame.from, frame.to);
|
|
100
|
+
};
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
const addRow = (collection, row, countDrop = true) => {
|
|
103
|
+
collection(current).push(row);
|
|
104
|
+
if (measure(current).bytes <= maxFrameBytes) return true;
|
|
105
|
+
collection(current).pop();
|
|
106
|
+
if (collection.kind === 'distincts' && current.metrics.distincts.length === 0) {
|
|
107
|
+
delete current.metrics.distincts;
|
|
108
|
+
}
|
|
109
|
+
if (rowCount(current) > 0) {
|
|
110
|
+
finishCurrent();
|
|
111
|
+
collection(current).push(row);
|
|
112
|
+
if (measure(current).bytes <= maxFrameBytes) return true;
|
|
113
|
+
collection(current).pop();
|
|
114
|
+
if (collection.kind === 'distincts' && current.metrics.distincts.length === 0) {
|
|
115
|
+
delete current.metrics.distincts;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (countDrop) dropped[collection.kind] += 1;
|
|
119
|
+
return false;
|
|
120
|
+
};
|
|
109
121
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const counters = (candidate) => candidate.metrics.counters;
|
|
123
|
+
counters.kind = 'counters';
|
|
124
|
+
const gauges = (candidate) => candidate.metrics.gauges;
|
|
125
|
+
gauges.kind = 'gauges';
|
|
126
|
+
const histograms = (candidate) => candidate.metrics.histograms;
|
|
127
|
+
histograms.kind = 'histograms';
|
|
128
|
+
const distincts = (candidate) => {
|
|
129
|
+
if (!candidate.metrics.distincts) candidate.metrics.distincts = [];
|
|
130
|
+
return candidate.metrics.distincts;
|
|
131
|
+
};
|
|
132
|
+
distincts.kind = 'distincts';
|
|
133
|
+
const events = (candidate) => candidate.events;
|
|
134
|
+
events.kind = 'events';
|
|
135
|
+
const logs = (candidate) => candidate.logs;
|
|
136
|
+
logs.kind = 'logs';
|
|
121
137
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
for (const row of frame.metrics.counters) addRow(counters, row);
|
|
139
|
+
for (const row of frame.metrics.gauges) addRow(gauges, row);
|
|
140
|
+
for (const row of frame.metrics.histograms) addRow(histograms, row);
|
|
141
|
+
for (const row of frame.metrics.distincts || []) addRow(distincts, row);
|
|
142
|
+
for (const row of frame.events) addRow(events, row);
|
|
143
|
+
for (const row of frame.logs) addRow(logs, row);
|
|
144
|
+
|
|
145
|
+
const droppedRows = Object.values(dropped).reduce((sum, value) => sum + value, 0);
|
|
146
|
+
if (
|
|
147
|
+
droppedRows > 0 &&
|
|
148
|
+
!addRow(counters, [INTERNAL.frameRowsDropped, null, droppedRows], false)
|
|
149
|
+
) {
|
|
150
|
+
throw new Error('maxFrameBytes cannot contain the frame drop metric');
|
|
129
151
|
}
|
|
130
|
-
|
|
152
|
+
if (frames.length === 0 || rowCount(current) > 0) finishCurrent();
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
frames,
|
|
156
|
+
jsons,
|
|
157
|
+
droppedRows,
|
|
158
|
+
droppedCounters: dropped.counters,
|
|
159
|
+
droppedGauges: dropped.gauges,
|
|
160
|
+
droppedHistograms: dropped.histograms,
|
|
161
|
+
droppedDistincts: dropped.distincts,
|
|
162
|
+
droppedEvents: dropped.events,
|
|
163
|
+
droppedLogs: dropped.logs
|
|
164
|
+
};
|
|
131
165
|
}
|
|
132
166
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const INTERNAL: {
|
|
|
15
15
|
readonly lastSyncMs: 'wardx.internal.last_sync_ms';
|
|
16
16
|
readonly configVersion: 'wardx.internal.config_version';
|
|
17
17
|
readonly processRssBytes: 'wardx.internal.process_rss_bytes';
|
|
18
|
+
readonly frameRowsDropped: 'wardx.internal.frame_rows_dropped';
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
@@ -43,6 +44,10 @@ export interface HistogramHandle {
|
|
|
43
44
|
observe(value: number, attrs?: Dimensions | null): void;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export interface DistinctHandle {
|
|
48
|
+
add(identifier: string): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export type StopTimer = (dims?: Dimensions | null) => void;
|
|
47
52
|
|
|
48
53
|
export interface SdkDefaults {
|
|
@@ -56,6 +61,7 @@ export interface SdkDefaults {
|
|
|
56
61
|
maxSeriesPerMetric: number;
|
|
57
62
|
maxDimensionKeys: number;
|
|
58
63
|
maxDimensionValueLength: number;
|
|
64
|
+
experimentStateMaxSubjects: number;
|
|
59
65
|
httpTimeoutMs: number;
|
|
60
66
|
histogramBuckets: number[];
|
|
61
67
|
}
|
|
@@ -67,7 +73,7 @@ export interface CreateWardxOptions extends Partial<SdkDefaults> {
|
|
|
67
73
|
role: string;
|
|
68
74
|
appVersion: string;
|
|
69
75
|
environment: string;
|
|
70
|
-
privacySalt
|
|
76
|
+
privacySalt: string;
|
|
71
77
|
tracer?: Tracer | null;
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -112,6 +118,7 @@ export interface Experiment {
|
|
|
112
118
|
enabled: boolean;
|
|
113
119
|
allocation: number;
|
|
114
120
|
salt: string;
|
|
121
|
+
goalMetric: string;
|
|
115
122
|
primaryMetric?: string;
|
|
116
123
|
variants: ExperimentVariant[];
|
|
117
124
|
}
|
|
@@ -137,6 +144,12 @@ export type LogRow = [timestamp: number, level: LogLevel, message: string, attrs
|
|
|
137
144
|
export type CounterRow = [name: string, dims: Dimensions | null, value: number];
|
|
138
145
|
export type GaugeRow = [name: string, dims: Dimensions | null, value: number, timestamp: number];
|
|
139
146
|
export type HistogramRow = [name: string, dims: Dimensions | null, snapshot: HistogramSnapshot];
|
|
147
|
+
export type DistinctRow = [name: string, dims: Dimensions | null, sketch: HllSketch];
|
|
148
|
+
|
|
149
|
+
export interface HllSketch {
|
|
150
|
+
precision: 9;
|
|
151
|
+
registers: string;
|
|
152
|
+
}
|
|
140
153
|
|
|
141
154
|
export interface HistogramExemplar {
|
|
142
155
|
value: number;
|
|
@@ -156,6 +169,7 @@ export interface MetricsSnapshot {
|
|
|
156
169
|
counters: CounterRow[];
|
|
157
170
|
gauges: GaugeRow[];
|
|
158
171
|
histograms: HistogramRow[];
|
|
172
|
+
distincts?: DistinctRow[];
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
export interface Frame {
|
|
@@ -167,9 +181,14 @@ export interface Frame {
|
|
|
167
181
|
logs: LogRow[];
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
export interface
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
export interface FrameBatch {
|
|
185
|
+
frames: Frame[];
|
|
186
|
+
jsons: string[];
|
|
187
|
+
droppedRows: number;
|
|
188
|
+
droppedCounters: number;
|
|
189
|
+
droppedGauges: number;
|
|
190
|
+
droppedHistograms: number;
|
|
191
|
+
droppedDistincts: number;
|
|
173
192
|
droppedLogs: number;
|
|
174
193
|
droppedEvents: number;
|
|
175
194
|
}
|
|
@@ -190,7 +209,7 @@ export interface InternalSnapshot {
|
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
export interface MeasureTraceRecord {
|
|
193
|
-
type: 'counter' | 'gauge' | 'histogram';
|
|
212
|
+
type: 'counter' | 'gauge' | 'histogram' | 'distinct';
|
|
194
213
|
name: string;
|
|
195
214
|
dims: Dimensions | null;
|
|
196
215
|
op: 'inc' | 'add' | 'set' | 'observe';
|
|
@@ -219,10 +238,12 @@ export interface FrameTraceRecord {
|
|
|
219
238
|
counters: number;
|
|
220
239
|
gauges: number;
|
|
221
240
|
histograms: number;
|
|
241
|
+
distincts: number;
|
|
222
242
|
events: number;
|
|
223
243
|
logs: number;
|
|
224
244
|
droppedLogs: number;
|
|
225
245
|
droppedEvents: number;
|
|
246
|
+
droppedRows: number;
|
|
226
247
|
}
|
|
227
248
|
|
|
228
249
|
export interface SyncTraceRecord {
|
|
@@ -299,11 +320,31 @@ export class Histogram implements HistogramHandle {
|
|
|
299
320
|
reset(): void;
|
|
300
321
|
}
|
|
301
322
|
|
|
323
|
+
export class HyperLogLog implements DistinctHandle {
|
|
324
|
+
name: string;
|
|
325
|
+
dims: Dimensions | null;
|
|
326
|
+
privacySalt: string;
|
|
327
|
+
registers: Uint8Array;
|
|
328
|
+
dirty: boolean;
|
|
329
|
+
constructor(name: string, dims: Dimensions | null, privacySalt: string);
|
|
330
|
+
add(identifier: string): void;
|
|
331
|
+
snapshot(): HllSketch;
|
|
332
|
+
reset(): void;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const HLL_PRECISION: 9;
|
|
336
|
+
export function encodeHllRegisters(registers: Uint8Array): string;
|
|
337
|
+
export function decodeHllRegisters(sketch: HllSketch): Uint8Array;
|
|
338
|
+
export function estimateHllRegisters(registers: Uint8Array): number;
|
|
339
|
+
export function estimateHyperLogLog(sketch: HllSketch): number;
|
|
340
|
+
export function mergeHyperLogLog(left: HllSketch, right: HllSketch): HllSketch;
|
|
341
|
+
|
|
302
342
|
export interface MetricsRegistryOptions {
|
|
303
343
|
maxSeriesPerMetric: number;
|
|
304
344
|
maxDimensionKeys: number;
|
|
305
345
|
maxDimensionValueLength: number;
|
|
306
346
|
defaultHistogramBuckets: number[];
|
|
347
|
+
privacySalt?: string;
|
|
307
348
|
onCardinalityDropped: () => void;
|
|
308
349
|
}
|
|
309
350
|
|
|
@@ -312,6 +353,7 @@ export class MetricsRegistry {
|
|
|
312
353
|
counter(name: string, dims?: Dimensions | null): CounterHandle;
|
|
313
354
|
gauge(name: string, dims?: Dimensions | null): GaugeHandle;
|
|
314
355
|
histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
|
|
356
|
+
distinct(name: string, dims?: Dimensions | null): DistinctHandle;
|
|
315
357
|
timer(name: string, dims?: Dimensions | null): StopTimer;
|
|
316
358
|
snapshotAndReset(): MetricsSnapshot;
|
|
317
359
|
isDirty(): boolean;
|
|
@@ -344,9 +386,15 @@ export class ConfigStore {
|
|
|
344
386
|
}
|
|
345
387
|
|
|
346
388
|
export class ExperimentResolver {
|
|
347
|
-
|
|
389
|
+
stateMaxSubjects: number;
|
|
390
|
+
stateBySubject: Map<string, unknown>;
|
|
391
|
+
constructor(options: {
|
|
392
|
+
privacySalt: string;
|
|
393
|
+
stateMaxSubjects: number;
|
|
394
|
+
onExposure: (payload: ExposurePayload) => void;
|
|
395
|
+
});
|
|
348
396
|
hashSubject(subjectId: string): string;
|
|
349
|
-
recordAssignment(subjectId: string, experiment: Experiment, variant: ExperimentVariant):
|
|
397
|
+
recordAssignment(subjectId: string, experiment: Experiment, variant: ExperimentVariant): Assignment;
|
|
350
398
|
assignmentsFor(subjectId: string): Assignment[];
|
|
351
399
|
resolve(
|
|
352
400
|
key: string,
|
|
@@ -354,7 +402,8 @@ export class ExperimentResolver {
|
|
|
354
402
|
subjectId: string | null | undefined,
|
|
355
403
|
experimentsByKey: Map<string, Experiment[]>
|
|
356
404
|
): ConfigValue;
|
|
357
|
-
|
|
405
|
+
exposedAssignmentForGoal(subjectId: string, goalMetric: string): Assignment | null;
|
|
406
|
+
applySnapshot(experiments: Experiment[]): void;
|
|
358
407
|
}
|
|
359
408
|
|
|
360
409
|
export class FrameBuilder {
|
|
@@ -368,7 +417,7 @@ export class FrameBuilder {
|
|
|
368
417
|
internal: InternalSnapshot;
|
|
369
418
|
}): Frame;
|
|
370
419
|
static mergeInternal(counters: CounterRow[], gauges: GaugeRow[], internal: InternalSnapshot): void;
|
|
371
|
-
static
|
|
420
|
+
static splitToMaxBytes(frame: Frame, maxFrameBytes: number): FrameBatch;
|
|
372
421
|
}
|
|
373
422
|
|
|
374
423
|
export class WardxCore {
|
|
@@ -388,14 +437,15 @@ export class WardxCore {
|
|
|
388
437
|
counter(name: string, dims?: Dimensions | null): CounterHandle;
|
|
389
438
|
gauge(name: string, dims?: Dimensions | null): GaugeHandle;
|
|
390
439
|
histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
|
|
440
|
+
distinct(name: string, dims?: Dimensions | null): DistinctHandle;
|
|
391
441
|
timer(name: string, dims?: Dimensions | null): StopTimer;
|
|
392
442
|
event(name: string, attrs?: Attrs | null): void;
|
|
393
443
|
identify(subjectId: string | null | undefined): void;
|
|
394
444
|
configGet<T>(key: string, fallback: T, context?: SubjectContext): T;
|
|
395
445
|
experimentGoal(name: string, context?: ExperimentGoalContext): void;
|
|
396
446
|
applyConfig(version: number, config: ConfigSnapshot): void;
|
|
397
|
-
snapshotIfDirty():
|
|
398
|
-
snapshotFrame():
|
|
447
|
+
snapshotIfDirty(): FrameBatch | null;
|
|
448
|
+
snapshotFrame(): FrameBatch;
|
|
399
449
|
takePendingFrames(): Frame[];
|
|
400
450
|
}
|
|
401
451
|
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,15 @@ export { ConfigStore } from './config/ConfigStore.js';
|
|
|
5
5
|
export { Counter } from './metrics/Counter.js';
|
|
6
6
|
export { Gauge } from './metrics/Gauge.js';
|
|
7
7
|
export { Histogram } from './metrics/Histogram.js';
|
|
8
|
+
export {
|
|
9
|
+
HLL_PRECISION,
|
|
10
|
+
HyperLogLog,
|
|
11
|
+
decodeHllRegisters,
|
|
12
|
+
encodeHllRegisters,
|
|
13
|
+
estimateHllRegisters,
|
|
14
|
+
estimateHyperLogLog,
|
|
15
|
+
mergeHyperLogLog
|
|
16
|
+
} from './metrics/HyperLogLog.js';
|
|
8
17
|
export { MetricsRegistry } from './metrics/MetricsRegistry.js';
|
|
9
18
|
export { EventBuffer } from './buffers/EventBuffer.js';
|
|
10
19
|
export { LogBuffer } from './buffers/LogBuffer.js';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const HLL_PRECISION = 9;
|
|
4
|
+
export const HLL_REGISTER_COUNT = 1 << HLL_PRECISION;
|
|
5
|
+
export const HLL_MAX_RANK = 64 - HLL_PRECISION + 1;
|
|
6
|
+
|
|
7
|
+
function assertRegisters(registers) {
|
|
8
|
+
if (!(registers instanceof Uint8Array) || registers.length !== HLL_REGISTER_COUNT) {
|
|
9
|
+
throw new Error(`HLL registers must contain exactly ${HLL_REGISTER_COUNT} bytes`);
|
|
10
|
+
}
|
|
11
|
+
for (const rank of registers) {
|
|
12
|
+
if (rank > HLL_MAX_RANK) throw new Error(`HLL register rank must be <= ${HLL_MAX_RANK}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rankAfterIndex(digest) {
|
|
17
|
+
let rank = 1;
|
|
18
|
+
for (let bit = HLL_PRECISION; bit < 64; bit++) {
|
|
19
|
+
if ((digest[bit >> 3] & (1 << (7 - (bit & 7)))) !== 0) return rank;
|
|
20
|
+
rank += 1;
|
|
21
|
+
}
|
|
22
|
+
return rank;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function encodeHllRegisters(registers) {
|
|
26
|
+
assertRegisters(registers);
|
|
27
|
+
return Buffer.from(registers).toString('base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function decodeHllRegisters(body) {
|
|
31
|
+
if (!body || body.precision !== HLL_PRECISION || typeof body.registers !== 'string') {
|
|
32
|
+
throw new Error(`HLL sketch must use precision ${HLL_PRECISION}`);
|
|
33
|
+
}
|
|
34
|
+
const decoded = Buffer.from(body.registers, 'base64');
|
|
35
|
+
if (decoded.toString('base64') !== body.registers) throw new Error('HLL registers must be canonical base64');
|
|
36
|
+
const registers = Uint8Array.from(decoded);
|
|
37
|
+
assertRegisters(registers);
|
|
38
|
+
return registers;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function estimateHllRegisters(registers) {
|
|
42
|
+
assertRegisters(registers);
|
|
43
|
+
let harmonic = 0;
|
|
44
|
+
let zeros = 0;
|
|
45
|
+
for (const rank of registers) {
|
|
46
|
+
harmonic += 2 ** -rank;
|
|
47
|
+
if (rank === 0) zeros += 1;
|
|
48
|
+
}
|
|
49
|
+
const m = HLL_REGISTER_COUNT;
|
|
50
|
+
const alpha = 0.7213 / (1 + 1.079 / m);
|
|
51
|
+
const raw = alpha * m * m / harmonic;
|
|
52
|
+
const corrected = raw <= 2.5 * m && zeros > 0 ? m * Math.log(m / zeros) : raw;
|
|
53
|
+
return Math.round(corrected);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function estimateHyperLogLog(body) {
|
|
57
|
+
return estimateHllRegisters(decodeHllRegisters(body));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mergeHyperLogLog(left, right) {
|
|
61
|
+
const leftRegisters = decodeHllRegisters(left);
|
|
62
|
+
const rightRegisters = decodeHllRegisters(right);
|
|
63
|
+
for (let index = 0; index < leftRegisters.length; index++) {
|
|
64
|
+
if (rightRegisters[index] > leftRegisters[index]) leftRegisters[index] = rightRegisters[index];
|
|
65
|
+
}
|
|
66
|
+
return { precision: HLL_PRECISION, registers: encodeHllRegisters(leftRegisters) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class HyperLogLog {
|
|
70
|
+
constructor(name, dims, privacySalt) {
|
|
71
|
+
if (typeof privacySalt !== 'string' || privacySalt.length === 0) {
|
|
72
|
+
throw new Error('distinct requires a non-empty privacySalt');
|
|
73
|
+
}
|
|
74
|
+
this.name = name;
|
|
75
|
+
this.dims = dims;
|
|
76
|
+
this.privacySalt = privacySalt;
|
|
77
|
+
this.registers = new Uint8Array(HLL_REGISTER_COUNT);
|
|
78
|
+
this.dirty = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
add(identifier) {
|
|
82
|
+
if (typeof identifier !== 'string' || identifier.length === 0) {
|
|
83
|
+
throw new Error('distinct.add requires a non-empty string');
|
|
84
|
+
}
|
|
85
|
+
const digest = createHash('sha256')
|
|
86
|
+
.update(this.privacySalt, 'utf8')
|
|
87
|
+
.update('\0', 'utf8')
|
|
88
|
+
.update(identifier, 'utf8')
|
|
89
|
+
.digest();
|
|
90
|
+
const index = (digest[0] << 1) | (digest[1] >> 7);
|
|
91
|
+
const rank = rankAfterIndex(digest);
|
|
92
|
+
if (rank > this.registers[index]) this.registers[index] = rank;
|
|
93
|
+
this.dirty = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
snapshot() {
|
|
97
|
+
return { precision: HLL_PRECISION, registers: encodeHllRegisters(this.registers) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
reset() {
|
|
101
|
+
this.registers.fill(0);
|
|
102
|
+
this.dirty = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const NOOP_DISTINCT = Object.freeze({ add() {} });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Counter, NOOP_COUNTER } from './Counter.js';
|
|
2
2
|
import { Gauge, NOOP_GAUGE } from './Gauge.js';
|
|
3
3
|
import { Histogram, NOOP_HISTOGRAM } from './Histogram.js';
|
|
4
|
+
import { HyperLogLog, NOOP_DISTINCT } from './HyperLogLog.js';
|
|
4
5
|
import { startTimer } from './Timer.js';
|
|
5
6
|
import { assertMetricName, dimKey, validateDimensions } from './dimensions.js';
|
|
6
7
|
|
|
@@ -48,16 +49,24 @@ export class MetricsRegistry {
|
|
|
48
49
|
this.maxDimensionKeys = options.maxDimensionKeys;
|
|
49
50
|
this.maxDimensionValueLength = options.maxDimensionValueLength;
|
|
50
51
|
this.defaultHistogramBuckets = options.defaultHistogramBuckets;
|
|
52
|
+
this.privacySalt = options.privacySalt;
|
|
51
53
|
this.onCardinalityDropped = options.onCardinalityDropped;
|
|
52
54
|
this.countersByName = new Map();
|
|
53
55
|
this.gaugesByName = new Map();
|
|
54
56
|
this.histogramsByName = new Map();
|
|
57
|
+
this.distinctsByName = new Map();
|
|
55
58
|
this.rejected = new Set();
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
_series(kindMap, Ctor, name, dims, extra) {
|
|
59
62
|
assertMetricName(name);
|
|
60
|
-
const kindPrefix = kindMap === this.histogramsByName
|
|
63
|
+
const kindPrefix = kindMap === this.histogramsByName
|
|
64
|
+
? 'h'
|
|
65
|
+
: kindMap === this.gaugesByName
|
|
66
|
+
? 'g'
|
|
67
|
+
: kindMap === this.distinctsByName
|
|
68
|
+
? 'd'
|
|
69
|
+
: 'c';
|
|
61
70
|
const checked = validateDimensions(dims, this.maxDimensionKeys, this.maxDimensionValueLength);
|
|
62
71
|
const key = dimKey(checked.ok ? checked.dims : dims);
|
|
63
72
|
const rejectKey = kindPrefix + '\0' + name + '\0' + key;
|
|
@@ -109,6 +118,13 @@ export class MetricsRegistry {
|
|
|
109
118
|
return series;
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
distinct(name, dims) {
|
|
122
|
+
const series = this._series(this.distinctsByName, HyperLogLog, name, dims, (resolvedDims) => {
|
|
123
|
+
return new HyperLogLog(name, resolvedDims, this.privacySalt);
|
|
124
|
+
});
|
|
125
|
+
return series || NOOP_DISTINCT;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
timer(name, dims) {
|
|
113
129
|
const histogram = this.histogram(name, dims);
|
|
114
130
|
return startTimer((duration, endDims) => {
|
|
@@ -148,7 +164,16 @@ export class MetricsRegistry {
|
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
}
|
|
151
|
-
|
|
167
|
+
const distincts = [];
|
|
168
|
+
for (const byKey of this.distinctsByName.values()) {
|
|
169
|
+
for (const series of byKey.values()) {
|
|
170
|
+
if (series.dirty) {
|
|
171
|
+
distincts.push([series.name, series.dims, series.snapshot()]);
|
|
172
|
+
series.reset();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { counters, gauges, histograms, distincts };
|
|
152
177
|
}
|
|
153
178
|
|
|
154
179
|
isDirty() {
|
|
@@ -167,6 +192,11 @@ export class MetricsRegistry {
|
|
|
167
192
|
if (series.count > 0) return true;
|
|
168
193
|
}
|
|
169
194
|
}
|
|
195
|
+
for (const byKey of this.distinctsByName.values()) {
|
|
196
|
+
for (const series of byKey.values()) {
|
|
197
|
+
if (series.dirty) return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
170
200
|
return false;
|
|
171
201
|
}
|
|
172
202
|
}
|
package/src/protocol.js
CHANGED
|
@@ -14,7 +14,8 @@ export const INTERNAL = {
|
|
|
14
14
|
bytesCompressed: 'wardx.internal.bytes_compressed',
|
|
15
15
|
lastSyncMs: 'wardx.internal.last_sync_ms',
|
|
16
16
|
configVersion: 'wardx.internal.config_version',
|
|
17
|
-
processRssBytes: 'wardx.internal.process_rss_bytes'
|
|
17
|
+
processRssBytes: 'wardx.internal.process_rss_bytes',
|
|
18
|
+
frameRowsDropped: 'wardx.internal.frame_rows_dropped'
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
@@ -34,7 +35,8 @@ export const REQUIRED_CREATE_KEYS = [
|
|
|
34
35
|
'project',
|
|
35
36
|
'role',
|
|
36
37
|
'appVersion',
|
|
37
|
-
'environment'
|
|
38
|
+
'environment',
|
|
39
|
+
'privacySalt'
|
|
38
40
|
];
|
|
39
41
|
|
|
40
42
|
export const REQUIRED_SDK_DEFAULT_KEYS = [
|
|
@@ -48,6 +50,7 @@ export const REQUIRED_SDK_DEFAULT_KEYS = [
|
|
|
48
50
|
'maxSeriesPerMetric',
|
|
49
51
|
'maxDimensionKeys',
|
|
50
52
|
'maxDimensionValueLength',
|
|
53
|
+
'experimentStateMaxSubjects',
|
|
51
54
|
'httpTimeoutMs',
|
|
52
55
|
'histogramBuckets'
|
|
53
56
|
];
|
package/src/settings.js
CHANGED
|
@@ -57,8 +57,8 @@ export function resolveSettings(options) {
|
|
|
57
57
|
if (settings.role === '*') {
|
|
58
58
|
throw new Error('role cannot be *');
|
|
59
59
|
}
|
|
60
|
-
if (settings.privacySalt
|
|
61
|
-
|
|
60
|
+
if (typeof settings.privacySalt !== 'string' || settings.privacySalt.length === 0) {
|
|
61
|
+
throw new Error('privacySalt must be a non-empty string');
|
|
62
62
|
}
|
|
63
63
|
assertPositiveNumber(settings, 'aggregateIntervalMs');
|
|
64
64
|
assertPositiveNumber(settings, 'syncIntervalMs');
|
|
@@ -68,6 +68,13 @@ export function resolveSettings(options) {
|
|
|
68
68
|
assertPositiveNumber(settings, 'maxSeriesPerMetric');
|
|
69
69
|
assertPositiveNumber(settings, 'maxDimensionKeys');
|
|
70
70
|
assertPositiveNumber(settings, 'maxDimensionValueLength');
|
|
71
|
+
assertPositiveNumber(settings, 'experimentStateMaxSubjects');
|
|
72
|
+
if (!Number.isInteger(settings.experimentStateMaxSubjects)) {
|
|
73
|
+
throw new Error('experimentStateMaxSubjects must be an integer');
|
|
74
|
+
}
|
|
75
|
+
if (settings.maxFrameBytes < 1024) {
|
|
76
|
+
throw new Error('maxFrameBytes must be at least 1024');
|
|
77
|
+
}
|
|
71
78
|
assertPositiveNumber(settings, 'httpTimeoutMs');
|
|
72
79
|
assertNumberInRange(settings, 'syncJitterMin', 0, 1);
|
|
73
80
|
assertNumberInRange(settings, 'syncJitterMax', 1, 2);
|
package/src/trace/wrap.js
CHANGED
|
@@ -44,3 +44,14 @@ export function wrapHistogram(tracer, series, noop, name, dims) {
|
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
export function wrapDistinct(tracer, series, noop, name, dims) {
|
|
49
|
+
const n = noop ? name : series.name;
|
|
50
|
+
const d = noop ? (dims ?? null) : series.dims;
|
|
51
|
+
return {
|
|
52
|
+
add(identifier) {
|
|
53
|
+
series.add(identifier);
|
|
54
|
+
emit(tracer, 'measure', { type: 'distinct', name: n, dims: d, op: 'add', value: 1, noop });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|