wardx 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
@@ -6,6 +6,41 @@ The SDK records logs, events, and metrics. The SDK also gets Remote Config and a
6
6
 
7
7
  A measure call changes local memory only. The SDK sends frames on a timer. The SDK uses HTTP `POST /v1/sync` with JSON and gzip.
8
8
 
9
+ ```text
10
+ AGENT
11
+ arisa.sh / Codex / Claude
12
+ │
13
+ MCP stdio
14
+ tools + wardx://project/{name}
15
+ ▼
16
+ ┌───────────────────────────────────────────────────┐
17
+ │ wardx-server (one process) │
18
+ │ N isolated projects │
19
+ │ │
20
+ │ MCP ──► ControlService │
21
+ │ ├── Remote Config snapshot │
22
+ │ ├── Experiment definitions │
23
+ │ ├── Aggregates │
24
+ │ ├── Recent logs │
25
+ │ └── Catalog │
26
+ │ │
27
+ │ HTTP POST /v1/sync │
28
+ │ ├── envelope store (config.sink) │
29
+ │ │ null | memory | ndjson │
30
+ │ └── per-project ingest │
31
+ │ aggregator, recent logs, clients │
32
+ │ config reply filtered by client.role │
33
+ └─────────────────────────▲─────────────────────────┘
34
+ │
35
+ frames up / that role's config down
36
+ ┌───────────────┴───────────────┐
37
+ ▼ ▼
38
+ Node SDK C# / Unity SDK
39
+ wardx / @wardx/core clients/csharp
40
+ role: game-server role: mobile
41
+ metrics / config.get same /v1/sync
42
+ ```
43
+
9
44
  ## Install
10
45
 
11
46
  ```bash
@@ -26,6 +61,7 @@ To receive frames, run an ingest server. Install `@wardx/server` and start it wi
26
61
  - The application has priority over telemetry.
27
62
  - Remote Config is always read from local memory.
28
63
  - The SDK sends names only. Descriptions live in the server catalog: ship them in the config file, or fill them during MCP onboarding.
64
+ - `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()`.
29
65
 
30
66
  **WARNING:** The SDK does not write a disk queue. The SDK does not retry the same frames.
31
67
 
@@ -332,7 +368,7 @@ function grantCoins(wardx, grant) {
332
368
  2. On the grant path, add the amount to `coins.awarded` and increment `coins.grants`. The dimension is `source`, not a user id.
333
369
  3. Observe the amount in `coins.award_size` with a lookup key (`grantId`). The histogram keeps those attrs only for the window max, as `exemplar`. Histogram `max` and the upper buckets are the inconsistency signal. The exemplar is the row to open in the database.
334
370
  4. Read `economy.maxAward` from Remote Config. Emit `coins.anomaly` only when a grant exceeds that bound. That event is rare.
335
- 5. From MCP, compare `coins.awarded / coins.grants` (mean grant) and `coins.award_size` max against `economy.maxAward`. If max is high, read `exemplar.attrs.grantId`.
371
+ 5. From MCP, the overview ranks histogram outcomes by `max` and includes the exemplar. Compare that max and `coins.awarded / coins.grants` (mean grant) against `economy.maxAward`. If max is high, read `exemplar.attrs.grantId`, then `get_recent_logs` with `coins_anomaly` or that `grantId`. If the role has `path` or `git`, search that checkout for `source` / `reason`. See `@wardx/server` use case 9.
336
372
 
337
373
  Do not put `userId` on a counter or histogram dimension. The SDK and the server cap series. A unique id per player creates a series per player and then drops. An exemplar is one sample per series per window, so a lookup key there does not explode cardinality. Do not `event()` once per grant on a backend that serves many users. Use an event only for the anomaly.
338
374
 
@@ -344,16 +380,28 @@ If histogram max stays at the legal cap and `coins.awarded` tracks completed mat
344
380
 
345
381
  **Objective:** Read the local snapshot. Do not wait for the network on the hot path.
346
382
 
383
+ A call with no subject returns the shared Remote Config value for that role. To vary per person, set a subject with `identify()`, or pass `{ subjectId }` on that call. Use a stable account id (`user.id`, `playerId`). Do not use `sessionId`. The SDK already creates a `sessionId` for the envelope. That id is not a join key and must not be the experiment subject.
384
+
347
385
  ```js
