@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 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. 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
 
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: 'dev_project_key'
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 fitted = core.snapshotFrame();
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('dev_project_key', 'user-1');
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 8 lowercase hex digits of `fnv1a32(privacySalt + ':' + subjectId)`.
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 fitted = core.snapshotIfDirty();
246
- if (fitted) {
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
- If a frame is larger than `maxFrameBytes`, `FrameBuilder.fitToMaxBytes` discards data in this order:
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
- 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.
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 trims frames. |
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
@@ -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.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 experiments = this.experiments.relevantExperiments(
141
- subjectId,
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: ++this.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 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;
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 = 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,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 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.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 fitToMaxBytes(frame, maxFrameBytes) {
92
- let { json, bytes } = measure(frame);
93
- if (bytes <= maxFrameBytes) return { frame, json, droppedLogs: 0, droppedEvents: 0 };
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
- let droppedLogs = 0;
96
- let droppedEvents = 0;
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
- 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
- }
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
- 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
- }
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
- if (bytes > maxFrameBytes && frame.metrics.histograms.length > 0) {
123
- frame.metrics.histograms = [];
124
- ({ json, bytes } = measure(frame));
125
- }
126
- if (bytes > maxFrameBytes) {
127
- frame.metrics.gauges = frame.metrics.gauges.filter(isInternalGauge);
128
- ({ json } = measure(frame));
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
- return { frame, json, droppedLogs, droppedEvents };
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?: string;
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 FittedFrame {
171
- frame: Frame;
172
- json: string;
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
- constructor(options: { privacySalt: string; onExposure: (payload: ExposurePayload) => void });
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): void;
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
- relevantExperiments(subjectId: string, experiments: Experiment[]): Assignment[];
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 fitToMaxBytes(frame: Frame, maxFrameBytes: number): FittedFrame;
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(): FittedFrame | null;
398
- snapshotFrame(): FittedFrame;
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 ? 'h' : kindMap === this.gaugesByName ? 'g' : 'c';
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
- return { counters, gauges, histograms };
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 === 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);
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
+ }