Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add experimental session replay capturing on iOS (UE 5.8+) ([#1462](https://github.com/getsentry/sentry-unreal/pull/1462))

### Fixes

- Fix deprecation warnings caused by `RHICreateTexture` usage in UE 5.8 ([#1466](https://github.com/getsentry/sentry-unreal/pull/1466))
Expand Down
31 changes: 31 additions & 0 deletions plugin-dev/Source/Sentry/Private/Apple/AppleSentrySubsystem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,32 @@ void FAppleSentrySubsystem::InitWithSettings(const USentrySettings* settings, co
// Wait synchronously until sentry-cocoa initialization finished in main thread
dispatch_group_wait(sentryDispatchGroup, DISPATCH_TIME_FOREVER);
dispatch_release(sentryDispatchGroup);

if (IsEnabled())
{
#ifdef USE_SENTRY_SESSION_REPLAY
if (settings->AttachSessionReplay)
{
SessionReplay = MakeUnique<FSentrySessionReplayRecorder>();
if (!SessionReplay->Initialize(settings, GetReplayPath()))
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
{
SessionReplay.Reset();
}
}
#endif
}
}

void FAppleSentrySubsystem::Close()
{
#ifdef USE_SENTRY_SESSION_REPLAY
if (SessionReplay)
{
SessionReplay->Shutdown();
SessionReplay.Reset();
}
#endif

[SENTRY_APPLE_CLASS(SentryObjCSDK) flush:0];
[SENTRY_APPLE_CLASS(SentryObjCSDK) close];
}
Expand Down Expand Up @@ -799,4 +821,13 @@ FString FAppleSentrySubsystem::GetLatestSessionReplay() const
return Replays[0];
}

#ifdef USE_SENTRY_SESSION_REPLAY
FString FAppleSentrySubsystem::GetReplayPath() const
{
const FString ReplayId = FGuid::NewGuid().ToString(EGuidFormats::DigitsWithHyphens).ToLower();
const FString ReplayPath = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SentryReplays"), FString::Printf(TEXT("replay-%s.mp4"), *ReplayId));
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
return FPaths::ConvertRelativePathToFull(ReplayPath);
}
#endif

#endif // !USE_SENTRY_NATIVE
11 changes: 11 additions & 0 deletions plugin-dev/Source/Sentry/Private/Apple/AppleSentrySubsystem.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

#include "Interface/SentrySubsystemInterface.h"

#ifdef USE_SENTRY_SESSION_REPLAY
#include "SessionReplay/SentrySessionReplayRecorder.h"
#endif

class FAppleSentrySubsystem : public ISentrySubsystem
{
public:
Expand Down Expand Up @@ -80,6 +84,13 @@ class FAppleSentrySubsystem : public ISentrySubsystem
int32 maxAttachmentSize = 0;

FString PrevSessionReplayPath;

private:
#ifdef USE_SENTRY_SESSION_REPLAY
FString GetReplayPath() const;

TUniquePtr<FSentrySessionReplayRecorder> SessionReplay;
#endif
};

#endif // !USE_SENTRY_NATIVE
28 changes: 0 additions & 28 deletions plugin-dev/Source/Sentry/Private/Mac/MacSentrySubsystem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,30 +116,11 @@ void FMacSentrySubsystem::InitWithSettings(const USentrySettings* settings, cons
TryCaptureScreenshot();
});
}

#ifdef USE_SENTRY_SESSION_REPLAY
if (settings->AttachSessionReplay)
{
SessionReplay = MakeUnique<FSentrySessionReplayRecorder>();
if (!SessionReplay->Initialize(settings, GetReplayPath()))
{
SessionReplay.Reset();
}
}
#endif
}
}