348
386
  const timeoutMs = wardx.config.get('matchmaking.timeoutMs', 5000);
349
387
  const chatEnabled = wardx.config.get('chat.enabled', false);
350
- const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
388
+
389
+ // Single-user process (desktop, one logged-in client)
390
+ wardx.identify(user.id);
391
+ const delayMs = wardx.config.get('message.delayMs', 1000);
392
+
393
+ // Many users in one process (game-server). Do not identify().
394
+ const otherDelayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
351
395
  ```
352
396
 
397
+ `identify(null)` clears the default. After that, `config.get` without `{ subjectId }` is shared Remote Config again. A per-call `{ subjectId }` overrides `identify()`.
398
+
399
+ If there is no identified subject and you omit `{ subjectId }`, that call is not in an experiment.
400
+
353
401
  ### Resolution order
354
402
 
355
403
  1. If the key is not in the snapshot, return the fallback.
356
- 2. If `subjectId` is missing, return the Remote Config value.
404
+ 2. If there is no subject (`identify` unset and no `{ subjectId }`), return the Remote Config value.
357
405
  3. If an experiment applies to the subject, return the variant value.
358
406
 
359
407
  The SDK updates the snapshot when a sync response contains a newer `configVersion`. Until that sync, `config.get` returns the fallback or the last snapshot.
@@ -364,12 +412,21 @@ The SDK updates the snapshot when a sync response contains a newer `configVersio
364
412
 
365
413
  **Objective:** Get the variant value. Then record `experiment.goal`.
366
414
 
367
- The ingest server config can define experiment `message-delay-v1` on key `message.delayMs`. See `@wardx/server`.
415
+ 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.
416
+
417
+ 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.
368
418
 
369
419
  ```js
420
+ wardx.identify(userId);
421
+ const delayMs = wardx.config.get('message.delayMs', 1000);
422
+ setTimeout(() => {
423
+ deliver(text);
424
+ wardx.counter('message.sent').inc();
425
+ wardx.experiment.goal('message.sent', { value: 1 });
426
+ }, delayMs);
427
+
370
428
  function sendMessage(wardx, userId, text) {
371
429
  const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
372
-
373
430
  setTimeout(() => {
374
431
  deliver(text);
375
432
  wardx.counter('message.sent').inc();
@@ -378,7 +435,9 @@ function sendMessage(wardx, userId, text) {
378
435
  }
379
436
  ```
380
437
 
381
- The first `config.get` with a `subjectId` in a session can emit event `experiment.exposure`. The payload contains:
438
+ The assignment is local and deterministic. The same `subjectId`, experiment `id`, and `salt` always map to the same variant. You do not persist the group. You do not ask the server which group the user is in. Changing the experiment `salt` redistributes the population. Keep the salt when you replace the same experiment `id`.
439
+
440
+ The first `config.get` that has a subject in a session can emit event `experiment.exposure`. The payload contains:
382
441
 
383
442
  - `experiment`
384
443
  - `variant`
@@ -386,9 +445,7 @@ The first `config.get` with a `subjectId` in a session can emit event `experimen
386
445
 
387
446
  The payload does not contain the raw `subjectId`.
388
447
 
389
- `experiment.goal` requires `subjectId`. The event includes the known assignments for that subject.
390
-
391
- The assignment is local and deterministic. The same subject, experiment, and salt always get the same variant.
448
+ `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.
392
449
 
393
450
  ## Use case 11: Continue when the ingest server is down
394
451
 
