The Node.js SDK reports Keewano analytics from a server, not a device. One process relays events on behalf of many end users: for each user you assemble a self-contained batch, tag it with that user's id, and the SDK ships it. The wire format is identical to the React Native SDK, so the same backend ingests both.
It is the right fit for server-authoritative events - a purchase your backend validates, a subscription a billing webhook confirms, an item your economy service grants - that you want attributed to the player who triggered them.
npm install @keewano/node-sdkRequires Node.js >= 20 (the SDK uses the built-in global fetch). There is no native
code and no React dependency.
Call init once when your process starts. apiKey is the only required option.
import { Keewano } from '@keewano/node-sdk';
await Keewano.init({ apiKey: 'your-project-api-key' });Unlike the device SDKs, init does nothing on its own - no environment burst, no
background listeners. It only prepares the send loop. Every event is supplied later, per
user, through reportUserBatch.
| Option | Type | Default | What it does |
|---|---|---|---|
apiKey |
string |
(required) | Your project key, sent as the K-Token header. |
endpoint |
string |
production URL | Override the ingress URL. Useful for staging or self-host. |
dataDir |
string |
<os.tmpdir()>/keewano |
Directory the default storage uses for on-disk batches. Ignored when storage is supplied. |
storage |
StorageAdapter |
NodeStorageAdapter |
Custom storage backend; replaces the file-system default. |
installId |
string |
project id | Override the relay install id (a 36-char hyphenated UUID). Defaults to the project id from the API key, which the backend requires to be non-zero. |
getExtraHeaders |
() => Record<string,string> | Promise<...> |
none | Extra HTTP headers on every request (for example an auth token for a proxy in front of staging). Resolved once per send cycle, so a short-lived token can refresh. Reserved K-* / Content-* headers always win. |
customEventSet |
CustomEventSet |
none | Your generated custom-events schema. See Custom Events. |
Note
The Node SDK has no consent gate and no opt-out flags. Those belong to the device SDKs, which capture user behaviour automatically; a relay only ships what you hand it per user.
reportUserBatch is the one event-emitting call. You give it the end user's id and a
build callback; the callback receives a reporter scoped to that user, and every event
you emit through it lands in a single batch attributed to that user.
await Keewano.reportUserBatch({
userId: '11111111-1111-4111-8111-111111111111',
build: (user) => {
user.reportInAppPurchase({ productName: 'gold_pack', price: { priceUsdCents: 499 } });
user.reportInAppPurchaseItemsGranted({
productName: 'gold_pack',
items: [{ name: 'gold', count: 1000 }],
});
},
});The promise resolves once the batch is persisted to disk; it ships on the next send-loop
pass, which is woken immediately. If the batch cannot be written (disk full, permissions),
the promise rejects and the events of that call are not queued - handle or log the
rejection on your side. One exception: a batch large enough to split into multiple slices
can fail mid-way with a leading subset already on disk; those slices will ship, so
re-reporting the same events after a rejection can duplicate them on the server. An empty
build (one that emits nothing) writes nothing and ships nothing.
userId is required - a batch with no user has nowhere to land. It accepts either form:
// a 36-char hyphenated UUID
await Keewano.reportUserBatch({ userId: '11111111-1111-4111-8111-111111111111', build });
// or a numeric id (up to 64-bit), passed as a bigint
await Keewano.reportUserBatch({ userId: 1234567890n, build });A numeric id is packed into the last 8 bytes of the user UUID, the same layout the device
SDKs use for setUserId, so the backend sees one consistent identity per player. The
all-zero / empty id is rejected.
The reporter is only live for the duration of the build call. Keep build synchronous
and emit every event inline. Three misuses throw rather than silently mis-attributing
events:
- an
asyncbuild (the SDK rejects the returned promise), - using the
userreporter afterbuildhas returned (a captured reference), - calling
reportUserBatchagain from inside abuild(a nested call).
// WRONG - the reporter is dead once build returns
let captured;
await Keewano.reportUserBatch({
userId,
build: (user) => {
captured = user;
},
});
captured.reportInstallCampaign('promo'); // throws
// WRONG - async build is rejected
await Keewano.reportUserBatch({
userId,
build: async (user) => {
await load();
},
}); // rejectsDo any async work - load the data, validate the receipt - BEFORE the call, then emit
synchronously inside build.
Note
Onboarding-milestone dedup is scoped per batch, so the same milestone reported for two different users in separate calls never collides.
The userId + build pair is the attribution contract, but you do not have to spell it
out at every call site. Write one thin helper module and call one-liners everywhere:
// analytics.ts - written once
import { Keewano, UserReporter } from '@keewano/node-sdk';
export const track = (userId: string | bigint, emit: (user: UserReporter) => void) =>
Keewano.reportUserBatch({ userId, build: emit });
export const trackPurchase = (userId: string, productName: string, priceUsdCents: number) =>
track(userId, (user) => {
user.reportInAppPurchase({ productName, price: { priceUsdCents } });
});// any other module
import { trackPurchase } from './analytics';
await trackPurchase(playerId, 'gold_pack', 499);Keewano is a process-wide singleton. Call init once in your entry point; every other
module just imports Keewano (or your helper) and reports - no instance passing, no
wiring. Concurrent reportUserBatch calls from different components are safe: each call
assembles its own isolated batch.
Two rules keep components independent:
- Do not call
reportUserBatchfrom inside another call'sbuild(it throws "nested call inside build"). If handler A triggers component B, let B report after A'sbuildhas returned. - One batch = one logical action of one user. Report per webhook / per action rather than accumulating; batches are cheap.
Separate processes (microservices, pm2 cluster workers) each run their own init with
their own dataDir - see One dataDir = one process.
The user reporter exposes the event families that make sense server-side. Each one
behaves exactly as it does on the device SDKs - follow the linked guide and read
Keewano.reportX(...) there as user.reportX(...) here.
| Method(s) | Guide |
|---|---|
reportInAppPurchase, reportInAppPurchaseItemsGranted |
In-App Purchases |
reportAdOffered, reportAdRevenue, reportAdItemsGranted |
Ad Revenue |
reportSubscriptionRevenue, reportSubscriptionItemsGranted |
Subscription Revenue |
reportItemsExchange, reportItemsReset |
Item Economy |
reportOnboardingMilestone |
Tutorial Tracking |
reportABTestGroupAssignment |
A/B Tests |
reportInstallCampaign, reportGameLanguage |
Marketing Campaign |
reportCustomEvent |
Custom Events |
logError |
a free-form error string for this user |
Note
UI events (button clicks, windows, screens) and the identity / consent calls are not part of the relay surface - they describe one device's session, which a server does not have.
Generate a typed schema with @keewano/codegen, targeting the Node relay:
npx keewano-codegen --input keewano-custom-events --target nodePass the generated customEventSet to init (the send loop registers it once per session,
before the first batch ships), then emit through the generated wrappers - or
user.reportCustomEvent(...) directly - inside a build callback:
import { Keewano } from '@keewano/node-sdk';
import { customEventSet, reportBestScore } from './keewano-custom-events/keewano-events.generated';
await Keewano.init({ apiKey: '...', customEventSet });
await Keewano.reportUserBatch({
userId,
build: (user) => reportBestScore(user, 12345),
});The node target emits wrappers that take the per-batch UserReporter as their first
argument. See Custom Events and the Codegen Reference.
With the default file-system adapter, every batch is written to disk before any
network attempt, so a crash or a dropped connection does not lose already-persisted
data. If you supply storage, that durability guarantee depends on the adapter's
implementation. The send loop retries on its own schedule and the store is bounded by
a size cap. This is the same engine the device SDKs use - see
Offline Analytics for the full behaviour.
Warning
A dataDir must be owned by exactly ONE SDK process at a time. Never point two
processes - pm2 cluster instances, cluster workers, multiple containers sharing a
volume - at the same directory.
The SDK assumes it is the only reader and writer under its dataDir. When two processes
share one directory, each keeps its own batch counter, so they produce colliding file
names and silently overwrite each other's batches; and a batch file both send loops can
see is shipped by both, so the backend receives it twice.
If you run multiple workers, give each one its own directory:
// pm2 cluster mode: NODE_APP_INSTANCE is the per-worker index
await Keewano.init({
apiKey: 'your-project-api-key',
dataDir: `/var/data/keewano/worker-${process.env.NODE_APP_INSTANCE ?? '0'}`,
});Restarting the same single process is safe: on init the SDK resumes batch numbering after the highest number already in the directory, so a restart never overwrites or renumbers batches a previous run left behind.
The send loop runs for the life of the process; you rarely need to stop it. On a clean
exit, call shutdown to stop the send loop and release resources:
await Keewano.shutdown();There is nothing buffered to lose: each batch is already on disk by the time
reportUserBatch resolves, and anything not yet shipped is picked up by the next run.
Keewano.isReady() returns true once init has resolved. Calling reportUserBatch
before that throws ("SDK not initialized"), so await your init call during process
startup before reporting.
| React Native / Expo | Node.js | |
|---|---|---|
| Model | one device, one user | one process, many users |
| Events | mostly automatic (taps, screens, lifecycle) | explicit, per user, inside build |
| Identity | anonymous install id, then setUserId |
userId required per batch; install id = project id |
| Consent gate | optional | none |
| Storage peer | react-native-fs / expo-file-system |
Node file system (built-in) |
Related: In-App Purchases | Custom Events | Offline Analytics | Data Format