wardx 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
@@ -32,8 +32,10 @@ To receive frames, run an ingest server. Install `@wardx/server` and start it wi
32
32
  - A measure call does not send network data.
33
33
  - A measure call does not wait for a Promise.
34
34
  - Delivery is at-most-once. If a sync fails, the SDK discards the batch.
35
+ - Physical frames are serialized, split to `maxFrameBytes`, and assigned consecutive sequence numbers. An individually oversized row is counted in `wardx.internal.frame_rows_dropped`.
35
36
  - The application has priority over telemetry.
36
37
  - Remote Config is always read from local memory.
38
+ - Remote Config must not contain secrets. The project key authenticates the project; client-selected `role` is routing metadata, not authorization.
37
39
  - The SDK sends names only. Descriptions live in the server catalog: ship them in the config file, or fill them during MCP onboarding.
38
40
  - `identify(subjectId)` sets the default subject for this instance. A per-call `{ subjectId }` overrides it. A process that serves many users must pass `subjectId` on each call and must not `identify()`.
39
41
 
@@ -48,11 +50,12 @@ To receive frames, run an ingest server. Install `@wardx/server` and start it wi
48
50
  | `endpoint` | Base URL of the ingest server, for example `http://127.0.0.1:8787`. |
49
51
  | `projectKey` | Value of header `X-Wardx-Key`. |
50
52
  | `project` | Project name. The name must match the server mapping. |
51
- | `role` | Name of this instance inside the project, for example `client`, `unity`, `game-server`, `desktop`. Not `*`. |
53
+ | `role` | Routing name of this instance inside the project, for example `client`, `unity`, `game-server`, `desktop`. Not `*`; not an authorization boundary. |
52
54
  | `appVersion` | Application version. |
53
55
  | `environment` | Environment name. |
56
+ | `privacySalt` | Required stable, project-specific salt for one-way subject hashes. |
54
57
 
55
- Optional keys include `privacySalt`, `tracer`, and the keys in `@wardx/core` `defaults.json`. If `privacySalt` is empty, the SDK uses `projectKey`. `tracer` is a local diagnostic hook. It does not go over the wire.
58
+ Optional keys include `tracer` and overrides for centralized values in `@wardx/core` `defaults.json`. `maxFrameBytes` is at least `1024`; `experimentStateMaxSubjects` defaults to `100000` and bounds assignment/exposure state in this SDK instance. Missing or empty `privacySalt` is rejected; it is never derived from the project credential. `tracer` is a local diagnostic hook. It does not go over the wire.
56
59
 
57
60
  The SDK starts a bootstrap sync immediately. The SDK then syncs on `syncIntervalMs` with jitter.
58
61
 
@@ -71,7 +74,8 @@ const wardx = createWardx({
71
74
  project: 'demo',
72
75
  role: 'client',
73
76
  appVersion: '2.4.1',
74
- environment: 'production'
77
+ environment: 'production',
78
+ privacySalt: 'demo-subject-hash-v1'
75
79
  });
76
80
 
77
81
  wardx.log.info('match_started', { mode: 'ranked', players: 4 });
@@ -388,7 +392,7 @@ The SDK updates the snapshot when a sync response contains a newer `configVersio
388
392
 
389
393
  The ingest server config can define experiment `message-delay-v1` on key `message.delayMs`. See `@wardx/server`. Variants live on the server. The app still reads the same key.
390
394
 
391
- On a client with one user, call `identify` once after login. Later `config.get` and `experiment.goal` use that subject. On a server that handles many users, pass `{ subjectId }` on every call. Do not `identify()` there: it is process-wide and would mix users.
395
+ On a client with one user, call `identify` once after login. Later `config.get` and `experiment.goal` use that subject. On a server that handles many users, pass `{ subjectId }` on every call. Do not use one SDK instance's default there; it would mix users.
392
396
 
393
397
  ```js
394
398
  wardx.identify(userId);
@@ -419,7 +423,7 @@ The first `config.get` that has a subject in a session can emit event `experimen
419
423
 
420
424
  The payload does not contain the raw `subjectId`.
421
425
 
422
- `experiment.goal` needs a subject: from `identify()` or from `{ subjectId }` on that call. The event includes the known assignments for that subject. Without a subject, the call throws.
426
+ `experiment.goal` needs a subject: from `identify()` or from `{ subjectId }` on that call. It emits nothing until that subject has an exposure whose `goalMetric` matches the goal name; a valid event contains exactly that one assignment. Without a subject, the call throws.
423
427
 
424
428
  ## Use case 11: Continue when the ingest server is down
425
429
 
@@ -434,7 +438,8 @@ const wardx = createWardx({
434
438
  project: 'demo',
435
439
  role: 'client',
436
440
  appVersion: '0.1.0',
437
- environment: 'development'
441
+ environment: 'development',
442
+ privacySalt: 'demo-subject-hash-v1'
438
443
  });
439
444
 
440
445
  wardx.counter('jobs.completed').inc();
@@ -458,7 +463,8 @@ const wardx = createWardx({
458
463
  project: 'demo',
459
464
  role: 'client',
460
465
  appVersion: '0.1.0',
461
- environment: 'production'
466
+ environment: 'production',
467
+ privacySalt: 'demo-subject-hash-v1'
462
468
  });
463
469
 
464
470
  async function onStop() {
@@ -470,7 +476,7 @@ process.on('SIGTERM', onStop);
470
476
  process.on('SIGINT', onStop);
471
477
  ```
472
478
 
