Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 11 additions & 5 deletions Source/PostHog/Private/PostHogSubsystem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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(); }
Expand Down
151 changes: 151 additions & 0 deletions Source/PostHog/Private/Tests/PostHogReplayStartupTests.cpp
Original file line number Diff line number Diff line change
@@ -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 <atomic>

namespace
{
// Observe recorder lifecycle without adding test-only state to the public API.
class FReplayStartupLog final : public FOutputDevice
{
public:
std::atomic<int32> 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<UPostHogSettings>();
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<UPostHogSettings>()->bAutoInitialize = bPreviousAutoInitialize;
}
bool Update() override
{
if (FPlatformTime::Seconds() > Deadline) { Test->AddError(TEXT("Replay startup scenario timed out")); return true; }
auto* Subsystem = Instance ? Instance->GetSubsystem<UPostHogSubsystem>() : 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<UGameInstance>(GEngine));
Instance->InitializeStandalone(FName(*FGuid::NewGuid().ToString()));
Subsystem = Instance->GetSubsystem<UPostHogSubsystem>();
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<UGameInstance> 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<FReplayStartupScenario>(this));
return true;
}
#endif
6 changes: 6 additions & 0 deletions Source/PostHog/Private/Tests/PostHogReplayTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion Source/PostHog/Public/PostHogSubsystem.h
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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<FPostHogReplayRecorder, ESPMode::ThreadSafe> Replay;
TSharedPtr<FPostHogInitialization, ESPMode::ThreadSafe> Initialization;
Expand Down
3 changes: 3 additions & 0 deletions Source/PostHog/Public/PostHogTypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
2 changes: 1 addition & 1 deletion Tests/native_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading