DataGauge for client & offline runtimes: Expo/React Native, Electron, browser PWAs.
The problem it solves
Clients go offline, apps get killed mid-session, and retries will
happen. So every event gets a durable id at enqueue time, the queue
survives restarts via pluggable storage, and replays are sent as arrays
with per-event at plus a flush-time sent_at, which lets the server
correct the device's clock skew. Ship it with a pk_live_ key: it is
public by design and can never write money.
Install
npm install @datagauge/client@^0.2.0This page documents 0.2.0, which is where the queue started telling you why
it paused or dropped: the failure table below and onError arrive with it. On
0.1.x flush() returns the same cappedUntil, pausedUntil and dropped,
but with nothing that says what the server actually said. Upgrading is a version
bump and nothing else.
Prefer zero dependencies? Every SDK is a single file, so vendor it and point your import at the local copy:
curl -o lib/datagauge.ts \
https://raw.githubusercontent.com/raymond-UI/datagauge/main/sdks/client/index.tsUsage
import AsyncStorage from "@react-native-async-storage/async-storage";
import { DataGaugeClient } from "@datagauge/client";
const dg = new DataGaugeClient({ key: "pk_live_...", storage: AsyncStorage });
await dg.track({ name: "screen_view", user: deviceId }); // durable immediately
await dg.flush(); // on start, on reconnect, on an interval, all safetrack returns once the event is persisted. flush is safe to call
repeatedly; on failure the queue is kept, and per-event ids make the
eventual replay idempotent.
API
new DataGaugeClient(opts: ClientOptions)
| Option | Type | Notes |
|---|---|---|
key | string | Required. pk_live_, safe to ship in the binary. |
storage | KeyValueStorage | AsyncStorage, localStorage, a file-backed shim. Without it the queue is memory-only. |
url | string | Ingest base. Defaults to https://in.datagauge.dev. |
maxQueue | number | Oldest events drop beyond this. Default 1000. |
fetcher | typeof fetch | Injectable for tests. |
now | () => number | Injectable clock for tests. |
randomId | () => string | Injectable id generator (defaults to crypto.randomUUID). |
onError | (f: SendFailure) => void | Optional. Called instead of console.error when events are dropped or sending stops. Not called for a refusal the client will simply ask about again. |
KeyValueStorage is { getItem(key), setItem(key, value) }, and each may
be sync or async. A corrupt or unavailable store never bricks the app; the
in-memory queue is kept.
dg.track(event): Promise<void>
Enqueue durably. event is { name, user?, props? }, and the SDK assigns the
id and at itself, so replays stay idempotent.
dg.flush(): Promise<FlushResult>
Send everything queued, in batches of up to 500. Safe to call on app start, on connectivity regained, or on an interval. On failure the queue is kept for the next attempt.
| Field | Type | Notes |
|---|---|---|
sent | number | Events the server accepted this flush. |
remaining | number | Events still queued. |
cappedUntil | number | Your organization's hard cap is refusing events; epoch ms at which it lifts. The queue is kept and replays when it does. |
pausedUntil | number | Sending has stopped because the server refused the key, not the events; epoch ms at which it tries again (5 minutes). The queue is kept: a key rotated on the server, or an invoice paid, must not need an app restart. |
dropped | number | Events discarded this flush because the server said they will never be accepted. |
What happens when ingest refuses the batch
Not every refusal means the same thing to a queue, and treating them alike is how a client either loses good data or replays a doomed batch forever:
| Response | What the queue does |
|---|---|
408, 425, 429, 5xx, offline | Keeps everything and asks again on the next flush. Not reported: this is the ordinary weather of a client SDK, and a channel that fires on every bad minute is a channel you filter out. |
413 | Halves the batch and retries, down to a single event. Only an event that is over the 1 MB cap on its own is dropped. |
403 with event_cap_reached | Keeps everything, sets cappedUntil, and reports it. |
401, 402, other 403, 404 | Keeps everything, sets pausedUntil, and reports it. |
400, 415, 422 | Drops that batch and reports it. |
The drop is the one that cannot be undone, so it is never silent. It happens
because the queue evicts its oldest events past maxQueue: a batch the
server will refuse identically forever would otherwise outlive the events that
could still have been sent, sitting at the head of the queue and blocking
everything behind it.
A 202 carrying a non-empty errors[] is also reported. That is the only place
you learn an event name or a prop was wrong, and a 202 reads as success.
onError
Route failures somewhere other than console.error:
const dg = new DataGaugeClient({
key: "pk_live_...",
storage: AsyncStorage,
onError: (f) => {
// f: { status, body, events, message, retriable }
Sentry.captureMessage(f.message, { level: "warning" });
},
});If your reporter itself throws, the failure it was called about is logged to the console instead, and the reporter's own error alongside it.
dg.pending: number
Number of events waiting to be sent.
Key hygiene
This SDK is the one place a public pk_live_ key belongs: it's built
to be extracted from an IPA or bundle and still be harmless, because a
public key can only write behavioral events. Never put an sk_live_ key in
client code; see Keys & scoping.
A pk_test_ key is the same key, aimed at your isolated test environment.
Ship it in a test build to keep those events out of your live numbers.