Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@ sentry_value_t FGenericPlatformSentrySubsystem::OnCrash(const sentry_ucontext_t*
MakeShareable(new FGenericPlatformSentryAttachment(SessionReplay->GetAttachmentPath(), TEXT("session-replay.mp4"), TEXT("video/mp4")));

AddFileAttachment(ReplayAttachment);

const FSentryReplayInfo ReplayInfo =
SessionReplay->BuildReplayInfo(SessionReplayId, FString(UTF8_TO_TCHAR(sentry_value_as_string(sentry_value_get_by_key(event, "event_id")))));

sentry_capture_session_replay(TCHAR_TO_UTF8(*ReplayInfo.VideoPath),
FGenericPlatformSentryConverters::ReplayEventToNative(ReplayInfo), FGenericPlatformSentryConverters::ReplayRecordingToNative(ReplayInfo));
}
#endif

Expand Down Expand Up @@ -646,10 +652,17 @@ void FGenericPlatformSentrySubsystem::InitWithSettings(const USentrySettings* se
// Clear replay videos captured during previous session if any
IFileManager::Get().DeleteDirectory(*FPaths::Combine(GetDatabasePath(), TEXT("replays")), false, true);

SessionReplayId = FGuid::NewGuid().ToString(EGuidFormats::Digits).ToLower();

SessionReplay = MakeUnique<FSentrySessionReplayRecorder>();
if (!SessionReplay->Initialize(settings, GetReplayPath()))
if (SessionReplay->Initialize(settings, GetReplayPath()))
{
SetContext(TEXT("replay"), { { TEXT("replay_id"), FSentryVariant(SessionReplayId) } });
}
else
{
SessionReplay.Reset();
SessionReplayId.Reset();
}
}
#endif
Expand Down Expand Up @@ -1334,8 +1347,7 @@ FString FGenericPlatformSentrySubsystem::GetScreenshotPath() const
#ifdef USE_SENTRY_SESSION_REPLAY
FString FGenericPlatformSentrySubsystem::GetReplayPath() const
{
const FString ReplayId = FGuid::NewGuid().ToString(EGuidFormats::DigitsWithHyphens).ToLower();
const FString ReplayPath = FPaths::Combine(GetDatabasePath(), TEXT("replays"), FString::Printf(TEXT("replay-%s.mp4"), *ReplayId));
const FString ReplayPath = FPaths::Combine(GetDatabasePath(), TEXT("replays"), FString::Printf(TEXT("replay-%s.mp4"), *SessionReplayId));
return FPaths::ConvertRelativePathToFull(ReplayPath);
}
#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ class FGenericPlatformSentrySubsystem : public ISentrySubsystem
#ifdef USE_SENTRY_SESSION_REPLAY
FString GetReplayPath() const;

FString SessionReplayId;

TUniquePtr<FSentrySessionReplayRecorder> SessionReplay;
#endif
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,71 @@ sentry_crash_reporting_mode_t FGenericPlatformSentryConverters::CrashReportingMo
}
}

#ifdef USE_SENTRY_SESSION_REPLAY
sentry_value_t FGenericPlatformSentryConverters::ReplayEventToNative(const FSentryReplayInfo& info)
{
sentry_value_t event = sentry_value_new_object();
sentry_value_set_by_key(event, "type", sentry_value_new_string("replay_event"));
sentry_value_set_by_key(event, "replay_type", sentry_value_new_string(TCHAR_TO_UTF8(*info.ReplayType)));
sentry_value_set_by_key(event, "segment_id", sentry_value_new_int32(info.SegmentId));
sentry_value_set_by_key(event, "replay_id", sentry_value_new_string(TCHAR_TO_UTF8(*info.ReplayId)));
sentry_value_set_by_key(event, "event_id", sentry_value_new_string(TCHAR_TO_UTF8(*info.ReplayId)));
sentry_value_set_by_key(event, "platform", sentry_value_new_string("native"));
sentry_value_set_by_key(event, "timestamp", sentry_value_new_double(info.EndTimestampSec));
sentry_value_set_by_key(event, "replay_start_timestamp", sentry_value_new_double(info.StartTimestampSec));
sentry_value_set_by_key(event, "urls", sentry_value_new_list());

sentry_value_t errorIds = sentry_value_new_list();
if (!info.ErrorEventId.IsEmpty())
{
sentry_value_append(errorIds, sentry_value_new_string(TCHAR_TO_UTF8(*info.ErrorEventId)));
}
sentry_value_set_by_key(event, "error_ids", errorIds);

return event;
}

