<!-- Kind: Integration guide. Status: Preview. Maintained with: sdk/event-collection and internal/eventcollection. Served at /user-metrics/javascript and /user-metrics/javascript.md. -->

# JavaScript SDK

Use the SDK for browser games and JavaScript apps. It handles persisted identity, sessions, progression attempts and durable delivery. Native wrappers provide foreground/background callbacks. Other engines can use the [Direct API](/user-metrics/api).

Before implementing, read [Events and metrics](/user-metrics/event-design): it explains which events produce activity, retention, session duration and progression metrics, and how to choose meaningful stages.

## Quick start

1. In Coal, select your existing game and open **User metrics → Setup**. An organization administrator with access to the game clicks **Create key** and copies the **public ingestion key**. Creating the first key activates collection. The SDK uses Coal’s shared collector automatically.
2. [Download the SDK package](/user-metrics/sdk.tgz), move it into your app's directory, and install it:

```sh
npm install ./coal-event-collection.tgz
```

3. Initialize one SDK instance after your app's consent policy permits collection. Replace `yourkey` with the public ingestion key from Coal.

```js
import { CoalEvents } from '@coal/event-collection';

let events;

// Call from your app after the player has granted consent.
async function enableGameMetrics() {
  if (events) return events;
  events = await CoalEvents.init({
    consent: true,
    key: 'yourkey',
    appVersion: '1.0.0',
    platform: 'web',
    onError: ({ code, status }) => {
      console.warn('Coal metrics:', code, status ?? '');
    },
  });
  return events;
}

// After enableGameMetrics() resolves:
events.track('inventory_opened', { source: 'menu' });
```

Initialization is asynchronous because it loads or creates a persisted installation ID. Await it before tracking. Initialize with the public ingestion key from the game’s Setup page.

**Preview distribution:** download the package from Coal. Keep the downloaded package in source control or your artifact store to reproduce builds. Collection is available only when your game's Coal access is eligible and the collector is configured.
## Install without a bundler

You can also [download the standalone ES module](/user-metrics/sdk.js) as `coal-events.js` and serve it from your own app. It has no runtime package dependencies.

```html
<script type="module">
  import { CoalEvents } from './coal-events.js';

  // Initialize from your consent-granted flow.
  async function enableMetrics() {
    return CoalEvents.init({
      consent: true,
      key: 'yourkey',
      appVersion: '1.0.0',
    });
  }
</script>
```

Use HTTPS for your deployed app, or localhost while developing. The collector accepts game keys from browser, embedded and packaged app origins. Download and bundle or self-host the module. Catch initialization errors and report them through your app’s diagnostics.
## Configure the SDK

| Option | Default | Purpose |
| --- | --- | --- |
| `consent` | Required: `true` | Explicitly permit initialization after your host app's consent flow. |
| `key` | Required | Public ingestion key from the selected game's settings. |
| `endpoint` | `https://coal-events.fly.dev` | Optional override for a local or separately hosted collector; use the collector base URL. |
| `appVersion` | Empty string | Your release/build version; set it consistently. Maximum 100 UTF-8 bytes. |
| `platform` | `'web'` | Platform label. Required on the wire; maximum 64 UTF-8 bytes. |
| `foreground` | `true` | Initial host app foreground state. Native wrappers should update it through `setForeground(boolean)`; document visibility must also permit activity. |
| `onError` | No callback | Receives observable queue, persistence, and delivery problems. Initialization errors also reject `init()`. |
| `heartbeatMs` | `30000` | Heartbeat interval while the page is visible; minimum 1,000 ms. |
| `awayMs` | `1800000` | Hidden/away interval that starts a new session on return; minimum 1,000 ms. |
| `flushMs` | `5000` | Timed delivery interval; minimum 100 ms. |
| `batchSize` | `50` | Maximum events in one request, from 1 to 100. |
| `batchBytes` | `262144` | Maximum encoded request size, from 16,450 to 262,144 bytes. |
| `queueLimits` | See offline delivery | Override `maxEvents`, `maxBytes`, and `maxAgeMs` with positive values. |

Use defaults initially. Heartbeats and flushes are separate: a heartbeat creates an event; a flush attempts delivery of a bounded batch. Each `flush()` attempts one bounded batch.
## Track custom events

