Skip to documentation
Coal Developer docs
Documentation User metrics
JavaScript & webRead as Markdown

JavaScript SDK

Collect game sessions, active app time, custom actions, and progression attempts with Coal. Start with a few meaningful events; keep the same names across releases so your data stays useful.

Current support: browsers and JavaScript web games. The SDK uses IndexedDB, sessionStorage, Fetch, and browser visibility events. Unity, Unreal, Godot, and native mobile SDKs are not available in this preview. A supported HTTP collection API is documented below for custom clients.

Quick start

  1. In Coal, select your existing game and open User metrics → Setup. An administrator of the owning organization 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, move it into your app's directory, and install it:
npm install ./coal-event-collection.tgz
  1. Initialize one SDK instance after your app's consent policy permits collection. Replace yourkey with the public ingestion key from Coal.
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. Players do not sign in to Coal. Your public key allows ingestion for one game; it does not allow exports or account administration. Never put provisioning or export credentials in a game build.

Preview distribution: the package is downloaded from Coal, not published on the npm registry. 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 as coal-events.js and serve it from your own app. It has no runtime package dependencies.

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

  // Run inside your consent-granted flow, not on every page load.
  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 must allow your app's exact browser origin, including its port. Ask the Coal operator to add that origin. Do not import the module directly from a different origin: download and bundle or self-host it. Browser/private-mode storage restrictions can prevent initialization; handle that failure without interrupting gameplay.

Collector address

All games use Coal’s shared hosted collector at https://coal-events.fly.dev. The ingestion key identifies the game and its owning organization; the endpoint is not game-specific. SDK integrations normally supply only the key, consent, and their app version.

All hosted games use https://coal-events.fly.dev; the SDK supplies this address automatically. For a local or separately hosted collector, override it explicitly:

const events = await CoalEvents.init({
  consent: true,
  key: 'yourkey',
  endpoint: 'https://collector.example.com',
  appVersion: '1.0.0',
});

Keep the public collector URL stable when moving collector machines or databases. If the URL itself changes, existing builds need an updated endpoint/default, and the SDK uses a new local identity/queue namespace. Omitting the endpoint preserves the same namespace as explicitly supplying the default URL. The endpoint is not a credential; keys allow writes only.

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 base URL without /v1/events.
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.
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. Calling flush() once does not drain an arbitrarily large queue.

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.

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. It does not wait for a network request. Once queued, the payload is a snapshot: later changes to your properties object cannot change a retry. 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. Avoid circular references, BigInt, names/emails, account identifiers, free-form player text, and sensitive information. NUL characters and unpaired Unicode surrogates are rejected by the collector.

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.

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; custom properties cannot override those fields.

Each attempt can complete or fail once. Unknown or already resolved IDs throw. 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. A missing outcome may mean abandonment, delivery loss, or an instrumentation gap—it does not prove a player failed.

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 uses a monotonic clock, excludes hidden time, and caps each one-second sample at two seconds to avoid counting long OS suspension or timer stalls. Wall-clock changes do not inflate duration. Browser throttling and missing final events can undercount. Visibility measures active-app time, not verified attention or gameplay.

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, not fingerprinted. 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. It is not a verified person or a cross-device user ID.

When interpreting raw events, use the maximum observed cumulative duration per session; do not sum heartbeat durations. Return days can describe installation activity. The first observed event is not a proven install date.

The host app owns its consent experience. Calling init() with anything other than consent: true rejects before creating identity or starting collection. Stop collection when consent is withdrawn:

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. It cannot undo an event already committed on the server. Initialize again only after consent permits it.

For an explicit local identity reset:

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

reset() stops collection and clears the session/attempt state. These reset options default to true. Resetting identity requires clearing pending events; keeping old events while changing identity is rejected. Resetting local state does not delete stored server events.

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.

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. resumeDelivery() does not change the key or fix a payload. 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.

IndexedDB is not an unlimited or guaranteed disk. Restricted storage, eviction, or a crash before an asynchronous persistence transaction completes can lose events. Page-exit delivery is best effort. Prefer normal timed delivery over depending on an unload request.

