209 lines
12 KiB
Markdown
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`.
|