# @datagauge/client

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

```sh
npm install @datagauge/client@^0.2.0
```

This 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:

```sh
curl -o lib/datagauge.ts \
  https://raw.githubusercontent.com/raymond-UI/datagauge/main/sdks/client/index.ts
```

## Usage

```ts
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 safe
```

`track` 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](/docs/api/events) 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`:

```ts
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](/docs/concepts/keys).

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.
