# Telemetry

`SessionTelemetry` records playtest data to local files so balance passes can be tuned against real session data instead of feel. It writes one JSONL file per app launch under `user://telemetry/`. **Nothing is ever transmitted** — the data stays on the player's machine, and no personal data is recorded.

## What it is

`scripts/core/SessionTelemetry.gd` is an autoload (registered as `SessionTelemetry` in `project.godot`). On `_ready()` it opens a new session file and subscribes to a set of `EventBus` signals. A whole session is only a few KB. A module-level constant gates the whole system:

```gdscript
const ENABLED := true
```

## Where it writes

Each launch creates one file:

```text
user://telemetry/run_<unix>.jsonl
```

`<unix>` is the launch time from `Time.get_unix_time_from_system()`. On Windows, `user://` resolves to `%APPDATA%\Godot\app_userdata\Underroot\`.

Every app launch creates a new file, so the directory is capped. `_MAX_SESSION_FILES` (`20`) newest files are kept; `_prune_old_sessions()` runs once at startup and deletes the oldest beyond the cap (on Web this prevents unbounded IndexedDB growth). Filenames sort oldest-first because the unix timestamp sorts lexicographically within its digit count.

## Privacy

No personal data is stored. The player-entered name is kept only as a one-way hash so a single player's sessions can be grouped during analysis without retaining the name itself:

```gdscript
func _hash_player_name() -> String:
	return "%08x" % SaveManager.get_player_name().hash()
```

This appears as `player_hash` in the session header. Nothing is sent anywhere — the JSONL files are the only output.

## Line format

Each line is a standalone JSON object with a `kind` field. There are three kinds.

### session (header)

Written once per session, lazily, the first time anything is logged (`_ensure_session_header()`). Fields include:

| Field | Meaning |
|---|---|
| `kind` | `"session"` |
| `ts` | Unix time of the header |
| `version` | App config version |
| `save_version` | `SaveManager.SAVE_VERSION` |
| `slot` | Active save slot |
| `player_hash` | One-way hash of the player name |
| `generation` | Current lineage generation |
| `low_perf` | `PerformanceMode.enabled` at session start |
| `idle_mult` | Survival idle multiplier |

### day (snapshot)

Written on each `EventBus.new_day`, but only while a run is active — `_run_active()` returns `SaveManager.get_active_slot() >= 0`, because `TimeManager` can roll a day on the title screen too. Each snapshot (`_day_snapshot()`) carries the support metrics, resource counts, Maw state, and frame rate:

- Progress: `day`, `generation`, `depth`, `blocks`, `pop`.
- Support: `support_food`, `support_shelter`, `support_safety`, `support_ratio` (min metric over population).
- Survival: `food`, `water`, `stored_food`, `stored_water`.
- Inventory: `inv` — amounts for the strategic materials in `_TRACKED_MATS` (`iron`, `iron_ore`, `coal`, `wood`, `stone`, `gold`, `bronze`, `steel`, `strange_root`, `quartz`), omitting any at zero.
- Defenses and threat: `walls`, `wall_hp`, `maw_front_x`, `maw_pressure`, `breach_eta_s`, `machines`.
- Frame rate: `fps_avg`, `fps_min`, `fps_n`.

Frame rate is sampled once per frame in `_process`, but only while a run is active **and** the window is focused (`DisplayServer.window_is_focused()`) — so title-screen and backgrounded frames, which idle-throttle, don't skew the read. `fps_avg` is the mean of `Engine.get_frames_per_second()` over the day's samples, `fps_min` the worst single reading, and `fps_n` the sample count behind them. Weight the average by `fps_n`: a day rolled during offline catch-up, or the moment a run loads, can carry few or zero samples (`fps_avg`/`fps_min` fall back to `0.0` when `fps_n` is `0`). The window resets after each snapshot is written.

### event (one-shot)

Written via `_log_event()` from the signal handlers wired in `_ready()`. Each event line carries `kind: "event"`, an `event` name, `ts`, the current `day`, and event-specific fields merged in. The tracked events:

| `event` | Source signal | Extra fields |
|---|---|---|
| `died` | `player_died` | `cause`, `generation` |
| `generation_started` | `generation_started` | `generation` |
| `specialist` | `specialist_arrived` | `id` |
| `astrolabe` | `astrolabe_activated` | `uses`, `chew_mult` |
| `milestone` | `milestone_reached` | `days` |
| `building` | `building_completed` | `id`, `level` |
| `storm` | `storm_ended` | storm result dict |
| `breach` | `maw_breached_base` | — |
| `recipe_unlocked` | `recipe_unlocked` | `id` |

Like day snapshots, events are dropped unless a run is active.

## Reading the data

Open the newest `run_<unix>.jsonl` under `user://telemetry/` and parse it line by line — each line is a complete JSON object, so a stream/JSONL reader works directly. Filter on `kind`: the single `"session"` line gives the run's context, `"day"` lines form a per-day time series (plot `support_ratio`, `breach_eta_s`, `maw_pressure`, inventory, and `fps_avg`/`fps_min` against `day`), and `"event"` lines mark discrete moments (deaths, rituals, breaches). Group across sessions by `player_hash` when you need one player's history.

Comparing `fps_min` day-over-day across builds turns "did that change hurt performance?" into a data question rather than a feel one: a steady floor is evidence it didn't, and a step-down that lines up with a particular build points at the regression. Read `fps_min` alongside `fps_n` and `low_perf` (from the session header) so you don't over-read a thin-sample day or compare a low-perf session against a full-graphics one.

## Key files

- `scripts/core/SessionTelemetry.gd` — the autoload: session/day/event logging, per-frame FPS sampling, file pruning, name hashing.
- `scripts/core/EventBus.gd` — declares the signals the logger subscribes to.

## Related

- [EventBus and the Signal Convention](/docs/underroot/eventbus-and-the-signal-convention)
- [The Autoload Model](/docs/underroot/the-autoload-model)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
- [Performance Mode](/docs/underroot/performance-mode)