wardx 0.7.0 → 0.9.0
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 +237 -105
- package/package.json +2 -2
- package/src/WardxNode.js +37 -9
- package/src/disabled.js +19 -0
- package/src/index.d.ts +6 -3
- package/src/index.js +4 -1
package/README.md
CHANGED
|
@@ -8,6 +8,26 @@ The SDK records logs, events, and metrics. The SDK also gets Remote Config and a
|
|
|
8
8
|
|
|
9
9
|
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.
|
|
10
10
|
|
|
11
|
+
## Disable the SDK
|
|
12
|
+
|
|
13
|
+
`enabled` defaults to `true`. Set it when creating the client:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const wardx = createWardx({ enabled: false });
|
|
17
|
+
const requests = wardx.counter('requests');
|
|
18
|
+
requests.inc();
|
|
19
|
+
await wardx.shutdown();
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
When disabled, credentials and other connection options are unnecessary. Metric
|
|
23
|
+
handles, timers, events, logs, identity, retention, and experiment goals do nothing.
|
|
24
|
+
The client does not initialize the telemetry engine, transport, or background
|
|
25
|
+
timers, and does not hash identifiers or call the tracer. `flush()` and `shutdown()`
|
|
26
|
+
resolve immediately. `config.get(key, fallback)` returns the supplied fallback.
|
|
27
|
+
|
|
28
|
+
The mode is fixed at creation; changing the options or `settings` afterward does
|
|
29
|
+
not toggle it. Cached metric handles remain safe to call, including after shutdown.
|
|
30
|
+
|
|
11
31
|
## Install
|
|
12
32
|
|
|
13
33
|
```bash
|
|
@@ -43,7 +63,7 @@ To receive frames, run an ingest server. Install `@wardx/server` and start it wi
|
|
|
43
63
|
|
|
44
64
|
## Start the SDK
|
|
45
65
|
|
|
46
|
-
`createWardx` requires these keys:
|
|
66
|
+
When enabled, `createWardx` requires these keys:
|
|
47
67
|
|
|
48
68
|
| Key | Description |
|
|
49
69
|
| --- | --- |
|
|
@@ -59,6 +79,48 @@ Optional keys include `tracer` and overrides for centralized values in `@wardx/c
|
|
|
59
79
|
|
|
60
80
|
The SDK starts a bootstrap sync immediately. The SDK then syncs on `syncIntervalMs` with jitter.
|
|
61
81
|
|
|
82
|
+
## Recommended: bind once, measure through handles
|
|
83
|
+
|
|
84
|
+
Create metric handles once per client and stable name/dimension combination,
|
|
85
|
+
then reuse them in handlers, callbacks, and loops. This avoids repeated dimension
|
|
86
|
+
validation, series-key construction, and registry lookup. Normal aggregation
|
|
87
|
+
windows and flushes reset values, not handles. Rebind when replacing the client;
|
|
88
|
+
do not mutate a dimension dictionary to retarget an existing handle.
|
|
89
|
+
|
|
90
|
+
For varying dimensions, bind one recorder per application-owned, bounded value
|
|
91
|
+
set (mode, region, source). Never build an unbounded handle cache keyed by user
|
|
92
|
+
IDs or arbitrary input. Keep histogram buckets fixed. Timer tokens measure one
|
|
93
|
+
operation: create a fresh token for each operation, not one token for the client.
|
|
94
|
+
For a hot duration path, reuse a histogram and observe an application-measured
|
|
95
|
+
elapsed duration instead. Events, logs, retention, and experiment goals remain
|
|
96
|
+
per-occurrence calls.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
function createMatchTelemetry(wardx, mode) {
|
|
100
|
+
const completed = wardx.counter('match.completed', { mode });
|
|
101
|
+
const duration = wardx.histogram('match.duration_ms', {
|
|
102
|
+
mode, buckets: [30_000, 60_000, 180_000, 600_000, 1_800_000]
|
|
103
|
+
});
|
|
104
|
+
const players = wardx.distinct('match.players', { mode });
|
|
105
|
+
return {
|
|
106
|
+
onCompleted(durationMs, userId) {
|
|
107
|
+
completed.inc();
|
|
108
|
+
duration.observe(durationMs);
|
|
109
|
+
players.add(userId);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rankedTelemetry = createMatchTelemetry(wardx, 'ranked');
|
|
115
|
+
// In each ranked-match callback:
|
|
116
|
+
rankedTelemetry.onCompleted(durationMs, userId);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The same setup works with `enabled: false`: cached handles are inert. An inline
|
|
120
|
+
lookup is valid for occasional instrumentation; prefer stored handles for
|
|
121
|
+
recurring work. API reference tables show lookup and measurement together only
|
|
122
|
+
to identify the methods.
|
|
123
|
+
|
|
62
124
|
## Use case 1: Instrument a Node.js service
|
|
63
125
|
|
|
64
126
|
**When:** You run a Node.js process and you need telemetry.
|
|
@@ -80,10 +142,14 @@ const wardx = createWardx({
|
|
|
80
142
|
|
|
81
143
|
wardx.log.info('match_started', { mode: 'ranked', players: 4 });
|
|
82
144
|
wardx.event('match.started', { mode: 'ranked', country: 'AR' });
|
|
83
|
-
wardx.counter('match.completed', { mode: 'ranked' })
|
|
84
|
-
|
|
85
|
-
wardx.
|
|
86
|
-
|
|
145
|
+
const matchCompleted = wardx.counter('match.completed', { mode: 'ranked' });
|
|
146
|
+
matchCompleted.inc();
|
|
147
|
+
const playersOnline = wardx.gauge('players.online');
|
|
148
|
+
playersOnline.set(12);
|
|
149
|
+
const requestDuration = wardx.histogram('request.duration', { buckets: [10, 25, 50, 100, 250] });
|
|
150
|
+
requestDuration.observe(42);
|
|
151
|
+
const shotTrafficHids = wardx.distinct('shot.traffic.hids', { result: 'violating' });
|
|
152
|
+
shotTrafficHids.add(hid);
|
|
87
153
|
|
|
88
154
|
const end = wardx.timer('matchmaking.duration');
|
|
89
155
|
end({ result: 'success' });
|
|
@@ -108,21 +174,26 @@ Pass the ingest URL, project key, and project name in `createWardx`.
|
|
|
108
174
|
**Objective:** Use `inc()` for one occurrence. Use `add(n)` for a finite sum.
|
|
109
175
|
|
|
110
176
|
```js
|
|
111
|
-
|
|
112
|
-
|
|
177
|
+
const requests = wardx.counter('http.requests', { route: 'matchmaking' });
|
|
178
|
+
const errors = wardx.counter('http.errors', { route: 'matchmaking', code: 500 });
|
|
179
|
+
const coinsAwarded = wardx.counter('coins.awarded', { source: 'match' });
|
|
180
|
+
const completedByMode = new Map(['ranked', 'casual'].map((mode) =>
|
|
181
|
+
[mode, wardx.counter('match.completed', { mode })]
|
|
182
|
+
));
|
|
183
|
+
|
|
184
|
+
function handleRequest(req, res) {
|
|
113
185
|
requests.inc();
|
|
114
|
-
|
|
115
|
-
if (res.statusCode >= 500) {
|
|
116
|
-
wardx.counter('http.errors', { route: 'matchmaking', code: 500 }).inc();
|
|
117
|
-
}
|
|
186
|
+
if (res.statusCode >= 500) errors.inc();
|
|
118
187
|
}
|
|
119
188
|
|
|
120
|
-
function grantCoins(
|
|
121
|
-
|
|
189
|
+
function grantCoins(amount) {
|
|
190
|
+
coinsAwarded.add(amount);
|
|
122
191
|
}
|
|
123
192
|
|
|
124
|
-
function completeMatch(
|
|
125
|
-
|
|
193
|
+
function completeMatch(mode) {
|
|
194
|
+
const completed = completedByMode.get(mode);
|
|
195
|
+
if (!completed) throw new Error(`Unsupported match mode: ${mode}`);
|
|
196
|
+
completed.inc();
|
|
126
197
|
}
|
|
127
198
|
```
|
|
128
199
|
|
|
@@ -131,7 +202,7 @@ To detect abnormal grants, pair this counter with a histogram and a rare anomaly
|
|
|
131
202
|
### Procedure
|
|
132
203
|
|
|
133
204
|
1. Call `counter(name, dims)` to get a series.
|
|
134
|
-
2. Keep that
|
|
205
|
+
2. Keep that handle in its owning module or component and reuse it across calls and windows.
|
|
135
206
|
3. Call `inc()` to add `1`.
|
|
136
207
|
4. Call `add(n)` to add a finite number.
|
|
137
208
|
|
|
@@ -146,11 +217,17 @@ A counter in a frame is a window delta. The counter is not a lifetime total.
|
|
|
146
217
|
**Objective:** Call `set(value)` with a finite number. The frame stores the last value and a timestamp.
|
|
147
218
|
|
|
148
219
|
```js
|
|
149
|
-
function
|
|
150
|
-
wardx.gauge('players.online', { region
|
|
151
|
-
wardx.gauge('matchmaking.queue_depth')
|
|
220
|
+
function createLobbyReporter(wardx, region) {
|
|
221
|
+
const playersOnline = wardx.gauge('players.online', { region });
|
|
222
|
+
const queueDepth = wardx.gauge('matchmaking.queue_depth');
|
|
223
|
+
return (lobby) => {
|
|
224
|
+
playersOnline.set(lobby.playerCount);
|
|
225
|
+
queueDepth.set(lobby.queue.length);
|
|
226
|
+
};
|
|
152
227
|
}
|
|
153
228
|
|
|
229
|
+
const reportLobby = createLobbyReporter(wardx, 'south-america');
|
|
230
|
+
|
|
154
231
|
function startQueueProbe(wardx, getQueueDepth) {
|
|
155
232
|
const queue = wardx.gauge('jobs.queue_depth');
|
|
156
233
|
const timer = setInterval(() => {
|
|
@@ -176,15 +253,19 @@ If you do not call `set` in a window, that series is not in the frame.
|
|
|
176
253
|
**Objective:** Call `observe(value)` so the SDK stores count, sum, min, max, and buckets.
|
|
177
254
|
|
|
178
255
|
```js
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
256
|
+
const requestDuration = wardx.histogram('http.duration_ms', { route: 'checkout' });
|
|
257
|
+
const payloadBytes = wardx.histogram('http.payload_bytes', {
|
|
258
|
+
buckets: [256, 1024, 4096, 16384, 65536]
|
|
259
|
+
});
|
|
260
|
+
const awardSize = wardx.histogram('coins.award_size');
|
|
261
|
+
|
|
262
|
+
function recordRequest(durationMs, bytes) {
|
|
263
|
+
requestDuration.observe(durationMs);
|
|
264
|
+
payloadBytes.observe(bytes);
|
|
184
265
|
}
|
|
185
266
|
|
|
186
|
-
function recordAward(
|
|
187
|
-
|
|
267
|
+
function recordAward(amount, grantId) {
|
|
268
|
+
awardSize.observe(amount, { grantId });
|
|
188
269
|
}
|
|
189
270
|
```
|
|
190
271
|
|
|
@@ -205,15 +286,18 @@ If you start and stop a duration in the same process, use `timer` instead of a h
|
|
|
205
286
|
**Objective:** Start a timer. Stop the timer when the work ends. The SDK records milliseconds in a histogram.
|
|
206
287
|
|
|
207
288
|
```js
|
|
208
|
-
|
|
289
|
+
const matchmakingOk = wardx.counter('matchmaking.ok');
|
|
290
|
+
const matchmakingError = wardx.counter('matchmaking.error');
|
|
291
|
+
|
|
292
|
+
export async function handleMatchmaking(req, res) {
|
|
209
293
|
const end = wardx.timer('matchmaking.duration', { route: 'matchmaking' });
|
|
210
294
|
try {
|
|
211
295
|
const result = await findMatch(req.body);
|
|
212
|
-
|
|
296
|
+
matchmakingOk.inc();
|
|
213
297
|
end({ result: 'success' });
|
|
214
298
|
res.end(JSON.stringify(result));
|
|
215
299
|
} catch (err) {
|
|
216
|
-
|
|
300
|
+
matchmakingError.inc();
|
|
217
301
|
wardx.log.error('matchmaking_failed', { code: err.code || 'unknown' });
|
|
218
302
|
end({ result: 'error' });
|
|
219
303
|
res.statusCode = 500;
|
|
@@ -238,26 +322,28 @@ The stop function records milliseconds. Dimensions that you pass to the stop fun
|
|
|
238
322
|
**Objective:** Call `event(name, attrs)`. Do not use an event when a counter is enough.
|
|
239
323
|
|
|
240
324
|
```js
|
|
241
|
-
function
|
|
242
|
-
wardx.
|
|
243
|
-
|
|
244
|
-
country: match.country,
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
wardx.counter('match.started', { mode: match.mode }).inc();
|
|
325
|
+
function createMatchStartedRecorder(wardx, mode) {
|
|
326
|
+
const started = wardx.counter('match.started', { mode });
|
|
327
|
+
return (match) => {
|
|
328
|
+
wardx.event('match.started', { mode, country: match.country, players: match.players.length });
|
|
329
|
+
started.inc();
|
|
330
|
+
};
|
|
248
331
|
}
|
|
249
332
|
|
|
250
|
-
function
|
|
251
|
-
wardx.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
amount: order.amount
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
333
|
+
function createPurchaseRecorder(wardx, product, currency) {
|
|
334
|
+
const purchases = wardx.counter('purchase.count', { product });
|
|
335
|
+
const amount = wardx.counter('purchase.amount', { currency });
|
|
336
|
+
return (order) => {
|
|
337
|
+
wardx.event('purchase', { product, currency, amount: order.amount });
|
|
338
|
+
purchases.inc();
|
|
339
|
+
amount.add(order.amount);
|
|
340
|
+
};
|
|
258
341
|
}
|
|
259
342
|
|
|
260
|
-
|
|
343
|
+
const onMatchStarted = createMatchStartedRecorder(wardx, 'ranked');
|
|
344
|
+
const onPurchase = createPurchaseRecorder(wardx, 'coins-small', 'USD');
|
|
345
|
+
|
|
346
|
+
function onSignup(user) {
|
|
261
347
|
wardx.event('signup.completed', { method: user.method });
|
|
262
348
|
}
|
|
263
349
|
```
|
|
@@ -283,21 +369,28 @@ Wardx does not store a user journey. Delivery is at-most-once. Production discar
|
|
|
283
369
|
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.
|
|
284
370
|
|
|
285
371
|
```js
|
|
286
|
-
function
|
|
287
|
-
wardx.
|
|
288
|
-
wardx.counter('onboarding.
|
|
372
|
+
function createOnboardingTelemetry(wardx, channel) {
|
|
373
|
+
const started = wardx.counter('onboarding.start', { channel });
|
|
374
|
+
const profile = wardx.counter('onboarding.profile');
|
|
375
|
+
const done = wardx.counter('onboarding.done');
|
|
376
|
+
return {
|
|
377
|
+
onStart() {
|
|
378
|
+
wardx.event('onboarding.start', { channel });
|
|
379
|
+
started.inc();
|
|
380
|
+
},
|
|
381
|
+
onProfile() {
|
|
382
|
+
wardx.event('onboarding.profile');
|
|
383
|
+
profile.inc();
|
|
384
|
+
},
|
|
385
|
+
onDone(userId) {
|
|
386
|
+
wardx.event('onboarding.done');
|
|
387
|
+
done.inc();
|
|
388
|
+
wardx.experiment.goal('onboarding.done', { subjectId: userId });
|
|
389
|
+
}
|
|
390
|
+
};
|
|
289
391
|
}
|
|
290
392
|
|
|
291
|
-
|
|
292
|
-
wardx.event('onboarding.profile');
|
|
293
|
-
wardx.counter('onboarding.profile').inc();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function onOnboardingDone(wardx, userId) {
|
|
297
|
-
wardx.event('onboarding.done');
|
|
298
|
-
wardx.counter('onboarding.done').inc();
|
|
299
|
-
wardx.experiment.goal('onboarding.done', { subjectId: userId });
|
|
300
|
-
}
|
|
393
|
+
const onboarding = createOnboardingTelemetry(wardx, 'organic');
|
|
301
394
|
```
|
|
302
395
|
|
|
303
396
|
### Procedure
|
|
@@ -361,22 +454,28 @@ Retention requires a server with this feature; older servers cannot compute it.
|
|
|
361
454
|
Wardx is not a ledger. Delivery is at-most-once. Production discards envelopes after ingest (`sink: "null"`). A player's wallet, and the row that explains one grant, live in the game database. Wardx answers whether the fleet is granting too much, or too large, in a 1-minute window.
|
|
362
455
|
|
|
363
456
|
```js
|
|
364
|
-
function
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
wardx.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
457
|
+
function createGrantRecorder(wardx, source) {
|
|
458
|
+
const awarded = wardx.counter('coins.awarded', { source });
|
|
459
|
+
const grants = wardx.counter('coins.grants', { source });
|
|
460
|
+
const size = wardx.histogram('coins.award_size', {
|
|
461
|
+
source, buckets: [10, 50, 100, 250, 500, 1000, 5000]
|
|
462
|
+
});
|
|
463
|
+
return ({ amount, reason, id }) => {
|
|
464
|
+
awarded.add(amount);
|
|
465
|
+
grants.inc();
|
|
466
|
+
size.observe(amount, { grantId: id, reason });
|
|
467
|
+
|
|
468
|
+
const maxAward = wardx.config.get('economy.maxAward', 500);
|
|
469
|
+
if (amount > maxAward) {
|
|
470
|
+
wardx.event('coins.anomaly', { source, amount, reason, grantId: id });
|
|
471
|
+
wardx.log.warn('coins_anomaly', { source, amount, reason, grantId: id });
|
|
472
|
+
}
|
|
473
|
+
};
|
|
379
474
|
}
|
|
475
|
+
|
|
476
|
+
const recordMatchGrant = createGrantRecorder(wardx, 'match');
|
|
477
|
+
// After committing each match reward:
|
|
478
|
+
recordMatchGrant({ amount: 50, reason: 'win', id: 'grant-42' });
|
|
380
479
|
```
|
|
381
480
|
|
|
382
481
|
`source` is a small set, for example `match`, `daily`, `purchase`, or `admin`.
|
|
@@ -423,7 +522,15 @@ If there is no identified subject and you omit `{ subjectId }`, that call is not
|
|
|
423
522
|
2. If there is no subject (`identify` unset and no `{ subjectId }`), return the Remote Config value.
|
|
424
523
|
3. If an experiment applies to the subject, return the variant value.
|
|
425
524
|
|
|
426
|
-
The
|
|
525
|
+
The server first filters keys and experiments by role, then resolves any conditional base values against the instance's metadata and attributes. An applicable experiment still overrides that resolved base. Attributes do not change experiment eligibility, allocation, assignment, or exposure tracking.
|
|
526
|
+
|
|
527
|
+
SDK 0.9.0 requires `@wardx/server` 0.9.0 or newer. Upgrade the server first: older servers reject the new client fields even when attributes are empty.
|
|
528
|
+
|
|
529
|
+
Pass an optional flat `attributes` object to `createWardx`, for example `attributes: { os: 'android', build: 119, channel: 'stable' }`. Names are application-defined; values must be strings, finite numbers, or booleans. Wardx's `platform` identifies the SDK runtime (`node`, `csharp`, or `unity`), so use a custom attribute for the operating system. Attributes are shared by the SDK instance, not set per `config.get` subject; do not use an instance's attributes to switch between concurrent users.
|
|
530
|
+
|
|
531
|
+
`wardx.setAttributes({ os: 'android', build: 120 })` replaces the entire attribute map with a copy. Use `{}` to clear it. The next successful sync resolves the new context; `await wardx.flush()` requests a sync now. Reads remain local and use the last snapshot until then. Attributes travel as client metadata; send only non-secret values.
|
|
532
|
+
|
|
533
|
+
The SDK updates the snapshot when the project version or the resolved configuration changes. Every successful server response includes an opaque `configContext` token, which the SDK returns on subsequent syncs. The same contract applies with or without rules, allowing context changes to refresh values at the same `configVersion`. Until a successful sync, `config.get` returns the fallback or the last snapshot.
|
|
427
534
|
|
|
428
535
|
## Use case 10: Run an A/B experiment and record a goal
|
|
429
536
|
|
|
@@ -436,19 +543,20 @@ The ingest server config can define experiment `message-delay-v1` on key `messag
|
|
|
436
543
|
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.
|
|
437
544
|
|
|
438
545
|
```js
|
|
546
|
+
const messagesSent = wardx.counter('message.sent');
|
|
439
547
|
wardx.identify(userId);
|
|
440
548
|
const delayMs = wardx.config.get('message.delayMs', 1000);
|
|
441
549
|
setTimeout(() => {
|
|
442
550
|
deliver(text);
|
|
443
|
-
|
|
551
|
+
messagesSent.inc();
|
|
444
552
|
wardx.experiment.goal('message.sent', { value: 1 });
|
|
445
553
|
}, delayMs);
|
|
446
554
|
|
|
447
|
-
function sendMessage(
|
|
555
|
+
function sendMessage(userId, text) {
|
|
448
556
|
const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
|
|
449
557
|
setTimeout(() => {
|
|
450
558
|
deliver(text);
|
|
451
|
-
|
|
559
|
+
messagesSent.inc();
|
|
452
560
|
wardx.experiment.goal('message.sent', { subjectId: userId, value: 1 });
|
|
453
561
|
}, delayMs);
|
|
454
562
|
}
|
|
@@ -483,7 +591,8 @@ const wardx = createWardx({
|
|
|
483
591
|
privacySalt: 'demo-subject-hash-v1'
|
|
484
592
|
});
|
|
485
593
|
|
|
486
|
-
wardx.counter('jobs.completed')
|
|
594
|
+
const jobsCompleted = wardx.counter('jobs.completed');
|
|
595
|
+
jobsCompleted.inc();
|
|
487
596
|
await wardx.shutdown();
|
|
488
597
|
```
|
|
489
598
|
|
|
@@ -561,22 +670,28 @@ Two signals:
|
|
|
561
670
|
|
|
562
671
|
```js
|
|
563
672
|
const SESSION_BUCKETS = [30_000, 60_000, 180_000, 300_000, 600_000, 1_200_000, 1_800_000, 3_600_000];
|
|
673
|
+
const sessionDuration = wardx.histogram('session.duration', { buckets: SESSION_BUCKETS });
|
|
674
|
+
const sessionTime = wardx.counter('session.time_ms');
|
|
675
|
+
const sessionsEnded = wardx.counter('session.ended');
|
|
564
676
|
|
|
565
|
-
function onPlaySessionStart(
|
|
677
|
+
function onPlaySessionStart(userId) {
|
|
566
678
|
wardx.identify(userId);
|
|
567
|
-
return { startedAt:
|
|
679
|
+
return { startedAt: performance.now(), reportedMs: 0 };
|
|
568
680
|
}
|
|
569
681
|
|
|
570
|
-
function
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
wardx.counter('session.ended').inc();
|
|
575
|
-
wardx.experiment.goal('session.duration', { value: durationMs });
|
|
682
|
+
function onPlayHeartbeat(session) {
|
|
683
|
+
const elapsedMs = performance.now() - session.startedAt;
|
|
684
|
+
sessionTime.add(elapsedMs - session.reportedMs);
|
|
685
|
+
session.reportedMs = elapsedMs;
|
|
576
686
|
}
|
|
577
687
|
|
|
578
|
-
function
|
|
579
|
-
|
|
688
|
+
function onPlaySessionEnd(session) {
|
|
689
|
+
const durationMs = performance.now() - session.startedAt;
|
|
690
|
+
sessionDuration.observe(durationMs);
|
|
691
|
+
sessionTime.add(durationMs - session.reportedMs);
|
|
692
|
+
session.reportedMs = durationMs;
|
|
693
|
+
sessionsEnded.inc();
|
|
694
|
+
wardx.experiment.goal('session.duration', { value: durationMs });
|
|
580
695
|
}
|
|
581
696
|
```
|
|
582
697
|
|
|
@@ -603,22 +718,29 @@ If you only increment `session.time_ms` and never emit the goal, MCP can still s
|
|
|
603
718
|
The keys must already exist in Remote Config. The game reads them with `config.get`. Variants may only change those keys.
|
|
604
719
|
|
|
605
720
|
```js
|
|
606
|
-
function
|
|
607
|
-
const
|
|
608
|
-
wardx.
|
|
609
|
-
wardx.counter('level.
|
|
610
|
-
return
|
|
721
|
+
function createLevelTelemetry(wardx, levelId) {
|
|
722
|
+
const started = wardx.counter('level.start', { level: levelId });
|
|
723
|
+
const failed = wardx.counter('level.fail', { level: levelId });
|
|
724
|
+
const completed = wardx.counter('level.complete', { level: levelId });
|
|
725
|
+
return {
|
|
726
|
+
onStart(userId) {
|
|
727
|
+
const enemyHp = wardx.config.get(`level.${levelId}.enemyHp`, 100, { subjectId: userId });
|
|
728
|
+
wardx.event('level.start', { level: levelId });
|
|
729
|
+
started.inc();
|
|
730
|
+
return enemyHp;
|
|
731
|
+
},
|
|
732
|
+
onFail() {
|
|
733
|
+
wardx.event('level.fail', { level: levelId });
|
|
734
|
+
failed.inc();
|
|
735
|
+
},
|
|
736
|
+
onComplete() {
|
|
737
|
+
wardx.event('level.complete', { level: levelId });
|
|
738
|
+
completed.inc();
|
|
739
|
+
}
|
|
740
|
+
};
|
|
611
741
|
}
|
|
612
742
|
|
|
613
|
-
|
|
614
|
-
wardx.event('level.fail', { level: levelId });
|
|
615
|
-
wardx.counter('level.fail', { level: levelId }).inc();
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function onLevelComplete(wardx, levelId) {
|
|
619
|
-
wardx.event('level.complete', { level: levelId });
|
|
620
|
-
wardx.counter('level.complete', { level: levelId }).inc();
|
|
621
|
-
}
|
|
743
|
+
const levelThree = createLevelTelemetry(wardx, 3);
|
|
622
744
|
```
|
|
623
745
|
|
|
624
746
|
`level` is a small set of ids. Do not put a unique run id on the counter.
|
|
@@ -636,12 +758,18 @@ From MCP, after onboarding: `upsert_experiment` on the existing keys (`level.3.e
|
|
|
636
758
|
**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.
|
|
637
759
|
|
|
638
760
|
```js
|
|
639
|
-
|
|
761
|
+
const paymentOk = wardx.counter('payment.ok');
|
|
762
|
+
const paymentErrors = new Map(['timeout', 'card_declined', 'unknown'].map((code) =>
|
|
763
|
+
[code, wardx.counter('payment.error', { code })]
|
|
764
|
+
));
|
|
765
|
+
|
|
766
|
+
function handleCheckout(req, res) {
|
|
640
767
|
try {
|
|
641
768
|
charge(req.body);
|
|
642
|
-
|
|
769
|
+
paymentOk.inc();
|
|
643
770
|
} catch (err) {
|
|
644
|
-
|
|
771
|
+
const errors = paymentErrors.get(err.code) ?? paymentErrors.get('unknown');
|
|
772
|
+
errors.inc();
|
|
645
773
|
wardx.log.error('payment_failed', {
|
|
646
774
|
name: err.name,
|
|
647
775
|
code: err.code || 'unknown',
|
|
@@ -659,6 +787,10 @@ function clipStack(err, max = 4096) {
|
|
|
659
787
|
}
|
|
660
788
|
```
|
|
661
789
|
|
|
790
|
+
Use the provider's finite error-code vocabulary in `paymentErrors`; map other
|
|
791
|
+
codes to `unknown` without growing the handle cache. The original code remains
|
|
792
|
+
in the log attrs.
|
|
793
|
+
|
|
662
794
|
`stack` is an attr string. Do not send the Error object.
|
|
663
795
|
|
|
664
796
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wardx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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.
|
|
40
|
+
"@wardx/core": "0.9.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/WardxNode.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
nextSyncDelayMs,
|
|
7
7
|
ulid
|
|
8
8
|
} from '@wardx/core';
|
|
9
|
+
import { disabledCore } from './disabled.js';
|
|
9
10
|
import { readFileSync } from 'node:fs';
|
|
10
11
|
import { dirname, join } from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,16 +18,24 @@ const pkg = JSON.parse(
|
|
|
17
18
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
|
|
18
19
|
);
|
|
19
20
|
|
|
21
|
+
function copyAttributes(attributes) {
|
|
22
|
+
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
23
|
+
throw new Error('attributes must be an object');
|
|
24
|
+
}
|
|
25
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
26
|
+
if (key.length === 0) throw new Error('attributes keys must be non-empty strings');
|
|
27
|
+
if (typeof value !== 'string' && typeof value !== 'boolean' && !(typeof value === 'number' && Number.isFinite(value))) {
|
|
28
|
+
throw new Error(`attributes.${key} must be a string, finite number, or boolean`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Object.fromEntries(Object.entries(attributes));
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
export class WardxNode {
|
|
21
35
|
constructor(settings) {
|
|
22
36
|
this.settings = settings;
|
|
23
|
-
this.
|
|
24
|
-
this.
|
|
25
|
-
this._stopped = false;
|
|
26
|
-
this._shutdownPromise = null;
|
|
27
|
-
this._syncChain = Promise.resolve();
|
|
28
|
-
this._instanceId = ulid();
|
|
29
|
-
this._sessionId = ulid();
|
|
37
|
+
this._disabled = settings.enabled === false;
|
|
38
|
+
this._core = this._disabled ? disabledCore : new WardxCore(settings);
|
|
30
39
|
this.log = this._core.log;
|
|
31
40
|
this.config = {
|
|
32
41
|
get: (key, fallback, context) => this._core.configGet(key, fallback, context)
|
|
@@ -34,6 +43,15 @@ export class WardxNode {
|
|
|
34
43
|
this.experiment = {
|
|
35
44
|
goal: (name, context) => this._core.experimentGoal(name, context)
|
|
36
45
|
};
|
|
46
|
+
if (this._disabled) return;
|
|
47
|
+
this._attributes = copyAttributes(settings.attributes === undefined ? {} : settings.attributes);
|
|
48
|
+
this._configContext = undefined;
|
|
49
|
+
this._transport = createHttpTransport(settings);
|
|
50
|
+
this._stopped = false;
|
|
51
|
+
this._shutdownPromise = null;
|
|
52
|
+
this._syncChain = Promise.resolve();
|
|
53
|
+
this._instanceId = ulid();
|
|
54
|
+
this._sessionId = ulid();
|
|
37
55
|
this._aggregateTimer = setInterval(() => {
|
|
38
56
|
this._core.internal.processRssBytes = readProcessRssBytes();
|
|
39
57
|
this._core.snapshotIfDirty();
|
|
@@ -51,6 +69,11 @@ export class WardxNode {
|
|
|
51
69
|
this._core.identify(subjectId);
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
setAttributes(attributes) {
|
|
73
|
+
if (this._disabled) return;
|
|
74
|
+
this._attributes = copyAttributes(attributes);
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
counter(name, dims) {
|
|
55
78
|
return this._core.counter(name, dims);
|
|
56
79
|
}
|
|
@@ -76,10 +99,12 @@ export class WardxNode {
|
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
flush() {
|
|
102
|
+
if (this._disabled) return Promise.resolve();
|
|
79
103
|
return this._enqueueSync({ flush: true });
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
shutdown() {
|
|
107
|
+
if (this._disabled) return Promise.resolve();
|
|
83
108
|
if (this._shutdownPromise === null) this._shutdownPromise = this._shutdown();
|
|
84
109
|
return this._shutdownPromise;
|
|
85
110
|
}
|
|
@@ -121,7 +146,7 @@ export class WardxNode {
|
|
|
121
146
|
this._core.snapshotIfDirty();
|
|
122
147
|
}
|
|
123
148
|
const frames = this._core.takePendingFrames();
|
|
124
|
-
if (!flags.bootstrap && frames.length === 0) return;
|
|
149
|
+
if (!flags.bootstrap && !flags.flush && frames.length === 0) return;
|
|
125
150
|
const envelope = {
|
|
126
151
|
protocol: PROTOCOL_VERSION,
|
|
127
152
|
project: this.settings.project,
|
|
@@ -135,9 +160,11 @@ export class WardxNode {
|
|
|
135
160
|
role: this.settings.role,
|
|
136
161
|
appVersion: this.settings.appVersion,
|
|
137
162
|
environment: this.settings.environment,
|
|
138
|
-
platform: PLATFORM
|
|
163
|
+
platform: PLATFORM,
|
|
164
|
+
attributes: this._attributes
|
|
139
165
|
},
|
|
140
166
|
configVersion: this._core.configStore.version,
|
|
167
|
+
configContext: this._configContext,
|
|
141
168
|
frames
|
|
142
169
|
};
|
|
143
170
|
const json = JSON.stringify(envelope);
|
|
@@ -205,6 +232,7 @@ export class WardxNode {
|
|
|
205
232
|
}
|
|
206
233
|
if (json.config) {
|
|
207
234
|
this._core.applyConfig(json.configVersion, json.config);
|
|
235
|
+
this._configContext = json.configContext;
|
|
208
236
|
}
|
|
209
237
|
}
|
|
210
238
|
}
|
package/src/disabled.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const noop = () => {};
|
|
2
|
+
const counter = Object.freeze({ inc: noop, add: noop });
|
|
3
|
+
const gauge = Object.freeze({ set: noop });
|
|
4
|
+
const histogram = Object.freeze({ observe: noop });
|
|
5
|
+
const distinct = Object.freeze({ add: noop });
|
|
6
|
+
|
|
7
|
+
export const disabledCore = Object.freeze({
|
|
8
|
+
counter: () => counter,
|
|
9
|
+
gauge: () => gauge,
|
|
10
|
+
histogram: () => histogram,
|
|
11
|
+
distinct: () => distinct,
|
|
12
|
+
timer: () => noop,
|
|
13
|
+
event: noop,
|
|
14
|
+
retentionActivity: noop,
|
|
15
|
+
identify: noop,
|
|
16
|
+
log: Object.freeze({ debug: noop, info: noop, warn: noop, error: noop }),
|
|
17
|
+
configGet: (key, fallback) => fallback,
|
|
18
|
+
experimentGoal: noop
|
|
19
|
+
});
|
package/src/index.d.ts
CHANGED
|
@@ -70,14 +70,17 @@ export interface ExperimentApi {
|
|
|
70
70
|
goal(name: string, context?: ExperimentGoalContext): void;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export type DisabledWardxOptions = Partial<CreateWardxOptions> & { enabled: false };
|
|
74
|
+
|
|
73
75
|
export class WardxNode {
|
|
74
|
-
settings: ResolvedSettings;
|
|
76
|
+
settings: ResolvedSettings | DisabledWardxOptions;
|
|
75
77
|
log: LogApi;
|
|
76
78
|
config: ConfigApi;
|
|
77
79
|
experiment: ExperimentApi;
|
|
78
|
-
constructor(settings: ResolvedSettings);
|
|
80
|
+
constructor(settings: ResolvedSettings | DisabledWardxOptions);
|
|
79
81
|
retentionActivity(userId: string): void;
|
|
80
82
|
identify(subjectId: string | null | undefined): void;
|
|
83
|
+
setAttributes(attributes: Record<string, string | number | boolean>): void;
|
|
81
84
|
counter(name: string, dims?: Dimensions | null): CounterHandle;
|
|
82
85
|
gauge(name: string, dims?: Dimensions | null): GaugeHandle;
|
|
83
86
|
histogram(name: string, a?: HistogramOptions | null, b?: HistogramOptions | null): HistogramHandle;
|
|
@@ -88,5 +91,5 @@ export class WardxNode {
|
|
|
88
91
|
shutdown(): Promise<void>;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
export function createWardx(options: CreateWardxOptions): WardxNode;
|
|
94
|
+
export function createWardx(options: CreateWardxOptions | DisabledWardxOptions): WardxNode;
|
|
92
95
|
export function createConsoleTracer(options?: ConsoleTracerOptions): Tracer;
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,10 @@ import { WardxNode } from './WardxNode.js';
|
|
|
3
3
|
import { createConsoleTracer } from './trace/createConsoleTracer.js';
|
|
4
4
|
|
|
5
5
|
export function createWardx(options) {
|
|
6
|
-
|
|
6
|
+
if (options?.enabled !== undefined && typeof options.enabled !== 'boolean') {
|
|
7
|
+
throw new Error('enabled must be a boolean');
|
|
8
|
+
}
|
|
9
|
+
return new WardxNode(options?.enabled === false ? { enabled: false } : resolveSettings(options));
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export { WardxNode, createConsoleTracer };
|