sentry_value_t FGenericPlatformSentryConverters::ReplayRecordingToNative(const FSentryReplayInfo& info)
{
const double tsMs = info.StartTimestampSec * 1000.0;

sentry_value_t metaData = sentry_value_new_object();
sentry_value_set_by_key(metaData, "href", sentry_value_new_string(""));
sentry_value_set_by_key(metaData, "width", sentry_value_new_int32(info.Width));
sentry_value_set_by_key(metaData, "height", sentry_value_new_int32(info.Height));
sentry_value_t metaEvent = sentry_value_new_object();
sentry_value_set_by_key(metaEvent, "type", sentry_value_new_int32(4));
sentry_value_set_by_key(metaEvent, "timestamp", sentry_value_new_double(tsMs));
sentry_value_set_by_key(metaEvent, "data", metaData);

sentry_value_t payload = sentry_value_new_object();
sentry_value_set_by_key(payload, "segmentId", sentry_value_new_int32(info.SegmentId));
sentry_value_set_by_key(payload, "size", sentry_value_new_double(static_cast<double>(info.SizeBytes)));
sentry_value_set_by_key(payload, "duration", sentry_value_new_double(static_cast<double>(info.DurationMs)));
sentry_value_set_by_key(payload, "encoding", sentry_value_new_string("h264"));
sentry_value_set_by_key(payload, "container", sentry_value_new_string("mp4"));
sentry_value_set_by_key(payload, "height", sentry_value_new_int32(info.Height));
sentry_value_set_by_key(payload, "width", sentry_value_new_int32(info.Width));
sentry_value_set_by_key(payload, "left", sentry_value_new_int32(0));
sentry_value_set_by_key(payload, "top", sentry_value_new_int32(0));
sentry_value_set_by_key(payload, "frameCount", sentry_value_new_int32(info.FrameCount));
sentry_value_set_by_key(payload, "frameRate", sentry_value_new_int32(info.FrameRate));
sentry_value_set_by_key(payload, "frameRateType", sentry_value_new_string("variable"));
sentry_value_t videoData = sentry_value_new_object();
sentry_value_set_by_key(videoData, "tag", sentry_value_new_string("video"));
sentry_value_set_by_key(videoData, "payload", payload);
sentry_value_t videoEvent = sentry_value_new_object();
sentry_value_set_by_key(videoEvent, "type", sentry_value_new_int32(5));
sentry_value_set_by_key(videoEvent, "timestamp", sentry_value_new_double(tsMs));
sentry_value_set_by_key(videoEvent, "data", videoData);

sentry_value_t recording = sentry_value_new_list();
sentry_value_append(recording, metaEvent);
sentry_value_append(recording, videoEvent);
return recording;
}
#endif // USE_SENTRY_SESSION_REPLAY

