@wardx/core 0.1.7 → 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 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. If `privacySalt` is empty, the core uses this key as the salt. |
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` | Salt for the hashed subject. If you omit this key, the core uses `projectKey`. |
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: 'dev_project_key'
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 fitted = core.snapshotFrame();
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('dev_project_key', 'user-1');
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 8 lowercase hex digits of `fnv1a32(privacySalt + ':' + subjectId)`.
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 fitted = core.snapshotIfDirty();
246
- if (fitted) {
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
- If a frame is larger than `maxFrameBytes`, `FrameBuilder.fitToMaxBytes` discards data in this order:
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
- 1. Logs with the lowest severity.
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 trims frames. |
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
@@ -9,6 +9,7 @@
9
9
  "maxSeriesPerMetric": 1000,
10
10
  "maxDimensionKeys": 8,
11
11
  "maxDimensionValueLength": 64,
12
+ "experimentStateMaxSubjects": 100000,
12
13
  "httpTimeoutMs": 10000,
13
14
  "histogramBuckets": [10, 25, 50, 100, 250, 500, 1000]
14
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wardx/core",
3
- "version": "0.1.7",
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 experiments = this.experiments.relevantExperiments(
141
- subjectId,
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: ++this.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 fitted = FrameBuilder.fitToMaxBytes(frame, this.settings.maxFrameBytes);
194
- this.internal.logsDropped += fitted.droppedLogs;
195
- this.internal.eventsDropped += fitted.droppedEvents;
196
- this.pendingFrames.push(fitted.frame);
197
- emit(this._tracer, 'frame', {
198
- seq: fitted.frame.seq,
199
- from: fitted.frame.from,
200
- to: fitted.frame.to,
201
- counters: fitted.frame.metrics.counters.length,
202
- gauges: fitted.frame.metrics.gauges.length,
203
- histograms: fitted.frame.metrics.histograms.length,
204
- events: fitted.frame.events.length,
205
- logs: fitted.frame.logs.length,
206
- droppedLogs: fitted.droppedLogs,
207
- droppedEvents: fitted.droppedEvents
208
- });
209
- return fitted;
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 = Array.isArray(snapshot.experiments) ? snapshot.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.exposureKeys = new Set();
55
- this.assignmentsBySubject = new Map();
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
- 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;
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
- list.push({ experiment: experiment.id, variant: variant.key });
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
- return this.assignmentsBySubject.get(subjectId) || [];
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(experiment, variant, subjectId);
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(experiment, variant, subjectId) {
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.id,
100
- variant: variant.key,
125
+ experiment: assignment.experiment,
126
+ variant: assignment.variant,
101
127
  subject: hashed
102
128
  });
103
129
  }
104
130
 
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;
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 (!experiment.enabled) continue;
112
- const variant = assignVariant(experiment, subjectId);
113
- if (!variant) continue;
114
- attached.push({ experiment: experiment.id, variant: variant.key });
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
  }
@@ -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 (fnv1a32(`${projectSalt}:${subjectId}`) >>> 0).toString(16).padStart(8, '0');
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 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
- });
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 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.');
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 fitToMaxBytes(frame, maxFrameBytes) {
92
- let { json, bytes } = measure(frame);
93
- if (bytes <= maxFrameBytes) return { frame, json, droppedLogs: 0, droppedEvents: 0 };
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
- let droppedLogs = 0;
96
- let droppedEvents = 0;
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
- 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
- }
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
- 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
- }
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
- if (bytes > maxFrameBytes && frame.metrics.histograms.length > 0) {
123
- frame.metrics.histograms = [];
124
- ({ json, bytes } = measure(frame));
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 (bytes > maxFrameBytes) {
127
- frame.metrics.gauges = frame.metrics.gauges.filter(isInternalGauge);
128
- ({ json } = measure(frame));
129
- }
130
- return { frame, json, droppedLogs, droppedEvents };
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?: string;
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 FittedFrame {
171
- frame: Frame;
172
- json: string;
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
- constructor(options: { privacySalt: string; onExposure: (payload: ExposurePayload) => void });
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): void;
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
- relevantExperiments(subjectId: string, experiments: Experiment[]): Assignment[];
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 fitToMaxBytes(frame: Frame, maxFrameBytes: number): FittedFrame;
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(): FittedFrame | null;
398
- snapshotFrame(): FittedFrame;
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 === undefined || settings.privacySalt === null || settings.privacySalt === '') {
61
- settings.privacySalt = settings.projectKey;
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);