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>
This commit is contained in:
Rainer Leit
2026-09-16 20:22:20 +03:00
co-authored by Claude Fable 5.1
parent 4f2c55cd2a
commit 3e52d26fb7
25 changed files with 881 additions and 23 deletions
@@ -0,0 +1,28 @@
#include "Telemetry/TelemetryEvent.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
#include "Policies/CondensedJsonPrintPolicy.h"
FString Telemetry::ToJsonLine(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetNumberField(TEXT("schema_version"), Envelope.SchemaVersion);
Root->SetStringField(TEXT("event_id"), Envelope.EventId.ToString(EGuidFormats::DigitsWithHyphensLower));
Root->SetStringField(TEXT("timestamp_utc"), Envelope.TimestampUtc.ToIso8601());
Root->SetNumberField(TEXT("game_time"), Envelope.GameTime);
Root->SetStringField(TEXT("session_id"), Envelope.SessionId.ToString(EGuidFormats::DigitsWithHyphensLower));
Root->SetStringField(TEXT("player_id"), Envelope.PlayerId);
Root->SetBoolField(TEXT("is_server"), Envelope.bIsServer);
Root->SetNumberField(TEXT("party_size"), Envelope.PartySize);
Root->SetStringField(TEXT("build"), Envelope.Build);
Root->SetStringField(TEXT("map"), Envelope.Map);
Root->SetBoolField(TEXT("cheats_used"), Envelope.bCheatsUsed);
Root->SetStringField(TEXT("name"), Event.Name.ToString());
Root->SetObjectField(TEXT("payload"), Event.Payload.IsValid() ? Event.Payload : MakeShared<FJsonObject>());
FString Line;
TSharedRef<TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>> Writer =
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Line);
FJsonSerializer::Serialize(Root, Writer);
return Line;
}
@@ -0,0 +1,59 @@
#pragma once
#include "CoreMinimal.h"
#include "Dom/JsonObject.h"
// One emission. Call sites fill Name (from TelemetryEvents) and Payload; the subsystem stamps the envelope.
struct SALTYCORE_API FTelemetryEvent
{
FName Name;
TSharedPtr<FJsonObject> Payload;
};
// Stamped by the subsystem onto every event. Serialised with snake_case keys; see Telemetry::ToJsonLine.
struct SALTYCORE_API FTelemetryEnvelope
{
int32 SchemaVersion = 1;
FGuid EventId; // unique per emission, for de-duplication
FDateTime TimestampUtc;
float GameTime = 0.f; // seconds since the world began; zero when there is no world yet
FGuid SessionId; // minted by the server, adopted by clients on join; invalid before a session
FString PlayerId; // hashed server-minted id; empty on the server
bool bIsServer = false; // authoritative events versus observed ones
int32 PartySize = 0;
FString Build; // FApp::GetBuildVersion() plus the git hash
FString Map;
bool bCheatsUsed = false; // sticky true after the first cheat this session
};
// A small fluent builder for payloads, so a call site reads as one expression:
// Telemetry->Emit(TelemetryEvents::CheatUsed, FTelemetryPayload().Set(TEXT("command"), Command));
class SALTYCORE_API FTelemetryPayload
{
public:
FTelemetryPayload() : Object(MakeShared<FJsonObject>()) {}
FTelemetryPayload& Set(const FString& Key, const FString& Value) { Object->SetStringField(Key, Value); return *this; }
FTelemetryPayload& Set(const FString& Key, const TCHAR* Value) { Object->SetStringField(Key, Value); return *this; }
FTelemetryPayload& Set(const FString& Key, FName Value) { Object->SetStringField(Key, Value.ToString()); return *this; }
FTelemetryPayload& Set(const FString& Key, const FGuid& Value) { Object->SetStringField(Key, Value.ToString(EGuidFormats::DigitsWithHyphensLower)); return *this; }
FTelemetryPayload& Set(const FString& Key, double Value) { Object->SetNumberField(Key, Value); return *this; }
FTelemetryPayload& Set(const FString& Key, float Value) { Object->SetNumberField(Key, Value); return *this; }
FTelemetryPayload& Set(const FString& Key, int32 Value) { Object->SetNumberField(Key, Value); return *this; }
FTelemetryPayload& Set(const FString& Key, int64 Value) { Object->SetNumberField(Key, static_cast<double>(Value)); return *this; }
FTelemetryPayload& Set(const FString& Key, bool Value) { Object->SetBoolField(Key, Value); return *this; }
TSharedPtr<FJsonObject> Build() const { return Object; }
operator TSharedPtr<FJsonObject>() const { return Object; }
private:
TSharedPtr<FJsonObject> Object;
};
namespace Telemetry
{
// The one serialisation of an event: the envelope's fields as snake_case keys, then "name" and "payload"
// (an empty object when the event has none), condensed to a single line with no trailing newline. Pure, so
// the sinks share it and the tests can check it without a sink.
SALTYCORE_API FString ToJsonLine(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event);
}
@@ -0,0 +1,15 @@
#pragma once
#include "CoreMinimal.h"
// The event names. Every call site passes one of these to UTelemetrySubsystem::Emit; a literal at a call site is a
// typo waiting for the analysis to find it. A name is added here, to the catalogue in Docs/Spec/Telemetry.md and to
// the feature spec's Telemetry section in the same change as its first emit call, so the three never disagree.
namespace TelemetryEvents
{
// Session (step 2)
inline const FName AppStarted(TEXT("app_started"));
inline const FName SessionStarted(TEXT("session_started"));
inline const FName SessionEnded(TEXT("session_ended"));
inline const FName CheatUsed(TEXT("cheat_used"));
}
@@ -0,0 +1,119 @@
#include "Telemetry/TelemetrySink.h"
#include "SaltyCore.h"
#include "HAL/PlatformFileManager.h"
#include "HAL/RunnableThread.h"
#include "HAL/Event.h"
#include "GenericPlatform/GenericPlatformFile.h"
#include "Misc/CoreDelegates.h"
#include "Misc/Paths.h"
void FLogTelemetrySink::Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
{
UE_LOG(LogTelemetry, Log, TEXT("%s"), *Telemetry::ToJsonLine(Envelope, Event));
}
FJsonlTelemetrySink::FJsonlTelemetrySink(const FString& InFilePath, double InDrainIntervalSeconds)
: FilePath(InFilePath)
, DrainIntervalSeconds(FMath::Max(InDrainIntervalSeconds, 0.01))
{
WakeEvent = FPlatformProcess::GetSynchEventFromPool(false);
DrainedEvent = FPlatformProcess::GetSynchEventFromPool(false);
Thread = FRunnableThread::Create(this, TEXT("TelemetryJsonlSink"), 0, TPri_BelowNormal);
OnExitHandle = FCoreDelegates::OnExit.AddRaw(this, &FJsonlTelemetrySink::Flush);
OnSystemErrorHandle = FCoreDelegates::OnHandleSystemError.AddRaw(this, &FJsonlTelemetrySink::Flush);
}
FJsonlTelemetrySink::~FJsonlTelemetrySink()
{
FCoreDelegates::OnExit.Remove(OnExitHandle);
FCoreDelegates::OnHandleSystemError.Remove(OnSystemErrorHandle);
bStopping = true;
WakeEvent->Trigger();
if (Thread)
{
Thread->WaitForCompletion(); // Run drains once more on its way out
delete Thread;
Thread = nullptr;
}
Drain(); // anything queued after the thread's last pass
File.Reset();
FPlatformProcess::ReturnSynchEventToPool(WakeEvent);
FPlatformProcess::ReturnSynchEventToPool(DrainedEvent);
}
void FJsonlTelemetrySink::Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event)
{
// The whole cost on the caller's thread: one string and one enqueue.
Queue.Enqueue(Telemetry::ToJsonLine(Envelope, Event));
}
void FJsonlTelemetrySink::Flush()
{
if (!Thread || bStopping)
{
Drain();
return;
}
const uint64 Ticket = ++FlushesRequested;
WakeEvent->Trigger();
// Bounded wait: a flush from the crash handler must not hang the process on a wedged disk.
const double Deadline = FPlatformTime::Seconds() + 5.0;
while (FlushesCompleted.Load() < Ticket && FPlatformTime::Seconds() < Deadline)
{
DrainedEvent->Wait(50);
}
}
uint32 FJsonlTelemetrySink::Run()
{
const uint32 IntervalMs = static_cast<uint32>(FMath::Clamp(DrainIntervalSeconds * 1000.0, 10.0, 3600.0 * 1000.0));
while (!bStopping)
{
WakeEvent->Wait(IntervalMs);
const uint64 Ticket = FlushesRequested.Load();
Drain();
FlushesCompleted.Store(Ticket);
DrainedEvent->Trigger();
}
Drain();
return 0;
}
void FJsonlTelemetrySink::Stop()
{
bStopping = true;
WakeEvent->Trigger();
}
void FJsonlTelemetrySink::Drain()
{
// Single consumer: the worker while it runs, the destructor after it has joined. Never both.
FString Line;
bool bWroteAnything = false;
while (Queue.Dequeue(Line))
{
if (!File)
{
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
PlatformFile.CreateDirectoryTree(*FPaths::GetPath(FilePath));
File.Reset(PlatformFile.OpenWrite(*FilePath, /*bAppend*/ true, /*bAllowRead*/ true));
if (!File)
{
UE_LOG(LogTelemetry, Warning, TEXT("Cannot open telemetry file %s; dropping events"), *FilePath);
continue;
}
}
Line.AppendChar(TEXT('\n'));
const FTCHARToUTF8 Utf8(*Line);
File->Write(reinterpret_cast<const uint8*>(Utf8.Get()), Utf8.Length());
bWroteAnything = true;
}
if (File && bWroteAnything)
{
File->Flush();
}
}
@@ -0,0 +1,69 @@
#pragma once
#include "CoreMinimal.h"
#include "Containers/Queue.h"
#include "HAL/Runnable.h"
#include "Telemetry/TelemetryEvent.h"
class FRunnableThread;
class FEvent;
class IFileHandle;
// Where events go. The emitter never knows. Three implementations on day one; a fourth adapts an analytics
// provider when there is one (Docs/Spec/Telemetry.md, Q2).
class SALTYCORE_API ITelemetrySink
{
public:
virtual ~ITelemetrySink() = default;
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
virtual void Flush() {}
};
// Does nothing. The shipping default until there is somewhere to send anything.
class SALTYCORE_API FNullTelemetrySink : public ITelemetrySink
{
public:
virtual void Emit(const FTelemetryEnvelope&, const FTelemetryEvent&) override {}
};
// One line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
class SALTYCORE_API FLogTelemetrySink : public ITelemetrySink
{
public:
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) override;
};
// Appends one line per event to a JSON Lines file. Emit only builds the line and queues it; a worker thread
// drains the queue to disk every DrainIntervalSeconds and on Flush, so the calling thread never touches the
// file. Flushed on quit and from the unhandled-exception handler, so a crash loses at most one interval.
class SALTYCORE_API FJsonlTelemetrySink : public ITelemetrySink, private FRunnable
{
public:
explicit FJsonlTelemetrySink(const FString& InFilePath, double DrainIntervalSeconds = 2.0);
virtual ~FJsonlTelemetrySink() override;
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) override;
virtual void Flush() override;
const FString& GetFilePath() const { return FilePath; }
private:
// FRunnable
virtual uint32 Run() override;
virtual void Stop() override;
void Drain();
FString FilePath;
double DrainIntervalSeconds;
TQueue<FString, EQueueMode::Mpsc> Queue;
TUniquePtr<IFileHandle> File;
FEvent* WakeEvent = nullptr;
FEvent* DrainedEvent = nullptr;
FRunnableThread* Thread = nullptr;
TAtomic<bool> bStopping{ false };
TAtomic<uint64> FlushesRequested{ 0 };
TAtomic<uint64> FlushesCompleted{ 0 };
FDelegateHandle OnExitHandle;
FDelegateHandle OnSystemErrorHandle;
};