@wardx/core 0.1.2 → 0.1.4

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
@@ -27,6 +27,7 @@ import { WardxCore, assignVariant, loadSdkDefaults } from '@wardx/core';
27
27
  - A measure call does not wait for a Promise.
28
28
  - If a buffer is full, the engine discards data. The engine does not block the application.
29
29
  - Counters in a frame are window deltas. Counters are not lifetime totals.
30
+ - `identify(subjectId)` sets the default subject for this instance. A per-call `{ subjectId }` overrides it.
30
31
 
31
32
  ## Settings
32
33
 
@@ -125,7 +126,7 @@ A dimension value must be a string, a number, or a boolean.
125
126
  ```js
126
127
  core.event('purchase', { product: 'premium' });
127
128
  core.log.info('match_started', { mode: 'ranked', players: 4 });
128
- core.log.error('payment_failed', { code: 'timeout' });
129
+ core.log.error('payment_failed', { code: 'timeout', stack: 'PaymentError: timeout' });
129
130
  ```
130
131
 
131
132
  Log levels: `debug`, `info`, `warn`, `error`.
@@ -138,7 +139,7 @@ Log levels: `debug`, `info`, `warn`, `error`.
138
139
 
139
140
  Dropped items increment `wardx.internal.events_dropped` or `wardx.internal.logs_dropped`.
140
141
 
141
- Use counters and histograms for rates and latency. Use events for rare product facts: a purchase, an experiment exposure or goal, a named screen. Use logs for failures. Do not put user ids on metric dimensions.
142
+ Use counters and histograms for rates and latency. Use events for rare product facts: a purchase, an experiment exposure or goal, a named screen. Use logs for failures. Put a clipped `stack` or a provider `code` on the log attrs so MCP `get_recent_logs` can show an agent where to look. The engine does not edit source. Do not put user ids on metric dimensions.
142
143
 
143
144
  A volume funnel is one event name and one counter per step. The engine does not join events by subject. See `docs/ARCHITECTURE.md`.
144
145
 
@@ -148,6 +149,8 @@ A volume funnel is one event name and one counter per step. The engine does not
148
149
 
149
150
  **Objective:** Get a config value. If an experiment applies, get the variant value.
150
151
 
152
+ There is a default subject after `identify(subjectId)`. Pass `{ subjectId }` on a call to override it, or when one process serves many users. Use a stable account id, not `sessionId`. With no subject, `configGet` returns the Remote Config value and that call is not in the A/B test.
153
+
151
154
  ```js
152
155
  core.applyConfig(13, {
153
156
  values: {
@@ -170,23 +173,26 @@ core.applyConfig(13, {
170
173
  });
171
174
 
172
175
  const fallback = 1000;
173
- const delay = core.configGet('message.delayMs', fallback, { subjectId: 'user-1' });
174
- core.experimentGoal('message.sent', { subjectId: 'user-1', value: 1 });
176
+ const shared = core.configGet('message.delayMs', fallback);
177
+ core.identify('user-1');
178
+ const delay = core.configGet('message.delayMs', fallback);
179
+ core.experimentGoal('message.sent', { value: 1 });
180
+ const other = core.configGet('message.delayMs', fallback, { subjectId: 'user-2' });
175
181
  ```
176
182
 
183
+ `shared` is always the snapshot value (`1000`). `delay` is `1000` or `400` for `user-1`. `other` is the variant for `user-2`. The same `subjectId`, experiment `id`, and `salt` always map to the same variant. You do not persist the group. Changing `salt` redistributes the population. `identify(null)` clears the default.
184
+
177
185
  ### Resolution order
178
186
 
179
187
  1. If the key is not in the snapshot, return `fallback`.
180
- 2. If `subjectId` is missing, return the Remote Config value.
188
+ 2. If there is no subject (`identify` unset and no `{ subjectId }`), return the Remote Config value.
181
189
  3. If no enabled experiment contains the key, return the Remote Config value.
182
190
  4. If the subject is not in the allocation, return the Remote Config value.
183
191
  5. If the subject is in the allocation, return the variant value.
