wardx 0.1.1 → 0.1.3

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
@@ -26,6 +26,7 @@ To receive frames, run an ingest server. Install `@wardx/server` and start it wi
26
26
  - The application has priority over telemetry.
27
27
  - Remote Config is always read from local memory.
28
28
  - The SDK sends names only. Descriptions live in the server catalog: ship them in the config file, or fill them during MCP onboarding.
29
+ - `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
30
 
30
31
  **WARNING:** The SDK does not write a disk queue. The SDK does not retry the same frames.
31
32
 
@@ -111,7 +112,7 @@ function completeMatch(wardx, mode) {
111
112
  }
112
113
  ```
113
114
 
114
- To detect abnormal grants, pair this counter with a histogram and a rare anomaly event. See use case 7.
115
+ To detect abnormal grants, pair this counter with a histogram and a rare anomaly event. See use case 8.
115
116
 
116
117
  ### Procedure
117
118
 
@@ -257,7 +258,47 @@ An event is one row in the frame. A counter is a window sum. Use both when you n
257
258
 
258
259
  If the event buffer is full, the SDK discards the new event and increments `wardx.internal.events_dropped`.
259
260
 
260
- ## Use case 7: Detect abnormal point accumulation
261
+ ## Use case 7: Measure a volume funnel
262
+
263
+ **When:** You need drop-off between screens or steps in a client, for example onboarding or checkout.
264
+
265
+ **Objective:** Emit one named event and one counter per step. Compare those counts. Do not reconstruct a per-user path.
266
+
267
+ Wardx does not store a user journey. Delivery is at-most-once. Production discards envelopes after ingest (`sink: "null"`). The aggregator counts events by name and role. Event attrs do not split that count. `sessionId` identifies the envelope. It is not a join key. There are no unique users, no ordered sequences, and no time between steps.
268
+
269
+ Give each step its own name. Do not reuse `screen.view` with a `surface` attr as the funnel. Use `surface` only as a counter dimension when you also need a breakdown of one step.
270
+
271
+ ```js
272
+ function onOnboardingStart(wardx, channel) {
273
+ wardx.event('onboarding.start', { channel });
274
+ wardx.counter('onboarding.start', { channel }).inc();
275
+ }
276
+
277
+ function onOnboardingProfile(wardx) {
278
+ wardx.event('onboarding.profile');
279
+ wardx.counter('onboarding.profile').inc();
280
+ }
281
+
282
+ function onOnboardingDone(wardx, userId) {
283
+ wardx.event('onboarding.done');
284
+ wardx.counter('onboarding.done').inc();
285
+ wardx.experiment.goal('onboarding.done', { subjectId: userId });
286
+ }
287
+ ```
288
+
289
+ ### Procedure
290
+
291
+ 1. Pick a prefix and a name per step: `onboarding.start`, `onboarding.profile`, `onboarding.done`.
292
+ 2. On the client path for that step, call `event` and `counter` with the same name.
293
+ 3. Put only low-cardinality attrs on the event. Put the breakdown you need to compare (`channel`, `mode`) on the counter dimensions.
294
+ 4. If the last step is an experiment conversion, also call `experiment.goal` with `subjectId`. That is one conversion, not an N-step funnel.
295
+ 5. From MCP, call `get_aggregates` with those names. Compare counter totals, or `eventNames` counts, in the same window. The drop from start to done is the volume funnel.
296
+
297
+ On a backend that serves many users, increment the counters in process. Do not `event()` once per user action.
298
+
299
+ Do not put `userId` on a counter dimension. Do not expect Mixpanel-style unique-user funnels from Wardx.
300
+
301
+ ## Use case 8: Detect abnormal point accumulation
261
302
 
262
303
  **When:** A game grants points, coins, or XP. You need to see whether the economy is consistent, or whether grants jumped outside the normal range.
263
304
 
@@ -298,38 +339,59 @@ Do not put `userId` on a counter or histogram dimension. The SDK and the server
298
339
 