Use custom events for actions that answer a specific game design question. Keep names stable and properties small, structured, and free of personal data.

```js
const eventId = events.track('inventory_opened', {
  source: 'menu',
  item_count: 4,
});

events.track('tutorial_hint_used', {
  hint: 'movement',
  difficulty: 'normal',
});
```

`track(name, properties)` synchronously returns an event UUID, then persists the queued event asynchronously. Delivery runs asynchronously using the saved payload snapshot. Calls while disabled return `null`.

Names begin with a letter and contain up to 100 ASCII characters using letters, digits, `_`, `.`, `:`, or `-`. Properties must be a JSON-serializable object, up to 8 KiB when encoded. Each whole event is limited to 16 KiB. Use finite numbers, valid Unicode text and small structured gameplay properties.

`session_started`, `session_heartbeat`, `session_ended`, `progression_started`, `progression_completed`, and `progression_failed` are reserved. Use the SDK lifecycle and progression helpers for them.
## Track progression

A progression ID identifies a level, mission, tutorial step, or other repeatable stage. Each start creates a separate **attempt ID**. Use the returned ID for its outcome, so retries and overlapping attempts remain distinguishable.

```js
const firstAttempt = events.progressionStarted('level_1', {
  order: 1,
  properties: { difficulty: 'normal' },
});

events.progressionFailed(firstAttempt, {
  properties: { reason: 'out_of_time' },
});

const secondAttempt = events.progressionStarted('level_1', { order: 1 });
events.progressionCompleted(secondAttempt, {
  properties: { score: 120 },
});
```

`progressionStarted(id, options)` returns the attempt UUID. A progression ID must be a nonempty string up to 100 characters; keep its UTF-8 encoding within 100 bytes for the collector. `order` is an optional numeric sequence for your stages. Outcome helpers return the queued event UUID. The SDK fills `progression_id`, `progression_order` when provided, and `attempt_id`.

Resolve each outstanding attempt once with its returned UUID. Outstanding attempts survive a tab reload through sessionStorage; retain the returned IDs in your app's own state if gameplay needs to reference them after reload. Up to 1,000 unresolved attempts are retained. Outstanding attempts appear as unresolved.
## Sessions and active time

After initialization, the SDK creates `session_started` and emits `session_heartbeat` approximately every 30 seconds while visible. `session_ended` is best effort on page exit. Every activity event carries **cumulative** `active_duration_ms` for that session.

Active time measures foreground app time using a monotonic clock. Each one-second sample is capped at two seconds.

A reload within 30 minutes resumes the tab's session and saved duration without counting reload downtime. A return after at least 30 minutes away starts a new session. Tabs have separate session IDs and share the installation's delivery queue.

The installation ID is random and stored locally. It remains stable for the same browser profile, app origin, collector endpoint, and game, including key rotation. Clearing storage, changing origin/profile, or resetting identity creates a new installation.

Coal uses the maximum observed cumulative duration per session. The first observed activity day establishes the installation cohort.
## Consent and reset

The host app owns its consent experience. Initialize with `consent: true` after collection is permitted. Stop collection when consent is withdrawn:

```js
await events.disable({ clearPending: true });
events = undefined;
```

`disable()` stops timers and lifecycle listeners and aborts in-flight delivery. Pending events are kept by default; `clearPending: true` clears them after earlier enqueues. Committed events remain in the daily aggregates. Initialize again only after consent permits it.

For an explicit local identity reset:

```js
await events.reset({ clearPending: true, resetIdentity: true });
events = undefined;
```

`reset()` stops collection and clears the session/attempt state. These reset options default to `true`. Use `clearPending: true` with `resetIdentity: true` to reset local identity and pending events together.
## Offline delivery and retries

The default IndexedDB queue retains up to **1,000 events**, **2 MiB**, and **seven days**. Oldest events are evicted on overflow and aged events expire when the queue is accessed. These losses are observable through diagnostics and `onError`.

```js
const diagnostics = await events.diagnostics();
console.log(diagnostics.pending, diagnostics.acknowledged);

await events.flush(); // Attempt one bounded batch.
```