Verify your integration

  1. Enable collection for the selected game and initialize after consent.
  2. Track one custom action and start two progression attempts. Complete one and fail the other.
  3. Flush, check accepted delivery in SDK diagnostics, then check the daily charts after the next dbt run. Open the authenticated example integration with ?product=<your Coal project ID or slug> appended to its URL.
  4. Check acknowledged and pending counts in SDK diagnostics, then verify daily session and progression counts after dbt publishes. A successful collector response means the batch committed durably.
  5. Use the example's offline transport control: enqueue an action while offline, verify pending increases, then reconnect and flush. Raw events should arrive once per event ID.
  6. Reload with pending events, grant consent again, and initialize. Verify the persisted queue recovers.

The example page reads the selected game's setup and displays SDK diagnostics and daily dbt aggregates. A hosted player build needs only the public ingestion key; the SDK supplies the collector endpoint. Coal cannot inspect individual events or player identifiers.

Troubleshooting

Symptom What to check
init() rejects Explicit consent, a nonempty key (and a valid endpoint if overriding it), browser support, and accessible IndexedDB. Catch the initialization error.
Browser CORS error Exact app origin is allowed on the collector, including the localhost port. A key does not bypass CORS.
pending grows while offline Expected. Reconnect and allow retry time; the bounded queue continues to retain events.
batch_rejected, status 403 Game enabled, key active, owning account eligible, and collector access renewed by Coal. Delivery pauses.
batch_rejected, status 400/409 Invalid payload or conflicting reuse of an event ID. Correct the cause; preserve the diagnostic before clearing.
HTTP 429 Rate or concurrency limit. The SDK backs off automatically.
HTTP 503 Durable commit unavailable. The SDK retries the same IDs.
persistence_error Browser storage transaction failed. Inspect browser storage restrictions and report the loss.
queue_overflow / queue_expired Queue bounds were exceeded or offline events aged out. Inspect dropped / expired.
No final session event Unload delivery is best effort; use the last observed cumulative heartbeat duration.

Diagnostics contain enqueued, acknowledged, dropped, expired, retries, permanentErrors, persistenceErrors, pending, enabled, blocked, and retryAt (Unix milliseconds). Counters describe this SDK instance; acknowledged can include recovered events from earlier instances. A healthy queue can temporarily have pending events between flushes.

Keys and collection access

An owning organization administrator manages ingestion keys in User metrics → Setup. Create key activates collection and adds an independent public key for the game, without changing other keys or their expiry dates. No separate enable switch is required. New keys have no automatic expiry. Deploy a new key in your game, then revoke the old key when older builds no longer need it. Revocation immediately stops delivery from builds using that key; it does not create another key. Control event delivery through the SDK’s consent and disable() controls, or revoke keys in Coal to stop accepting their events. Account eligibility still determines whether ingestion is permitted.

The collector checks an access projection renewed by Coal's worker every 15 seconds, with leases lasting at most 60 seconds. If renewal or authoritative access lookup fails, the lease expires and ingestion stops. Access withdrawal can take up to 60 seconds plus an earlier admitted transaction's bounded time, with synchronized server clocks. Existing committed events are retained.

Public keys are extractable write-only identifiers. They do not prove events came from genuine players. Keep server-only provisioning/export credentials out of game clients, browser storage, code examples, and logs.

Collection API

Custom clients can send the same protocol to POST /v1/events on the collector. This does not constitute a supported native game-engine SDK. Generate and persist installation/session identity, unique event IDs, consent state, offline queues, lifecycle timing, and retries in your own integration.

curl --fail-with-body 'https://coal-events.fly.dev/v1/events' \
  -H 'Content-Type: application/json' \
  -H 'X-Coal-Ingestion-Key: yourkey' \
  --data '{"schema_version":1,"events":[{
    "event_id":"00000000-0000-4000-8000-000000000021",
    "installation_id":"00000000-0000-4000-8000-000000000022",
    "session_id":"00000000-0000-4000-8000-000000000023",
    "name":"inventory_opened",
    "occurred_at":"2026-10-06T12:00:00.000Z",
    "properties":{"source":"menu"},
    "platform":"web","app_version":"1.0.0","schema_version":1
  }]}'