299
340
  If histogram max stays at the legal cap and `coins.awarded` tracks completed matches times the known reward, the economy is consistent at fleet scale. A specific player still requires the database audit log.
300
341
 
301
- ## Use case 8: Get Remote Config for a user
342
+ ## Use case 9: Get Remote Config for a user
302
343
 
303
344
  **When:** The server has a config snapshot. You need a value in the application.
304
345
 
305
346
  **Objective:** Read the local snapshot. Do not wait for the network on the hot path.
306
347
 
348
+ 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.
349
+
307
350
  ```js
308
351
  const timeoutMs = wardx.config.get('matchmaking.timeoutMs', 5000);
309
352
  const chatEnabled = wardx.config.get('chat.enabled', false);
310
- const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
353
+
354
+ // Single-user process (desktop, one logged-in client)
355
+ wardx.identify(user.id);
356
+ const delayMs = wardx.config.get('message.delayMs', 1000);
357
+
358
+ // Many users in one process (game-server). Do not identify().
359
+ const otherDelayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
311
360
  ```
312
361
 
362
+ `identify(null)` clears the default. After that, `config.get` without `{ subjectId }` is shared Remote Config again. A per-call `{ subjectId }` overrides `identify()`.
363
+
364
+ If there is no identified subject and you omit `{ subjectId }`, that call is not in an experiment.
365
+
313
366
  ### Resolution order
314
367
 
315
368
  1. If the key is not in the snapshot, return the fallback.
316
- 2. If `subjectId` is missing, return the Remote Config value.
369
+ 2. If there is no subject (`identify` unset and no `{ subjectId }`), return the Remote Config value.
317
370
  3. If an experiment applies to the subject, return the variant value.
318
371
 
319
372
  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.
320
373
 
321
- ## Use case 9: Run an A/B experiment and record a goal
374
+ ## Use case 10: Run an A/B experiment and record a goal
322
375
 
323
376
  **When:** A Remote Config key is in an experiment. You need a variant for a user. You need a goal event.
324
377
 
325
378
  **Objective:** Get the variant value. Then record `experiment.goal`.
326
379
 
327
- The ingest server config can define experiment `message-delay-v1` on key `message.delayMs`. See `@wardx/server`.
380
+ 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.
381
+
382
+ 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.
328
383
 
329
384
  ```js
385
+ wardx.identify(userId);
386
+ const delayMs = wardx.config.get('message.delayMs', 1000);
387
+ setTimeout(() => {
388
+ deliver(text);
389
+ wardx.counter('message.sent').inc();
390
+ wardx.experiment.goal('message.sent', { value: 1 });
391
+ }, delayMs);
392
+
330
393
  function sendMessage(wardx, userId, text) {
331
394
  const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
332
-
333
395
  setTimeout(() => {
334
396
  deliver(text);
335
397
  wardx.counter('message.sent').inc();
@@ -338,7 +400,9 @@ function sendMessage(wardx, userId, text) {
338
400
  }
339
401
  ```
340
402
 
341
- The first `config.get` with a `subjectId` in a session can emit event `experiment.exposure`. The payload contains:
403
+ 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`.
404
+
405
+ The first `config.get` that has a subject in a session can emit event `experiment.exposure`. The payload contains:
342
406
 
343
407
  - `experiment`
344
408
  - `variant`
@@ -346,11 +410,9 @@ The first `config.get` with a `subjectId` in a session can emit event `experimen
346
410
 
347
411
  The payload does not contain the raw `subjectId`.
348
412
 
349
- `experiment.goal` requires `subjectId`. The event includes the known assignments for that subject.
350
-
351
- The assignment is local and deterministic. The same subject, experiment, and salt always get the same variant.
413
+ `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.
352
414
 
353
- ## Use case 10: Continue when the ingest server is down
415
+ ## Use case 11: Continue when the ingest server is down
354
416
 
355
417
  **When:** The network fails, or the ingest server is not available.
356
418
 
@@ -374,7 +436,7 @@ The SDK still records in memory. A failed sync increments `wardx.internal.frames
374
436
 
