@wardx/core 0.2.2 → 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
@@ -61,7 +61,7 @@ The defaults live in `defaults.json`. Do not omit a required key. The loader doe
61
61
 
62
62
  **When:** You write a runtime that is not Node.js, or you test the engine without HTTP.
63
63
 
64
- **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.
65
65
 
66
66
  ```js
67
67
  import { WardxCore, loadSdkDefaults } from '@wardx/core';
@@ -83,6 +83,7 @@ core.counter('match.completed', { mode: 'ranked' }).inc();
83
83
  core.counter('coins.awarded').add(25);
84
84
  core.gauge('players.online').set(12921);
85
85
  core.histogram('request.duration').observe(42);
86
+ core.distinct('shot.traffic.hids', { result: 'violating' }).add(hid);
86
87
 
87
88
  const endTimer = core.timer('matchmaking.duration');
88
89
  endTimer({ result: 'success' });
@@ -96,7 +97,7 @@ const frames = core.takePendingFrames();
96
97
  1. Load the SDK defaults.
97
98
  2. Add the identity fields.
98
99
  3. Construct `WardxCore`.
99
- 4. Call `counter`, `gauge`, `histogram`, or `timer`.
100
+ 4. Call `counter`, `gauge`, `histogram`, `distinct`, or `timer`.
100
101
  5. Call `snapshotFrame` when you need a frame.
101
102
  6. Call `takePendingFrames` to get the pending frames.
102
103
 
@@ -115,6 +116,12 @@ core.histogram('coins.award_size').observe(80, { grantId: 'g-80' });
115
116
 
116
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.
117
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
+
118
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`.
119
126
 
120
127
  A dimension value must be a string, a number, or a boolean.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wardx/core",
3
- "version": "0.2.2",
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",
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
  }
@@ -68,6 +70,12 @@ export class WardxCore {
68
70
  );
69
71
  }
