Files
UnrealPrototyping/Docs/Spec/Telemetry.md
T
Rainer LeitandClaude Fable 5.1 3e52d26fb7 Step 2: the telemetry seam
ITelemetrySink with the null, log and JSON Lines sinks, FTelemetryEvent and the envelope, TelemetryEvents,
UTelemetrySubsystem on the game instance, ASaltyGameMode minting the session id into the replicated
ASaltyGameState, bs.TelemetryTest, the git hash in the build string (D-42) and four Salty.Core.Telemetry tests
replacing the placeholder. Proved standalone and with a headless server plus client sharing one session id.

Also enables the engine's Editor, AutomationTest, GameplayTags, ConfigSettings and LiveCoding MCP toolsets
(D-43) so the editor's MCP server exposes more than the skills toolset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:22:20 +03:00

12 KiB

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 first. Step 2 in ../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

// 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.
// 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 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
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.