void FMacSentrySubsystem::Close()
{
#ifdef USE_SENTRY_SESSION_REPLAY
if (SessionReplay)
{
SessionReplay->Shutdown();
SessionReplay.Reset();
}
#endif

if (OnHandleSystemErrorDelegateHandle.IsValid())
{
FCoreDelegates::OnHandleSystemError.Remove(OnHandleSystemErrorDelegateHandle);
Expand All @@ -149,15 +130,6 @@ void FMacSentrySubsystem::Close()
FAppleSentrySubsystem::Close();
}

#ifdef USE_SENTRY_SESSION_REPLAY
FString FMacSentrySubsystem::GetReplayPath() const
{
const FString ReplayId = FGuid::NewGuid().ToString(EGuidFormats::DigitsWithHyphens).ToLower();
const FString ReplayPath = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SentryReplays"), FString::Printf(TEXT("replay-%s.mp4"), *ReplayId));
return FPaths::ConvertRelativePathToFull(ReplayPath);
}
#endif

TSharedPtr<ISentryId> FMacSentrySubsystem::CaptureEnsure(const FString& type, const FString& message)
{
TSharedPtr<ISentryId> id = FAppleSentrySubsystem::CaptureEnsure(type, message);
Expand Down
10 changes: 0 additions & 10 deletions plugin-dev/Source/Sentry/Private/Mac/MacSentrySubsystem.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ class FMacSentrySubsystem : public FGenericPlatformSentrySubsystem

#include "Apple/AppleSentrySubsystem.h"

#ifdef USE_SENTRY_SESSION_REPLAY
#include "SessionReplay/SentrySessionReplayRecorder.h"
#endif

class FMacSentrySubsystem : public FAppleSentrySubsystem
{
public:
Expand All @@ -53,12 +49,6 @@ class FMacSentrySubsystem : public FAppleSentrySubsystem

private:
FDelegateHandle OnHandleSystemErrorDelegateHandle;

#ifdef USE_SENTRY_SESSION_REPLAY
FString GetReplayPath() const;

TUniquePtr<FSentrySessionReplayRecorder> SessionReplay;
#endif
};

#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,12 @@ void FSentryBackBufferCapture::CaptureBackBuffer_RenderThread(const FTextureRHIR
FTextureRHIRef ScratchTex = AcquireCachedTexture_RenderThread(Scratch, W, H, BackBuffer->GetFormat(),
SrvRt, ERHIAccess::SRVGraphics, TEXT("SentrySessionReplayScratch"));

#if PLATFORM_MAC
#if PLATFORM_APPLE
FTextureRHIRef ConvertedTex = AcquireCachedTexture_RenderThread(Converted, W, H, PF_B8G8R8A8,
SrvRt, ERHIAccess::SRVGraphics, TEXT("SentrySessionReplayConverted"));
#endif

#if PLATFORM_MAC
#if PLATFORM_APPLE
// VT requires pool slot to have CPUReadback flag so it can be read via Metal's getBytes().
// Note: Metal forbids RT|CPUReadback on the same texture so an extra copy is needed (step 3)
constexpr ETextureCreateFlags PoolFlags = ETextureCreateFlags::CPUReadback;
Expand All @@ -149,7 +149,7 @@ void FSentryBackBufferCapture::CaptureBackBuffer_RenderThread(const FTextureRHIR
return;
}

#if PLATFORM_MAC
#if PLATFORM_APPLE
if (!ConvertedTex.IsValid())
{
return;
Expand All @@ -161,7 +161,7 @@ void FSentryBackBufferCapture::CaptureBackBuffer_RenderThread(const FTextureRHIR
return;
}

#if PLATFORM_MAC
#if PLATFORM_APPLE
FTextureRHIRef DrawTarget = ConvertedTex;
#else
FTextureRHIRef DrawTarget = EncoderTex;
Expand Down Expand Up @@ -202,8 +202,8 @@ void FSentryBackBufferCapture::CaptureBackBuffer_RenderThread(const FTextureRHIR
GraphBuilder.Execute();
}

#if PLATFORM_MAC
// Step 3 (Mac only): hardware-copy Converted -> EncoderPool slot. Both are BGRA8 same-format
#if PLATFORM_APPLE
// Step 3 (Apple only): hardware-copy Converted -> EncoderPool slot. Both are BGRA8 same-format
{
FRHITransitionInfo Transitions[] = {
FRHITransitionInfo(DrawTarget.GetReference(), ERHIAccess::Unknown, ERHIAccess::CopySrc),
Expand All @@ -217,7 +217,7 @@ void FSentryBackBufferCapture::CaptureBackBuffer_RenderThread(const FTextureRHIR
}
#endif

#if PLATFORM_MAC
#if PLATFORM_APPLE
RHICmdList.Transition(FRHITransitionInfo(EncoderTex.GetReference(), ERHIAccess::CopyDest, ERHIAccess::CPURead));
#else
RHICmdList.Transition(FRHITransitionInfo(EncoderTex.GetReference(), ERHIAccess::Unknown, ERHIAccess::SRVGraphics));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ class SWindow;
* 2. AddDrawTexturePass scratch -> a BGRA8 destination. When the scratch
* format is already BGRA8 this stays a hardware copy; otherwise the
* engine's built-in pixel-shader path converts HDR/10-bit to BGRA8.
* Destination is the encoder pool slot on Windows; on Mac it's the
* Destination is the encoder pool slot on Windows; on Apple platforms it's the
* "converted" texture because Metal forbids RenderTargetable | CPUReadback.
* 3. Mac only: hardware-copy converted -> encoder pool slot (CPUReadback BGRA8).
* 3. Apple only: hardware-copy converted -> encoder pool slot (CPUReadback BGRA8).
* The pool slot is then handed to the encoder.
*/
class FSentryBackBufferCapture
Expand Down Expand Up @@ -94,7 +94,7 @@ class FSentryBackBufferCapture
// don't carry the SRV flag, so they can't be sampled in a shader directly
FCachedTexture Scratch;

// BGRA8 RenderTargetable texture. Used on Mac as the draw pass output
// BGRA8 RenderTargetable texture. Used on Apple platforms as the draw pass output
// before the final hardware copy into the CPUReadback EncoderPool slot
FCachedTexture Converted;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "SentrySessionReplayRecorder.h"

#include "HAL/Event.h"
#include "HAL/PlatformMisc.h"
#include "HAL/RunnableThread.h"
#include "Misc/ScopeLock.h"
#include "RHI.h"
Expand All @@ -27,6 +28,9 @@ FSentryVideoEncoder::FSentryVideoEncoder(FSentrySessionReplayRecorder& InRecorde
, BitrateBps(InBitrateKbps * 1000)
, FragmentSeconds(InFragmentSeconds)
{
#if PLATFORM_IOS
ExpectedOrientation = FPlatformMisc::GetDeviceOrientation();
Comment thread
tustanivsky marked this conversation as resolved.
#endif
Comment thread
tustanivsky marked this conversation as resolved.
}

FSentryVideoEncoder::~FSentryVideoEncoder()
Expand Down Expand Up @@ -175,7 +179,7 @@ uint32 FSentryVideoEncoder::Run()
}

// VT interprets SendFrame's timestamp as microseconds (see Engine's VideoEncoderVT.hpp)
#if PLATFORM_MAC
#if PLATFORM_APPLE
static constexpr double SendTimestampScale = 1'000'000.0;
#else
static constexpr double SendTimestampScale = 1'000.0;
Expand All @@ -184,8 +188,8 @@ uint32 FSentryVideoEncoder::Run()
const double TimestampSeconds = FMath::Max(0.0, Frame.CaptureTimeSeconds - CaptureTimeBaseSeconds);
const double ScaledTimestamp = TimestampSeconds * SendTimestampScale;

#if PLATFORM_MAC
// Restart the encoder every hour of recording. On Mac this stays well clear of the
#if PLATFORM_APPLE
// Restart the encoder every hour of recording. On Apple platforms this stays well clear of the
// uint32-microseconds wrap at ~71 min which would otherwise feed VT a backward PTS
// and corrupt all subsequent fragments. On Windows the natural wrap is at ~49 days
// so realistically periodic refresh of encoder state won't be needed there
Expand Down Expand Up @@ -237,11 +241,28 @@ uint32 FSentryVideoEncoder::Run()
return 0;
}

bool FSentryVideoEncoder::ShouldSwapDimensions(uint32 ResourceWidth, uint32 ResourceHeight) const
{
switch (ExpectedOrientation)
{
case EDeviceScreenOrientation::Portrait:
case EDeviceScreenOrientation::PortraitUpsideDown:
return ResourceWidth > ResourceHeight;
case EDeviceScreenOrientation::LandscapeLeft:
case EDeviceScreenOrientation::LandscapeRight:
return ResourceHeight > ResourceWidth;
default:
return false;
}
}

bool FSentryVideoEncoder::EnsureEncoderOpen(uint32 ResourceWidth, uint32 ResourceHeight)
{
if (bEncoderOpen)
{
if ((ResourceWidth != Width || ResourceHeight != Height) && !bResolutionChanged)
const bool bSameSize = ResourceWidth == Width && ResourceHeight == Height;
const bool bSameTransposedSize = ResourceWidth == Height && ResourceHeight == Width;
if (!bSameSize && !bSameTransposedSize && !bResolutionChanged)
{
UE_LOG(LogSentrySdk, Warning, TEXT("Session replay: capture resolution changed from %ux%u to %ux%u; recording stays locked to the original size and may be cropped or black."),
Width, Height, ResourceWidth, ResourceHeight);
Expand All @@ -255,6 +276,15 @@ bool FSentryVideoEncoder::EnsureEncoderOpen(uint32 ResourceWidth, uint32 Resourc
return false;
}

// If the first frame arrives in the device's native (transposed) dimensions
// swap them so the video is written with the correct resolution from the start
if (ShouldSwapDimensions(ResourceWidth, ResourceHeight))
{
UE_LOG(LogSentrySdk, Log, TEXT("Session replay: first frame is %ux%u but the app runs in %s orientation; opening the encoder with swapped dimensions."),
ResourceWidth, ResourceHeight, ResourceHeight > ResourceWidth ? TEXT("landscape") : TEXT("portrait"));
Swap(ResourceWidth, ResourceHeight);
}
Comment thread
cursor[bot] marked this conversation as resolved.

FVideoEncoderConfigH264 Config;
Config.Width = ResourceWidth;
Config.Height = ResourceHeight;
Expand All @@ -270,7 +300,7 @@ bool FSentryVideoEncoder::EnsureEncoderOpen(uint32 ResourceWidth, uint32 Resourc
Config.bFillData = 0;
Config.MultipassMode = EMultipassMode::Disabled;

#if PLATFORM_MAC
#if PLATFORM_APPLE
// Work around a VT bug where H.264 Auto maps to a null EntropyCodingMode
// causing a crash in CFStringGetLength. Use CABAC instead (supported by Main/High profiles)
Config.EntropyCodingMode = EH264EntropyCodingMode::CABAC;
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -384,7 +414,7 @@ void FSentryVideoEncoder::DrainPackets()
// when the source renders below the configured target rate). A skipped
// packet never updates the marker, so its interval folds into the next
// sample. The first sample falls back to a nominal 1/Framerate
#if PLATFORM_MAC
#if PLATFORM_APPLE
const uint32 PacketTimestampMs = static_cast<uint32>(FPlatformTime::ToMilliseconds64(Packet.Timestamp));
#else
const uint32 PacketTimestampMs = static_cast<uint32>(Packet.Timestamp);
Comment thread
tustanivsky marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ class FSentryVideoEncoder : public FRunnable
static constexpr int32 MaxQueueDepth = 5;

private:
// Checks if frame dimensions match with the app's fixed screen orientation
bool ShouldSwapDimensions(uint32 ResourceWidth, uint32 ResourceHeight) const;

bool EnsureEncoderOpen(uint32 ResourceWidth, uint32 ResourceHeight);

// Pulls available packets from the encoder, converts them to AVCC samples and emits a fragment at each keyframe boundary
Expand All @@ -66,14 +69,19 @@ class FSentryVideoEncoder : public FRunnable
// Tears down the current encoder and resets per-encoder state so the next frame
// re-baselines against a fresh VT timestamp origin and republishes a new init
// segment. Used to avoid uint32 overflow of the SendFrame timestamp (~71 min of
// microseconds on Mac, ~49 days of milliseconds on Windows). Must be called only
// microseconds on Apple platforms, ~49 days of milliseconds on Windows). Must be called only
// from the encoder thread
void Restart();

FSentrySessionReplayRecorder& Recorder;

bool bEncoderOpen = false;
bool bResolutionChanged = false;

// Screen orientation the app runs in, captured once at construction (iOS only)
// and assumed constant for the session. Unknown disables orientation handling
EDeviceScreenOrientation ExpectedOrientation = EDeviceScreenOrientation::Unknown;

TSharedPtr<TVideoEncoder<FVideoResourceRHI>> Encoder;

bool bFirstFrameValidated = false;
Expand Down
18 changes: 16 additions & 2 deletions plugin-dev/Source/Sentry/Sentry.Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,7 @@ public Sentry(ReadOnlyTargetRules Target) : base(Target)
}
}

if (bAttachSessionReplay && IsPluginEnabled(Target, "AVCodecsCore") &&
(Target.Platform == UnrealTargetPlatform.Win64 || Target.Platform == UnrealTargetPlatform.Mac || Target.Platform == UnrealTargetPlatform.Linux))
if (bAttachSessionReplay && IsSessionReplaySupported(Target) && IsPluginEnabled(Target, "AVCodecsCore"))
{
PrivateDependencyModuleNames.AddRange(new string[]
{
Expand Down Expand Up @@ -288,6 +287,21 @@ private bool IsPluginEnabled(ReadOnlyTargetRules Target, string PluginName)
return false;
}

private bool IsSessionReplaySupported(ReadOnlyTargetRules Target)
{
// On Windows/Linux Arm64, session replay capturing is not supported
// On Android, session replay capturing is handled by sentry-java

#if UE_5_8_OR_LATER
if (Target.Platform == UnrealTargetPlatform.IOS)
{
return true;
}
#endif

return Target.Platform == UnrealTargetPlatform.Win64 || Target.Platform == UnrealTargetPlatform.Mac || Target.Platform == UnrealTargetPlatform.Linux;
}

private void StageCrashReporterResources(ReadOnlyTargetRules Target)
{
if (Target.Type == TargetType.Editor)
Comment thread
tustanivsky marked this conversation as resolved.
Expand Down
Loading
Loading