375
437
  Do not use this SDK if you must not lose events. This SDK is best-effort.
376
438
 
377
- ## Use case 11: Stop the SDK in a graceful shutdown
439
+ ## Use case 12: Stop the SDK in a graceful shutdown
378
440
 
379
441
  **When:** The process receives `SIGTERM` or you stop a test.
380
442
 
@@ -403,7 +465,7 @@ process.on('SIGINT', onStop);
403
465
 
404
466
  `flush` sends the current pending frames and does not stop the timers. Use `shutdown` when the process stops.
405
467
 
406
- ## Use case 12: Trace measure calls while instrumenting
468
+ ## Use case 13: Trace measure calls while instrumenting
407
469
 
408
470
  **When:** You are adding counters, events, and logs and you want to see each call and each sync on stderr.
409
471
 
@@ -427,6 +489,125 @@ A tracer is a duck-typed object. Implement any of `measure`, `event`, `log`, `fr
427
489
 
428
490
  The tracer runs on the measure path. Use it in development. Remove `tracer` before production. It does not change frames, delivery, or Remote Config.
429
491
 
492
+ ## Use case 14: Measure play-session duration
493
+
494
+ **When:** You want to maximize how long people play, or how much time the fleet spent in a window.
495
+
496
+ **Objective:** Record a play-session clock in the application. Do not use the SDK `sessionId`.
497
+
498
+ 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.
499
+
500
+ Two signals:
501
+
502
+ 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.
503
+ 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.
504
+
505
+ ```js
506
+ const SESSION_BUCKETS = [30_000, 60_000, 180_000, 300_000, 600_000, 1_200_000, 1_800_000, 3_600_000];
507
+
508
+ function onPlaySessionStart(wardx, userId) {
509
+ wardx.identify(userId);
510
+ return { startedAt: Date.now() };
511
+ }
512
+
513
+ function onPlaySessionEnd(wardx, session) {
514
+ const durationMs = Date.now() - session.startedAt;
515
+ wardx.histogram('session.duration', { buckets: SESSION_BUCKETS }).observe(durationMs);
516
+ wardx.counter('session.time_ms').add(durationMs);
517
+ wardx.counter('session.ended').inc();
518
+ wardx.experiment.goal('session.duration', { value: durationMs });
519
+ }
520
+
521
+ function onPlayHeartbeat(wardx, elapsedMs) {
522
+ wardx.counter('session.time_ms').add(elapsedMs);
523
+ }
524
+ ```
525
+
526
+ On a process that serves many users, skip `identify()` and pass `{ subjectId }` on `experiment.goal`.
527
+
528
+ ### Procedure
529
+
530
+ 1. Start a local clock when the play session starts. Do not use `sessionId`.
531
+ 2. When it ends, observe `session.duration` with minute-scale buckets. Default histogram buckets are for short durations in milliseconds.
532
+ 3. Add the same number to `session.time_ms`. Increment `session.ended`.
533
+ 4. Call `experiment.goal('session.duration', { value: durationMs })` with a subject. Emit that goal once per ended session. `analyze_experiment` then has `goalSum` and `goalMean` per variant. Mean session ms is `goalSum / goals`.
534
+ 5. From MCP, read `session.time_ms` in `get_aggregates` for fleet minutes. Compare variants with `analyze_experiment`, not with a counter dimension.
535
+
536
+ 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`.
537
+
538
+ 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`.
539
+
540
+ ## Use case 15: A/B test level difficulty to increase session time
541
+
542
+ **When:** You suspect a level is too hard or too easy, and you want longer sessions.
543
+
544
+ **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.
545
+
546
+ The keys must already exist in Remote Config. The game reads them with `config.get`. Variants may only change those keys.
547
+
548
+ ```js
549
+ function onLevelStart(wardx, userId, levelId) {
550
+ const enemyHp = wardx.config.get(`level.${levelId}.enemyHp`, 100, { subjectId: userId });
551
+ wardx.event('level.start', { level: levelId });
552
+ wardx.counter('level.start', { level: levelId }).inc();
553
+ return enemyHp;
554
+ }
555
+
556
+ function onLevelFail(wardx, levelId) {
557
+ wardx.event('level.fail', { level: levelId });
558
+ wardx.counter('level.fail', { level: levelId }).inc();
559
+ }
560
+
561
+ function onLevelComplete(wardx, levelId) {
562
+ wardx.event('level.complete', { level: levelId });
563
+ wardx.counter('level.complete', { level: levelId }).inc();
564
+ }
565
+ ```
566
+
567
+ `level` is a small set of ids. Do not put a unique run id on the counter.
568
+
569
+ 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`.
570
+
571
+ Call `experiment.goal('session.duration', { value: durationMs })` when the play session ends (use case 14).
572
+
573
+ 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'`, and variants that only change those keys. Later `analyze_experiment`: compare `goalMean` for the duration goal. Compare the funnel counts with `get_aggregates`. See `@wardx/server` use case 7.
574
+
575
+ ## Use case 16: Surface an error so an agent can open the source
576
+
577
+ **When:** A server or client fails and you want an agent to see enough to patch the file.
578
+
579
+ **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.
580
+
581
+ ```js
582
+ function handleCheckout(req, res, wardx) {
583
+ try {
584
+ charge(req.body);
585
+ wardx.counter('payment.ok').inc();
586
+ } catch (err) {
587
+ wardx.counter('payment.error', { code: err.code || 'unknown' }).inc();
588
+ wardx.log.error('payment_failed', {
589
+ name: err.name,
590
+ code: err.code || 'unknown',
591
+ stack: clipStack(err)
592
+ });
593
+ res.statusCode = 500;
594
+ res.end();
595
+ }
596
+ }
597
+
598
+ function clipStack(err, max = 4096) {
599
+ const stack = err instanceof Error ? err.stack : String(err);
600
+ if (!stack) return null;
601
+ return stack.length <= max ? stack : stack.slice(0, max);
602
+ }
603
+ ```
604
+
605
+ `stack` is an attr string. Do not send the Error object.
606
+
607
+ 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.
608
+
609
+ The log ring is recent only (`recentLogsMax`). It is not a history search. See `@wardx/server` use case 8.
610
+
430
611
  ## API
