Files
2026-09-25 17:02:24 +03:00

209 lines
12 KiB
Markdown

# Telemetry
Owns the seam gameplay emits events through, the envelope, the format on disk and the catalogue of events. It
does **not** own any backend, storage or analysis; there is none, and the format is chosen so one can be bolted
on later without touching the game.
Read [Architecture.md](Architecture.md) first. Step 2 in [`../Steps.md`](../Steps.md), before the character
exists, on purpose.
## Why this exists before there is anyone to measure
Two reasons, and the second is the real one.
1. **Retrofitting emit calls is expensive and lossy.** Instrumenting a finished system means re-reading it and
guessing what mattered at the time. Instrumenting as it is written costs a line.
2. **Some questions can only be answered with data that starts early.** Which camera mode people live in, whether
the anvil is fun or a chore, whether a crafted sword's damage curve is sane, whether prompts explain themselves.
Every one of those is a distribution over sessions, and the sessions that never emitted are gone.
So the schema is fixed now, the sink writes a local file, and the catalogue grows one step at a time the way the
cheats do.
## Decisions
```
[DECIDED] One subsystem, one sink interface, three sinks on day one: null (the shipping default), log (the
editor), JSON Lines to a file (playtests). The emitter never knows where events go.
```
```
[DECIDED] JSON Lines. One event per line, UTF-8, snake_case keys. Append-only, so a crash mid-write loses one line
and not the file; schema-flexible, so a new field breaks no old reader; greppable during development; and the
native food of every log and analytics pipeline that might sit downstream. Events, never aggregates: every metric is
computed from raw facts later, because aggregation choices change and raw events do not.
```
```
[DECIDED] No personal data. Player identity on the wire is the server-minted player id, hashed. No names, no
platform ids, no chat, no free text. Established now because a field is much harder to remove than to never add.
```
## Layout
```
Source/<Project>Core/Telemetry/
├── TelemetrySink.h // ITelemetrySink, FNullTelemetrySink, FLogTelemetrySink, FJsonlTelemetrySink
├── TelemetryEvent.h // FTelemetryEvent, FTelemetryEnvelope
└── TelemetryEvents.h // the event name constants: TelemetryEvents::PlayerDowned etc. Never a literal at a call site.
Source/<Project>/Core/
└── TelemetrySubsystem.h // UGameInstanceSubsystem: owns the sink, stamps the envelope, exposes Emit
```
## Types
```cpp
// Source/<Project>Core/Telemetry/TelemetryEvent.h
struct FTelemetryEvent
{
FName Name; // from TelemetryEvents, never a literal
TSharedPtr<FJsonObject> Payload; // event-specific fields; built with a small fluent helper
// The subsystem stamps the envelope; call sites fill Name and Payload only.
};
struct FTelemetryEnvelope // stamped by the subsystem onto every event
{
int32 SchemaVersion = 1;
FGuid EventId; // unique per emission, for de-duplication
FDateTime TimestampUtc;
float GameTime; // seconds since the world began
FGuid SessionId; // minted by the server, adopted by clients on join
FString PlayerId; // hashed server-minted id; empty on the server
bool bIsServer; // authoritative events versus observed ones
int32 PartySize;
FString Build; // FApp::GetBuildVersion() plus the git hash from Scripts/
FString Map;
bool bCheatsUsed; // sticky true after the first cheat this session: tainted sessions are filterable, not deleted
};
// Source/<Project>Core/Telemetry/TelemetrySink.h
class ITelemetrySink
{
public:
virtual ~ITelemetrySink() = default;
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
virtual void Flush() {}
};
// FNullTelemetrySink: does nothing. The shipping default until there is somewhere to send anything.
// FLogTelemetrySink: one line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
// FJsonlTelemetrySink: appends to Saved/Telemetry/session_<utc>_<id>.jsonl through a queue drained by a background
// task every two seconds and on Flush; the game thread only builds a small object per event. Flushed on quit and
// from the unhandled-exception handler so a crash loses at most two seconds of events.
```
```cpp
// Source/<Project>/Core/TelemetrySubsystem.h
UCLASS()
class UTelemetrySubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
void Emit(FName Name, TSharedPtr<FJsonObject> Payload = nullptr); // stamps the envelope, hands to the sink
void BeginSession(FGuid SessionId); // server mints; a client adopts the replicated id
void SetSink(TUniquePtr<ITelemetrySink> Sink); // Log in the editor, Jsonl with -telemetry, Null otherwise
void MarkCheatUsed(); // called by every bs.* console command
};
```
**There is no static `Telemetry::Log(...)` and there will not be one.** Gameplay code resolves the subsystem from
its game instance once and keeps the pointer. A static helper is a global with gameplay state in it, which
[Architecture.md](Architecture.md) bans, and it makes every emitting class untestable.
## Where the sink lives on each peer
Every peer emits its own events to its own file. The server's file is the authoritative record of what happened;
a client's file is the record of what that player saw and did. `session_id` is the same across all of them and
`is_server` tells them apart. Nothing is forwarded, because the interesting questions are per-peer by nature.
Which sink is installed: the log sink in the editor, the file sink when the executable is launched with
`-telemetry` or the console variable `telemetry.File 1` is set, the null sink otherwise. Playtests run with the file
on and the folder is collected by hand afterwards; that is the whole backend for now.
## The catalogue
Each feature spec's Telemetry section lists what that feature emits; this table is the rollup and the two must
agree. A row is added to a spec and here in the same pull request as the emit call.
| Group | Events |
| --- | --- |
| Session | `app_started`, `session_started`, `session_ended`, `player_joined`, `player_left`, `settings_changed`, `cheat_used` |
| Movement | `movement_sample`, `jump`, `land`, `camera_mode_changed` |
| Interaction | `prop_interacted`, `interaction_refused`, `object_picked_up`, `object_dropped`, `object_thrown`, `object_handed_over` |
| Stats | `status_applied`, `status_blocked`, `status_removed` |
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
| World | `world_map_opened` (layer, metres_per_pixel, opened_by) |
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
### The rows that carry weight
```
item_assembled family, piece_ids[], name, quality, synergy, substance_value, time_at_bench_s
weapon_equipped family, crafted, damage, quality
enemy_killed enemy, killer_class, weapon, ability, time_to_kill_s
```
Together these are the crafting-to-combat loop as data: does a better-made sword kill faster, and by how much.
That is the one question the whole prototype exists to answer, and it cannot be answered from memory.
```
activity_completed activity, operators, quality, auto_play, faults, contribution_by_player
```
`quality` against `auto_play` says whether the anvil is fun or a chore: if the hold-to-work floor is where most
sessions live, the minigame is not earning its place.
```
interaction_refused prop, reason
assembly_rejected family, reason, part
```
The two cheapest possible measures of whether the game explains itself. A reason that dominates is a prompt that
does not.
## Tests
- Automation: the JSONL sink writes one valid line per event and never blocks the calling thread longer than
building the object; the envelope carries every field; an event with no payload serialises as an empty object.
- Functional: launching the gym with the log sink installed emits `app_started` and `session_started` in that
order; every event name a feature test triggers is a constant in `TelemetryEvents`.
## Open questions
- **Q1. Sampling.** `movement_sample` and `player_damaged` are the high-frequency rows. Every 5 s and every hit
are fine at prototype scale; revisit when a file exceeds a few megabytes per session.
- **Q2. An analytics provider.** The engine has an `IAnalyticsProvider` interface that several backends implement.
When there is a backend, a fourth sink adapts to it; the emitter does not change.
- **Q3. Opt-out.** A playtest build needs a telemetry toggle in settings before strangers play it. With the
settings pass, not before.
- **Q4. Typed payloads.** A JSON object per event is convenient and unsafe. A typed struct per event name is the
later answer if typos in payload keys start costing analysis time.
## What was built, and where it differs
Step 2, 2026-09-16.
- **Layout as specified.** `SaltyCore/Telemetry/` holds `TelemetryEvent.h` (event, envelope, the `FTelemetryPayload`
builder and the pure `Telemetry::ToJsonLine`), `TelemetrySink.h` (the interface and the three sinks) and
`TelemetryEvents.h` (the four session names; the catalogue grows with each emit call). `Salty/Core/` holds
`UTelemetrySubsystem`, `ASaltyGameMode`, `ASaltyGameState` and `SaltyCheats.cpp`.
- **The serialisation is one pure function** the sinks share, so the tests check the envelope without a sink.
The JSON Lines sink is an `FRunnable` draining an MPSC queue every two seconds and on `Flush`; `Emit` only
builds the line. Files are `Saved/Telemetry/session_<utc>_<8 hex>.jsonl`, the hex being a per-instance id so
two PIE instances started in the same second never share a file.
- **Sink choice:** `-telemetry` or `telemetry.File 1` wins everywhere, including the editor, so a PIE session
with a dedicated server writes one file per instance; the log sink is the editor default; null otherwise.
- **The session id** is minted in `ASaltyGameMode::InitGameState`, written to `ASaltyGameState::SessionId`
(replicated, `OnRep` adopts it) and begun on the server directly. `ATemplateGameMode` now derives from
`ASaltyGameMode` so the template Blueprint game mode mints a session until step 3 replaces it.
- **`MarkCheatUsed` takes an optional command name** and puts it in the `cheat_used` payload; the spec's
no-argument signature still works. `bs.TelemetryTest` is that call, so it emits and taints in one.
- **`player_id` is empty on every peer** until step 3 mints one on the player state; `party_size` is the game
state's player count and is zero at `session_started` because the join has not happened yet.
- **The build string** is `FApp::GetBuildVersion()` plus the git short hash read by `Salty.Build.cs` (D-42).
- **Proved:** four `Salty.Core.Telemetry.*` tests; a standalone `-telemetry` run wrote `app_started`,
`session_started`, `session_ended`; a headless `-server` and a `-game` client each wrote a file carrying the
same `session_id` with `is_server` true and false; `bs.TelemetryTest` wrote `cheat_used` and every later line
carried `cheats_used: true`.