Events are removed only after a successful response acknowledges the full batch. Retries keep their original IDs and payloads, so a lost response after a server commit can be retried safely. Network failures, HTTP 408/429, and 5xx use exponential backoff with jitter, capped at 60 seconds. A longer `Retry-After` is respected. Returning online triggers another attempt.

Other HTTP failures pause the queue, retain its events, and report `batch_rejected`. Fix the key, access, payload, or configuration issue, then call `events.resumeDelivery()` and `await events.flush()`. A rejected payload stays queued until explicitly cleared or reset. To use a newly created key, disable the old instance and initialize a new one with the new key; the same game's persisted identity and pending queue are retained.

Use normal timed delivery and monitor queue diagnostics for persistence failures, dropped events and expiry.
## Custom adapters

Advanced integrations may provide `persistence` and `transport` options to `CoalEvents.init()`. The default exports are `IndexedDBPersistence` and `FetchTransport`. Use these interfaces to connect your app’s storage and HTTP transport.

| Persistence method | Result / behavior |
| --- | --- |
| `identity(makeId)` | Promise of the stable installation UUID; persist a generated ID atomically. |
| `enqueue(event, limits, now)` | Promise of the number evicted; atomically store and enforce count/byte/age limits. |
| `read(limit, now, maxAgeMs)` | Promise of `{ events, expired }`; return oldest retained events and expire aged entries. |
| `remove(eventIds)` | Promise; remove only the named acknowledged events. |
| `count()` | Promise of pending count. |
| `clear({ identity: false })` | Promise; clear pending events and optionally persisted installation identity. |

`transport.send(endpoint, key, batch, { signal, keepalive })` returns a Promise of `{ status, retryAfter, acknowledged }`. Honor the AbortSignal: the SDK aborts after ten seconds or when disabled. Reject on network failure. Parse the collector's JSON acknowledgment for a successful response; pass `Retry-After` through when present. `keepalive` is an optional unload hint.

The SDK's tab-session and progression metadata use sessionStorage separately from the queue adapter. Connect native app state through `setForeground` as described below. Preserve host consent, monotonic active time, bounded durable storage, and original retry IDs in any custom integration.
## Native wrapper lifecycle and resumed attempts

Keep one SDK instance per app session. Connect the host wrapper's authoritative foreground/background callback to `events.setForeground(isActive)`. Active time accumulates while the app is foregrounded and the document is visible.

Persist your own run UUID with the game checkpoint and reuse it as `attemptId`:

```js
const attempt = events.progressionStarted('quest_1', {
  attemptId: savedRunId, // UUID created by your game, saved with its checkpoint
  order: 1,
});
// After the run reaches its actual outcome:
events.progressionCompleted(attempt);
```

Outstanding attempt metadata persists in IndexedDB independently of sessionStorage. Repeating a start for the same UUID and progression recovers the outstanding attempt. Completed/failed attempts are removed from the outstanding set. Resetting identity clears this metadata; a custom persistence adapter must implement `loadAttempts` and `saveAttempt` for cross-session recovery. IndexedDB can still be evicted by the platform.

Use the same consent policy across analytics providers, initialize each independently, and keep production and test games in separate collector scopes. All keys for one game contribute to the same aggregates.

## Method reference

| Method | Return | Use |
| --- | --- | --- |
| `CoalEvents.init(options)` | `Promise<CoalEvents>` | Consent-gated initialization. |
| `events.track(name, properties?)` | Event UUID or `null` | Queue a custom event synchronously. |
| `events.progressionStarted(id, options?)` | Attempt UUID or `null` | Start a separately identifiable attempt. |
| `events.progressionCompleted(attemptId, options?)` | Event UUID or `null` | Resolve an attempt as completed. |
| `events.progressionFailed(attemptId, options?)` | Event UUID or `null` | Resolve an attempt as failed. |
| `events.flush(options?)` | `Promise<boolean>` | Try one bounded batch; `false` can mean paused, retrying, or disabled. |
| `events.diagnostics()` | `Promise<object>` | Read instance counters and persisted queue count. |
| `events.resumeDelivery()` | No value | Explicitly unpause after fixing a permanent error. |
| `events.disable(options?)` | `Promise<void>` | Stop collection; optionally clear pending events. |
| `events.reset(options?)` | `Promise<void>` | Stop and clear local session/identity as requested. |