70
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
+
71
79
  timer(name, dims) {
72
80
  if (this._tracer === null) return this.metrics.timer(name, dims);
73
81
  return startTimer((duration, endDims) => {
@@ -202,6 +210,7 @@ export class WardxCore {
202
210
  counters: physical.metrics.counters.length,
203
211
  gauges: physical.metrics.gauges.length,
204
212
  histograms: physical.metrics.histograms.length,
213
+ distincts: physical.metrics.distincts?.length || 0,
205
214
  events: physical.events.length,
206
215
  logs: physical.logs.length,
207
216
  droppedLogs: i === 0 ? batch.droppedLogs : 0,
@@ -21,6 +21,7 @@ function rowCount(frame) {
21
21
  frame.metrics.counters.length +
22
22
  frame.metrics.gauges.length +
23
23
  frame.metrics.histograms.length +
24
+ (frame.metrics.distincts?.length || 0) +
24
25
  frame.events.length +
25
26
  frame.logs.length
26
27
  );
@@ -39,7 +40,8 @@ export class FrameBuilder {
39
40
  metrics: {
40
41
  counters,
41
42
  gauges,
42
- histograms
43
+ histograms,
44
+ ...(metrics.distincts?.length > 0 ? { distincts: metrics.distincts.slice() } : {})
43
45
  },
44
46
  events,
45
47
  logs
@@ -81,6 +83,7 @@ export class FrameBuilder {
81
83
  counters: 0,
82
84
  gauges: 0,
83
85
  histograms: 0,
86
+ distincts: 0,
84
87
  events: 0,
85
88
  logs: 0
86
89
  };
@@ -100,11 +103,17 @@ export class FrameBuilder {
100
103
  collection(current).push(row);
101
104
  if (measure(current).bytes <= maxFrameBytes) return true;
102
105
  collection(current).pop();
106
+ if (collection.kind === 'distincts' && current.metrics.distincts.length === 0) {
107
+ delete current.metrics.distincts;
108
+ }
103
109
  if (rowCount(current) > 0) {
104
110
  finishCurrent();
105
111
  collection(current).push(row);
106
112
  if (measure(current).bytes <= maxFrameBytes) return true;
107
113
  collection(current).pop();
114
+ if (collection.kind === 'distincts' && current.metrics.distincts.length === 0) {
115
+ delete current.metrics.distincts;
116
+ }
108
117
  }
109
118
  if (countDrop) dropped[collection.kind] += 1;
110
119
  return false;
@@ -116,6 +125,11 @@ export class FrameBuilder {
116
125
  gauges.kind = 'gauges';
117
126
  const histograms = (candidate) => candidate.metrics.histograms;
118
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';
119
133
  const events = (candidate) => candidate.events;
120
134
  events.kind = 'events';
121
135
  const logs = (candidate) => candidate.logs;
@@ -124,6 +138,7 @@ export class FrameBuilder {
124
138
  for (const row of frame.metrics.counters) addRow(counters, row);
125
139
  for (const row of frame.metrics.gauges) addRow(gauges, row);
126
140
  for (const row of frame.metrics.histograms) addRow(histograms, row);
141
+ for (const row of frame.metrics.distincts || []) addRow(distincts, row);
127
142
  for (const row of frame.events) addRow(events, row);
128
143
  for (const row of frame.logs) addRow(logs, row);
129
144
 
@@ -143,6 +158,7 @@ export class FrameBuilder {
143
158
  droppedCounters: dropped.counters,
144
159
  droppedGauges: dropped.gauges,
145
160
  droppedHistograms: dropped.histograms,
161
+ droppedDistincts: dropped.distincts,
146
162
  droppedEvents: dropped.events,
147
163
  droppedLogs: dropped.logs
148
164
  };
package/src/index.d.ts CHANGED
@@ -44,6 +44,10 @@ export interface HistogramHandle {
44
44
  observe(value: number, attrs?: Dimensions | null): void;
45
45
  }
46
46
 
47
+ export interface DistinctHandle {
48
+ add(identifier: string): void;
49
+ }
50
+
47
51
  export type StopTimer = (dims?: Dimensions | null) => void;
48
52
 
49
53
  export interface SdkDefaults {
@@ -140,6 +144,12 @@ export type LogRow = [timestamp: number, level: LogLevel, message: string, attrs
140
144
  export type CounterRow = [name: string, dims: Dimensions | null, value: number];
141
145
  export type GaugeRow = [name: string, dims: Dimensions | null, value: number, timestamp: number];
142
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
+ }
143
153
 
144
154
  export interface HistogramExemplar {
145
155
  value: number;
@@ -159,6 +169,7 @@ export interface MetricsSnapshot {
159
169
  counters: CounterRow[];
160
170
  gauges: GaugeRow[];
161
171
  histograms: HistogramRow[];
172
+ distincts?: DistinctRow[];
162
173
  }
163
174
 
164
175
  export interface Frame {
@@ -177,6 +188,7 @@ export interface FrameBatch {
177
188
  droppedCounters: number;
178
189
  droppedGauges: number;
179
190
  droppedHistograms: number;
191
+ droppedDistincts: number;
180
192
  droppedLogs: number;
181
193
  droppedEvents: number;
182
194
  }
@@ -197,7 +209,7 @@ export interface InternalSnapshot {
197
209
  }
198
210
 
199
211
  export interface MeasureTraceRecord {
200
- type: 'counter' | 'gauge' | 'histogram';
212
+ type: 'counter' | 'gauge' | 'histogram' | 'distinct';
201
213
  name: string;
202
214
  dims: Dimensions | null;
203
215
  op: 'inc' | 'add' | 'set' | 'observe';
@@ -226,6 +238,7 @@ export interface FrameTraceRecord {
226
238
  counters: number;
227
239
  gauges: number;
228
240
  histograms: number;
241
+ distincts: number;
229
242
  events: number;
230
243
  logs: number;
231
244
  droppedLogs: number;
@@ -307,11 +320,31 @@ export class Histogram implements HistogramHandle {
307
320
  reset(): void;
308
321
  }
309
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
+
310
342
  export interface MetricsRegistryOptions {
311
343
  maxSeriesPerMetric: number;
312
344
  maxDimensionKeys: number;
313
345
  maxDimensionValueLength: number;
314
346
  defaultHistogramBuckets: number[];
347
+ privacySalt?: string;
315
348
  onCardinalityDropped: () => void;
316
349
  }
317
350
 
@@ -320,6 +353,7 @@ export class MetricsRegistry {
320
353
  counter(name: string, dims?: Dimensions | null): CounterHandle;
321
354
  gauge(name: string, dims?: Dimensions | null): GaugeHandle;
322
355
  histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
356
+ distinct(name: string, dims?: Dimensions | null): DistinctHandle;
323
357
  timer(name: string, dims?: Dimensions | null): StopTimer;
324
358
  snapshotAndReset(): MetricsSnapshot;
325
359
  isDirty(): boolean;
@@ -403,6 +437,7 @@ export class WardxCore {
403
437
  counter(name: string, dims?: Dimensions | null): CounterHandle;
404
438
  gauge(name: string, dims?: Dimensions | null): GaugeHandle;
405
439
  histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
440
+ distinct(name: string, dims?: Dimensions | null): DistinctHandle;
406
441
  timer(name: string, dims?: Dimensions | null): StopTimer;
407
442
  event(name: string, attrs?: Attrs | null): void;
408
443
  identify(subjectId: string | null | undefined): void;
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/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
+ }