wardx 0.1.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 ADDED
@@ -0,0 +1,454 @@
1
+ # wardx
2
+
3
+ `wardx` is the Node.js SDK for Wardx.
4
+
5
+ The SDK records logs, events, and metrics. The SDK also gets Remote Config and assigns experiment variants.
6
+
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
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install wardx
13
+ ```
14
+
15
+ ```js
16
+ import { createWardx } from 'wardx';
17
+ ```
18
+
19
+ To receive frames, run an ingest server. Install `@wardx/server` and start it with a config file.
20
+
21
+ ## Design rules
22
+
23
+ - A measure call does not send network data.
24
+ - A measure call does not wait for a Promise.
25
+ - Delivery is at-most-once. If a sync fails, the SDK discards the batch.
26
+ - The application has priority over telemetry.
27
+ - Remote Config is always read from local memory.
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
+
30
+ **WARNING:** The SDK does not write a disk queue. The SDK does not retry the same frames.
31
+
32
+ ## Start the SDK
33
+
34
+ `createWardx` requires these keys:
35
+
36
+ | Key | Description |
37
+ | --- | --- |
38
+ | `endpoint` | Base URL of the ingest server, for example `http://127.0.0.1:8787`. |
39
+ | `projectKey` | Value of header `X-Wardx-Key`. |
40
+ | `project` | Project name. The name must match the server mapping. |
41
+ | `role` | Name of this instance inside the project, for example `client`, `unity`, `game-server`, `desktop`. Not `*`. |
42
+ | `appVersion` | Application version. |
43
+ | `environment` | Environment name. |
44
+
45
+ Optional keys include `privacySalt`, `tracer`, and the keys in `@wardx/core` `defaults.json`. If `privacySalt` is empty, the SDK uses `projectKey`. `tracer` is a local diagnostic hook. It does not go over the wire.
46
+
47
+ The SDK starts a bootstrap sync immediately. The SDK then syncs on `syncIntervalMs` with jitter.
48
+
49
+ ## Use case 1: Instrument a Node.js service
50
+
51
+ **When:** You run a Node.js process and you need telemetry.
52
+
53
+ **Objective:** Record logs, events, and metrics. Then send one batch.
54
+
55
+ ```js
56
+ import { createWardx } from 'wardx';
57
+
58
+ const wardx = createWardx({
59
+ endpoint: 'http://127.0.0.1:8787',
60
+ projectKey: 'dev_project_key',
61
+ project: 'demo',
62
+ role: 'client',
63
+ appVersion: '2.4.1',
64
+ environment: 'production'
65
+ });
66
+
67
+ wardx.log.info('match_started', { mode: 'ranked', players: 4 });
68
+ wardx.event('match.started', { mode: 'ranked', country: 'AR' });
69
+ wardx.counter('match.completed', { mode: 'ranked' }).inc();
70
+ wardx.gauge('players.online').set(12);
71
+ wardx.histogram('request.duration', { buckets: [10, 25, 50, 100, 250] }).observe(42);
72
+
73
+ const end = wardx.timer('matchmaking.duration');
74
+ end({ result: 'success' });
75
+
76
+ await wardx.flush();
77
+ await wardx.shutdown();
78
+ ```
79
+
80
+ ### Procedure
81
+
82
+ 1. Call `createWardx` with the required keys.
83
+ 2. Record logs, events, and metrics on the request path or the game loop.
84
+ 3. Call `flush` when you need to send now.
85
+ 4. Call `shutdown` when the process stops.
86
+
87
+ Pass the ingest URL, project key, and project name in `createWardx`.
88
+
89
+ ## Use case 2: Count occurrences with a counter
90
+
91
+ **When:** You count how many times a thing happens, or you add a quantity.
92
+
93
+ **Objective:** Use `inc()` for one occurrence. Use `add(n)` for a finite sum.
94
+
95
+ ```js
96
+ function handleRequest(req, res, wardx) {
97
+ const requests = wardx.counter('http.requests', { route: 'matchmaking' });
98
+ requests.inc();
99
+
100
+ if (res.statusCode >= 500) {
101
+ wardx.counter('http.errors', { route: 'matchmaking', code: 500 }).inc();
102
+ }
103
+ }
104
+
105
+ function grantCoins(wardx, amount) {
106
+ wardx.counter('coins.awarded', { source: 'match' }).add(amount);
107
+ }
108
+
109
+ function completeMatch(wardx, mode) {
110
+ wardx.counter('match.completed', { mode }).inc();
111
+ }
112
+ ```
113
+
114
+ To detect abnormal grants, pair this counter with a histogram and a rare anomaly event. See use case 7.
115
+
116
+ ### Procedure
117
+
118
+ 1. Call `counter(name, dims)` to get a series.
119
+ 2. Keep that object if you increment in a loop.
120
+ 3. Call `inc()` to add `1`.
121
+ 4. Call `add(n)` to add a finite number.
122
+
123
+ Each dimension set is a separate series. Use a small set of values, for example `mode`, `route`, or `code`. Do not put a user id in a dimension. If the series count is above `maxSeriesPerMetric`, the SDK returns a no-op counter.
124
+
125
+ A counter in a frame is a window delta. The counter is not a lifetime total.
126
+
127
+ ## Use case 3: Record a current value with a gauge
128
+
129
+ **When:** You need the last known size of a set, for example players online or queue depth.
130
+
131
+ **Objective:** Call `set(value)` with a finite number. The frame stores the last value and a timestamp.
132
+
133
+ ```js
134
+ function reportLobby(wardx, lobby) {
135
+ wardx.gauge('players.online', { region: lobby.region }).set(lobby.playerCount);
136
+ wardx.gauge('matchmaking.queue_depth').set(lobby.queue.length);
137
+ }
138
+
139
+ function startQueueProbe(wardx, getQueueDepth) {
140
+ const queue = wardx.gauge('jobs.queue_depth');
141
+ const timer = setInterval(() => {
142
+ queue.set(getQueueDepth());
143
+ }, 1000);
144
+ timer.unref();
145
+ return timer;
146
+ }
147
+ ```
148
+
149
+ ### Procedure
150
+
151
+ 1. Call `gauge(name, dims)` to get a series.
152
+ 2. Call `set(value)` when the value changes, or on a probe interval.
153
+ 3. Do not call `inc()` on a gauge. A gauge does not add. A gauge replaces.
154
+
155
+ If you do not call `set` in a window, that series is not in the frame.
156
+
157
+ ## Use case 4: Record a distribution with a histogram
158
+
159
+ **When:** You already have a numeric sample, for example a duration in milliseconds or a payload size.
160
+
161
+ **Objective:** Call `observe(value)` so the SDK stores count, sum, min, max, and buckets.
162
+
163
+ ```js
164
+ function recordRequest(wardx, durationMs, bytes) {
165
+ wardx.histogram('http.duration_ms', { route: 'checkout' }).observe(durationMs);
166
+ wardx.histogram('http.payload_bytes', {
167
+ buckets: [256, 1024, 4096, 16384, 65536]
168
+ }).observe(bytes);
169
+ }
170
+
171
+ function recordAward(wardx, amount, grantId) {
172
+ wardx.histogram('coins.award_size').observe(amount, { grantId });
173
+ }
174
+ ```
175
+
176
+ The default buckets come from SDK defaults: `[10, 25, 50, 100, 250, 500, 1000]`. Set `buckets` when the unit is not a short duration in milliseconds.
177
+
178
+ Do not change the buckets of an existing series. The SDK throws an error.
179
+
180
+ A sample above the last bound stays in `count`, `sum`, `min`, and `max`. That sample does not increment a bucket.
181
+
182
+ `observe(value, attrs)` keeps `attrs` only for the window max. The histogram body then includes `exemplar`. Attrs use the same limits as dimensions. Pass a lookup key (`grantId`, `matchId`), not a user id as a metric dimension.
183
+
184
+ If you start and stop a duration in the same process, use `timer` instead of a histogram. See use case 5.
185
+
186
+ ## Use case 5: Measure elapsed time with a timer
187
+
188
+ **When:** You need the time of an HTTP handler, a matchmaking call, or a database query.
189
+
190
+ **Objective:** Start a timer. Stop the timer when the work ends. The SDK records milliseconds in a histogram.
191
+
192
+ ```js
193
+ export async function handleMatchmaking(req, res, wardx) {
194
+ const end = wardx.timer('matchmaking.duration', { route: 'matchmaking' });
195
+ try {
196
+ const result = await findMatch(req.body);
197
+ wardx.counter('matchmaking.ok').inc();
198
+ end({ result: 'success' });
199
+ res.end(JSON.stringify(result));
200
+ } catch (err) {
201
+ wardx.counter('matchmaking.error').inc();
202
+ wardx.log.error('matchmaking_failed', { code: err.code || 'unknown' });
203
+ end({ result: 'error' });
204
+ res.statusCode = 500;
205
+ res.end();
206
+ }
207
+ }
208
+
209
+ function parseReplay(buffer, wardx) {
210
+ const end = wardx.timer('replay.parse');
211
+ const replay = decodeReplay(buffer);
212
+ end();
213
+ return replay;
214
+ }
215
+ ```
216
+
217
+ The stop function records milliseconds. Dimensions that you pass to the stop function merge with the start dimensions. Call the stop function one time.
218
+
219
+ ## Use case 6: Record a product event
220
+
221
+ **When:** You need a discrete product fact with attributes, for example a purchase or a match start.
222
+
223
+ **Objective:** Call `event(name, attrs)`. Do not use an event when a counter is enough.
224
+
225
+ ```js
226
+ function onMatchStarted(wardx, match) {
227
+ wardx.event('match.started', {
228
+ mode: match.mode,
229
+ country: match.country,
230
+ players: match.players.length
231
+ });
232
+ wardx.counter('match.started', { mode: match.mode }).inc();
233
+ }
234
+
235
+ function onPurchase(wardx, order) {
236
+ wardx.event('purchase', {
237
+ product: order.product,
238
+ currency: order.currency,
239
+ amount: order.amount
240
+ });
241
+ wardx.counter('purchase.count', { product: order.product }).inc();
242
+ wardx.counter('purchase.amount', { currency: order.currency }).add(order.amount);
243
+ }
244
+
245
+ function onSignup(wardx, user) {
246
+ wardx.event('signup.completed', { method: user.method });
247
+ }
248
+ ```
249
+
250
+ ### Procedure
251
+
252
+ 1. Call `event(name, attrs)` on the product path.
253
+ 2. Put facts that you need on each occurrence in `attrs`.
254
+ 3. Add a counter when you also need an aggregatable count.
255
+
256
+ An event is one row in the frame. A counter is a window sum. Use both when you need the fact and the rate.
257
+
258
+ If the event buffer is full, the SDK discards the new event and increments `wardx.internal.events_dropped`.
259
+
260
+ ## Use case 7: Detect abnormal point accumulation
261
+
262
+ **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
+
264
+ **Objective:** Measure the grant stream as rates and a size distribution. Do not measure per player.
265
+
266
+ 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.
267
+
268
+ ```js
269
+ function grantCoins(wardx, grant) {
270
+ const { source, amount, reason, id } = grant;
271
+
272
+ wardx.counter('coins.awarded', { source }).add(amount);
273
+ wardx.counter('coins.grants', { source }).inc();
274
+ wardx.histogram('coins.award_size', {
275
+ source,
276
+ buckets: [10, 50, 100, 250, 500, 1000, 5000]
277
+ }).observe(amount, { grantId: id, reason });
278
+
279
+ const maxAward = wardx.config.get('economy.maxAward', 500);
280
+ if (amount > maxAward) {
281
+ wardx.event('coins.anomaly', { source, amount, reason, grantId: id });
282
+ wardx.log.warn('coins_anomaly', { source, amount, reason, grantId: id });
283
+ }
284
+ }
285
+ ```
286
+
287
+ `source` is a small set, for example `match`, `daily`, `purchase`, or `admin`.
288
+
289
+ ### Procedure
290
+
291
+ 1. Write the wallet change in the game database in the same transaction as the grant. That row is the audit of the player.
292
+ 2. On the grant path, add the amount to `coins.awarded` and increment `coins.grants`. The dimension is `source`, not a user id.
293
+ 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.
294
+ 4. Read `economy.maxAward` from Remote Config. Emit `coins.anomaly` only when a grant exceeds that bound. That event is rare.
295
+ 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`.
296
+
297
+ 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.
298
+
299
+ 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
+
301
+ ## Use case 8: Get Remote Config for a user
302
+
303
+ **When:** The server has a config snapshot. You need a value in the application.
304
+
305
+ **Objective:** Read the local snapshot. Do not wait for the network on the hot path.
306
+
307
+ ```js
308
+ const timeoutMs = wardx.config.get('matchmaking.timeoutMs', 5000);
309
+ const chatEnabled = wardx.config.get('chat.enabled', false);
310
+ const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
311
+ ```
312
+
313
+ ### Resolution order
314
+
315
+ 1. If the key is not in the snapshot, return the fallback.
316
+ 2. If `subjectId` is missing, return the Remote Config value.
317
+ 3. If an experiment applies to the subject, return the variant value.
318
+
319
+ 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
+
321
+ ## Use case 9: Run an A/B experiment and record a goal
322
+
323
+ **When:** A Remote Config key is in an experiment. You need a variant for a user. You need a goal event.
324
+
325
+ **Objective:** Get the variant value. Then record `experiment.goal`.
326
+
327
+ The ingest server config can define experiment `message-delay-v1` on key `message.delayMs`. See `@wardx/server`.
328
+
329
+ ```js
330
+ function sendMessage(wardx, userId, text) {
331
+ const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
332
+
333
+ setTimeout(() => {
334
+ deliver(text);
335
+ wardx.counter('message.sent').inc();
336
+ wardx.experiment.goal('message.sent', { subjectId: userId, value: 1 });
337
+ }, delayMs);
338
+ }
339
+ ```
340
+
341
+ The first `config.get` with a `subjectId` in a session can emit event `experiment.exposure`. The payload contains:
342
+
343
+ - `experiment`
344
+ - `variant`
345
+ - `subject` (hashed)
346
+
347
+ The payload does not contain the raw `subjectId`.
348
+
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.
352
+
353
+ ## Use case 10: Continue when the ingest server is down
354
+
355
+ **When:** The network fails, or the ingest server is not available.
356
+
357
+ **Objective:** Keep the application. Discard failed batches.
358
+
359
+ ```js
360
+ const wardx = createWardx({
361
+ endpoint: 'http://127.0.0.1:1',
362
+ projectKey: 'dev_project_key',
363
+ project: 'demo',
364
+ role: 'client',
365
+ appVersion: '0.1.0',
366
+ environment: 'development'
367
+ });
368
+
369
+ wardx.counter('jobs.completed').inc();
370
+ await wardx.shutdown();
371
+ ```
372
+
373
+ The SDK still records in memory. A failed sync increments `wardx.internal.frames_failed`. The next cycle sends new data only.
374
+
375
+ Do not use this SDK if you must not lose events. This SDK is best-effort.
376
+
377
+ ## Use case 11: Stop the SDK in a graceful shutdown
378
+
379
+ **When:** The process receives `SIGTERM` or you stop a test.
380
+
381
+ **Objective:** Send the last frame, then release the HTTP agent.
382
+
383
+ ```js
384
+ const wardx = createWardx({
385
+ endpoint: 'http://127.0.0.1:8787',
386
+ projectKey: 'dev_project_key',
387
+ project: 'demo',
388
+ role: 'client',
389
+ appVersion: '0.1.0',
390
+ environment: 'production'
391
+ });
392
+
393
+ async function onStop() {
394
+ await wardx.shutdown();
395
+ process.exit(0);
396
+ }
397
+
398
+ process.on('SIGTERM', onStop);
399
+ process.on('SIGINT', onStop);
400
+ ```
401
+
402
+ `shutdown` is safe to call more than one time. The second call returns immediately.
403
+
404
+ `flush` sends the current pending frames and does not stop the timers. Use `shutdown` when the process stops.
405
+
406
+ ## Use case 12: Trace measure calls while instrumenting
407
+
408
+ **When:** You are adding counters, events, and logs and you want to see each call and each sync on stderr.
409
+
410
+ **Objective:** Pass a tracer object. The SDK does not print on its own.
411
+
412
+ ```js
413
+ import { createWardx, createConsoleTracer } from 'wardx';
414
+
415
+ const wardx = createWardx({
416
+ endpoint: 'http://127.0.0.1:8787',
417
+ projectKey: 'dev_project_key',
418
+ project: 'demo',
419
+ role: 'client',
420
+ appVersion: '0.1.0',
421
+ environment: 'development',
422
+ tracer: createConsoleTracer()
423
+ });
424
+ ```
425
+
426
+ A tracer is a duck-typed object. Implement any of `measure`, `event`, `log`, `frame`, and `sync`. Omit the rest. `createConsoleTracer()` writes one line per hook to stderr.
427
+
428
+ 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
+
430
+ ## API
431
+
432
+ | Call | Description |
433
+ | --- | --- |
434
+ | `createWardx(options)` | Creates the SDK. Starts aggregate and sync timers. Optional `tracer`. |
435
+ | `createConsoleTracer(options)` | Local stderr tracer. Optional `options.stream`. |
436
+ | `counter(name, dims)` | Returns a counter. `inc()` or `add(n)`. |
437
+ | `gauge(name, dims)` | Returns a gauge. `set(value)`. |
438
+ | `histogram(name, dimsOrBuckets)` | Returns a histogram. `observe(value)` or `observe(value, attrs)`. |
439
+ | `timer(name, dims)` | Starts a timer. The returned function records milliseconds. |
440
+ | `event(name, attrs)` | Buffers a product event. |
441
+ | `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. |
444
+ | `flush()` | Sends pending frames now. Returns a Promise. |
445
+ | `shutdown()` | Stops timers, sends pending frames, and closes the HTTP agent. |
446
+
447
+ The SDK creates one `instanceId` and one `sessionId` per process. The IDs are ULIDs.
448
+
449
+ Sync delay is `syncIntervalMs * random(syncJitterMin, syncJitterMax)`. The default interval is 15 seconds. The default jitter is 0.85 to 1.15.
450
+
451
+ ## Related packages
452
+
453
+ - Engine: `@wardx/core`
454
+ - Ingest server: `@wardx/server`
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "wardx",
3
+ "version": "0.1.0",
4
+ "description": "Node.js SDK for Wardx telemetry, Remote Config, and experiments.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "dependencies": {
16
+ "@wardx/core": "0.1.0"
17
+ }
18
+ }
@@ -0,0 +1,193 @@
1
+ import {
2
+ PLATFORM,
3
+ PROTOCOL_VERSION,
4
+ SDK_NAME,
5
+ WardxCore,
6
+ nextSyncDelayMs,
7
+ ulid
8
+ } from '@wardx/core';
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { gzipBuffer } from './compression/gzip.js';
13
+ import { createHttpTransport } from './transport/HttpTransport.js';
14
+ import { readProcessRssBytes } from './runtime/processMetrics.js';
15
+
16
+ const pkg = JSON.parse(
17
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
18
+ );
19
+
20
+ export class WardxNode {
21
+ constructor(settings) {
22
+ this.settings = settings;
23
+ this._core = new WardxCore(settings);
24
+ this._transport = createHttpTransport(settings);
25
+ this._stopped = false;
26
+ this._syncChain = Promise.resolve();
27
+ this._instanceId = ulid();
28
+ this._sessionId = ulid();
29
+ this.log = this._core.log;
30
+ this.config = {
31
+ get: (key, fallback, context) => this._core.configGet(key, fallback, context)
32
+ };
33
+ this.experiment = {
34
+ goal: (name, context) => this._core.experimentGoal(name, context)
35
+ };
36
+ this._aggregateTimer = setInterval(() => {
37
+ this._core.internal.processRssBytes = readProcessRssBytes();
38
+ this._core.snapshotIfDirty();
39
+ }, settings.aggregateIntervalMs);
40
+ this._aggregateTimer.unref();
41
+ this._scheduleSync();
42
+ this._enqueueSync({ bootstrap: true });
43
+ }
44
+
45
+ counter(name, dims) {
46
+ return this._core.counter(name, dims);
47
+ }
48
+
49
+ gauge(name, dims) {
50
+ return this._core.gauge(name, dims);
51
+ }
52
+
53
+ histogram(name, a, b) {
54
+ return this._core.histogram(name, a, b);
55
+ }
56
+
57
+ timer(name, dims) {
58
+ return this._core.timer(name, dims);
59
+ }
60
+
61
+ event(name, attrs) {
62
+ this._core.event(name, attrs);
63
+ }
64
+
65
+ flush() {
66
+ return this._enqueueSync({ flush: true });
67
+ }
68
+
69
+ async shutdown() {
70
+ if (this._stopped) return;
71
+ this._stopped = true;
72
+ clearInterval(this._aggregateTimer);
73
+ if (this._syncTimer) clearTimeout(this._syncTimer);
74
+ await this._enqueueSync({ flush: true });
75
+ this._transport.close();
76
+ }
77
+
78
+ _scheduleSync() {
79
+ if (this._stopped) return;
80
+ const delay = nextSyncDelayMs(this.settings);
81
+ this._syncTimer = setTimeout(() => {
82
+ this._enqueueSync({}).finally(() => this._scheduleSync());
83
+ }, delay);
84
+ this._syncTimer.unref();
85
+ }
86
+
87
+ _enqueueSync(flags) {
88
+ this._syncChain = this._syncChain.then(() => this._syncOnce(flags), () => this._syncOnce(flags));
89
+ return this._syncChain;
90
+ }
91
+
92
+ async _syncOnce(flags) {
93
+ if (this._stopped && !flags.flush) return;
94
+ try {
95
+ await this._syncOnceInner(flags);
96
+ } catch {
97
+ this._core.internal.framesFailed += 1;
98
+ }
99
+ }
100
+
101
+ async _syncOnceInner(flags) {
102
+ if (flags.flush || flags.bootstrap) {
103
+ this._core.internal.processRssBytes = readProcessRssBytes();
104
+ this._core.snapshotIfDirty();
105
+ }
106
+ const frames = this._core.takePendingFrames();
107
+ if (!flags.bootstrap && frames.length === 0) return;
108
+ const envelope = {
109
+ protocol: PROTOCOL_VERSION,
110
+ project: this.settings.project,
111
+ sdk: {
112
+ name: SDK_NAME,
113
+ version: pkg.version
114
+ },
115
+ client: {
116
+ instanceId: this._instanceId,
117
+ sessionId: this._sessionId,
118
+ role: this.settings.role,
119
+ appVersion: this.settings.appVersion,
120
+ environment: this.settings.environment,
121
+ platform: PLATFORM
122
+ },
123
+ configVersion: this._core.configStore.version,
124
+ frames
125
+ };
126
+ const json = JSON.stringify(envelope);
127
+ const compressed = gzipBuffer(json);
128
+ const bytesUncompressed = Buffer.byteLength(json);
129
+ const bytesCompressed = compressed.length;
130
+ this._core.internal.bytesUncompressed += bytesUncompressed;
131
+ this._core.internal.bytesCompressed += bytesCompressed;
132
+ const started = performance.now();
133
+ const phase = flags.bootstrap ? 'bootstrap' : flags.flush ? 'flush' : 'tick';
134
+ try {
135
+ const result = await this._transport.post(compressed);
136
+ this._core.internal.lastSyncMs = performance.now() - started;
137
+ if (!result.ok) {
138
+ this._core.internal.framesFailed += Math.max(frames.length, 1);
139
+ this._traceSync({
140
+ phase,
141
+ frames: frames.length,
142
+ bytesUncompressed,
143
+ bytesCompressed,
144
+ ms: this._core.internal.lastSyncMs,
145
+ ok: false,
146
+ status: result.status
147
+ });
148
+ return;
149
+ }
150
+ this._core.internal.framesSent += frames.length;
151
+ this._applyResponse(result.json);
152
+ this._traceSync({
153
+ phase,
154
+ frames: frames.length,
155
+ bytesUncompressed,
156
+ bytesCompressed,
157
+ ms: this._core.internal.lastSyncMs,
158
+ ok: true,
159
+ status: result.status,
160
+ configVersion: this._core.configStore.version,
161
+ appliedConfig: Boolean(result.json && result.json.config)
162
+ });
163
+ } catch {
164
+ this._core.internal.lastSyncMs = performance.now() - started;
165
+ this._core.internal.framesFailed += Math.max(frames.length, 1);
166
+ this._traceSync({
167
+ phase,
168
+ frames: frames.length,
169
+ bytesUncompressed,
170
+ bytesCompressed,
171
+ ms: this._core.internal.lastSyncMs,
172
+ ok: false
173
+ });
174
+ }
175
+ }
176
+
177
+ _traceSync(record) {
178
+ const tracer = this.settings.tracer;
179
+ if (tracer == null) return;
180
+ const fn = tracer.sync;
181
+ if (typeof fn === 'function') fn.call(tracer, record);
182
+ }
183
+
184
+ _applyResponse(json) {
185
+ if (!json || json.ok !== true) return;
186
+ if (typeof json.configVersion === 'number') {
187
+ this._core.internal.configVersion = json.configVersion;
188
+ }
189
+ if (json.config) {
190
+ this._core.applyConfig(json.configVersion, json.config);
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,9 @@
1
+ import { gzipSync, gunzipSync } from 'node:zlib';
2
+
3
+ export function gzipBuffer(input) {
4
+ return gzipSync(input);
5
+ }
6
+
7
+ export function gunzipBuffer(input) {
8
+ return gunzipSync(input);
9
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { resolveSettings } from '@wardx/core';
2
+ import { WardxNode } from './WardxNode.js';
3
+ import { createConsoleTracer } from './trace/createConsoleTracer.js';
4
+
5
+ export function createWardx(options) {
6
+ return new WardxNode(resolveSettings(options));
7
+ }
8
+
9
+ export { WardxNode, createConsoleTracer };
@@ -0,0 +1,3 @@
1
+ export function readProcessRssBytes() {
2
+ return process.memoryUsage().rss;
3
+ }
@@ -0,0 +1,56 @@
1
+ function formatDims(dims) {
2
+ if (!dims || typeof dims !== 'object') return '';
3
+ const keys = Object.keys(dims);
4
+ if (keys.length === 0) return '';
5
+ return ' ' + keys.map((key) => `${key}=${dims[key]}`).join(',');
6
+ }
7
+
8
+ function write(stream, kind, rest) {
9
+ stream.write(`wardx ${kind.padEnd(10)} ${rest}\n`);
10
+ }
11
+
12
+ export function createConsoleTracer(options) {
13
+ const stream = options && options.stream ? options.stream : process.stderr;
14
+ return {
15
+ measure(record) {
16
+ const noop = record.noop ? ' noop' : '';
17
+ const attrs = record.attrs ? formatDims(record.attrs) : '';
18
+ write(
19
+ stream,
20
+ record.type,
21
+ `${record.name}${formatDims(record.dims)} ${record.op} ${record.value}${attrs}${noop}`
22
+ );
23
+ },
24
+ event(record) {
25
+ const dropped = record.dropped ? ' dropped' : '';
26
+ write(stream, 'event', `${record.name}${formatDims(record.attrs)}${dropped}`);
27
+ },
28
+ log(record) {
29
+ const dropped = record.dropped ? ' dropped' : '';
30
+ write(stream, 'log', `${record.level} ${record.message}${formatDims(record.attrs)}${dropped}`);
31
+ },
32
+ frame(record) {
33
+ const dropped = [];
34
+ if (record.droppedLogs) dropped.push(`droppedLogs=${record.droppedLogs}`);
35
+ if (record.droppedEvents) dropped.push(`droppedEvents=${record.droppedEvents}`);
36
+ const extra = dropped.length > 0 ? ` ${dropped.join(' ')}` : '';
37
+ write(
38
+ stream,
39
+ 'frame',
40
+ `seq=${record.seq} counters=${record.counters} gauges=${record.gauges} histograms=${record.histograms} events=${record.events} logs=${record.logs}${extra}`
41
+ );
42
+ },
43
+ sync(record) {
44
+ const result = record.ok ? 'ok' : 'fail';
45
+ const config =
46
+ record.configVersion === undefined ? '' : ` config=${record.configVersion}`;
47
+ const applied = record.appliedConfig ? ' +config' : '';
48
+ const status = record.status === undefined ? '' : ` status=${record.status}`;
49
+ write(
50
+ stream,
51
+ 'sync',
52
+ `${record.phase} frames=${record.frames} gzip=${record.bytesCompressed}B ${record.ms.toFixed(1)}ms ${result}${status}${config}${applied}`
53
+ );
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,70 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+
4
+ function syncUrl(endpoint) {
5
+ const url = new URL(endpoint);
6
+ if (url.pathname === '/' || url.pathname === '') {
7
+ url.pathname = '/v1/sync';
8
+ }
9
+ return url;
10
+ }
11
+
12
+ export function createHttpTransport({ endpoint, projectKey, httpTimeoutMs }) {
13
+ const url = syncUrl(endpoint);
14
+ const lib = url.protocol === 'https:' ? https : http;
15
+ const agent = new lib.Agent({ keepAlive: true });
16
+
17
+ return {
18
+ post(gzippedBody) {
19
+ return new Promise((resolve, reject) => {
20
+ const req = lib.request(
21
+ {
22
+ protocol: url.protocol,
23
+ hostname: url.hostname,
24
+ port: url.port,
25
+ path: `${url.pathname}${url.search}`,
26
+ method: 'POST',
27
+ agent,
28
+ headers: {
29
+ 'content-type': 'application/json',
30
+ 'content-encoding': 'gzip',
31
+ 'content-length': gzippedBody.length,
32
+ 'x-wardx-key': projectKey,
33
+ accept: 'application/json'
34
+ }
35
+ },
36
+ (res) => {
37
+ const chunks = [];
38
+ res.on('data', (chunk) => chunks.push(chunk));
39
+ res.on('end', () => {
40
+ const buf = Buffer.concat(chunks);
41
+ const text = buf.length === 0 ? '{}' : buf.toString('utf8');
42
+ let json;
43
+ try {
44
+ json = JSON.parse(text);
45
+ } catch {
46
+ resolve({ ok: false, status: res.statusCode, json: null, text });
47
+ return;
48
+ }
49
+ resolve({
50
+ ok: res.statusCode >= 200 && res.statusCode < 300,
51
+ status: res.statusCode,
52
+ json,
53
+ text
54
+ });
55
+ });
56
+ }
57
+ );
58
+ req.setTimeout(httpTimeoutMs, () => {
59
+ req.destroy(new Error('wardx sync timed out'));
60
+ });
61
+ req.on('error', reject);
62
+ req.write(gzippedBody);
63
+ req.end();
64
+ });
65
+ },
66
+ close() {
67
+ agent.destroy();
68
+ }
69
+ };
70
+ }