473
- `shutdown` is safe to call more than one time. The second call returns immediately.
479
+ `shutdown` is safe to call more than once. Concurrent callers receive the same promise and all await the final flush and single transport close.
474
480
 
475
481
  `flush` sends the current pending frames and does not stop the timers. Use `shutdown` when the process stops.
476
482
 
@@ -490,6 +496,7 @@ const wardx = createWardx({
490
496
  role: 'client',
491
497
  appVersion: '0.1.0',
492
498
  environment: 'development',
499
+ privacySalt: 'demo-subject-hash-v1',
493
500
  tracer: createConsoleTracer()
494
501
  });
495
502
  ```
@@ -579,7 +586,7 @@ The volume funnel `level.start` → `level.fail` / `level.complete` is the diffi
579
586
 
580
587
  Call `experiment.goal('session.duration', { value: durationMs })` when the play session ends (use case 14).
581
588
 
582
- From MCP, after onboarding: `upsert_experiment` on the existing keys (`level.3.enemyHp`, …) with a hypothesis such as "Lower HP on level 3 increases session duration", `primaryMetric: 'session.time_ms'`, `goalKind: 'mean'`, `control`, `minExposures`, `confidence`, and variants that only change those keys. Later `analyze_experiment`: follow `decision` and compare `goalMean` for the duration goal. `ship_experiment` when status is `winner`. Compare the funnel counts with `get_aggregates`. See `@wardx/server` use case 7.
589
+ From MCP, after onboarding: `upsert_experiment` on the existing keys (`level.3.enemyHp`, …) with a hypothesis such as "Lower HP on level 3 increases session duration", `primaryMetric: 'session.time_ms'`, `goalMetric: 'session.duration'`, `assignmentUnitKind: 'session'`, `outcomeKind: 'mean'`, and the complete fixed-horizon sample/time/alpha/effect/direction/health policy. Later `analyze_experiment` uses only trusted, server-deduplicated evidence; follow its persisted terminal `decision` and compare `goalMean`. `ship_experiment` additionally requires the current `expectedVersion` and a reason. Compare current funnel counts with `get_aggregates` and completed baselines with `get_aggregate_history`.
583
590
 
584
591
  ## Use case 16: Surface an error so an agent can open the source
585
592
 
@@ -629,13 +636,13 @@ The log ring is recent only (`recentLogsMax`). It is not a history search. See `
629
636
  | `timer(name, dims)` | Starts a timer. The returned function records milliseconds. |
630
637
  | `event(name, attrs)` | Buffers a product event. |
631
638
  | `log.debug\|info\|warn\|error(message, attrs)` | Buffers a structured log. |
632
- | `identify(subjectId)` | Sets the default subject for this instance. `identify(null)` clears it. Process-wide: do not use on a game-server that serves many users. |
639
+ | `identify(subjectId)` | Sets the default subject for this SDK instance. `identify(null)` clears it. Do not share that default across users on a multi-user server. |
633
640
  | `config.get(key, fallback, context)` | Reads Remote Config. Uses `identify()` or `{ subjectId }`. A per-call `{ subjectId }` overrides `identify()`. Omit both for the shared value. |
634
641
  | `experiment.goal(name, context)` | Emits `experiment.goal`. Needs a subject from `identify()` or `{ subjectId }`. Optional `value` for a quantitative goal such as session duration. |
635
642
  | `flush()` | Sends pending frames now. Returns a Promise. |
636
643
  | `shutdown()` | Stops timers, sends pending frames, and closes the HTTP agent. |
637
644
 
638
- The SDK creates one `instanceId` and one `sessionId` per process. The IDs are ULIDs.
645
+ Each `createWardx()` SDK instance creates its own `instanceId` and `sessionId`. The IDs are ULIDs; they are not process-wide singletons or subject/journey keys.
639
646
 
640
647
  Sync delay is `syncIntervalMs * random(syncJitterMin, syncJitterMax)`. The default interval is 15 seconds. The default jitter is 0.85 to 1.15.
641
648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wardx",
3
- "version": "0.1.7",
3
+ "version": "0.2.2",
4
4
  "description": "Node.js SDK for Wardx telemetry, Remote Config, and experiments.",
5
5
  "keywords": [
6
6
  "wardx",
@@ -37,6 +37,6 @@
37
37
  "src"
38
38
  ],
39
39
  "dependencies": {
40
- "@wardx/core": "0.1.7"
40
+ "@wardx/core": "0.2.2"
41
41
  }
42
- }
42
+ }
package/src/WardxNode.js CHANGED
@@ -23,6 +23,7 @@ export class WardxNode {
23
23
  this._core = new WardxCore(settings);
24
24
  this._transport = createHttpTransport(settings);
25
25
  this._stopped = false;
26
+ this._shutdownPromise = null;
26
27
  this._syncChain = Promise.resolve();
27
28
  this._instanceId = ulid();
28
29
  this._sessionId = ulid();
@@ -70,8 +71,12 @@ export class WardxNode {
70
71
  return this._enqueueSync({ flush: true });
71
72
  }
72
73
 
73
- async shutdown() {
74
- if (this._stopped) return;
74
+ shutdown() {
75
+ if (this._shutdownPromise === null) this._shutdownPromise = this._shutdown();
76
+ return this._shutdownPromise;
77
+ }
78
+
79
+ async _shutdown() {
75
80
  this._stopped = true;
76
81
  clearInterval(this._aggregateTimer);
77
82
  if (this._syncTimer) clearTimeout(this._syncTimer);