wardx 0.1.2 → 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 +152 -10
- package/package.json +2 -2
- package/src/WardxNode.js +4 -0
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
|
|
|
@@ -344,16 +345,28 @@ If histogram max stays at the legal cap and `coins.awarded` tracks completed mat
|
|
|
344
345
|
|
|
345
346
|
**Objective:** Read the local snapshot. Do not wait for the network on the hot path.
|
|
346
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
|
+
|
|
347
350
|
```js
|
|
348
351
|
const timeoutMs = wardx.config.get('matchmaking.timeoutMs', 5000);
|
|
349
352
|
const chatEnabled = wardx.config.get('chat.enabled', false);
|
|
350
|
-
|
|
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 });
|
|
351
360
|
```
|
|
352
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
|
+
|
|
353
366
|
### Resolution order
|
|
354
367
|
|
|
355
368
|
1. If the key is not in the snapshot, return the fallback.
|
|
356
|
-
2. If `
|
|
369
|
+
2. If there is no subject (`identify` unset and no `{ subjectId }`), return the Remote Config value.
|
|
357
370
|
3. If an experiment applies to the subject, return the variant value.
|
|
358
371
|
|
|
359
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.
|
|
@@ -364,12 +377,21 @@ The SDK updates the snapshot when a sync response contains a newer `configVersio
|
|
|
364
377
|
|
|
365
378
|
**Objective:** Get the variant value. Then record `experiment.goal`.
|
|
366
379
|
|
|
367
|
-
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.
|
|
368
383
|
|
|
369
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
|
+
|
|
370
393
|
function sendMessage(wardx, userId, text) {
|
|
371
394
|
const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
|
|
372
|
-
|
|
373
395
|
setTimeout(() => {
|
|
374
396
|
deliver(text);
|
|
375
397
|
wardx.counter('message.sent').inc();
|
|
@@ -378,7 +400,9 @@ function sendMessage(wardx, userId, text) {
|
|
|
378
400
|
}
|
|
379
401
|
```
|
|
380
402
|
|
|
381
|
-
The
|
|
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:
|
|
382
406
|
|
|
383
407
|
- `experiment`
|
|
384
408
|
- `variant`
|
|
@@ -386,9 +410,7 @@ The first `config.get` with a `subjectId` in a session can emit event `experimen
|
|
|
386
410
|
|
|
387
411
|
The payload does not contain the raw `subjectId`.
|
|
388
412
|
|
|
389
|
-
`experiment.goal`
|
|
390
|
-
|
|
391
|
-
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.
|
|
392
414
|
|
|
393
415
|
## Use case 11: Continue when the ingest server is down
|
|
394
416
|
|
|
@@ -467,6 +489,125 @@ A tracer is a duck-typed object. Implement any of `measure`, `event`, `log`, `fr
|
|
|
467
489
|
|
|
468
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.
|
|
469
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
|
+
|
|
470
611
|
## API
|
|
471
612
|
|
|
472
613
|
| Call | Description |
|
|
@@ -479,8 +620,9 @@ The tracer runs on the measure path. Use it in development. Remove `tracer` befo
|
|
|
479
620
|
| `timer(name, dims)` | Starts a timer. The returned function records milliseconds. |
|
|
480
621
|
| `event(name, attrs)` | Buffers a product event. |
|
|
481
622
|
| `log.debug\|info\|warn\|error(message, attrs)` | Buffers a structured log. |
|
|
482
|
-
| `
|
|
483
|
-
| `
|
|
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. |
|
|
484
626
|
| `flush()` | Sends pending frames now. Returns a Promise. |
|
|
485
627
|
| `shutdown()` | Stops timers, sends pending frames, and closes the HTTP agent. |
|
|
486
628
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wardx",
|
|
3
|
-
"version": "0.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.
|
|
35
|
+
"@wardx/core": "0.1.3"
|
|
36
36
|
}
|
|
37
37
|
}
|