ESentryLevel FGenericPlatformSentryConverters::SentryLevelToUnreal(sentry_value_t level)
{
FString levelStr = FString(UTF8_TO_TCHAR(sentry_value_as_string(level)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
#include "SentryDataTypes.h"
#include "SentryVariant.h"

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

#include "GenericPlatform/Convenience/GenericPlatformSentryInclude.h"
#include "GenericPlatform/GenericPlatformStackWalk.h"

Expand All @@ -29,6 +33,11 @@ class FGenericPlatformSentryConverters
static sentry_minidump_mode_t MinidumpModeToNative(ESentryMinidumpMode mode);
static sentry_crash_reporting_mode_t CrashReportingModeToNative(ESentryCrashReportingMode mode);

#ifdef USE_SENTRY_SESSION_REPLAY
static sentry_value_t ReplayEventToNative(const FSentryReplayInfo& info);
static sentry_value_t ReplayRecordingToNative(const FSentryReplayInfo& info);
#endif

/** Conversions from native types */
static ESentryLevel SentryLevelToUnreal(sentry_value_t level);
static ESentryLevel SentryLevelToUnreal(sentry_level_t level);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 Sentry. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"

/**
* Description of a recorded session-replay clip.
*
* See https://develop.sentry.dev/sdk/telemetry/replays/ for details.
*/
struct FSentryReplayInfo
{
FString ReplayId;

// Segment index within the replay (0 for the single crash-time buffer segment)
int32 SegmentId = 0;

// Replay capture mode: "buffer" (rolling window) or "session" (continuous)
FString ReplayType = TEXT("buffer");

double StartTimestampSec = 0.0;
double EndTimestampSec = 0.0;

FString ErrorEventId;

FString VideoPath;

int32 Width = 0;
int32 Height = 0;

int64 DurationMs = 0;
int64 SizeBytes = 0;
int32 FrameCount = 0;
int32 FrameRate = 0;
};
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,26 @@ void FSentrySessionReplayRecorder::Shutdown()
}
}

FSentryReplayInfo FSentrySessionReplayRecorder::BuildReplayInfo(const FString& ReplayId, const FString& ErrorEventId) const
{
const FDateTime NowUtc = FDateTime::UtcNow();

FSentryReplayInfo Info;
Info.ReplayId = ReplayId;
Info.ErrorEventId = ErrorEventId;
Info.VideoPath = AttachmentPath;
Info.Width = Encoder ? static_cast<int32>(Encoder->GetWidth()) : 0;
Info.Height = Encoder ? static_cast<int32>(Encoder->GetHeight()) : 0;
Info.FrameRate = Encoder ? static_cast<int32>(Encoder->GetFramerate()) : 0;
Info.DurationMs = LatestDurationMs;
Info.FrameCount = LatestFrameCount;
Info.SizeBytes = IFileManager::Get().FileSize(*AttachmentPath);
Info.EndTimestampSec = static_cast<double>(NowUtc.ToUnixTimestamp()) + NowUtc.GetMillisecond() / 1000.0;
Info.StartTimestampSec = Info.EndTimestampSec - static_cast<double>(Info.DurationMs) / 1000.0;

return Info;
}

void FSentrySessionReplayRecorder::OnInitSegmentReady(TArray<uint8>&& NewInitSegment)
{
FScopeLock Lock(&RingLock);
Expand All @@ -154,14 +174,20 @@ void FSentrySessionReplayRecorder::OnInitSegmentReady(TArray<uint8>&& NewInitSeg
}
}

void FSentrySessionReplayRecorder::OnFragmentReady(TArray<uint8>&& Fragment)
void FSentrySessionReplayRecorder::OnFragmentReady(TArray<uint8>&& Fragment, uint32 FrameCount, uint64 DurationTicks)
{
FScopeLock Lock(&RingLock);
if (FragmentRing.Num() >= FragmentRingCapacity)
{
FragmentRing.PopFront();
}
FragmentRing.Add(MoveTemp(Fragment));

FFragment Entry;
Entry.Bytes = MoveTemp(Fragment);
Entry.FrameCount = FrameCount;
Entry.DurationTicks = DurationTicks;

FragmentRing.Add(MoveTemp(Entry));
}

bool FSentrySessionReplayRecorder::Init()
Expand Down Expand Up @@ -209,6 +235,8 @@ void FSentrySessionReplayRecorder::DoRotation()
constexpr int32 TfdtFieldOffset = 60;

TArray<uint8> Snapshot;
int64 TotalFrames = 0;
uint64 TotalTicks = 0;
{
FScopeLock Lock(&RingLock);
if (InitSegment.Num() == 0 || FragmentRing.Num() == 0)
Expand All @@ -219,7 +247,7 @@ void FSentrySessionReplayRecorder::DoRotation()
int64 Reserve = InitSegment.Num();
for (int32 i = 0; i < FragmentRing.Num(); ++i)
{
Reserve += FragmentRing[i].Num();
Reserve += FragmentRing[i].Bytes.Num();
}
Snapshot.Reserve(static_cast<int32>(Reserve));
Snapshot.Append(InitSegment);
Expand All @@ -228,18 +256,25 @@ void FSentrySessionReplayRecorder::DoRotation()
// Without this, evicted fragments leave the kept ones with absolute
// session-clock tfdt values, and players that compute duration from
// "last sample end time" overstate the clip length
const uint64 FirstTfdt = FSentryFMP4Writer::ReadU64BE(FragmentRing[0], TfdtFieldOffset);
const uint64 FirstTfdt = FSentryFMP4Writer::ReadU64BE(FragmentRing[0].Bytes, TfdtFieldOffset);
for (int32 i = 0; i < FragmentRing.Num(); ++i)
{
const FFragment& Fragment = FragmentRing[i];
const int32 FragStartInSnapshot = Snapshot.Num();
Snapshot.Append(FragmentRing[i]);
Snapshot.Append(Fragment.Bytes);

const uint64 OrigTfdt = FSentryFMP4Writer::ReadU64BE(FragmentRing[i], TfdtFieldOffset);
const uint64 OrigTfdt = FSentryFMP4Writer::ReadU64BE(Fragment.Bytes, TfdtFieldOffset);
const uint64 NewTfdt = (OrigTfdt >= FirstTfdt) ? (OrigTfdt - FirstTfdt) : 0;
FSentryFMP4Writer::PatchU64(Snapshot, FragStartInSnapshot + TfdtFieldOffset, NewTfdt);

TotalFrames += Fragment.FrameCount;
TotalTicks += Fragment.DurationTicks;
}
}

LatestFrameCount = static_cast<int32>(TotalFrames);
LatestDurationMs = static_cast<int64>((TotalTicks * 1000) / FSentryFMP4Writer::TrackTimescale);

if (WriteSnapshot(Snapshot))
{
bSnapshotOnDisk.AtomicSet(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#include "HAL/Runnable.h"
#include "HAL/ThreadSafeBool.h"

#include "SentryReplayInfo.h"

class FRunnableThread;
class FEvent;
class FSentryVideoEncoder;
Expand Down Expand Up @@ -43,10 +45,13 @@ class FSentrySessionReplayRecorder : public FRunnable
bool HasSnapshotOnDisk() const { return bSnapshotOnDisk; }
const FString& GetAttachmentPath() const { return AttachmentPath; }

// Assembles the platform-agnostic replay model from the most recent on-disk snapshot
FSentryReplayInfo BuildReplayInfo(const FString& ReplayId, const FString& ErrorEventId) const;

// Called by the encoder thread when the init segment (ftyp+moov) is ready
void OnInitSegmentReady(TArray<uint8>&& InitSegment);
// Called by the encoder thread when a fragment (moof+mdat) is complete
void OnFragmentReady(TArray<uint8>&& Fragment);
void OnFragmentReady(TArray<uint8>&& Fragment, uint32 FrameCount, uint64 DurationTicks);

// FRunnable (rotation thread)
virtual bool Init() override;
Expand Down Expand Up @@ -74,10 +79,20 @@ class FSentrySessionReplayRecorder : public FRunnable
TUniquePtr<FSentryVideoEncoder> Encoder;
TUniquePtr<FSentryBackBufferCapture> Capture;

struct FFragment
{
TArray<uint8> Bytes;
uint32 FrameCount = 0;
uint64 DurationTicks = 0;
};

// Fragment ring + init segment, protected by RingLock
FCriticalSection RingLock;
TArray<uint8> InitSegment;
TRingBuffer<TArray<uint8>> FragmentRing;
TRingBuffer<FFragment> FragmentRing;

int32 LatestFrameCount = 0;
int64 LatestDurationMs = 0;

// Rotation thread
FRunnableThread* RotationThread = nullptr;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,7 @@ void FSentryVideoEncoder::Restart()
{
DrainPackets();

if (CurrentSamples.Num() > 0 && bInitSegmentPublished)
{
TArray<uint8> Frag = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples);
Recorder.OnFragmentReady(MoveTemp(Frag));
}
CurrentSamples.Reset();
FlushCurrentFragment();

Encoder.Reset();

Expand All @@ -331,6 +326,22 @@ void FSentryVideoEncoder::Restart()
}
}

void FSentryVideoEncoder::FlushCurrentFragment()
{
if (CurrentSamples.Num() > 0 && bInitSegmentPublished)
{
const uint32 FrameCount = static_cast<uint32>(CurrentSamples.Num());
uint64 DurationTicks = 0;
for (const FSentryH264Sample& Sample : CurrentSamples)
{
DurationTicks += Sample.Duration;
}
TArray<uint8> Fragment = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples);
Recorder.OnFragmentReady(MoveTemp(Fragment), FrameCount, DurationTicks);
}
CurrentSamples.Reset();
}

void FSentryVideoEncoder::DrainPackets()
{
if (!Encoder.IsValid())
Expand Down Expand Up @@ -372,9 +383,7 @@ void FSentryVideoEncoder::DrainPackets()

if (Packet.bIsKeyframe && CurrentSamples.Num() > 0)
{
TArray<uint8> Frag = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples);
Recorder.OnFragmentReady(MoveTemp(Frag));
CurrentSamples.Reset();
FlushCurrentFragment();
}

// The encoder echoes back the capture timestamp we passed to SendFrame
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ class FSentryVideoEncoder : public FRunnable

uint32 GetFramerate() const { return Framerate; }

uint32 GetWidth() const { return Width; }
uint32 GetHeight() const { return Height; }

bool IsEncodingDisabled() const { return bEncodingDisabled; }

// FRunnable
Expand All @@ -63,6 +66,9 @@ class FSentryVideoEncoder : public FRunnable
// Pulls available packets from the encoder, converts them to AVCC samples and emits a fragment at each keyframe boundary
void DrainPackets();

// Builds a fragment from the accumulated samples and hands it to the recorder
void FlushCurrentFragment();

// 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
Expand Down
Loading
Loading