431
612
 
432
613
  | Call | Description |
@@ -439,8 +620,9 @@ The tracer runs on the measure path. Use it in development. Remove `tracer` befo
439
620
  | `timer(name, dims)` | Starts a timer. The returned function records milliseconds. |
440
621
  | `event(name, attrs)` | Buffers a product event. |
441
622
  | `log.debug\|info\|warn\|error(message, attrs)` | Buffers a structured log. |
442
- | `config.get(key, fallback, context)` | Reads Remote Config. `context.subjectId` enables experiments. |
443
- | `experiment.goal(name, context)` | Emits `experiment.goal`. `context.subjectId` is required. |
623
+ | `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. |
624
+ | `config.get(key, fallback, context)` | Reads Remote Config. Uses `identify()` or `{ subjectId }`. A per-call `{ subjectId }` overrides `identify()`. Omit both for the shared value. |
625
+ | `experiment.goal(name, context)` | Emits `experiment.goal`. Needs a subject from `identify()` or `{ subjectId }`. Optional `value` for a quantitative goal such as session duration. |
444
626
  | `flush()` | Sends pending frames now. Returns a Promise. |
445
627
  | `shutdown()` | Stops timers, sends pending frames, and closes the HTTP agent. |
446
628
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wardx",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Node.js SDK for Wardx telemetry, Remote Config, and experiments.",
5
5
  "keywords": [
6
6
  "wardx",
@@ -32,6 +32,6 @@
32
32
  "src"
33
33
  ],
34
34
  "dependencies": {
35
- "@wardx/core": "0.1.1"
35
+ "@wardx/core": "0.1.3"
36
36
  }
37
- }
37
+ }
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
  }