From 5f0d6e4fb74681e13cebdcf82fe9d36708b14997 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 26 Oct 2026 16:14:50 -0400 Subject: [PATCH] feat: add manual session replay startup --- README.md | 7 + Source/PostHog/Private/PostHogSubsystem.cpp | 16 +- .../Tests/PostHogReplayStartupTests.cpp | 151 ++++++++++++++++++ .../Private/Tests/PostHogReplayTests.cpp | 6 + Source/PostHog/Public/PostHogSubsystem.h | 8 +- Source/PostHog/Public/PostHogTypes.h | 3 + Tests/native_integration.py | 2 +- 7 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 Source/PostHog/Private/Tests/PostHogReplayStartupTests.cpp diff --git a/README.md b/README.md index 038c510..a49f8b0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,13 @@ Malformed serialized payloads currently inherit Rust's raw-string fallback. This ## Session replay (proof of concept) +When enabled, replay starts automatically after setup by default. To start after a gameplay milestone, +set `Options.Replay.bEnabled = true` and `Options.Replay.bAutoStart = false` before setup. +After setup completes, call `UPostHogSubsystem::StartSessionReplay()` or the **Start Session Replay** +Blueprint node. Repeated calls are safe. A `true` result means the recorder is running, +not that frames have been captured or delivered; consent, foreground/session state, +and remote replay configuration still gate capture. Shutdown stops the recorder. + `Replay.bEnabled` (Project Settings → PostHog → Replay, or `FPostHogOptions::Replay`) records screenshot replay. About once per `Replay.IntervalMs`, the subsystem copies the game window's back buffer without stalling the game thread. A worker thread then scales the frame to `Replay.MaxDimension` and encodes it as a JPEG at `Replay.JpegQuality`. Each frame is one `$snapshot` event that the PostHog player shows as a mobile-style screenshot replay. Recording also requires the server's `session_recording` config, consent, and an active session. It pauses while the application is in the background. This is a proof of concept. Frames are not masked, so everything on screen is sent. It is desktop only and has been run once on macOS arm64; nothing else is validated. In the editor, frames show the whole editor window, so use `-game` builds. See [implementation status](docs/implementation-status.md) for the limitations. diff --git a/Source/PostHog/Private/PostHogSubsystem.cpp b/Source/PostHog/Private/PostHogSubsystem.cpp index 90b47b0..dfc2ea3 100644 --- a/Source/PostHog/Private/PostHogSubsystem.cpp +++ b/Source/PostHog/Private/PostHogSubsystem.cpp @@ -185,11 +185,7 @@ bool UPostHogSubsystem::Tick(float DeltaTime) auto Current = Client; Current->StartApplicationLifecycle(bForeground); if (State == EPostHogSubsystemState::Ready && Client == Current) { - if (ReplaySettings.bEnabled) - { - Replay = FPostHogReplayRecorder::Start(Current, ReplaySettings); - if (Replay) { Replay->SetPaused(!bForeground); } - } + if (ReplaySettings.bAutoStart) { StartSessionReplay(); } OnSetupCompleted.Broadcast(true); } } @@ -236,6 +232,16 @@ void UPostHogSubsystem::StopObserving() Signals.Reset(); } +bool UPostHogSubsystem::StartSessionReplay() +{ + auto Current = GetClient(); + if (!Current || !ReplaySettings.bEnabled) { return false; } + if (Replay) { return true; } + Replay = FPostHogReplayRecorder::Start(Current, ReplaySettings); + if (Replay) { Replay->SetPaused(!bForeground); } + return Replay.IsValid(); +} + void UPostHogSubsystem::StopReplay() { if (Replay) { Replay->Stop(); Replay.Reset(); } diff --git a/Source/PostHog/Private/Tests/PostHogReplayStartupTests.cpp b/Source/PostHog/Private/Tests/PostHogReplayStartupTests.cpp new file mode 100644 index 0000000..f8ae198 --- /dev/null +++ b/Source/PostHog/Private/Tests/PostHogReplayStartupTests.cpp @@ -0,0 +1,151 @@ +#if WITH_DEV_AUTOMATION_TESTS +#include "PostHogSubsystem.h" +#include "Engine/Engine.h" +#include "Engine/GameInstance.h" +#include "Engine/World.h" +#include "Framework/Application/SlateApplication.h" +#include "HAL/FileManager.h" +#include "Misc/App.h" +#include "Misc/AutomationTest.h" +#include "Misc/OutputDevice.h" +#include "Misc/OutputDeviceRedirector.h" +#include "Misc/Paths.h" +#include "UObject/StrongObjectPtr.h" +#include + +namespace +{ +// Observe recorder lifecycle without adding test-only state to the public API. +class FReplayStartupLog final : public FOutputDevice +{ +public: + std::atomic Attempts{0}, Starts{0}, Stops{0}; + virtual bool CanBeUsedOnAnyThread() const override { return true; } + virtual bool CanBeUsedOnMultipleThreads() const override { return true; } + virtual void Serialize(const TCHAR* Message, ELogVerbosity::Type, const FName& Category) override + { + if (Category != TEXT("LogPostHogReplay")) { return; } + const FString Text(Message); + if (Text.StartsWith(TEXT("Session replay started:"))) { ++Attempts; ++Starts; } + else if (Text.StartsWith(TEXT("Session replay disabled:"))) { ++Attempts; } + else if (Text.StartsWith(TEXT("Session replay stopped:"))) { ++Stops; } + } +}; + +bool StartFromBlueprint(UPostHogSubsystem* Subsystem) +{ + struct { bool ReturnValue = false; } Params; + Subsystem->ProcessEvent(Subsystem->FindFunctionChecked(TEXT("StartSessionReplay")), &Params); + return Params.ReturnValue; +} + +class FReplayStartupScenario final : public IAutomationLatentCommand +{ +public: + explicit FReplayStartupScenario(FAutomationTestBase* InTest) : Test(InTest) + { + auto* Settings = GetMutableDefault(); + bPreviousAutoInitialize = Settings->bAutoInitialize; + Settings->bAutoInitialize = false; + Config.ProjectToken = TEXT("phc_MOCK"); + Config.Host = FPlatformMisc::GetEnvironmentVariable(TEXT("POSTHOG_TEST_HOST")); + Config.StorageRoot = FPaths::ProjectSavedDir() / TEXT("PostHogReplayStartupTests") / FGuid::NewGuid().ToString(); + Config.bCaptureApplicationLifecycleEvents = false; + // The recorder can start without remote permission, but must not capture the editor. + Config.bPreloadFeatureFlags = false; + bCanRender = FApp::CanEverRender() && FSlateApplication::IsInitialized() && FSlateApplication::Get().GetRenderer() != nullptr; + GLog->AddOutputDevice(&Log); + Deadline = FPlatformTime::Seconds() + 30; + } + ~FReplayStartupScenario() + { + if (Instance) + { + UWorld* World = Instance->GetWorld(); + Instance->Shutdown(); + if (World) { World->SetGameInstance(nullptr); GEngine->DestroyWorldContext(World); World->DestroyWorld(false); } + Instance.Reset(); + } + GLog->RemoveOutputDevice(&Log); + GetMutableDefault()->bAutoInitialize = bPreviousAutoInitialize; + } + bool Update() override + { + if (FPlatformTime::Seconds() > Deadline) { Test->AddError(TEXT("Replay startup scenario timed out")); return true; } + auto* Subsystem = Instance ? Instance->GetSubsystem() : nullptr; + switch (Phase) + { + case 0: + if (!Config.Host.StartsWith(TEXT("http://127.0.0.1:"))) { Test->AddError(TEXT("Set POSTHOG_TEST_HOST to the local SDK mock server")); return true; } + Instance.Reset(NewObject(GEngine)); + Instance->InitializeStandalone(FName(*FGuid::NewGuid().ToString())); + Subsystem = Instance->GetSubsystem(); + Test->TestFalse(TEXT("not ready"), StartFromBlueprint(Subsystem)); + Test->TestTrue(TEXT("setup replay disabled"), Subsystem->SetupWithConfig(Config, true)); + Test->TestFalse(TEXT("initializing"), StartFromBlueprint(Subsystem)); + ++Phase; return false; + case 1: + if (!Subsystem->IsInitialized()) { return false; } + Test->TestFalse(TEXT("local replay opt-in required"), StartFromBlueprint(Subsystem)); + Test->TestEqual(TEXT("disabled does not create a recorder"), Log.Attempts.load(), 0); + Subsystem->Shutdown(); + ++Phase; return false; + case 2: + if (Subsystem->GetState() != EPostHogSubsystemState::Idle) { return false; } + Config.Replay.bEnabled = true; + Config.Replay.bAutoStart = false; + Test->TestTrue(TEXT("setup manual replay"), Subsystem->SetupWithConfig(Config, true)); + ++Phase; return false; + case 3: + if (!Subsystem->IsInitialized()) { return false; } + Test->TestEqual(TEXT("manual mode does not start during setup"), Log.Attempts.load(), 0); + Test->TestEqual(TEXT("explicit Blueprint start"), StartFromBlueprint(Subsystem), bCanRender); + Test->TestEqual(TEXT("repeated Blueprint start"), StartFromBlueprint(Subsystem), bCanRender); + Test->TestEqual(TEXT("one recorder when rendering is available"), Log.Starts.load(), bCanRender ? 1 : 0); + Test->TestEqual(TEXT("idempotent start, or retry without a renderer"), Log.Attempts.load(), bCanRender ? 1 : 2); + Test->TestFalse(TEXT("start does not bypass remote permission"), Subsystem->GetClient()->IsSessionReplayAllowed()); + Subsystem->Shutdown(); + Test->TestFalse(TEXT("shutdown rejects start"), StartFromBlueprint(Subsystem)); + Test->TestEqual(TEXT("shutdown stops the recorder"), Log.Stops.load(), bCanRender ? 1 : 0); + ++Phase; return false; + case 4: + if (Subsystem->GetState() != EPostHogSubsystemState::Idle) { return false; } + Config.Replay.bAutoStart = true; + Test->TestTrue(TEXT("setup automatic replay"), Subsystem->SetupWithConfig(Config, true)); + ++Phase; return false; + case 5: + if (!Subsystem->IsInitialized()) { return false; } + Test->TestEqual(TEXT("automatic mode starts during setup"), Log.Attempts.load(), bCanRender ? 2 : 3); + Test->TestEqual(TEXT("automatic mode creates a second recorder"), Log.Starts.load(), bCanRender ? 2 : 0); + Subsystem->Shutdown(); + ++Phase; return false; + case 6: + if (Subsystem->GetState() != EPostHogSubsystemState::Idle) { return false; } + Test->TestEqual(TEXT("all recorders stopped"), Log.Stops.load(), bCanRender ? 2 : 0); + IFileManager::Get().DeleteDirectory(*Config.StorageRoot, false, true); + return true; + } + return false; + } +private: + FAutomationTestBase* Test; + FPostHogConfig Config; + TStrongObjectPtr Instance; + FReplayStartupLog Log; + bool bPreviousAutoInitialize = false; + bool bCanRender = false; + int32 Phase = 0; + double Deadline = 0; +}; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPostHogReplayStartupTest, "PostHog.ReplayStartup", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +bool FPostHogReplayStartupTest::RunTest(const FString&) +{ + const auto* Function = UPostHogSubsystem::StaticClass()->FindFunctionByName(TEXT("StartSessionReplay")); + TestTrue(TEXT("Blueprint callable, not pure"), Function && Function->HasAnyFunctionFlags(FUNC_BlueprintCallable) && !Function->HasAnyFunctionFlags(FUNC_BlueprintPure)); + FAutomationTestFramework::Get().EnqueueLatentCommand(MakeShared(this)); + return true; +} +#endif diff --git a/Source/PostHog/Private/Tests/PostHogReplayTests.cpp b/Source/PostHog/Private/Tests/PostHogReplayTests.cpp index 034f6ba..38f6cd0 100644 --- a/Source/PostHog/Private/Tests/PostHogReplayTests.cpp +++ b/Source/PostHog/Private/Tests/PostHogReplayTests.cpp @@ -2,6 +2,7 @@ #include "PostHogClient.h" #include "PostHogFlagCache.h" #include "PostHogJson.h" +#include "PostHogSettings.h" #include "Async/Async.h" #include "HAL/PlatformMisc.h" #include "Misc/AutomationTest.h" @@ -11,6 +12,11 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(FPostHogReplayConfigTest, "PostHog.ReplayConfig EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) bool FPostHogReplayConfigTest::RunTest(const FString& Parameters) { + TestTrue(TEXT("replay defaults to automatic start"), FPostHogReplaySettings().bAutoStart); + TestFalse(TEXT("replay requires explicit opt-in"), FPostHogReplaySettings().bEnabled); + FPostHogOptions Options; + Options.Replay.bAutoStart = false; + TestFalse(TEXT("options preserve manual start"), Options.ToNative().Replay.bAutoStart); FPostHogFlagCache Cache; auto Enabled = PostHogJson::Decode(TEXT(R"({"flags":{},"errors_while_computing":false,"quota_limited":false,"session_recording":{"endpoint":"/s/"}})")); TestEqual(TEXT("enabled response"), Cache.Apply(Enabled.ToSharedRef()), EPostHogFlagReloadStatus::Success); diff --git a/Source/PostHog/Public/PostHogSubsystem.h b/Source/PostHog/Public/PostHogSubsystem.h index c5f1648..ef57248 100644 --- a/Source/PostHog/Public/PostHogSubsystem.h +++ b/Source/PostHog/Public/PostHogSubsystem.h @@ -39,6 +39,12 @@ class POSTHOG_API UPostHogSubsystem : public UGameInstanceSubsystem UFUNCTION(BlueprintPure, Category = "PostHog") EPostHogSubsystemState GetState() const { return State; } + /** Starts the recorder after setup; requires Replay.bEnabled. Already started returns true. + * True means the recorder is running, not that frames are being captured or delivered: + * consent, foreground/session state, and remote replay configuration still gate capture. */ + UFUNCTION(BlueprintCallable, Category = "PostHog|Session Replay") + bool StartSessionReplay(); + /** Native callers may retain the shared pointer, but teardown still closes it. */ FPostHogClient::FPtr GetClient() const; UPROPERTY(BlueprintAssignable, Category = "PostHog") @@ -57,7 +63,7 @@ class POSTHOG_API UPostHogSubsystem : public UGameInstanceSubsystem EPostHogSubsystemState State = EPostHogSubsystemState::Idle; bool bForeground = true; FPostHogClient::FPtr Client; - /** Replay options kept from setup; the recorder starts once the client is ready. */ + /** Replay options kept from setup for automatic or explicit recorder startup. */ FPostHogReplaySettings ReplaySettings; TSharedPtr Replay; TSharedPtr Initialization; diff --git a/Source/PostHog/Public/PostHogTypes.h b/Source/PostHog/Public/PostHogTypes.h index dcbd15e..2a414b4 100644 --- a/Source/PostHog/Public/PostHogTypes.h +++ b/Source/PostHog/Public/PostHogTypes.h @@ -25,4 +25,7 @@ struct POSTHOG_API FPostHogReplaySettings int32 MaxDimension = 960; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Replay", meta = (ClampMin = "1", ClampMax = "100")) int32 JpegQuality = 65; + /** Start recording when setup completes. Disable to call StartSessionReplay later. Requires bEnabled. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Replay") + bool bAutoStart = true; }; diff --git a/Tests/native_integration.py b/Tests/native_integration.py index 532c82f..7593276 100644 --- a/Tests/native_integration.py +++ b/Tests/native_integration.py @@ -391,7 +391,7 @@ def test_unreal_editor_native_integration(self): self.assertEqual(process.returncode, 0, str(evidence / "stdout.log")) result = json.loads((report / "index.json").read_text(encoding="utf-8-sig")) self.assertEqual(result["failed"], 0, result) - self.assertEqual(result["succeeded"], 8, result) + self.assertEqual(result["succeeded"] + result.get("succeededWithWarnings", 0), 9, result) wire = self.wire_events() wire_by_name = {event["event"]: event for event in wire} materialized = self.http("/__captured/events")