@@ -467,6 +524,125 @@ A tracer is a duck-typed object. Implement any of `measure`, `event`, `log`, `fr
467
524
 
468
525
  The tracer runs on the measure path. Use it in development. Remove `tracer` before production. It does not change frames, delivery, or Remote Config.
469
526
 
527
+ ## Use case 14: Measure play-session duration
528
+
529
+ **When:** You want to maximize how long people play, or how much time the fleet spent in a window.
530
+
531
+ **Objective:** Record a play-session clock in the application. Do not use the SDK `sessionId`.
532
+
533
+ A play session is an interval you own: app open to close, login to logout, or match start to leave. The SDK `sessionId` identifies the envelope. It is not that clock. Wardx does not join events by subject, so it cannot compute duration after the fact.
534
+
535
+ Two signals:
536
+
537
+ 1. **Ended session (distribution + A/B).** When the session ends, observe the elapsed milliseconds. Emit one `experiment.goal` with that value so `analyze_experiment` can split by variant.
538
+ 2. **Fleet play time (accumulated).** Add the same milliseconds to `session.time_ms`. `get_aggregates` then shows how much time the fleet played in that minute. Optional: add a heartbeat while the session is open so a crash still counts the minutes already played.
539
+
540
+ ```js
541
+ const SESSION_BUCKETS = [30_000, 60_000, 180_000, 300_000, 600_000, 1_200_000, 1_800_000, 3_600_000];
542
+
543
+ function onPlaySessionStart(wardx, userId) {
544
+ wardx.identify(userId);
545
+ return { startedAt: Date.now() };
546
+ }
547
+
548
+ function onPlaySessionEnd(wardx, session) {
549
+ const durationMs = Date.now() - session.startedAt;
550
+ wardx.histogram('session.duration', { buckets: SESSION_BUCKETS }).observe(durationMs);
551
+ wardx.counter('session.time_ms').add(durationMs);
552
+ wardx.counter('session.ended').inc();
553
+ wardx.experiment.goal('session.duration', { value: durationMs });
554
+ }
555
+
556
+ function onPlayHeartbeat(wardx, elapsedMs) {
557
+ wardx.counter('session.time_ms').add(elapsedMs);
558
+ }
559
+ ```
560
+
561
+ On a process that serves many users, skip `identify()` and pass `{ subjectId }` on `experiment.goal`.
562
+
563
+ ### Procedure
564
+
565
+ 1. Start a local clock when the play session starts. Do not use `sessionId`.
566
+ 2. When it ends, observe `session.duration` with minute-scale buckets. Default histogram buckets are for short durations in milliseconds.
567
+ 3. Add the same number to `session.time_ms`. Increment `session.ended`.
568
+ 4. Call `experiment.goal('session.duration', { value: durationMs })` with a subject. Emit that goal once per ended session. `analyze_experiment` then has `goalSum`, `goalMean`, and a `decision` per variant. Mean session ms is `goalSum / goals`.
569
+ 5. From MCP, read `session.time_ms` in `get_aggregates` for fleet minutes. Compare variants with `analyze_experiment`, not with a counter dimension. Ship a winner with `ship_experiment`.
570
+
571
+ Do not put `userId` on the histogram. Do not emit `experiment.goal` on every heartbeat: that would count many goals for one session. The heartbeat only adds to `session.time_ms`.
572
+
573
+ If you only increment `session.time_ms` and never emit the goal, MCP can still see fleet play time. It cannot compare variants. One experiment should have one quantitative `experiment.goal` name. Mixing a duration value with a `value: 1` conversion on the same experiment corrupts `goalMean`.
574
+
575
+ ## Use case 15: A/B test level difficulty to increase session time
576
+
577
+ **When:** You suspect a level is too hard or too easy, and you want longer sessions.
578
+
579
+ **Objective:** Put the difficulty knobs in Remote Config. Measure starts, fails, and completes. Experiment on those knobs. Use session duration from use case 14 as the goal.
580
+
581
+ The keys must already exist in Remote Config. The game reads them with `config.get`. Variants may only change those keys.
582
+
583
+ ```js
584
+ function onLevelStart(wardx, userId, levelId) {
585
+ const enemyHp = wardx.config.get(`level.${levelId}.enemyHp`, 100, { subjectId: userId });
586
+ wardx.event('level.start', { level: levelId });
587
+ wardx.counter('level.start', { level: levelId }).inc();
588
+ return enemyHp;
589
+ }
590
+
591
+ function onLevelFail(wardx, levelId) {
592
+ wardx.event('level.fail', { level: levelId });
593
+ wardx.counter('level.fail', { level: levelId }).inc();
594
+ }
595
+
596
+ function onLevelComplete(wardx, levelId) {
597
+ wardx.event('level.complete', { level: levelId });
598
+ wardx.counter('level.complete', { level: levelId }).inc();
599
+ }
600
+ ```
601
+
602
+ `level` is a small set of ids. Do not put a unique run id on the counter.
603
+
604
+ The volume funnel `level.start` → `level.fail` / `level.complete` is the difficulty signal. A high fail-to-start ratio means the level is hard. That comparison is counts in one window, not unique players. Keep `level.complete` as a counter. Do not also emit `experiment.goal` for it if the experiment goal is `session.duration`.
605
+
606
+ Call `experiment.goal('session.duration', { value: durationMs })` when the play session ends (use case 14).
607
+
608
+ 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.
609
+
610
+ ## Use case 16: Surface an error so an agent can open the source
611
+
612
+ **When:** A server or client fails and you want an agent to see enough to patch the file.
613
+
614
+ **Objective:** Count the failure. Log the error with a stack or a provider code. Wardx does not edit source. MCP returns the row. The agent uses `path` or `git` on that role, plus its own file permissions, to change the code.
615
+
616
+ ```js
617
+ function handleCheckout(req, res, wardx) {
618
+ try {
619
+ charge(req.body);
620
+ wardx.counter('payment.ok').inc();
621
+ } catch (err) {
622
+ wardx.counter('payment.error', { code: err.code || 'unknown' }).inc();
623
+ wardx.log.error('payment_failed', {
624
+ name: err.name,
625
+ code: err.code || 'unknown',
626
+ stack: clipStack(err)
627
+ });
628
+ res.statusCode = 500;
629
+ res.end();
630
+ }
631
+ }
632
+
633
+ function clipStack(err, max = 4096) {
634
+ const stack = err instanceof Error ? err.stack : String(err);
635
+ if (!stack) return null;
636
+ return stack.length <= max ? stack : stack.slice(0, max);
637
+ }
638
+ ```
639
+
640
+ `stack` is an attr string. Do not send the Error object.
641
+
642
+ From MCP: `get_aggregates` for the rate, then `get_recent_logs` with `level: 'error'` and the message. If the role has `path` or `git` in the catalog, the agent opens that checkout and edits there. If those fields are empty, Wardx has no source hint. Do not invent a path.
643
+
644
+ The log ring is recent only (`recentLogsMax`). It is not a history search. See `@wardx/server` use case 8.
645
+
470
646
  ## API
471
647
 
472
648
  | Call | Description |
@@ -479,8 +655,9 @@ The tracer runs on the measure path. Use it in development. Remove `tracer` befo
479
655
  | `timer(name, dims)` | Starts a timer. The returned function records milliseconds. |
480
656
  | `event(name, attrs)` | Buffers a product event. |
481
657
  | `log.debug\|info\|warn\|error(message, attrs)` | Buffers a structured log. |
482
- | `config.get(key, fallback, context)` | Reads Remote Config. `context.subjectId` enables experiments. |
483
- | `experiment.goal(name, context)` | Emits `experiment.goal`. `context.subjectId` is required. |
658
+ | `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. |
659
+ | `config.get(key, fallback, context)` | Reads Remote Config. Uses `identify()` or `{ subjectId }`. A per-call `{ subjectId }` overrides `identify()`. Omit both for the shared value. |
660
+ | `experiment.goal(name, context)` | Emits `experiment.goal`. Needs a subject from `identify()` or `{ subjectId }`. Optional `value` for a quantitative goal such as session duration. |
484
661
  | `flush()` | Sends pending frames now. Returns a Promise. |