184
192
 
185
- The assignment is deterministic. The same `experimentId`, `subjectId`, and `salt` always give the same variant.
186
-
187
193
  The first resolve for a subject in a session emits event `experiment.exposure`. The payload contains a hashed subject. The payload does not contain the raw `subjectId`.
188
194
 
189
- `experimentGoal` emits event `experiment.goal`. You must supply `subjectId`. You can supply `value`.
195
+ `experimentGoal` emits event `experiment.goal`. The subject comes from `identify()` or from `{ subjectId }` on that call. You can supply `value`. Use milliseconds for a session-duration goal. The server stores that number as `goalSum` and `goalMean` per variant. Without a subject, the call throws. One experiment should have one quantitative goal name.
190
196
 
191
197
  ## Use case 4: Assign a variant without WardxCore
192
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wardx/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Runtime-agnostic Wardx engine for metrics, events, logs, Remote Config, and experiments.",
5
5
  "keywords": [
6
6
  "wardx",
@@ -27,8 +27,13 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "exports": {
30
- ".": "./src/index.js"
30
+ ".": {
31
+ "types": "./src/index.d.ts",
32
+ "import": "./src/index.js",
33
+ "default": "./src/index.js"
34
+ }
31
35
  },
36
+ "types": "./src/index.d.ts",
32
37
  "files": [
33
38
  "src",
34
39
  "defaults.json"
@@ -36,4 +41,4 @@
36
41
  "publishConfig": {
37
42
  "access": "public"
38
43
  }
39
- }
44
+ }
package/src/WardxCore.js CHANGED
@@ -40,6 +40,7 @@ export class WardxCore {
40
40
  this.seq = 0;
41
41
  this.pendingFrames = [];
42
42
  this.windowStart = Date.now();
43
+ this._subjectId = null;
43
44
  this.log = {
44
45
  debug: (message, attrs) => this._log('debug', message, attrs),
45
46
  info: (message, attrs) => this._log('info', message, attrs),
@@ -96,30 +97,48 @@ export class WardxCore {
96
97
  return wrapped;
97
98
  }
98
99
 
100
+ identify(subjectId) {
101
+ if (subjectId === undefined || subjectId === null) {
102
+ this._subjectId = null;
103
+ return;
104
+ }
105
+ if (typeof subjectId !== 'string' || subjectId.length === 0) {
106
+ throw new Error('identify requires a non-empty subjectId');
107
+ }
108
+ this._subjectId = subjectId;
109
+ }
110
+
111
+ _subjectIdFrom(context) {
112
+ if (context && context.subjectId !== undefined && context.subjectId !== null) {
113
+ return context.subjectId;
114
+ }
115
+ return this._subjectId;
116
+ }
117
+
99
118
  configGet(key, fallback, context) {
100
119
  if (!this.configStore.has(key)) return fallback;
101
120
  const remote = this.configStore.getRaw(key);
102
- if (!context || context.subjectId === undefined || context.subjectId === null) {
103
- return remote;
104
- }
121
+ const subjectId = this._subjectIdFrom(context);
122
+ if (subjectId === undefined || subjectId === null) return remote;
105
123
  return this.experiments.resolve(
106
124
  key,
107
125
  remote,
108
- context.subjectId,
126
+ subjectId,
109
127
  this.configStore.experimentsByKey
110
128
  );
111
129
  }
112
130
 
113
131
  experimentGoal(name, context) {
114
- if (!context || context.subjectId === undefined || context.subjectId === null) {
132
+ const subjectId = this._subjectIdFrom(context);
133
+ if (subjectId === undefined || subjectId === null) {
115
134
  throw new Error('experiment.goal requires subjectId');
116
135
  }
117
136
  if (typeof name !== 'string' || name.length === 0) {
118
137
  throw new Error('experiment.goal requires a metric name');
119
138
  }
120
- const subject = this.experiments.hashSubject(context.subjectId);
139
+ const subject = this.experiments.hashSubject(subjectId);
121
140
  const experiments = this.experiments.relevantExperiments(
122
- context.subjectId,
141
+ subjectId,
123
142
  this.configStore.experiments
124
143
  );
125
144
  const payload = {
@@ -127,7 +146,7 @@ export class WardxCore {
127
146
  subject,
128
147
  experiments
129
148
  };
130
- if (context.value !== undefined) payload.value = context.value;
149
+ if (context && context.value !== undefined) payload.value = context.value;
131
150
  this.event('experiment.goal', payload);
132
151
  }
133
152
 
package/src/index.d.ts ADDED
@@ -0,0 +1,410 @@
1
+ export const PROTOCOL_VERSION: 1;
2
+ export const SDK_NAME: 'wardx-node';
3
+ export const PLATFORM: 'node';
4
+
5
+ export const INTERNAL: {
6
+ readonly eventsBuffered: 'wardx.internal.events_buffered';
7
+ readonly logsBuffered: 'wardx.internal.logs_buffered';
8
+ readonly eventsDropped: 'wardx.internal.events_dropped';
9
+ readonly logsDropped: 'wardx.internal.logs_dropped';
10
+ readonly cardinalityDropped: 'wardx.internal.cardinality_dropped';
11
+ readonly framesSent: 'wardx.internal.frames_sent';
12
+ readonly framesFailed: 'wardx.internal.frames_failed';
13
+ readonly bytesUncompressed: 'wardx.internal.bytes_uncompressed';
14
+ readonly bytesCompressed: 'wardx.internal.bytes_compressed';
15
+ readonly lastSyncMs: 'wardx.internal.last_sync_ms';
16
+ readonly configVersion: 'wardx.internal.config_version';
17
+ readonly processRssBytes: 'wardx.internal.process_rss_bytes';
18
+ };
19
+
20
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
21
+ export type DimensionValue = string | number | boolean;
22
+ export interface Dimensions {
23
+ [name: string]: DimensionValue;
24
+ }
25
+ export type Attrs = Record<string, unknown>;
26
+ export type ConfigValue = string | number | boolean;
27
+
28
+ export interface HistogramOptions {
29
+ buckets?: number[];
30
+ [dimension: string]: DimensionValue | number[] | undefined;
31
+ }
32
+
33
+ export interface CounterHandle {
34
+ inc(): void;
35
+ add(n: number): void;
36
+ }
37
+
38
+ export interface GaugeHandle {
39
+ set(value: number): void;
40
+ }
41
+
42
+ export interface HistogramHandle {
43
+ observe(value: number, attrs?: Dimensions | null): void;
44
+ }
45
+
46
+ export type StopTimer = (dims?: Dimensions | null) => void;
47
+
48
+ export interface SdkDefaults {
49
+ aggregateIntervalMs: number;
50
+ syncIntervalMs: number;
51
+ syncJitterMin: number;
52
+ syncJitterMax: number;
53
+ maxBufferedEvents: number;
54
+ maxBufferedLogs: number;
55
+ maxFrameBytes: number;
56
+ maxSeriesPerMetric: number;
57
+ maxDimensionKeys: number;
58
+ maxDimensionValueLength: number;
59
+ httpTimeoutMs: number;
60
+ histogramBuckets: number[];
61
+ }
62
+
63
+ export interface CreateWardxOptions extends Partial<SdkDefaults> {
64
+ endpoint: string;
65
+ projectKey: string;
66
+ project: string;
67
+ role: string;
68
+ appVersion: string;
69
+ environment: string;
70
+ privacySalt?: string;
71
+ tracer?: Tracer | null;
72
+ }
73
+
74
+ export interface CoreSettings extends SdkDefaults {
75
+ privacySalt: string;
76
+ endpoint?: string;
77
+ projectKey?: string;
78
+ project?: string;
79
+ role?: string;
80
+ appVersion?: string;
81
+ environment?: string;
82
+ tracer?: Tracer | null;
83
+ }
84
+
85
+ export interface ResolvedSettings extends SdkDefaults {
86
+ endpoint: string;
87
+ projectKey: string;
88
+ project: string;
89
+ role: string;
90
+ appVersion: string;
91
+ environment: string;
92
+ privacySalt: string;
93
+ tracer?: Tracer | null;
94
+ }
95
+
96
+ export interface SubjectContext {
97
+ subjectId?: string | null;
98
+ }
99
+
100
+ export interface ExperimentGoalContext extends SubjectContext {
101
+ value?: number;
102
+ }
103
+
104
+ export interface ExperimentVariant {
105
+ key: string;
106
+ weight: number;
107
+ values: Record<string, ConfigValue>;
108
+ }
109
+
110
+ export interface Experiment {
111
+ id: string;
112
+ enabled: boolean;
113
+ allocation: number;
114
+ salt: string;
115
+ primaryMetric?: string;
116
+ variants: ExperimentVariant[];
117
+ }
118
+
119
+ export interface ConfigSnapshot {
120
+ values?: Record<string, ConfigValue>;
121
+ experiments?: Experiment[];
122
+ }
123
+
124
+ export interface Assignment {
125
+ experiment: string;
126
+ variant: string;
127
+ }
128
+
129
+ export interface ExposurePayload {
130
+ experiment: string;
131
+ variant: string;
132
+ subject: string;
133
+ }
134
+
135
+ export type EventRow = [timestamp: number, name: string, attrs: Attrs | null];
136
+ export type LogRow = [timestamp: number, level: LogLevel, message: string, attrs: Attrs | null];
137
+ export type CounterRow = [name: string, dims: Dimensions | null, value: number];
138
+ export type GaugeRow = [name: string, dims: Dimensions | null, value: number, timestamp: number];
139
+ export type HistogramRow = [name: string, dims: Dimensions | null, snapshot: HistogramSnapshot];
140
+
141
+ export interface HistogramExemplar {
142
+ value: number;
143
+ attrs: Dimensions;
144
+ }
145
+
146
+ export interface HistogramSnapshot {
147
+ count: number;
148
+ sum: number;
149
+ min: number;
150
+ max: number;
151
+ buckets: Array<[bound: number, count: number]>;
152
+ exemplar?: HistogramExemplar;
153
+ }
154
+
155
+ export interface MetricsSnapshot {
156
+ counters: CounterRow[];
157
+ gauges: GaugeRow[];
158
+ histograms: HistogramRow[];
159
+ }
160
+
161
+ export interface Frame {
162
+ seq: number;
163
+ from: number;
164
+ to: number;
165
+ metrics: MetricsSnapshot;
166
+ events: EventRow[];
167
+ logs: LogRow[];
168
+ }
169
+
170
+ export interface FittedFrame {
171
+ frame: Frame;
172
+ json: string;
173
+ droppedLogs: number;
174
+ droppedEvents: number;
175
+ }
176
+
177
+ export interface InternalSnapshot {
178
+ eventsDropped: number;
179
+ logsDropped: number;
180
+ cardinalityDropped: number;
181
+ framesSent: number;
182
+ framesFailed: number;
183
+ bytesUncompressed: number;
184
+ bytesCompressed: number;
185
+ eventsBuffered: number;
186
+ logsBuffered: number;
187
+ lastSyncMs: number;
188
+ configVersion: number;
189
+ processRssBytes: number;
190
+ }
191
+
192
+ export interface MeasureTraceRecord {
193
+ type: 'counter' | 'gauge' | 'histogram';
194
+ name: string;
195
+ dims: Dimensions | null;
196
+ op: 'inc' | 'add' | 'set' | 'observe';
197
+ value: number;
198
+ attrs?: Dimensions | null;
199
+ noop: boolean;
200
+ }
201
+
202
+ export interface EventTraceRecord {
203
+ name: string;
204
+ attrs: Attrs | null;
205
+ dropped: boolean;
206
+ }
207
+
208
+ export interface LogTraceRecord {
209
+ level: LogLevel;
210
+ message: string;
211
+ attrs: Attrs | null;
212
+ dropped: boolean;
213
+ }
214
+
215
+ export interface FrameTraceRecord {
216
+ seq: number;
217
+ from: number;
218
+ to: number;
219
+ counters: number;
220
+ gauges: number;
221
+ histograms: number;
222
+ events: number;
223
+ logs: number;
224
+ droppedLogs: number;
225
+ droppedEvents: number;
226
+ }
227
+
228
+ export interface SyncTraceRecord {
229
+ phase: 'bootstrap' | 'flush' | 'tick';
230
+ frames: number;
231
+ bytesUncompressed: number;
232
+ bytesCompressed: number;
233
+ ms: number;
234
+ ok: boolean;
235
+ status?: number;
236
+ configVersion?: number;
237
+ appliedConfig?: boolean;
238
+ }
239
+
240
+ export interface Tracer {
241
+ measure?(record: MeasureTraceRecord): void;
242
+ event?(record: EventTraceRecord): void;
243
+ log?(record: LogTraceRecord): void;
244
+ frame?(record: FrameTraceRecord): void;
245
+ sync?(record: SyncTraceRecord): void;
246
+ }
247
+
248
+ export interface LogApi {
249
+ debug(message: string, attrs?: Attrs | null): void;
250
+ info(message: string, attrs?: Attrs | null): void;
251
+ warn(message: string, attrs?: Attrs | null): void;
252
+ error(message: string, attrs?: Attrs | null): void;
253
+ }
254
+
255
+ export interface InternalMetricsState extends InternalSnapshot {
256
+ hasCounterActivity(): boolean;
257
+ snapshotAndReset(): InternalSnapshot;
258
+ }
259
+
260
+ export class Counter implements CounterHandle {
261
+ name: string;
262
+ dims: Dimensions | null;
263
+ value: number;
264
+ constructor(name: string, dims?: Dimensions | null);
265
+ inc(): void;
266
+ add(n: number): void;
267
+ }
268
+
269
+ export class Gauge implements GaugeHandle {
270
+ name: string;
271
+ dims: Dimensions | null;
272
+ value: number;
273
+ timestamp: number;
274
+ dirty: boolean;
275
+ constructor(name: string, dims?: Dimensions | null);
276
+ set(value: number): void;
277
+ }
278
+
279
+ export class Histogram implements HistogramHandle {
280
+ name: string;
281
+ dims: Dimensions | null;
282
+ bounds: number[];
283
+ maxDimensionKeys: number | null;
284
+ maxDimensionValueLength: number | null;
285
+ counts: number[];
286
+ count: number;
287
+ sum: number;
288
+ min: number;
289
+ max: number;
290
+ exemplar: HistogramExemplar | null;
291
+ constructor(
292
+ name: string,
293
+ dims: Dimensions | null,
294
+ bounds: number[],
295
+ limits?: { maxDimensionKeys: number; maxDimensionValueLength: number } | null
296
+ );
297
+ observe(value: number, attrs?: Dimensions | null): void;
298
+ snapshot(): HistogramSnapshot;
299
+ reset(): void;
300
+ }
301
+
302
+ export interface MetricsRegistryOptions {
303
+ maxSeriesPerMetric: number;
304
+ maxDimensionKeys: number;
305
+ maxDimensionValueLength: number;
306
+ defaultHistogramBuckets: number[];
307
+ onCardinalityDropped: () => void;
308
+ }
309
+
310
+ export class MetricsRegistry {
311
+ constructor(options: MetricsRegistryOptions);
312
+ counter(name: string, dims?: Dimensions | null): CounterHandle;
313
+ gauge(name: string, dims?: Dimensions | null): GaugeHandle;
314
+ histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
315
+ timer(name: string, dims?: Dimensions | null): StopTimer;
316
+ snapshotAndReset(): MetricsSnapshot;
317
+ isDirty(): boolean;
318
+ }
319
+
320
+ export class EventBuffer {
321
+ max: number;
322
+ constructor(maxBufferedEvents: number);
323
+ get length(): number;
324
+ push(name: string, attrs?: Attrs | null): boolean;
325
+ swap(): EventRow[];
326
+ }
327
+
328
+ export class LogBuffer {
329
+ max: number;
330
+ constructor(maxBufferedLogs: number);
331
+ get length(): number;
332
+ push(level: LogLevel, message: string, attrs?: Attrs | null): boolean;
333
+ swap(): LogRow[];
334
+ }
335
+
336
+ export class ConfigStore {
337
+ version: number;
338
+ values: Record<string, ConfigValue>;
339
+ experiments: Experiment[];
340
+ experimentsByKey: Map<string, Experiment[]>;
341
+ applySnapshot(snapshot: { version: number } & ConfigSnapshot): void;
342
+ has(key: string): boolean;
343
+ getRaw(key: string): ConfigValue | undefined;
344
+ }
345
+
346
+ export class ExperimentResolver {
347
+ constructor(options: { privacySalt: string; onExposure: (payload: ExposurePayload) => void });
348
+ hashSubject(subjectId: string): string;
349
+ recordAssignment(subjectId: string, experiment: Experiment, variant: ExperimentVariant): void;
350
+ assignmentsFor(subjectId: string): Assignment[];
351
+ resolve(
352
+ key: string,
353
+ remoteValue: ConfigValue,
354
+ subjectId: string | null | undefined,
355
+ experimentsByKey: Map<string, Experiment[]>
356
+ ): ConfigValue;
357
+ relevantExperiments(subjectId: string, experiments: Experiment[]): Assignment[];
358
+ }
359
+
360
+ export class FrameBuilder {
361
+ static build(input: {
362
+ seq: number;
363
+ from: number;
364
+ to: number;
365
+ metrics: MetricsSnapshot;
366
+ events: EventRow[];
367
+ logs: LogRow[];
368
+ internal: InternalSnapshot;
369
+ }): Frame;
370
+ static mergeInternal(counters: CounterRow[], gauges: GaugeRow[], internal: InternalSnapshot): void;
371
+ static fitToMaxBytes(frame: Frame, maxFrameBytes: number): FittedFrame;
372
+ }
373
+
374
+ export class WardxCore {
375
+ settings: CoreSettings;
376
+ stopped: boolean;
377
+ internal: InternalMetricsState;
378
+ metrics: MetricsRegistry;
379
+ events: EventBuffer;
380
+ logs: LogBuffer;
381
+ configStore: ConfigStore;
382
+ experiments: ExperimentResolver;
383
+ seq: number;
384
+ pendingFrames: Frame[];
385
+ windowStart: number;
386
+ log: LogApi;
387
+ constructor(settings: CoreSettings);
388
+ counter(name: string, dims?: Dimensions | null): CounterHandle;
389
+ gauge(name: string, dims?: Dimensions | null): GaugeHandle;
390
+ histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
391
+ timer(name: string, dims?: Dimensions | null): StopTimer;
392
+ event(name: string, attrs?: Attrs | null): void;
393
+ identify(subjectId: string | null | undefined): void;
394
+ configGet<T>(key: string, fallback: T, context?: SubjectContext): T;
395
+ experimentGoal(name: string, context?: ExperimentGoalContext): void;
396
+ applyConfig(version: number, config: ConfigSnapshot): void;
397
+ snapshotIfDirty(): FittedFrame | null;
398
+ snapshotFrame(): FittedFrame;
399
+ takePendingFrames(): Frame[];
400
+ }
401
+
402
+ export function fnv1a32(input: string | Uint8Array): number;
403
+ export function assignmentHash(experimentId: string, subjectId: string, salt: string): number;
404
+ export function hashToUnitInterval(hash: number): number;
405
+ export function subjectHash(projectSalt: string, subjectId: string): string;
406
+ export function assignVariant(experiment: Experiment, subjectId: string): ExperimentVariant | null;
407
+ export function resolveSettings(options: CreateWardxOptions): ResolvedSettings;
408
+ export function loadSdkDefaults(): SdkDefaults;
409
+ export function nextSyncDelayMs(settings: Pick<ResolvedSettings, 'syncIntervalMs' | 'syncJitterMin' | 'syncJitterMax'>): number;
410
+ export function ulid(now?: number): string;