The UUIDs and timestamp above are illustrative. Generate new event IDs for new actions; reuse the exact event ID and payload only for delivery retries. Old offline timestamps are accepted. Do not add customer/account/game IDs: the key determines authoritative ownership and client-supplied scope is rejected.

Field Required Contract
Batch schema_version Yes 1.
Batch events Yes 1–100 event objects; entire request up to 256 KiB.
event_id Yes Nonzero UUID, unique per event within the game.
installation_id Yes Nonzero UUID persisted by the client.
session_id Yes Nonzero UUID for this session.
name Yes Stable event name matching the custom event rules above.
occurred_at Yes UTC RFC3339 timestamp, optionally with fractional seconds.
properties No for custom events JSON object, up to 8 KiB; numbers in the JavaScript range.
platform Yes Nonempty string, up to 64 UTF-8 bytes.
app_version Recommended String, up to 100 UTF-8 bytes; the SDK always supplies it.
Event schema_version Yes 1.

Each event is limited to 16 KiB. Session activity events require nonnegative properties.active_duration_ms up to 31,536,000,000. Progression events require a nonzero UUID properties.attempt_id and a nonempty properties.progression_id up to 100 UTF-8 bytes. progression_order is optional. Unknown batch/event envelope fields are rejected.

Successful response, after commit:

{"acknowledged":1,"inserted":1,"duplicates":0}

The whole validated batch commits atomically. Retries deduplicate by game and event ID; equivalent JSON reuse acknowledges duplicates. Reusing an ID with different content returns 409 and rolls back the whole batch. A 400/413/415 means validation, size, or content-type failure. 401/403 means ingestion access is denied. 429 means retry later; 503 means durable commit unavailable. A success without a matching acknowledgment count must not clear the queue.

Aggregate access

Coal reads only account/game-scoped daily metrics computed by the collector's dbt worker. Provisioning and metrics authorization are server-only. Raw export remains a dedicated collector operator capability and is not available in Coal or player clients.

Custom adapters

Advanced integrations may provide persistence and transport options to CoalEvents.init(). The default exports are IndexedDBPersistence and FetchTransport. These interfaces are extension points, not a tested Unity/native implementation.

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, not a delivery guarantee.

The SDK's tab-session and progression metadata use sessionStorage separately from the queue adapter. Replacing transport/persistence alone does not establish native lifecycle support. Preserve host consent, monotonic active time, bounded durable storage, and original retry IDs in any custom integration.

For AI coding tools

Use the Markdown version of this guide as context. It is the same maintained source as this page, including exact method names, limits, and protocol examples. The downloaded npm package includes guide.md, so an agent can read it offline.

Give your coding tool this instruction together with your app's actual consent and lifecycle code:

Integrate Coal's JavaScript event-collection SDK using its guide.
Initialize one instance only after our consent flow permits collection.
Use the selected game's public ingestion key and the default hosted endpoint.
Override endpoint only for a local or separately hosted collector.
Await initialization. Track a small set of stable game actions.
Pair each progression outcome with its returned attempt ID.
Handle initialization failures and the SDK's onError diagnostics.
Verify offline retry/reload recovery and consent withdrawal.
Do not invent a Unity SDK, npm registry package, custom-user-ID API,
or put server export/provisioning credentials in the player app.

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.

Read metrics in Coal

Open User metrics → Setup for the game's write-only public key. The SDK uses the shared collector automatically; its optional endpoint override is documented above for local or separately hosted collectors. Organization administrators create and revoke individual keys here. Use the example integration to grant consent, initialize the browser SDK and send a session, custom event and progression attempt. SDK diagnostics confirm accepted delivery immediately. Daily charts update after the collector's next dbt run (about 15 minutes).

Overview, Retention and Progression consume only daily UTC aggregates. Custom-event collection remains supported, but its aggregate reports are not exposed in Coal yet. Coal has no raw-event endpoint, raw database access, raw export credential or individual-player view. First observed installations are browser identities, not verified people or actual install dates. Exact-day D1/D7/D30 retention appears only once the return day has ended. Sessions use maximum observed cumulative duration, attributed to their first observed UTC day; this is approximate active-app time. Unresolved attempts do not prove abandonment. Today is provisional and offline delivery can revise historical daily metrics.