485
662
  | `shutdown()` | Stops timers, sends pending frames, and closes the HTTP agent. |
486
663
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wardx",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Node.js SDK for Wardx telemetry, Remote Config, and experiments.",
5
5
  "keywords": [
6
6
  "wardx",
@@ -26,12 +26,17 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "exports": {
29
- ".": "./src/index.js"
29
+ ".": {
30
+ "types": "./src/index.d.ts",
31
+ "import": "./src/index.js",
32
+ "default": "./src/index.js"
33
+ }
30
34
  },
35
+ "types": "./src/index.d.ts",
31
36
  "files": [
32
37
  "src"
33
38
  ],
34
39
  "dependencies": {
35
- "@wardx/core": "0.1.2"
40
+ "@wardx/core": "0.1.4"
36
41
  }
37
- }
42
+ }
package/src/WardxNode.js CHANGED
@@ -42,6 +42,10 @@ export class WardxNode {
42
42
  this._enqueueSync({ bootstrap: true });
43
43
  }
44
44
 
45
+ identify(subjectId) {
46
+ this._core.identify(subjectId);
47
+ }
48
+
45
49
  counter(name, dims) {
46
50
  return this._core.counter(name, dims);
47
51
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type {
2
+ Attrs,
3
+ ConfigSnapshot,
4
+ ConfigValue,
5
+ CoreSettings,
6
+ CounterHandle,
7
+ CreateWardxOptions,
8
+ DimensionValue,
9
+ Dimensions,
10
+ EventTraceRecord,
11
+ Experiment,
12
+ ExperimentGoalContext,
13
+ ExperimentVariant,
14
+ FrameTraceRecord,
15
+ GaugeHandle,
16
+ HistogramHandle,
17
+ HistogramOptions,
18
+ LogApi,
19
+ LogLevel,
20
+ LogTraceRecord,
21
+ MeasureTraceRecord,
22
+ ResolvedSettings,
23
+ SdkDefaults,
24
+ StopTimer,
25
+ SubjectContext,
26
+ SyncTraceRecord,
27
+ Tracer
28
+ } from '@wardx/core';
29
+
30
+ export type {
31
+ Attrs,
32
+ ConfigSnapshot,
33
+ ConfigValue,
34
+ CoreSettings,
35
+ CounterHandle,
36
+ CreateWardxOptions,
37
+ DimensionValue,
38
+ Dimensions,
39
+ EventTraceRecord,
40
+ Experiment,
41
+ ExperimentGoalContext,
42
+ ExperimentVariant,
43
+ FrameTraceRecord,
44
+ GaugeHandle,
45
+ HistogramHandle,
46
+ HistogramOptions,
47
+ LogApi,
48
+ LogLevel,
49
+ LogTraceRecord,
50
+ MeasureTraceRecord,
51
+ ResolvedSettings,
52
+ SdkDefaults,
53
+ StopTimer,
54
+ SubjectContext,
55
+ SyncTraceRecord,
56
+ Tracer
57
+ };
58
+
59
+ export interface ConsoleTracerOptions {
60
+ stream?: { write(chunk: string): unknown };
61
+ }
62
+
63
+ export interface ConfigApi {
64
+ get<T>(key: string, fallback: T, context?: SubjectContext): T;
65
+ }
66
+
67
+ export interface ExperimentApi {
68
+ goal(name: string, context?: ExperimentGoalContext): void;
69
+ }
70
+
71
+ export class WardxNode {
72
+ settings: ResolvedSettings;
73
+ log: LogApi;
74
+ config: ConfigApi;
75
+ experiment: ExperimentApi;
76
+ constructor(settings: ResolvedSettings);
77
+ identify(subjectId: string | null | undefined): void;
78
+ counter(name: string, dims?: Dimensions | null): CounterHandle;
79
+ gauge(name: string, dims?: Dimensions | null): GaugeHandle;
80
+ histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
81
+ timer(name: string, dims?: Dimensions | null): StopTimer;
82
+ event(name: string, attrs?: Attrs | null): void;
83
+ flush(): Promise<void>;
84
+ shutdown(): Promise<void>;
85
+ }
86
+
87
+ export function createWardx(options: CreateWardxOptions): WardxNode;
88
+ export function createConsoleTracer(options?: ConsoleTracerOptions): Tracer;