wardx
wardx is the Node.js SDK for Wardx.
The SDK records logs, events, and metrics. The SDK also gets Remote Config and assigns experiment variants.
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.
Install
npm install wardx
import { createWardx } from 'wardx';
To receive frames, run an ingest server. Install @wardx/server and start it with a config file.
Design rules
- A measure call does not send network data.
- A measure call does not wait for a Promise.
- Delivery is at-most-once. If a sync fails, the SDK discards the batch.
- The application has priority over telemetry.
- Remote Config is always read from local memory.
- The SDK sends names only. Descriptions live in the server catalog: ship them in the config file, or fill them during MCP onboarding.
identify(subjectId)sets the default subject for this instance. A per-call{ subjectId }overrides it. A process that serves many users must passsubjectIdon each call and must notidentify().
WARNING: The SDK does not write a disk queue. The SDK does not retry the same frames.
Start the SDK
createWardx requires these keys:
| Key | Description |
|---|---|
endpoint |
Base URL of the ingest server, for example http://127.0.0.1:8787. |
projectKey |
Value of header X-Wardx-Key. |
project |
Project name. The name must match the server mapping. |
role |
Name of this instance inside the project, for example client, unity, game-server, desktop. Not *. |
appVersion |
Application version. |
environment |
Environment name. |
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.
The SDK starts a bootstrap sync immediately. The SDK then syncs on syncIntervalMs with jitter.
Use case 1: Instrument a Node.js service
When: You run a Node.js process and you need telemetry.
Objective: Record logs, events, and metrics. Then send one batch.
import { createWardx } from 'wardx';
const wardx = createWardx({
endpoint: 'http://127.0.0.1:8787',
projectKey: 'dev_project_key',
project: 'demo',
role: 'client',
appVersion: '2.4.1',
environment: 'production'
});
wardx.log.info('match_started', { mode: 'ranked', players: 4 });
wardx.event('match.started', { mode: 'ranked', country: 'AR' });
wardx.counter('match.completed', { mode: 'ranked' }).inc();
wardx.gauge('players.online').set(12);
wardx.histogram('request.duration', { buckets: [10, 25, 50, 100, 250] }).observe(42);
const end = wardx.timer('matchmaking.duration');
end({ result: 'success' });
await wardx.flush();
await wardx.shutdown();
Procedure
- Call
createWardxwith the required keys. - Record logs, events, and metrics on the request path or the game loop.
- Call
flushwhen you need to send now. - Call
shutdownwhen the process stops.
Pass the ingest URL, project key, and project name in createWardx.
Use case 2: Count occurrences with a counter
When: You count how many times a thing happens, or you add a quantity.
Objective: Use inc() for one occurrence. Use add(n) for a finite sum.
function handleRequest(req, res, wardx) {
const requests = wardx.counter('http.requests', { route: 'matchmaking' });
requests.inc();
if (res.statusCode >= 500) {
wardx.counter('http.errors', { route: 'matchmaking', code: 500 }).inc();
}
}
function grantCoins(wardx, amount) {
wardx.counter('coins.awarded', { source: 'match' }).add(amount);
}
function completeMatch(wardx, mode) {
wardx.counter('match.completed', { mode }).inc();
}
To detect abnormal grants, pair this counter with a histogram and a rare anomaly event. See use case 8.
Procedure
- Call
counter(name, dims)to get a series. - Keep that object if you increment in a loop.
- Call
inc()to add1. - Call
add(n)to add a finite number.
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.
A counter in a frame is a window delta. The counter is not a lifetime total.
Use case 3: Record a current value with a gauge
When: You need the last known size of a set, for example players online or queue depth.
Objective: Call set(value) with a finite number. The frame stores the last value and a timestamp.
function reportLobby(wardx, lobby) {
wardx.gauge('players.online', { region: lobby.region }).set(lobby.playerCount);
wardx.gauge('matchmaking.queue_depth').set(lobby.queue.length);
}
function startQueueProbe(wardx, getQueueDepth) {
const queue = wardx.gauge('jobs.queue_depth');
const timer = setInterval(() => {
queue.set(getQueueDepth());
}, 1000);
timer.unref();
return timer;
}
Procedure
- Call
gauge(name, dims)to get a series. - Call
set(value)when the value changes, or on a probe interval. - Do not call
inc()on a gauge. A gauge does not add. A gauge replaces.
If you do not call set in a window, that series is not in the frame.
Use case 4: Record a distribution with a histogram
When: You already have a numeric sample, for example a duration in milliseconds or a payload size.
Objective: Call observe(value) so the SDK stores count, sum, min, max, and buckets.
function recordRequest(wardx, durationMs, bytes) {
wardx.histogram('http.duration_ms', { route: 'checkout' }).observe(durationMs);
wardx.histogram('http.payload_bytes', {
buckets: [256, 1024, 4096, 16384, 65536]
}).observe(bytes);
}
function recordAward(wardx, amount, grantId) {
wardx.histogram('coins.award_size').observe(amount, { grantId });
}
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.
Do not change the buckets of an existing series. The SDK throws an error.
A sample above the last bound stays in count, sum, min, and max. That sample does not increment a bucket.
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.
If you start and stop a duration in the same process, use timer instead of a histogram. See use case 5.
Use case 5: Measure elapsed time with a timer
When: You need the time of an HTTP handler, a matchmaking call, or a database query.
Objective: Start a timer. Stop the timer when the work ends. The SDK records milliseconds in a histogram.
export async function handleMatchmaking(req, res, wardx) {
const end = wardx.timer('matchmaking.duration', { route: 'matchmaking' });
try {
const result = await findMatch(req.body);
wardx.counter('matchmaking.ok').inc();
end({ result: 'success' });
res.end(JSON.stringify(result));
} catch (err) {
wardx.counter('matchmaking.error').inc();
wardx.log.error('matchmaking_failed', { code: err.code || 'unknown' });
end({ result: 'error' });
res.statusCode = 500;
res.end();
}
}
function parseReplay(buffer, wardx) {
const end = wardx.timer('replay.parse');
const replay = decodeReplay(buffer);
end();
return replay;
}
The stop function records milliseconds. Dimensions that you pass to the stop function merge with the start dimensions. Call the stop function one time.
Use case 6: Record a product event
When: You need a discrete product fact with attributes, for example a purchase or a match start.
Objective: Call event(name, attrs). Do not use an event when a counter is enough.
function onMatchStarted(wardx, match) {
wardx.event('match.started', {
mode: match.mode,
country: match.country,
players: match.players.length
});
wardx.counter('match.started', { mode: match.mode }).inc();
}
function onPurchase(wardx, order) {
wardx.event('purchase', {
product: order.product,
currency: order.currency,
amount: order.amount
});
wardx.counter('purchase.count', { product: order.product }).inc();
wardx.counter('purchase.amount', { currency: order.currency }).add(order.amount);
}
function onSignup(wardx, user) {
wardx.event('signup.completed', { method: user.method });
}
Procedure
- Call
event(name, attrs)on the product path. - Put facts that you need on each occurrence in
attrs. - Add a counter when you also need an aggregatable count.
An event is one row in the frame. A counter is a window sum. Use both when you need the fact and the rate.
If the event buffer is full, the SDK discards the new event and increments wardx.internal.events_dropped.
Use case 7: Measure a volume funnel
When: You need drop-off between screens or steps in a client, for example onboarding or checkout.
Objective: Emit one named event and one counter per step. Compare those counts. Do not reconstruct a per-user path.
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.
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.
function onOnboardingStart(wardx, channel) {
wardx.event('onboarding.start', { channel });
wardx.counter('onboarding.start', { channel }).inc();
}
function onOnboardingProfile(wardx) {
wardx.event('onboarding.profile');
wardx.counter('onboarding.profile').inc();
}
function onOnboardingDone(wardx, userId) {
wardx.event('onboarding.done');
wardx.counter('onboarding.done').inc();
wardx.experiment.goal('onboarding.done', { subjectId: userId });
}
Procedure
- Pick a prefix and a name per step:
onboarding.start,onboarding.profile,onboarding.done. - On the client path for that step, call
eventandcounterwith the same name. - Put only low-cardinality attrs on the event. Put the breakdown you need to compare (
channel,mode) on the counter dimensions. - If the last step is an experiment conversion, also call
experiment.goalwithsubjectId. That is one conversion, not an N-step funnel. - From MCP, call
get_aggregateswith those names. Compare counter totals, oreventNamescounts, in the same window. The drop from start to done is the volume funnel.
On a backend that serves many users, increment the counters in process. Do not event() once per user action.
Do not put userId on a counter dimension. Do not expect Mixpanel-style unique-user funnels from Wardx.
Use case 8: Detect abnormal point accumulation
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.
Objective: Measure the grant stream as rates and a size distribution. Do not measure per player.
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.
function grantCoins(wardx, grant) {
const { source, amount, reason, id } = grant;
wardx.counter('coins.awarded', { source }).add(amount);
wardx.counter('coins.grants', { source }).inc();
wardx.histogram('coins.award_size', {
source,
buckets: [10, 50, 100, 250, 500, 1000, 5000]
}).observe(amount, { grantId: id, reason });
const maxAward = wardx.config.get('economy.maxAward', 500);
if (amount > maxAward) {
wardx.event('coins.anomaly', { source, amount, reason, grantId: id });
wardx.log.warn('coins_anomaly', { source, amount, reason, grantId: id });
}
}
source is a small set, for example match, daily, purchase, or admin.
Procedure
- Write the wallet change in the game database in the same transaction as the grant. That row is the audit of the player.
- On the grant path, add the amount to
coins.awardedand incrementcoins.grants. The dimension issource, not a user id. - Observe the amount in
coins.award_sizewith a lookup key (grantId). The histogram keeps those attrs only for the window max, asexemplar. Histogrammaxand the upper buckets are the inconsistency signal. The exemplar is the row to open in the database. - Read
economy.maxAwardfrom Remote Config. Emitcoins.anomalyonly when a grant exceeds that bound. That event is rare. - From MCP, compare
coins.awarded / coins.grants(mean grant) andcoins.award_sizemax againsteconomy.maxAward. If max is high, readexemplar.attrs.grantId.
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.
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.
Use case 9: Get Remote Config for a user
When: The server has a config snapshot. You need a value in the application.
Objective: Read the local snapshot. Do not wait for the network on the hot path.
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.
const timeoutMs = wardx.config.get('matchmaking.timeoutMs', 5000);
const chatEnabled = wardx.config.get('chat.enabled', false);
// Single-user process (desktop, one logged-in client)
wardx.identify(user.id);
const delayMs = wardx.config.get('message.delayMs', 1000);
// Many users in one process (game-server). Do not identify().
const otherDelayMs = wardx.config.get('message.delayMs', 1000, { subjectId: req.userId });
identify(null) clears the default. After that, config.get without { subjectId } is shared Remote Config again. A per-call { subjectId } overrides identify().
If there is no identified subject and you omit { subjectId }, that call is not in an experiment.
Resolution order
- If the key is not in the snapshot, return the fallback.
- If there is no subject (
identifyunset and no{ subjectId }), return the Remote Config value. - If an experiment applies to the subject, return the variant value.
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.
Use case 10: Run an A/B experiment and record a goal
When: A Remote Config key is in an experiment. You need a variant for a user. You need a goal event.
Objective: Get the variant value. Then record experiment.goal.
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.
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.
wardx.identify(userId);
const delayMs = wardx.config.get('message.delayMs', 1000);
setTimeout(() => {
deliver(text);
wardx.counter('message.sent').inc();
wardx.experiment.goal('message.sent', { value: 1 });
}, delayMs);
function sendMessage(wardx, userId, text) {
const delayMs = wardx.config.get('message.delayMs', 1000, { subjectId: userId });
setTimeout(() => {
deliver(text);
wardx.counter('message.sent').inc();
wardx.experiment.goal('message.sent', { subjectId: userId, value: 1 });
}, delayMs);
}
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.
The first config.get that has a subject in a session can emit event experiment.exposure. The payload contains:
experimentvariantsubject(hashed)
The payload does not contain the raw subjectId.
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.
Use case 11: Continue when the ingest server is down
When: The network fails, or the ingest server is not available.
Objective: Keep the application. Discard failed batches.
const wardx = createWardx({
endpoint: 'http://127.0.0.1:1',
projectKey: 'dev_project_key',
project: 'demo',
role: 'client',
appVersion: '0.1.0',
environment: 'development'
});
wardx.counter('jobs.completed').inc();
await wardx.shutdown();
The SDK still records in memory. A failed sync increments wardx.internal.frames_failed. The next cycle sends new data only.
Do not use this SDK if you must not lose events. This SDK is best-effort.
Use case 12: Stop the SDK in a graceful shutdown
When: The process receives SIGTERM or you stop a test.
Objective: Send the last frame, then release the HTTP agent.
const wardx = createWardx({
endpoint: 'http://127.0.0.1:8787',
projectKey: 'dev_project_key',
project: 'demo',
role: 'client',
appVersion: '0.1.0',
environment: 'production'
});
async function onStop() {
await wardx.shutdown();
process.exit(0);
}
process.on('SIGTERM', onStop);
process.on('SIGINT', onStop);
shutdown is safe to call more than one time. The second call returns immediately.
flush sends the current pending frames and does not stop the timers. Use shutdown when the process stops.
Use case 13: Trace measure calls while instrumenting
When: You are adding counters, events, and logs and you want to see each call and each sync on stderr.
Objective: Pass a tracer object. The SDK does not print on its own.
import { createWardx, createConsoleTracer } from 'wardx';
const wardx = createWardx({
endpoint: 'http://127.0.0.1:8787',
projectKey: 'dev_project_key',
project: 'demo',
role: 'client',
appVersion: '0.1.0',
environment: 'development',
tracer: createConsoleTracer()
});
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.
The tracer runs on the measure path. Use it in development. Remove tracer before production. It does not change frames, delivery, or Remote Config.
Use case 14: Measure play-session duration
When: You want to maximize how long people play, or how much time the fleet spent in a window.
Objective: Record a play-session clock in the application. Do not use the SDK sessionId.
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.
Two signals:
- Ended session (distribution + A/B). When the session ends, observe the elapsed milliseconds. Emit one
experiment.goalwith that value soanalyze_experimentcan split by variant. - Fleet play time (accumulated). Add the same milliseconds to
session.time_ms.get_aggregatesthen 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.
const SESSION_BUCKETS = [30_000, 60_000, 180_000, 300_000, 600_000, 1_200_000, 1_800_000, 3_600_000];
function onPlaySessionStart(wardx, userId) {
wardx.identify(userId);
return { startedAt: Date.now() };
}
function onPlaySessionEnd(wardx, session) {
const durationMs = Date.now() - session.startedAt;
wardx.histogram('session.duration', { buckets: SESSION_BUCKETS }).observe(durationMs);
wardx.counter('session.time_ms').add(durationMs);
wardx.counter('session.ended').inc();
wardx.experiment.goal('session.duration', { value: durationMs });
}
function onPlayHeartbeat(wardx, elapsedMs) {
wardx.counter('session.time_ms').add(elapsedMs);
}
On a process that serves many users, skip identify() and pass { subjectId } on experiment.goal.
Procedure
- Start a local clock when the play session starts. Do not use
sessionId. - When it ends, observe
session.durationwith minute-scale buckets. Default histogram buckets are for short durations in milliseconds. - Add the same number to
session.time_ms. Incrementsession.ended. - Call
experiment.goal('session.duration', { value: durationMs })with a subject. Emit that goal once per ended session.analyze_experimentthen hasgoalSumandgoalMeanper variant. Mean session ms isgoalSum / goals. - From MCP, read
session.time_msinget_aggregatesfor fleet minutes. Compare variants withanalyze_experiment, not with a counter dimension.
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.
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.
Use case 15: A/B test level difficulty to increase session time
When: You suspect a level is too hard or too easy, and you want longer sessions.
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.
The keys must already exist in Remote Config. The game reads them with config.get. Variants may only change those keys.
function onLevelStart(wardx, userId, levelId) {
const enemyHp = wardx.config.get(`level.${levelId}.enemyHp`, 100, { subjectId: userId });
wardx.event('level.start', { level: levelId });
wardx.counter('level.start', { level: levelId }).inc();
return enemyHp;
}
function onLevelFail(wardx, levelId) {
wardx.event('level.fail', { level: levelId });
wardx.counter('level.fail', { level: levelId }).inc();
}
function onLevelComplete(wardx, levelId) {
wardx.event('level.complete', { level: levelId });
wardx.counter('level.complete', { level: levelId }).inc();
}
level is a small set of ids. Do not put a unique run id on the counter.
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.
Call experiment.goal('session.duration', { value: durationMs }) when the play session ends (use case 14).
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.
Use case 16: Surface an error so an agent can open the source
When: A server or client fails and you want an agent to see enough to patch the file.
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.
function handleCheckout(req, res, wardx) {
try {
charge(req.body);
wardx.counter('payment.ok').inc();
} catch (err) {
wardx.counter('payment.error', { code: err.code || 'unknown' }).inc();
wardx.log.error('payment_failed', {
name: err.name,
code: err.code || 'unknown',
stack: clipStack(err)
});
res.statusCode = 500;
res.end();
}
}
function clipStack(err, max = 4096) {
const stack = err instanceof Error ? err.stack : String(err);
if (!stack) return null;
return stack.length <= max ? stack : stack.slice(0, max);
}
stack is an attr string. Do not send the Error object.
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.
The log ring is recent only (recentLogsMax). It is not a history search. See @wardx/server use case 8.
API
| Call | Description |
|---|---|
createWardx(options) |
Creates the SDK. Starts aggregate and sync timers. Optional tracer. |
createConsoleTracer(options) |
Local stderr tracer. Optional options.stream. |
counter(name, dims) |
Returns a counter. inc() or add(n). |
gauge(name, dims) |
Returns a gauge. set(value). |
histogram(name, dimsOrBuckets) |
Returns a histogram. observe(value) or observe(value, attrs). |
timer(name, dims) |
Starts a timer. The returned function records milliseconds. |
event(name, attrs) |
Buffers a product event. |
log.debug|info|warn|error(message, attrs) |
Buffers a structured log. |
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. |
config.get(key, fallback, context) |
Reads Remote Config. Uses identify() or { subjectId }. A per-call { subjectId } overrides identify(). Omit both for the shared value. |
experiment.goal(name, context) |
Emits experiment.goal. Needs a subject from identify() or { subjectId }. Optional value for a quantitative goal such as session duration. |
flush() |
Sends pending frames now. Returns a Promise. |
shutdown() |
Stops timers, sends pending frames, and closes the HTTP agent. |
The SDK creates one instanceId and one sessionId per process. The IDs are ULIDs.
Sync delay is syncIntervalMs * random(syncJitterMin, syncJitterMax). The default interval is 15 seconds. The default jitter is 0.85 to 1.15.
Related packages
- Engine:
@wardx/core - Ingest server:
@wardx/server