@wardx/core 0.1.6 → 0.2.2
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 +15 -16
- package/defaults.json +1 -0
- package/package.json +2 -2
- package/src/WardxCore.js +26 -23
- 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 +87 -69
- package/src/index.d.ts +25 -10
- package/src/protocol.js +5 -2
- package/src/settings.js +9 -2
package/README.md
CHANGED
|
@@ -37,24 +37,25 @@ 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
|
|
|
@@ -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);
|
|
@@ -86,7 +87,7 @@ core.histogram('request.duration').observe(42);
|
|
|
86
87
|
const endTimer = core.timer('matchmaking.duration');
|
|
87
88
|
endTimer({ result: 'success' });
|
|
88
89
|
|
|
89
|
-
const
|
|
90
|
+
const batch = core.snapshotFrame();
|
|
90
91
|
const frames = core.takePendingFrames();
|
|
91
92
|
```
|
|
92
93
|
|
|
@@ -165,6 +166,7 @@ core.applyConfig(13, {
|
|
|
165
166
|
allocation: 1,
|
|
166
167
|
salt: '3ad8f9',
|
|
167
168
|
primaryMetric: 'message.sent',
|
|
169
|
+
goalMetric: 'message.sent',
|
|
168
170
|
variants: [
|
|
169
171
|
{ key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
|
|
170
172
|
{ key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
|
|
@@ -218,7 +220,7 @@ const experiment = {
|
|
|
218
220
|
const variant = assignVariant(experiment, 'user-1');
|
|
219
221
|
const hash = assignmentHash(experiment.id, 'user-1', experiment.salt);
|
|
220
222
|
const bucket = hashToUnitInterval(hash);
|
|
221
|
-
const hashedSubject = subjectHash('
|
|
223
|
+
const hashedSubject = subjectHash('demo-subject-hash-v1', 'user-1');
|
|
222
224
|
```
|
|
223
225
|
|
|
224
226
|
Hash input:
|
|
@@ -230,7 +232,7 @@ bucket = hash / 2^32
|
|
|
230
232
|
|
|
231
233
|
If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
232
234
|
|
|
233
|
-
`subjectHash` returns
|
|
235
|
+
`subjectHash` returns 64 lowercase hex digits of `SHA-256(UTF8(privacySalt) || 0x00 || UTF8(subjectId))`.
|
|
234
236
|
|
|
235
237
|
## Use case 5: Build a frame for a custom transport
|
|
236
238
|
|
|
@@ -242,8 +244,8 @@ If `bucket >= allocation`, `assignVariant` returns `null`.
|
|
|
242
244
|
import { FrameBuilder, PROTOCOL_VERSION, SDK_NAME, PLATFORM } from '@wardx/core';
|
|
243
245
|
|
|
244
246
|
core.counter('match.completed').inc();
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
+
const batch = core.snapshotIfDirty();
|
|
248
|
+
if (batch) {
|
|
247
249
|
const frames = core.takePendingFrames();
|
|
248
250
|
const envelope = {
|
|
249
251
|
protocol: PROTOCOL_VERSION,
|
|
@@ -265,12 +267,9 @@ if (fitted) {
|
|
|
265
267
|
|
|
266
268
|
`snapshotIfDirty` returns `null` when there is no new data.
|
|
267
269
|
|
|
268
|
-
|
|
270
|
+
`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
271
|
|
|
270
|
-
|
|
271
|
-
2. Events from the end of the buffer.
|
|
272
|
-
3. Application histograms.
|
|
273
|
-
4. Application gauges. Internal gauges stay.
|
|
272
|
+
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
273
|
|
|
275
274
|
Internal series use the prefix `wardx.internal.`.
|
|
276
275
|
|
|
@@ -285,7 +284,7 @@ Internal series use the prefix `wardx.internal.`.
|
|
|
285
284
|
| `fnv1a32`, `assignmentHash`, `hashToUnitInterval`, `subjectHash` | Hash helpers. |
|
|
286
285
|
| `Counter`, `Gauge`, `Histogram`, `MetricsRegistry` | Metric types. |
|
|
287
286
|
| `EventBuffer`, `LogBuffer` | In-memory buffers. |
|
|
288
|
-
| `FrameBuilder` | Builds and
|
|
287
|
+
| `FrameBuilder` | Builds and splits frames to the serialized byte limit. |
|
|
289
288
|
| `resolveSettings`, `loadSdkDefaults`, `nextSyncDelayMs` | Settings helpers. |
|
|
290
289
|
| `PROTOCOL_VERSION`, `SDK_NAME`, `PLATFORM`, `INTERNAL` | Protocol constants. |
|
|
291
290
|
| `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.2.2",
|
|
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
|
@@ -33,6 +33,7 @@ export class WardxCore {
|
|
|
33
33
|
this.configStore = new ConfigStore();
|
|
34
34
|
this.experiments = new ExperimentResolver({
|
|
35
35
|
privacySalt: settings.privacySalt,
|
|
36
|
+
stateMaxSubjects: settings.experimentStateMaxSubjects,
|
|
36
37
|
onExposure: (payload) => {
|
|
37
38
|
this.event('experiment.exposure', payload);
|
|
38
39
|
}
|
|
@@ -137,14 +138,12 @@ export class WardxCore {
|
|
|
137
138
|
throw new Error('experiment.goal requires a metric name');
|
|
138
139
|
}
|
|
139
140
|
const subject = this.experiments.hashSubject(subjectId);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
this.configStore.experiments
|
|
143
|
-
);
|
|
141
|
+
const assignment = this.experiments.exposedAssignmentForGoal(subjectId, name);
|
|
142
|
+
if (assignment === null) return;
|
|
144
143
|
const payload = {
|
|
145
144
|
metric: name,
|
|
146
145
|
subject,
|
|
147
|
-
experiments
|
|
146
|
+
experiments: [{ experiment: assignment.experiment, variant: assignment.variant }]
|
|
148
147
|
};
|
|
149
148
|
if (context && context.value !== undefined) payload.value = context.value;
|
|
150
149
|
this.event('experiment.goal', payload);
|
|
@@ -156,6 +155,7 @@ export class WardxCore {
|
|
|
156
155
|
values: config.values,
|
|
157
156
|
experiments: config.experiments
|
|
158
157
|
});
|
|
158
|
+
this.experiments.applySnapshot(this.configStore.experiments);
|
|
159
159
|
this.internal.configVersion = version;
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -182,7 +182,7 @@ export class WardxCore {
|
|
|
182
182
|
const logs = this.logs.swap();
|
|
183
183
|
const internal = this.internal.snapshotAndReset();
|
|
184
184
|
const frame = FrameBuilder.build({
|
|
185
|
-
seq:
|
|
185
|
+
seq: this.seq + 1,
|
|
186
186
|
from,
|
|
187
187
|
to,
|
|
188
188
|
metrics,
|
|
@@ -190,23 +190,26 @@ export class WardxCore {
|
|
|
190
190
|
logs,
|
|
191
191
|
internal
|
|
192
192
|
});
|
|
193
|
-
const
|
|
194
|
-
this.
|
|
195
|
-
this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
193
|
+
const batch = FrameBuilder.splitToMaxBytes(frame, this.settings.maxFrameBytes);
|
|
194
|
+
this.seq = batch.frames.at(-1).seq;
|
|
195
|
+
this.pendingFrames.push(...batch.frames);
|
|
196
|
+
for (let i = 0; i < batch.frames.length; i++) {
|
|
197
|
+
const physical = batch.frames[i];
|
|
198
|
+
emit(this._tracer, 'frame', {
|
|
199
|
+
seq: physical.seq,
|
|
200
|
+
from: physical.from,
|
|
201
|
+
to: physical.to,
|
|
202
|
+
counters: physical.metrics.counters.length,
|
|
203
|
+
gauges: physical.metrics.gauges.length,
|
|
204
|
+
histograms: physical.metrics.histograms.length,
|
|
205
|
+
events: physical.events.length,
|
|
206
|
+
logs: physical.logs.length,
|
|
207
|
+
droppedLogs: i === 0 ? batch.droppedLogs : 0,
|
|
208
|
+
droppedEvents: i === 0 ? batch.droppedEvents : 0,
|
|
209
|
+
droppedRows: i === 0 ? batch.droppedRows : 0
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return batch;
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
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,29 @@
|
|
|
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
|
-
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.');
|
|
19
|
+
function rowCount(frame) {
|
|
20
|
+
return (
|
|
21
|
+
frame.metrics.counters.length +
|
|
22
|
+
frame.metrics.gauges.length +
|
|
23
|
+
frame.metrics.histograms.length +
|
|
24
|
+
frame.events.length +
|
|
25
|
+
frame.logs.length
|
|
26
|
+
);
|
|
44
27
|
}
|
|
45
28
|
|
|
46
29
|
export class FrameBuilder {
|
|
@@ -88,45 +71,80 @@ export class FrameBuilder {
|
|
|
88
71
|
}
|
|
89
72
|
}
|
|
90
73
|
|
|
91
|
-
static
|
|
92
|
-
|
|
93
|
-
|
|
74
|
+
static splitToMaxBytes(frame, maxFrameBytes) {
|
|
75
|
+
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1024) {
|
|
76
|
+
throw new Error('maxFrameBytes must be an integer at least 1024');
|
|
77
|
+
}
|
|
78
|
+
const frames = [];
|
|
79
|
+
const jsons = [];
|
|
80
|
+
const dropped = {
|
|
81
|
+
counters: 0,
|
|
82
|
+
gauges: 0,
|
|
83
|
+
histograms: 0,
|
|
84
|
+
events: 0,
|
|
85
|
+
logs: 0
|
|
86
|
+
};
|
|
87
|
+
let current = emptyFrame(frame.seq, frame.from, frame.to);
|
|
88
|
+
|
|
89
|
+
const finishCurrent = () => {
|
|
90
|
+
const measured = measure(current);
|
|
91
|
+
if (measured.bytes > maxFrameBytes) {
|
|
92
|
+
throw new Error('frame splitter produced an oversized frame');
|
|
93
|
+
}
|
|
94
|
+
frames.push(current);
|
|
95
|
+
jsons.push(measured.json);
|
|
96
|
+
current = emptyFrame(frame.seq + frames.length, frame.from, frame.to);
|
|
97
|
+
};
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
const addRow = (collection, row, countDrop = true) => {
|
|
100
|
+
collection(current).push(row);
|
|
101
|
+
if (measure(current).bytes <= maxFrameBytes) return true;
|
|
102
|
+
collection(current).pop();
|
|
103
|
+
if (rowCount(current) > 0) {
|
|
104
|
+
finishCurrent();
|
|
105
|
+
collection(current).push(row);
|
|
106
|
+
if (measure(current).bytes <= maxFrameBytes) return true;
|
|
107
|
+
collection(current).pop();
|
|
108
|
+
}
|
|
109
|
+
if (countDrop) dropped[collection.kind] += 1;
|
|
110
|
+
return false;
|
|
111
|
+
};
|
|
97
112
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
113
|
+
const counters = (candidate) => candidate.metrics.counters;
|
|
114
|
+
counters.kind = 'counters';
|
|
115
|
+
const gauges = (candidate) => candidate.metrics.gauges;
|
|
116
|
+
gauges.kind = 'gauges';
|
|
117
|
+
const histograms = (candidate) => candidate.metrics.histograms;
|
|
118
|
+
histograms.kind = 'histograms';
|
|
119
|
+
const events = (candidate) => candidate.events;
|
|
120
|
+
events.kind = 'events';
|
|
121
|
+
const logs = (candidate) => candidate.logs;
|
|
122
|
+
logs.kind = 'logs';
|
|
109
123
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
}
|
|
124
|
+
for (const row of frame.metrics.counters) addRow(counters, row);
|
|
125
|
+
for (const row of frame.metrics.gauges) addRow(gauges, row);
|
|
126
|
+
for (const row of frame.metrics.histograms) addRow(histograms, row);
|
|
127
|
+
for (const row of frame.events) addRow(events, row);
|
|
128
|
+
for (const row of frame.logs) addRow(logs, row);
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
130
|
+
const droppedRows = Object.values(dropped).reduce((sum, value) => sum + value, 0);
|
|
131
|
+
if (
|
|
132
|
+
droppedRows > 0 &&
|
|
133
|
+
!addRow(counters, [INTERNAL.frameRowsDropped, null, droppedRows], false)
|
|
134
|
+
) {
|
|
135
|
+
throw new Error('maxFrameBytes cannot contain the frame drop metric');
|
|
125
136
|
}
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
if (frames.length === 0 || rowCount(current) > 0) finishCurrent();
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
frames,
|
|
141
|
+
jsons,
|
|
142
|
+
droppedRows,
|
|
143
|
+
droppedCounters: dropped.counters,
|
|
144
|
+
droppedGauges: dropped.gauges,
|
|
145
|
+
droppedHistograms: dropped.histograms,
|
|
146
|
+
droppedEvents: dropped.events,
|
|
147
|
+
droppedLogs: dropped.logs
|
|
148
|
+
};
|
|
131
149
|
}
|
|
132
150
|
}
|
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';
|
|
@@ -56,6 +57,7 @@ export interface SdkDefaults {
|
|
|
56
57
|
maxSeriesPerMetric: number;
|
|
57
58
|
maxDimensionKeys: number;
|
|
58
59
|
maxDimensionValueLength: number;
|
|
60
|
+
experimentStateMaxSubjects: number;
|
|
59
61
|
httpTimeoutMs: number;
|
|
60
62
|
histogramBuckets: number[];
|
|
61
63
|
}
|
|
@@ -67,7 +69,7 @@ export interface CreateWardxOptions extends Partial<SdkDefaults> {
|
|
|
67
69
|
role: string;
|
|
68
70
|
appVersion: string;
|
|
69
71
|
environment: string;
|
|
70
|
-
privacySalt
|
|
72
|
+
privacySalt: string;
|
|
71
73
|
tracer?: Tracer | null;
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -112,6 +114,7 @@ export interface Experiment {
|
|
|
112
114
|
enabled: boolean;
|
|
113
115
|
allocation: number;
|
|
114
116
|
salt: string;
|
|
117
|
+
goalMetric: string;
|
|
115
118
|
primaryMetric?: string;
|
|
116
119
|
variants: ExperimentVariant[];
|
|
117
120
|
}
|
|
@@ -167,9 +170,13 @@ export interface Frame {
|
|
|
167
170
|
logs: LogRow[];
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
export interface
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
export interface FrameBatch {
|
|
174
|
+
frames: Frame[];
|
|
175
|
+
jsons: string[];
|
|
176
|
+
droppedRows: number;
|
|
177
|
+
droppedCounters: number;
|
|
178
|
+
droppedGauges: number;
|
|
179
|
+
droppedHistograms: number;
|
|
173
180
|
droppedLogs: number;
|
|
174
181
|
droppedEvents: number;
|
|
175
182
|
}
|
|
@@ -223,6 +230,7 @@ export interface FrameTraceRecord {
|
|
|
223
230
|
logs: number;
|
|
224
231
|
droppedLogs: number;
|
|
225
232
|
droppedEvents: number;
|
|
233
|
+
droppedRows: number;
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
export interface SyncTraceRecord {
|
|
@@ -344,9 +352,15 @@ export class ConfigStore {
|
|
|
344
352
|
}
|
|
345
353
|
|
|
346
354
|
export class ExperimentResolver {
|
|
347
|
-
|
|
355
|
+
stateMaxSubjects: number;
|
|
356
|
+
stateBySubject: Map<string, unknown>;
|
|
357
|
+
constructor(options: {
|
|
358
|
+
privacySalt: string;
|
|
359
|
+
stateMaxSubjects: number;
|
|
360
|
+
onExposure: (payload: ExposurePayload) => void;
|
|
361
|
+
});
|
|
348
362
|
hashSubject(subjectId: string): string;
|
|
349
|
-
recordAssignment(subjectId: string, experiment: Experiment, variant: ExperimentVariant):
|
|
363
|
+
recordAssignment(subjectId: string, experiment: Experiment, variant: ExperimentVariant): Assignment;
|
|
350
364
|
assignmentsFor(subjectId: string): Assignment[];
|
|
351
365
|
resolve(
|
|
352
366
|
key: string,
|
|
@@ -354,7 +368,8 @@ export class ExperimentResolver {
|
|
|
354
368
|
subjectId: string | null | undefined,
|
|
355
369
|
experimentsByKey: Map<string, Experiment[]>
|
|
356
370
|
): ConfigValue;
|
|
357
|
-
|
|
371
|
+
exposedAssignmentForGoal(subjectId: string, goalMetric: string): Assignment | null;
|
|
372
|
+
applySnapshot(experiments: Experiment[]): void;
|
|
358
373
|
}
|
|
359
374
|
|
|
360
375
|
export class FrameBuilder {
|
|
@@ -368,7 +383,7 @@ export class FrameBuilder {
|
|
|
368
383
|
internal: InternalSnapshot;
|
|
369
384
|
}): Frame;
|
|
370
385
|
static mergeInternal(counters: CounterRow[], gauges: GaugeRow[], internal: InternalSnapshot): void;
|
|
371
|
-
static
|
|
386
|
+
static splitToMaxBytes(frame: Frame, maxFrameBytes: number): FrameBatch;
|
|
372
387
|
}
|
|
373
388
|
|
|
374
389
|
export class WardxCore {
|
|
@@ -394,8 +409,8 @@ export class WardxCore {
|
|
|
394
409
|
configGet<T>(key: string, fallback: T, context?: SubjectContext): T;
|
|
395
410
|
experimentGoal(name: string, context?: ExperimentGoalContext): void;
|
|
396
411
|
applyConfig(version: number, config: ConfigSnapshot): void;
|
|
397
|
-
snapshotIfDirty():
|
|
398
|
-
snapshotFrame():
|
|
412
|
+
snapshotIfDirty(): FrameBatch | null;
|
|
413
|
+
snapshotFrame(): FrameBatch;
|
|
399
414
|
takePendingFrames(): Frame[];
|
|
400
415
|
}
|
|
401
416
|
|
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);
|