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 +199 -17
- package/package.json +3 -3
- 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
|
|
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
443
|
-
| `
|
|
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.
|
|
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
|
+
}
|