diff --git a/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.cpp index 2af9d6d38..3701a28cc 100644 --- a/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.cpp +++ b/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.cpp @@ -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 @@ -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(); - if (!SessionReplay->Initialize(settings, GetReplayPath())) + if (SessionReplay->Initialize(settings, GetReplayPath())) + { + SetContext(TEXT("replay"), { { TEXT("replay_id"), FSentryVariant(SessionReplayId) } }); + } + else { SessionReplay.Reset(); + SessionReplayId.Reset(); } } #endif @@ -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 diff --git a/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.h b/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.h index 0c7887f87..f93987c8a 100644 --- a/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.h +++ b/plugin-dev/Source/Sentry/Private/GenericPlatform/GenericPlatformSentrySubsystem.h @@ -165,6 +165,8 @@ class FGenericPlatformSentrySubsystem : public ISentrySubsystem #ifdef USE_SENTRY_SESSION_REPLAY FString GetReplayPath() const; + FString SessionReplayId; + TUniquePtr SessionReplay; #endif }; diff --git a/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.cpp b/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.cpp index 59f487527..021a7ed22 100644 --- a/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.cpp +++ b/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.cpp @@ -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(info.SizeBytes))); + sentry_value_set_by_key(payload, "duration", sentry_value_new_double(static_cast(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))); diff --git a/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.h b/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.h index 8eb9de7a8..2391db031 100644 --- a/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.h +++ b/plugin-dev/Source/Sentry/Private/GenericPlatform/Infrastructure/GenericPlatformSentryConverters.h @@ -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" @@ -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); diff --git a/plugin-dev/Source/Sentry/Private/SessionReplay/SentryReplayInfo.h b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryReplayInfo.h new file mode 100644 index 000000000..f4f71907b --- /dev/null +++ b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryReplayInfo.h @@ -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; +}; diff --git a/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.cpp b/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.cpp index 6ca0ea7f2..6df08a60c 100644 --- a/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.cpp +++ b/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.cpp @@ -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(Encoder->GetWidth()) : 0; + Info.Height = Encoder ? static_cast(Encoder->GetHeight()) : 0; + Info.FrameRate = Encoder ? static_cast(Encoder->GetFramerate()) : 0; + Info.DurationMs = LatestDurationMs; + Info.FrameCount = LatestFrameCount; + Info.SizeBytes = IFileManager::Get().FileSize(*AttachmentPath); + Info.EndTimestampSec = static_cast(NowUtc.ToUnixTimestamp()) + NowUtc.GetMillisecond() / 1000.0; + Info.StartTimestampSec = Info.EndTimestampSec - static_cast(Info.DurationMs) / 1000.0; + + return Info; +} + void FSentrySessionReplayRecorder::OnInitSegmentReady(TArray&& NewInitSegment) { FScopeLock Lock(&RingLock); @@ -154,14 +174,20 @@ void FSentrySessionReplayRecorder::OnInitSegmentReady(TArray&& NewInitSeg } } -void FSentrySessionReplayRecorder::OnFragmentReady(TArray&& Fragment) +void FSentrySessionReplayRecorder::OnFragmentReady(TArray&& 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() @@ -209,6 +235,8 @@ void FSentrySessionReplayRecorder::DoRotation() constexpr int32 TfdtFieldOffset = 60; TArray Snapshot; + int64 TotalFrames = 0; + uint64 TotalTicks = 0; { FScopeLock Lock(&RingLock); if (InitSegment.Num() == 0 || FragmentRing.Num() == 0) @@ -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(Reserve)); Snapshot.Append(InitSegment); @@ -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(TotalFrames); + LatestDurationMs = static_cast((TotalTicks * 1000) / FSentryFMP4Writer::TrackTimescale); + if (WriteSnapshot(Snapshot)) { bSnapshotOnDisk.AtomicSet(true); diff --git a/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.h b/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.h index 2eb549ff7..907adaabe 100644 --- a/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.h +++ b/plugin-dev/Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.h @@ -11,6 +11,8 @@ #include "HAL/Runnable.h" #include "HAL/ThreadSafeBool.h" +#include "SentryReplayInfo.h" + class FRunnableThread; class FEvent; class FSentryVideoEncoder; @@ -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&& InitSegment); // Called by the encoder thread when a fragment (moof+mdat) is complete - void OnFragmentReady(TArray&& Fragment); + void OnFragmentReady(TArray&& Fragment, uint32 FrameCount, uint64 DurationTicks); // FRunnable (rotation thread) virtual bool Init() override; @@ -74,10 +79,20 @@ class FSentrySessionReplayRecorder : public FRunnable TUniquePtr Encoder; TUniquePtr Capture; + struct FFragment + { + TArray Bytes; + uint32 FrameCount = 0; + uint64 DurationTicks = 0; + }; + // Fragment ring + init segment, protected by RingLock FCriticalSection RingLock; TArray InitSegment; - TRingBuffer> FragmentRing; + TRingBuffer FragmentRing; + + int32 LatestFrameCount = 0; + int64 LatestDurationMs = 0; // Rotation thread FRunnableThread* RotationThread = nullptr; diff --git a/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.cpp b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.cpp index a09e4d8f6..179958379 100644 --- a/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.cpp +++ b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.cpp @@ -301,12 +301,7 @@ void FSentryVideoEncoder::Restart() { DrainPackets(); - if (CurrentSamples.Num() > 0 && bInitSegmentPublished) - { - TArray Frag = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples); - Recorder.OnFragmentReady(MoveTemp(Frag)); - } - CurrentSamples.Reset(); + FlushCurrentFragment(); Encoder.Reset(); @@ -331,6 +326,22 @@ void FSentryVideoEncoder::Restart() } } +void FSentryVideoEncoder::FlushCurrentFragment() +{ + if (CurrentSamples.Num() > 0 && bInitSegmentPublished) + { + const uint32 FrameCount = static_cast(CurrentSamples.Num()); + uint64 DurationTicks = 0; + for (const FSentryH264Sample& Sample : CurrentSamples) + { + DurationTicks += Sample.Duration; + } + TArray Fragment = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples); + Recorder.OnFragmentReady(MoveTemp(Fragment), FrameCount, DurationTicks); + } + CurrentSamples.Reset(); +} + void FSentryVideoEncoder::DrainPackets() { if (!Encoder.IsValid()) @@ -372,9 +383,7 @@ void FSentryVideoEncoder::DrainPackets() if (Packet.bIsKeyframe && CurrentSamples.Num() > 0) { - TArray Frag = FSentryFMP4Writer::BuildFragment(NextFragmentSequence++, CurrentFragmentDecodeTime, CurrentSamples); - Recorder.OnFragmentReady(MoveTemp(Frag)); - CurrentSamples.Reset(); + FlushCurrentFragment(); } // The encoder echoes back the capture timestamp we passed to SendFrame diff --git a/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.h b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.h index cb94dabb8..08f35cb92 100644 --- a/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.h +++ b/plugin-dev/Source/Sentry/Private/SessionReplay/SentryVideoEncoder.h @@ -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 @@ -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 diff --git a/scripts/packaging/package.snapshot b/scripts/packaging/package.snapshot index 1ef0a2689..54651f935 100644 --- a/scripts/packaging/package.snapshot +++ b/scripts/packaging/package.snapshot @@ -223,6 +223,7 @@ Source/Sentry/Private/SessionReplay/SentryBackBufferCapture.cpp Source/Sentry/Private/SessionReplay/SentryBackBufferCapture.h Source/Sentry/Private/SessionReplay/SentryFMP4Writer.cpp Source/Sentry/Private/SessionReplay/SentryFMP4Writer.h +Source/Sentry/Private/SessionReplay/SentryReplayInfo.h Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.cpp Source/Sentry/Private/SessionReplay/SentrySessionReplayRecorder.h Source/Sentry/Private/SessionReplay/SentryVideoEncoder.cpp @@ -467,6 +468,8 @@ Source/ThirdParty/Mac/Native/lib/libsentry.a Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/_CodeSignature/CodeResources Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/CodeResources Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/Info.plist +Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/MacOS/._Sentry.CrashReporter.deps.json +Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/MacOS/._Sentry.CrashReporter.runtimeconfig.json Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/MacOS/libclrgcexp.dylib Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/MacOS/libclrjit.dylib Source/ThirdParty/Mac/Sentry.CrashReporter.app/Contents/MacOS/libcoreclr.dylib