# Analytics

Your game does not have to measure anything. This is a capability, not an obligation — but if you
want to know how far players get into your tutorial, which difficulty they quit on, or whether the
level you rebalanced is actually finishing more often, the platform will count it for you and your
game reaches it with the token your frame already holds.

Two things decide whether this feature fits your game, and both are unusual enough to read before
you design around them:

- **Nothing is recorded that you did not declare.** There is no free-text property. Every event,
  every dimension and every value a dimension may take is published in your game config ahead of
  time, and anything else is refused per event rather than stored.
- **Detail older than 30 days is gone.** Raw events live 30 days. The daily counters built from
  them live 400. A question you can only answer by reading individual events is a question you can
  only ask about the last month.

What you record here is **yours**. It feeds your own reporting and nothing the platform treats as
authoritative — see [section 8](#8-what-analytics-is-not) before you attach a reward to it.

## 1. Two roles, and they are not the same person

The same split as save slots and leaderboards: one half is something you publish, the other is
code your game runs.

|                       | Who                      | When                                  |
| --------------------- | ------------------------ | ------------------------------------- |
| **Declaring events**  | you, the developer       | once, in your game config             |
| **Recording events**  | your game's running code | whenever the thing happens            |

An event your config does not declare does not exist. Recording it is not an error your game can
catch — it is accepted into the queue, sent, and refused one event at a time when it arrives.
Section 7 is how you see that.

## 2. Declare the events, in your game config

The `analytics_events` section names each event, the dimensions it is broken down by and the
numbers it carries:

```yaml
analytics_events:
  - id: level_completed
    description: A level ended in a win or a loss.
    dimensions:
      outcome: { values: [win, loss, quit] }
      difficulty: { values: [easy, normal, hard] }
      level: { range: { min: 1, max: 50 } }
    measures:
      duration_ms: int
      score: int
```

**A dimension is a closed set, and that is the point.** Either a `values` list or a bounded
integer `range`, each at most 64 wide, at most three per event, and the product of an event's
dimension sizes is capped — the example above is 3 × 3 × 50. A string with no `values` list is a
config error rather than a warning, because the number of distinct rows an event can produce has
to be knowable before a player produces any of them.

**Measures are the numbers**, at most four per event, `int` or `float`. The platform keeps their
sum, count, minimum and maximum per day, and the individual value for as long as the raw window
holds it.

Ship it the way you ship any config:

```bash
gemctl config schema                                  # the authoritative field list
gemctl config upload --wait                           # register a version
gemctl config promote <version> --channel <channel>   # this is what makes it live
```

**Uploading is not shipping.** Until a version carrying `analytics_events` is promoted to the
channel your game runs on, every event your game records is refused `analytics_not_declared` —
including on a channel you use for development.

## 3. Funnels are declared too

A funnel is an ordered list of two to eight event ids, declared once and read back as players at
each step:

```yaml
analytics_funnels:
  - id: first_session
    steps: [gem_session_start, tutorial_completed, level_completed]
```

Steps may name your own events or the platform's. **A funnel is answered two different ways and
the answer says which.** Inside the raw window it is ordered and can cross days, with the time
each player took to reach each step. Outside it, the only thing left is the daily counters, so the
answer becomes same-day and unordered: a player counts at a step if every earlier step has a row
on the same day. A window that straddles the boundary is answered entirely from the counters
rather than mixing the two definitions in one number.

**So do not compare a funnel reading from last week against one from last quarter.** They are
different questions with the same name.

## 4. What the platform records for you

The SDK emits these on its own. You do not declare them, you cannot suppress them individually,
and a game that declares no events at all still gets them:

| Event                              | Dimensions                       | Measures      |
| ---------------------------------- | -------------------------------- | ------------- |
| `gem_session_start`                | —                                | —             |
| `gem_session_end`                  | `reason`                         | `duration_ms` |
| `gem_pause` / `gem_resume`         | —                                | —             |
| `gem_boot_ready`                   | —                                | `boot_ms`     |
| `gem_match_started` / `gem_match_ended` | `transport` (`dedicated`, `p2p`) | —        |
| `gem_auth_lost`                    | —                                | —             |

`reason` on a session end is one of `grant_expiring`, `session_ended`, `navigating`,
`kill_switch` or `pagehide`.

**The `gem_` prefix is reserved.** Your config cannot declare an id beginning with it and
`gem.analytics.track` refuses one rather than sending it, so pick another name.

**These columns are the whole of it, and `setContext` does not add to them.** The dimensions above
are fixed by the platform, and since no config can declare a `gem_` id there is no way to widen
one — a key that is not in this table would refuse the whole event rather than annotate it. Your
context therefore reaches your own `track` calls and stops there: these arrive carrying exactly
what the table says, however much context you have set.

Two of these are worth knowing the shape of before you build a report on them:

- **`gem_session_start` is the handshake, not the first frame.** A game opened outside the arcade
  never establishes a play session, so it starts no session and sends nothing. That is expected
  while you are developing locally.
- **`gem_session_end` on a closing page is best effort.** The browser gives a page that is going
  away a small budget for requests in flight and the SDK spends part of it here; the room leave
  gets the larger part, because a stranded room membership is the failure a player notices.
  Treat `duration_ms` from a `pagehide` end as approximate.

`gem_match_ended` is skipped for a match this page never saw start — a reload into a running
match, for instance — because `transport` is a declared dimension and the SDK will not guess one.
**Match starts and match ends therefore do not reconcile exactly**; use starts for counting matches
and ends for breaking them down.

## 5. Record an event

```js
gem.analytics.track('level_completed', {
  outcome: 'win', // a string is a dimension
  difficulty: 'normal',
  level: 7,
  duration_ms: 48120, // a number is a measure
  score: 3100,
});
```

**A string is a dimension and a number is a measure**, and which one a key is was settled by your
config, not by the call. Passing `level: '7'` where the config declares a range is a refusal, not
a conversion.

`track` is synchronous, never throws and never rejects. It puts the event on an in-memory queue
that goes out on a timer, when it reaches 50 events, and on the way out of the page. **Nothing
is sent by the call itself**, so a game that never flushes still reports.

Values every event should carry go in the context instead of on every call site:

```js
gem.analytics.setContext({ build: 'v1.4.2' });
```

**Context reaches your events, never the platform's.** It is merged into what you pass `track` and
into nothing else — the `gem_*` events in section 4 have fixed dimensions no config can widen, so
a context key on one would refuse the event instead of labelling it. Setting a context cannot cost
you your session, pause, boot or match data.

**A context key is declared like any other.** One key your event does not carry refuses every
event it reaches, not just the one you were thinking about — which is why the SDK does not stamp
its own version or your game's onto your events. It offers them instead, for a game that has
declared the dimensions to take them:

```js
gem.analytics.setContext(gem.analytics.environment);
```

Every dimension you add costs cardinality against the product cap, so do that on the handful of
events where knowing which build produced a number is worth a dimension slot.

You can send the queue deliberately and read what happened to it:

```js
const { accepted, rejected, dropped, duplicate } = await gem.analytics.flush();
```

You rarely need to. The SDK flushes on its own every ten seconds — that is also the floor, because
the platform limits how often one player may post — and again on every way out of the page, so the
only reason to call it by hand is to look at the answer.

`GemOptions.analytics` carries the three knobs: `enabled`, `flushIntervalMs` and `maxQueue`.
**`enabled: false` turns your own `track` calls into no-ops and leaves the platform's `gem_*`
events alone** — it is narrower than it looks.

## 6. What your dedicated server records, and why it differs

A server bundle reaches the same surface as `gem.analytics`, with one signature difference:

```js
export default defineServer((gem) => ({
  async shutdown() {
    gem.analytics.track(winnerId, 'match_result', { outcome: 'win', score: 3100 });
    await gem.analytics.flush();
  },
}));
```

**The player id comes first and is required.** A server credential names no player, so an event
from a server has to say who it is about and the platform will not infer one. Take the id from
`join()` or from the host's player list.

**Flush before your match ends.** A dedicated server is torn down when the match is over and
nothing gets a last word in for it — there is no closing page here to trigger a final send.
`await gem.analytics.flush()` in `shutdown` is the whole of it, and skipping it loses the tail of
the queue. See [server-bundles.md](server-bundles.md) for the rest of a bundle's lifecycle.

**A server cannot emit `gem_*` events.** Those are declared client-sourced, so a server-side
`gem_match_started` is refused; record your own event instead.

## 7. When something is refused

**Refusals arrive inside a successful response, one per event.** A batch carrying one bad name is
not rejected — its other events are written and the bad one comes back at its index. The SDK
prints each refusal to the console in development, and `flush()` returns them in `rejected`.

| What you get                   | What happened                                          | What to do                                                        |
| ------------------------------ | ------------------------------------------------------ | ----------------------------------------------------------------- |
| `analytics_not_declared`       | This channel resolves no `analytics_events` at all      | Promote a config version that has the section to this channel      |
| `unknown_event`                | The channel declares a set and your name is not in it   | Check the id against your config — most often a typo or a new event not yet promoted |
| `unknown_dimension`            | A key you sent is not on that event                     | Declare it, or stop sending it — context keys count                |
| `missing_dimension`            | A dimension the event declares was absent               | Send every declared dimension on every event                       |
| `dimension_value_not_declared` | The value is outside the closed set                     | Add the value to the config, or map it to one that exists          |
| `unknown_measure`              | A number you sent is not a declared measure             | Declare it, or drop it                                             |
| `measure_wrong_type`           | An `int` measure got a fraction, or a dimension got a number | Match the declared type — a string is a dimension, a number is a measure |
| `source_not_permitted`         | This side may not emit this event                       | Narrow or widen `sources` on the event, or record it from the other side |

Two results are not refusals and should not be treated as one:

- **`dropped`** counts events the local queue threw away because it was full. The oldest go first
  and the cap defaults to 500. It is nonzero only when your game produces events faster than they
  can be sent, and it is worth alarming on.
- **`duplicate: true`** means the platform had already seen that batch and wrote nothing. That is
  a retry whose first attempt in fact landed — it is what stops a send racing a page close from
  being counted twice.

A rate limit or a temporarily unreachable backend **keeps the queue** and rejects the `flush`
promise, so the next send carries the same events. A refusal that would fail identically forever
discards its batch instead, because a queue that can never empty fills up and starts dropping the
events that would explain the problem.

## 8. What analytics is not

**Client events are game-asserted browser facts.** Anything a browser can record, a modified
browser can record differently — the same boundary that keeps a leaderboard score off the client
in [leaderboards.md](leaderboards.md) and an economy grant behind a recipe in
[inventory-and-catalogs.md](inventory-and-catalogs.md).

So what you record here feeds **your** reporting and nothing else. It is not a basis for:

- **rewards or grants** — run those through the economy, where the arithmetic happens on the
  server;
- **daily challenges or streaks** — those are config-declared and settled by the platform;
- **store ranking or discovery** — the platform does not read your events for any of it;
- **player progress the game reads back** — analytics is write-only from the game's side. Progress
  that decides what a player sees belongs in [saving-player-progress.md](saving-player-progress.md).

When a fact has to be true rather than reported, record it from a dedicated server, which holds a
credential a player does not. That is the authoritative half of the pair, and it is the only half
that is.

**Nothing per-player comes back.** The reads answer with counts and with how many distinct players
reached a step; there is no drill-down to an individual, on any surface.
