From 3d2ce290f30de2e8a00a0713cbb1f4d730dac9b1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 14:14:54 +0200 Subject: [PATCH 01/15] reworked screenshots --- .../ConfigurationWindow/EnrichmentTab.cs | 8 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 130 ++--- .../ScriptableSentryUnityOptions.cs | 2 + .../SentryMonoBehaviour.Screenshot.cs | 49 ++ src/Sentry.Unity/SentryScreenshot.cs | 39 +- src/Sentry.Unity/SentryScreenshotCache.cs | 208 ++++++++ src/Sentry.Unity/SentryUnityOptions.cs | 9 + src/Sentry.Unity/SentryUnitySdk.cs | 56 +- src/sentry-dotnet | 2 +- .../ScreenshotEventProcessorTests.cs | 496 ++++-------------- 10 files changed, 461 insertions(+), 538 deletions(-) create mode 100644 src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs create mode 100644 src/Sentry.Unity/SentryScreenshotCache.cs diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs index 0d5a29783..49769b583 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs @@ -34,7 +34,7 @@ internal static void Display(ScriptableSentryUnityOptions options) options.AttachScreenshot = EditorGUILayout.BeginToggleGroup( new GUIContent("Attach Screenshots to Events", "Try to attach current screenshot on events.\n" + "This is an early-access feature and may not work on all platforms (it is explicitly disabled on WebGL).\n" + - "Additionally, the screenshot is captured mid-frame, when an event happens, so it may be incomplete.\n" + + "The SDK periodically captures the screen and attaches the most recent capture to an event.\n" + "A screenshot might not be able to be attached, for example when the error happens on a background thread."), options.AttachScreenshot); EditorGUI.indentLevel++; @@ -51,6 +51,12 @@ internal static void Display(ScriptableSentryUnityOptions options) new GUIContent("Compression", "The compression of the screenshot."), options.ScreenshotCompression, 1, 100); + options.ScreenshotCaptureIntervalMilliseconds = EditorGUILayout.IntSlider( + new GUIContent("Capture Interval (ms)", "How often the SDK captures the screen.\n" + + "This is the upper bound on how stale an attached screenshot can be.\n" + + "Lower values increase the per-frame rendering cost."), + options.ScreenshotCaptureIntervalMilliseconds, 0, 1000); + EditorGUI.indentLevel--; EditorGUILayout.EndToggleGroup(); } diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index c7c854aba..10cd94aa4 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,122 +1,64 @@ -using System; -using System.Collections; -using System.Threading; using Sentry.Extensibility; -using Sentry.Internal; -using UnityEngine; namespace Sentry.Unity; -public class ScreenshotEventProcessor : ISentryEventProcessor +public class ScreenshotEventProcessor : ISentryEventProcessorWithHint { private readonly SentryUnityOptions _options; - private readonly ISentryMonoBehaviour _sentryMonoBehaviour; - private volatile int _isCapturingScreenshot; + private readonly SentryScreenshotCache _cache; - public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } + public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) + : this(sentryOptions, GetOrCreateCache(sentryOptions)) { } - internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoBehaviour sentryMonoBehaviour) + internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, SentryScreenshotCache cache) { _options = sentryOptions; - _sentryMonoBehaviour = sentryMonoBehaviour; + _cache = cache; } - public SentryEvent Process(SentryEvent @event) + private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options) { - // Only ever capture one screenshot per frame - if (Interlocked.CompareExchange(ref _isCapturingScreenshot, 1, 0) == 0) + if (options.ScreenshotCache is { } cache) { - _options.LogDebug("Starting coroutine to capture a screenshot."); - // Capture must run on the main thread after WaitForEndOfFrame (ReadPixels needs a complete frame), but the - // event processor pipeline is synchronous and may run on any thread - blocking here would deadlock when - // called from the main thread. So we capture in a coroutine and ship the screenshot as a separate envelope. - _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(@event)); + return cache; } - return @event; + cache = new SentryScreenshotCache(options); + SentryMonoBehaviour.Instance.StartScreenshotCache(cache, options.ScreenshotCaptureInterval); + options.ScreenshotCache = cache; + return cache; } - internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) - { - _options.LogDebug("Screenshot capture triggered. Waiting for End of Frame."); - - // WaitForEndOfFrame does not work in headless mode so we're making it configurable for CI. - // See https://docs.unity3d.com/6000.1/Documentation/ScriptReference/WaitForEndOfFrame.html - yield return WaitForEndOfFrame(); + public SentryEvent? Process(SentryEvent @event) => @event; - Texture2D? screenshot = null; - try + public SentryEvent? Process(SentryEvent @event, SentryHint hint) + { + // Reading the cached frame back off the GPU is a main thread only operation. + if (!MainThreadData.IsMainThread()) { - if (!@event.IsCaptured) - { - _options.LogDebug("Skipping screenshot for event {0}. Event was not captured.", @event.EventId); - yield break; - } - - if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) - { - yield break; - } - - screenshot = CreateNewScreenshotTexture2D(_options); - - if (_options.BeforeSendScreenshotInternal != null) - { - var modifiedScreenshot = _options.BeforeSendScreenshotInternal(screenshot, @event); - - if (modifiedScreenshot == null) - { - _options.LogInfo("Screenshot discarded by BeforeSendScreenshot callback."); - yield break; - } - - // Clean up - If the user returned a new texture object and did not modify the passed in one - if (modifiedScreenshot != screenshot) - { - _options.LogDebug("Applying modified screenshot."); - UnityEngine.Object.Destroy(screenshot); - screenshot = modifiedScreenshot; - } - } - - var screenshotBytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - if (screenshotBytes is null || screenshotBytes.Length == 0) - { - _options.LogWarning("Screenshot capture returned empty data for event {0}", @event.EventId); - yield break; - } - - var attachment = new SentryAttachment( - AttachmentType.Default, - new ByteAttachmentContent(screenshotBytes), - "screenshot.jpg", - "image/jpeg"); - - _options.LogDebug("Screenshot captured for event {0}", @event.EventId); - - CaptureAttachment(@event.EventId, attachment); + _options.LogDebug("Screenshot capture skipped. Can't capture screenshots on other than the main thread."); + return @event; } - catch (Exception e) + + if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) { - _options.LogError(e, "Failed to capture screenshot."); + _options.LogInfo("Screenshot capture skipped by BeforeCaptureScreenshot callback."); + return @event; } - finally - { - Interlocked.Exchange(ref _isCapturingScreenshot, 0); - if (screenshot != null) - { - UnityEngine.Object.Destroy(screenshot); - } - } - } + var beforeSend = _options.BeforeSendScreenshotInternal; + var screenshotBytes = _cache.TryEncodeLatest(beforeSend is null + ? null + : screenshot => beforeSend(screenshot, @event)); - internal virtual Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) - => SentryScreenshot.CreateNewScreenshotTexture2D(options); + if (screenshotBytes is null) + { + return @event; + } - internal virtual void CaptureAttachment(SentryId eventId, SentryAttachment attachment) - => (Sentry.SentrySdk.CurrentHub as Hub)?.CaptureAttachment(eventId, attachment); + hint.AddAttachment(screenshotBytes, "screenshot.jpg", AttachmentType.Default, "image/jpeg"); + _options.LogDebug("Screenshot attached to event {0}", @event.EventId); - internal virtual YieldInstruction WaitForEndOfFrame() - => new WaitForEndOfFrame(); + return @event; + } } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 6bfb920f7..ebbe40afb 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -68,6 +68,7 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public bool AttachScreenshot { get; set; } [field: SerializeField] public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.High; [field: SerializeField] public int ScreenshotCompression { get; set; } = 75; + [field: SerializeField] public int ScreenshotCaptureIntervalMilliseconds { get; set; } = 250; [field: SerializeField] public bool AttachViewHierarchy { get; set; } = false; [field: SerializeField] public int MaxViewHierarchyRootObjects { get; set; } = 100; @@ -216,6 +217,7 @@ internal SentryUnityOptions ToSentryUnityOptions( AttachScreenshot = AttachScreenshot, ScreenshotQuality = ScreenshotQuality, ScreenshotCompression = ScreenshotCompression, + ScreenshotCaptureInterval = TimeSpan.FromMilliseconds(ScreenshotCaptureIntervalMilliseconds), AttachViewHierarchy = AttachViewHierarchy, MaxViewHierarchyRootObjects = MaxViewHierarchyRootObjects, MaxViewHierarchyObjectChildCount = MaxViewHierarchyObjectChildCount, diff --git a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs new file mode 100644 index 000000000..9b1c1fa0d --- /dev/null +++ b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections; +using UnityEngine; + +namespace Sentry.Unity; + +/// +/// Drives the refresh of the screenshot cache. +/// +public partial class SentryMonoBehaviour +{ + private Coroutine? _screenshotCoroutine; + + internal void StartScreenshotCache(SentryScreenshotCache cache, TimeSpan interval) + { + if (_screenshotCoroutine is not null) + { + StopCoroutine(_screenshotCoroutine); + } + + _screenshotCoroutine = StartCoroutine(ScreenshotCacheCoroutine(cache, interval)); + } + + private static IEnumerator ScreenshotCacheCoroutine(SentryScreenshotCache cache, TimeSpan interval) + { + // Reusing a WaitForSecondsRealtime is not an option - it captures its deadline on construction, + // so every wait after the first returns immediately. Tracking the deadline here avoids both that + // and an allocation per refresh. + var intervalSeconds = (float)interval.TotalSeconds; + var nextRefresh = 0f; + var endOfFrame = new WaitForEndOfFrame(); + + while (true) + { + // Capturing mid-frame yields an incomplete image, so the cache is only ever refreshed once + // the frame has been rendered. This never resumes in headless mode, which leaves the cache + // empty rather than holding a blank image. + yield return endOfFrame; + + if (Time.realtimeSinceStartup < nextRefresh) + { + continue; + } + + cache.Refresh(); + nextRefresh = Time.realtimeSinceStartup + intervalSeconds; + } + } +} diff --git a/src/Sentry.Unity/SentryScreenshot.cs b/src/Sentry.Unity/SentryScreenshot.cs index 2e829d8ce..9e35633e6 100644 --- a/src/Sentry.Unity/SentryScreenshot.cs +++ b/src/Sentry.Unity/SentryScreenshot.cs @@ -16,26 +16,37 @@ internal static int GetTargetResolution(ScreenshotQuality quality) }; } + /// + /// Scales the given size down to the quality's target resolution while conserving the original ratio, + /// based on which, width or height, is the smaller. + /// + internal static (int Width, int Height) GetTargetSize(ScreenshotQuality quality, int width, int height) + { + if (quality is ScreenshotQuality.Full) + { + return (width, height); + } + + var targetResolution = GetTargetResolution(quality); + var ratioW = targetResolution / (float)width; + var ratioH = targetResolution / (float)height; + var ratio = Mathf.Min(ratioH, ratioW); + if (ratio is > 0.0f and < 1.0f) + { + width = Mathf.FloorToInt(width * ratio); + height = Mathf.FloorToInt(height * ratio); + } + + return (width, height); + } + public static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => CreateNewScreenshotTexture2D(options, Screen.width, Screen.height); // For testing internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options, int width, int height) { - // Make sure the screenshot size does not exceed the target size by scaling the image while conserving the - // original ratio based on which, width or height, is the smaller - if (options.ScreenshotQuality is not ScreenshotQuality.Full) - { - var targetResolution = GetTargetResolution(options.ScreenshotQuality); - var ratioW = targetResolution / (float)width; - var ratioH = targetResolution / (float)height; - var ratio = Mathf.Min(ratioH, ratioW); - if (ratio is > 0.0f and < 1.0f) - { - width = Mathf.FloorToInt(width * ratio); - height = Mathf.FloorToInt(height * ratio); - } - } + (width, height) = GetTargetSize(options.ScreenshotQuality, width, height); RenderTexture? renderTextureFull = null; RenderTexture? renderTextureResized = null; diff --git a/src/Sentry.Unity/SentryScreenshotCache.cs b/src/Sentry.Unity/SentryScreenshotCache.cs new file mode 100644 index 000000000..1774eb5cd --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotCache.cs @@ -0,0 +1,208 @@ +using System; +using Sentry.Extensibility; +using UnityEngine; + +namespace Sentry.Unity; + +/// +/// Holds the most recently completed frame so that it can be attached to an event synchronously, +/// from within the event processor pipeline, instead of being sent as a separate envelope. +/// +internal class SentryScreenshotCache : IDisposable +{ + private readonly SentryUnityOptions _options; + + private RenderTexture? _cache; + private int _width; + private int _height; + + private int _generation; + private byte[]? _encoded; + private int _encodedGeneration = -1; + + internal SentryScreenshotCache(SentryUnityOptions options) + { + _options = options; + } + + internal bool HasContent => _generation > 0; + + /// + /// Captures the current back buffer into the cache. Must run on the main thread at end of frame - + /// capturing mid-frame yields an incomplete image. + /// + internal virtual void Refresh() + { + var screenWidth = Screen.width; + var screenHeight = Screen.height; + if (screenWidth <= 0 || screenHeight <= 0) + { + return; + } + + var (targetWidth, targetHeight) = SentryScreenshot.GetTargetSize(_options.ScreenshotQuality, screenWidth, screenHeight); + EnsureCache(targetWidth, targetHeight); + + var previous = RenderTexture.active; + try + { + // Capturing straight into the downscaled cache keeps this - the only recurring cost of + // attaching screenshots - to a single operation. Mirroring is handled on read instead. + ScreenCapture.CaptureScreenshotIntoRenderTexture(_cache); + _generation++; + } + catch (Exception e) + { + _options.LogError(e, "Failed to refresh the screenshot cache."); + } + finally + { + RenderTexture.active = previous; + } + } + + /// + /// Encodes the cached frame as JPG. Must run on the main thread. Returns null when the cache has + /// not been populated yet or the transform discarded the screenshot. + /// + internal virtual byte[]? TryEncodeLatest(Func? transform = null) + { + if (!HasContent) + { + _options.LogDebug("Screenshot cache is empty. Skipping."); + return null; + } + + // Every event between two refreshes sees the same frame, so the encode is only worth doing once. + // A transform may return a different image per event, which makes the cached bytes unusable. + if (transform is null && _encodedGeneration == _generation) + { + return _encoded; + } + + Texture2D? screenshot = null; + try + { + screenshot = ReadCache(); + + if (transform is not null) + { + var transformed = transform.Invoke(screenshot); + if (transformed == null) + { + return null; + } + + if (transformed != screenshot) + { + UnityEngine.Object.Destroy(screenshot); + screenshot = transformed; + } + } + + var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); + if (bytes is null || bytes.Length == 0) + { + _options.LogWarning("Screenshot encoding returned empty data."); + return null; + } + + if (transform is null) + { + _encoded = bytes; + _encodedGeneration = _generation; + } + + return bytes; + } + catch (Exception e) + { + _options.LogError(e, "Failed to encode the cached screenshot."); + return null; + } + finally + { + if (screenshot != null) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } + + private Texture2D ReadCache() + { + var previous = RenderTexture.active; + RenderTexture? mirrored = null; + try + { + var source = _cache!; + + // The image may be mirrored on some platforms - mirror it back. + // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. + // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. + if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) + { + mirrored = RenderTexture.GetTemporary(_width, _height); + Graphics.Blit(source, mirrored, new Vector2(1, -1), new Vector2(0, 1)); + source = mirrored; + } + + RenderTexture.active = source; + var screenshot = new Texture2D(_width, _height, TextureFormat.RGB24, false); + screenshot.ReadPixels(new Rect(0, 0, _width, _height), 0, 0); + screenshot.Apply(); + return screenshot; + } + finally + { + RenderTexture.active = previous; + + if (mirrored) + { + RenderTexture.ReleaseTemporary(mirrored); + } + } + } + + private void EnsureCache(int width, int height) + { + if (_cache != null && _width == width && _height == height) + { + return; + } + + ReleaseCache(); + + _cache = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = "Sentry.ScreenshotCache" }; + _cache.Create(); + _width = width; + _height = height; + + // The previous frame is gone and the new texture is uninitialized. + _generation = 0; + _encodedGeneration = -1; + _encoded = null; + + _options.LogDebug("Screenshot cache allocated at {0}x{1}.", width, height); + } + + private void ReleaseCache() + { + if (_cache == null) + { + return; + } + + _cache.Release(); + UnityEngine.Object.Destroy(_cache); + _cache = null; + } + + public void Dispose() + { + ReleaseCache(); + _encoded = null; + _encodedGeneration = -1; + _generation = 0; + } +} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 1cb15ad5b..3f5fd8519 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -190,6 +190,15 @@ public sealed class SentryUnityOptions : SentryOptions /// public int ScreenshotCompression { get; set; } = 75; + /// + /// How often the SDK refreshes the screenshot it attaches to error events. Errors are attached the + /// most recently captured frame, so this doubles as the upper bound on how stale that frame can be. + /// Lowering it increases the per-frame rendering cost. Set to zero to capture every frame. + /// + public TimeSpan ScreenshotCaptureInterval { get; set; } = TimeSpan.FromMilliseconds(250); + + internal SentryScreenshotCache? ScreenshotCache { get; set; } + /// /// Controls whether structured logs should be captured for each Unity log type. /// diff --git a/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 707483405..4b619a621 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -67,6 +67,8 @@ public void Close() try { ApplicationAdapter.Instance.Quitting -= Close; + _options.ScreenshotCache?.Dispose(); + _options.ScreenshotCache = null; _options.DisposeGameMetricAttributes(); _options.NativeSupportCloseCallback?.Invoke(); _options.NativeSupportCloseCallback = null; @@ -125,35 +127,45 @@ public SentryId CaptureFeedback(string message, string? email, string? name, boo SentryHint? hint = null; if (addScreenshot) { - Texture2D? screenshot = null; - - try - { - screenshot = SentryScreenshot.CreateNewScreenshotTexture2D(_options); - var screenshotBytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - - if (screenshotBytes.Length > 0) - { - hint = SentryHint.WithAttachments( - new SentryAttachment( - AttachmentType.Default, - new ByteAttachmentContent(screenshotBytes), - "screenshot.jpg", - "image/jpeg")); - } - } - finally + var screenshotBytes = CaptureFeedbackScreenshot(); + if (screenshotBytes is { Length: > 0 }) { - if (screenshot) - { - UnityEngine.Object.Destroy(screenshot); - } + hint = SentryHint.WithAttachments( + new SentryAttachment( + AttachmentType.Default, + new ByteAttachmentContent(screenshotBytes), + "screenshot.jpg", + "image/jpeg")); } } return Sentry.SentrySdk.CurrentHub.CaptureFeedback(message, email, name, hint: hint); } + private byte[]? CaptureFeedbackScreenshot() + { + // The cache holds a fully rendered frame. Capturing here instead would happen mid-frame, + // i.e. while handling the input that submitted the feedback, and yield an incomplete image. + if (_options.ScreenshotCache is { HasContent: true } cache) + { + return cache.TryEncodeLatest(); + } + + Texture2D? screenshot = null; + try + { + screenshot = SentryScreenshot.CreateNewScreenshotTexture2D(_options); + return screenshot.EncodeToJPG(_options.ScreenshotCompression); + } + finally + { + if (screenshot) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } + internal static void SetUpWindowsPlayerCaching(SentryUnitySdk unitySdk, SentryUnityOptions options) { // On Windows-Standalone, we disable cache dir in case multiple app instances run over the same path. diff --git a/src/sentry-dotnet b/src/sentry-dotnet index b5f25119b..788fdb4de 160000 --- a/src/sentry-dotnet +++ b/src/sentry-dotnet @@ -1 +1 @@ -Subproject commit b5f25119b5cbf39bd22c1a1874ea5d3b59481d58 +Subproject commit 788fdb4de5e83dc15b788f7e426b3cb3f915c04f diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index bb7f396f3..5bd1b00a3 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections; -using System.IO; -using System.Text.RegularExpressions; +using System.Linq; using NUnit.Framework; using Sentry.Unity.Tests.Stubs; using UnityEngine; @@ -11,490 +10,175 @@ namespace Sentry.Unity.Tests; public class ScreenshotEventProcessorTests { - /// - /// Subclass that mocks screenshot capture and WaitForEndOfFrame but uses the REAL - /// CaptureAttachment implementation (via Hub.CaptureAttachment), allowing us to verify - /// that the attachment envelope actually reaches the HTTP transport. - /// - private class RealCaptureScreenshotEventProcessor : ScreenshotEventProcessor + private class TestScreenshotCache : SentryScreenshotCache { - public RealCaptureScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) - : base(options, sentryMonoBehaviour) { } + public int EncodeCallCount { get; private set; } + public byte[]? Bytes { get; set; } = { 1, 2, 3 }; + public Texture2D? TransformInput { get; set; } - internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) - => new Texture2D(1, 1); + public TestScreenshotCache(SentryUnityOptions options) : base(options) { } - internal override YieldInstruction WaitForEndOfFrame() - => new YieldInstruction(); + internal override void Refresh() { } - // CaptureAttachment is intentionally NOT overridden — the base implementation - // calls Hub.CaptureAttachment which sends a standalone attachment envelope. - } - - private class TestScreenshotEventProcessor : ScreenshotEventProcessor - { - public Func CreateScreenshotFunc { get; set; } - public Action CaptureAttachmentAction { get; set; } - public Func WaitForEndOfFrameFunc { get; set; } - - public TestScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) - : base(options, sentryMonoBehaviour) + internal override byte[]? TryEncodeLatest(Func? transform = null) { - CreateScreenshotFunc = _ => new Texture2D(1, 1); - CaptureAttachmentAction = (_, _) => { }; - WaitForEndOfFrameFunc = () => new YieldInstruction(); - } + EncodeCallCount++; - internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) - => CreateScreenshotFunc.Invoke(options); + if (transform is null) + { + return Bytes; + } - internal override void CaptureAttachment(SentryId eventId, SentryAttachment attachment) - => CaptureAttachmentAction(eventId, attachment); + var input = TransformInput ??= new Texture2D(1, 1); + return transform.Invoke(input) is null ? null : Bytes; + } + } - internal override YieldInstruction WaitForEndOfFrame() - => WaitForEndOfFrameFunc.Invoke(); + private static (ScreenshotEventProcessor Processor, TestScreenshotCache Cache) GetSut(SentryUnityOptions? options = null) + { + options ??= new SentryUnityOptions(); + var cache = new TestScreenshotCache(options); + return (new ScreenshotEventProcessor(options, cache), cache); } + [Test] - public void Process_FirstCallInAFrame_StartsCoroutine() + public void Process_CachedScreenshotAvailable_AttachesToHint() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); + var (processor, _) = GetSut(); + var hint = new SentryHint(); - screenshotProcessor.Process(new SentryEvent()); + processor.Process(new SentryEvent(), hint); - Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); + var attachment = hint.Attachments.Single(); + Assert.AreEqual("screenshot.jpg", attachment.FileName); + Assert.AreEqual("image/jpeg", attachment.ContentType); + Assert.AreEqual(AttachmentType.Default, attachment.Type); } - [UnityTest] - public IEnumerator Process_ExecutesCoroutine_CapturesScreenshotAndCapturesAttachment() + [Test] + public void Process_CacheReturnsNothing_DoesNotAttach() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var capturedEventId = SentryId.Empty; - SentryAttachment? capturedAttachment = null; - screenshotProcessor.CaptureAttachmentAction = (eventId, attachment) => - { - capturedEventId = eventId; - capturedAttachment = attachment; - }; - - var eventId = SentryId.Create(); - var sentryEvent = new SentryEvent(eventId: eventId) { IsCaptured = true }; + var (processor, cache) = GetSut(); + cache.Bytes = null; + var hint = new SentryHint(); - screenshotProcessor.Process(sentryEvent); + processor.Process(new SentryEvent(), hint); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; - - Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); - Assert.AreEqual(eventId, capturedEventId); - Assert.NotNull(capturedAttachment); // Sanity check - Assert.AreEqual("screenshot.jpg", capturedAttachment!.FileName); - Assert.AreEqual("image/jpeg", capturedAttachment.ContentType); - Assert.AreEqual(AttachmentType.Default, capturedAttachment.Type); + Assert.IsEmpty(hint.Attachments); } - [UnityTest] - public IEnumerator Process_CalledMultipleTimesQuickly_OnlyExecutesScreenshotCaptureOnce() + [Test] + public void Process_MultipleEventsInTheSameFrame_EachGetsAnAttachment() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => - { - screenshotCaptureCallCount++; - return new Texture2D(1, 1); - }; - - var attachmentCaptureCallCount = 0; - screenshotProcessor.CaptureAttachmentAction = (_, _) => - { - attachmentCaptureCallCount++; - }; - - // Process multiple events quickly (before any coroutine can complete) - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); + var (processor, _) = GetSut(); + var firstHint = new SentryHint(); + var secondHint = new SentryHint(); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; + processor.Process(new SentryEvent(), firstHint); + processor.Process(new SentryEvent(), secondHint); - Assert.AreEqual(1, screenshotCaptureCallCount); - Assert.AreEqual(1, attachmentCaptureCallCount); + Assert.AreEqual(1, firstHint.Attachments.Count); + Assert.AreEqual(1, secondHint.Attachments.Count); } - [UnityTest] - public IEnumerator Process_ScreenshotCaptureThrowsException_HandlesGracefully() + [Test] + public void Process_BeforeCaptureScreenshotReturnsFalse_SkipsTheCacheEntirely() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - screenshotProcessor.CreateScreenshotFunc = _ => throw new Exception("Screenshot capture failed"); - - var attachmentCaptureCallCount = 0; - screenshotProcessor.CaptureAttachmentAction = (_, _) => - { - attachmentCaptureCallCount++; - }; - - var sentryEvent = new SentryEvent { IsCaptured = true }; - screenshotProcessor.Process(sentryEvent); + var options = new SentryUnityOptions(); + options.SetBeforeCaptureScreenshot(_ => false); + var (processor, cache) = GetSut(options); + var hint = new SentryHint(); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; + processor.Process(new SentryEvent(), hint); - Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); - Assert.AreEqual(0, attachmentCaptureCallCount); + Assert.AreEqual(0, cache.EncodeCallCount); + Assert.IsEmpty(hint.Attachments); } - [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallback_ReceivesScreenshotAndEvent() + [Test] + public void Process_BeforeCaptureScreenshotReceivesEvent() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); - - Texture2D? receivedScreenshot = null; SentryEvent? receivedEvent = null; - - options.SetBeforeSendScreenshot((screenshot, @event) => + options.SetBeforeCaptureScreenshot(@event => { - receivedScreenshot = screenshot; receivedEvent = @event; - return screenshot; + return true; }); - - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); + var (processor, _) = GetSut(options); var eventId = SentryId.Create(); - var sentryEvent = new SentryEvent(eventId: eventId) { IsCaptured = true }; - - screenshotProcessor.Process(sentryEvent); - - yield return null; - yield return null; + processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); - Assert.NotNull(receivedScreenshot); Assert.NotNull(receivedEvent); Assert.AreEqual(eventId, receivedEvent!.EventId); } - [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallback_ReturnsNull_SkipsAttachment() + [Test] + public void Process_BeforeSendScreenshotReturnsNull_DoesNotAttach() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); - options.SetBeforeSendScreenshot((_, _) => null); + var (processor, _) = GetSut(options); + var hint = new SentryHint(); - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var attachmentCaptureCallCount = 0; - screenshotProcessor.CaptureAttachmentAction = (_, _) => - { - attachmentCaptureCallCount++; - }; - - var sentryEvent = new SentryEvent { IsCaptured = true }; - screenshotProcessor.Process(sentryEvent); - - yield return null; - yield return null; + processor.Process(new SentryEvent(), hint); - Assert.AreEqual(0, attachmentCaptureCallCount); + Assert.IsEmpty(hint.Attachments); } - [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallbackReturnsNewTexture_AttachesNewTexture() - { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var options = new SentryUnityOptions(); - - var newTexture = new Texture2D(10, 10); - var newTextureBytes = newTexture.EncodeToJPG(options.ScreenshotCompression); - var beforeSendInvoked = false; - - options.SetBeforeSendScreenshot((_, _) => - { - beforeSendInvoked = true; - return newTexture; - }); - - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var attachmentCaptured = false; - byte[]? capturedBytes = null; - - screenshotProcessor.CaptureAttachmentAction = (_, attachment) => - { - attachmentCaptured = true; - if (attachment.Content is ByteAttachmentContent byteContent) - { - using var stream = byteContent.GetStream(); - using var memoryStream = new MemoryStream(); - stream.CopyTo(memoryStream); - capturedBytes = memoryStream.ToArray(); - } - }; - - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - - yield return null; - yield return null; - - Assert.IsTrue(beforeSendInvoked); // Sanity Check - Assert.IsTrue(attachmentCaptured); // Sanity Check - Assert.NotNull(capturedBytes); - Assert.AreEqual(newTextureBytes.Length, capturedBytes!.Length); - - UnityEngine.Object.Destroy(newTexture); - } - - [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallbackModifiesTexture_UsesModifiedTexture() + [Test] + public void Process_BeforeSendScreenshotReceivesScreenshotAndEvent() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); - - var callbackInvoked = false; - byte[]? modifiedTextureBytes = null; - + Texture2D? receivedScreenshot = null; + SentryEvent? receivedEvent = null; options.SetBeforeSendScreenshot((screenshot, @event) => { - callbackInvoked = true; - - // User modifies the texture in place - - var pixels = screenshot.GetPixels(); - for (var i = 0; i < pixels.Length; i++) - { - pixels[i] = Color.red; - } - screenshot.SetPixels(pixels); - screenshot.Apply(); - - modifiedTextureBytes = screenshot.EncodeToJPG(options.ScreenshotCompression); - + receivedScreenshot = screenshot; + receivedEvent = @event; return screenshot; }); + var (processor, cache) = GetSut(options); - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var attachmentCaptured = false; - byte[]? capturedBytes = null; - - screenshotProcessor.CaptureAttachmentAction = (_, attachment) => - { - attachmentCaptured = true; - if (attachment.Content is ByteAttachmentContent byteContent) - { - using var stream = byteContent.GetStream(); - using var memoryStream = new MemoryStream(); - stream.CopyTo(memoryStream); - capturedBytes = memoryStream.ToArray(); - } - }; - - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - - yield return null; - yield return null; - - Assert.IsTrue(callbackInvoked); // Sanity Check - Assert.IsTrue(attachmentCaptured); // Sanity Check - Assert.NotNull(modifiedTextureBytes); - Assert.NotNull(capturedBytes); - Assert.AreEqual(modifiedTextureBytes!.Length, capturedBytes!.Length); - } - - [UnityTest] - public IEnumerator Process_BeforeCaptureScreenshotCallback_ReturnsFalse_SkipsCapture() - { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var options = new SentryUnityOptions(); - - options.SetBeforeCaptureScreenshot(_ => false); - - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => - { - screenshotCaptureCallCount++; - return new Texture2D(1, 1); - }; - - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - - yield return null; - yield return null; - - // BeforeCaptureScreenshot should prevent capture entirely - Assert.AreEqual(0, screenshotCaptureCallCount); - } - - [UnityTest] - public IEnumerator Process_BeforeCaptureScreenshotCallbackReturnsTrue_CapturesScreenshot() - { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var options = new SentryUnityOptions(); - - var callbackInvoked = false; - options.SetBeforeCaptureScreenshot(_ => - { - callbackInvoked = true; - return true; - }); - - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => - { - screenshotCaptureCallCount++; - return new Texture2D(1, 1); - }; - - screenshotProcessor.Process(new SentryEvent { IsCaptured = true }); - - yield return null; - yield return null; - - Assert.IsTrue(callbackInvoked); - Assert.AreEqual(1, screenshotCaptureCallCount); - } - - [UnityTest] - public IEnumerator Process_EventNotCaptured_SkipsAttachment() - { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => - { - screenshotCaptureCallCount++; - return new Texture2D(1, 1); - }; - - var attachmentCaptureCallCount = 0; - screenshotProcessor.CaptureAttachmentAction = (_, _) => - { - attachmentCaptureCallCount++; - }; - - screenshotProcessor.Process(new SentryEvent()); - - yield return null; - yield return null; - - Assert.AreEqual(0, screenshotCaptureCallCount); - Assert.AreEqual(0, attachmentCaptureCallCount); - } - - [UnityTest] - public IEnumerator Process_EventCapturedSuccessfully_ScreenshotAttachmentIsSent() - { - // Positive control: when the event IS captured, the screenshot coroutine should send - // the attachment. This validates the test infrastructure so the negative test below - // is meaningful — if this test passes but the next one doesn't, the IsCaptured flag - // is doing its job. - - var httpHandler = new TestHttpClientHandler("ScreenshotSuccessTest"); - var sentryMonoBehaviour = GetTestMonoBehaviour(); - - var options = new SentryUnityOptions(application: new TestApplication()) - { - Dsn = SentryTests.TestDsn, - CreateHttpMessageHandler = () => httpHandler - }; - - // Register test screenshot processor as an event processor — it will be called - // during DoSendEvent → ProcessEvent, just like the real ScreenshotEventProcessor. - options.AddEventProcessor(new RealCaptureScreenshotEventProcessor(options, sentryMonoBehaviour)); - - SentrySdk.Init(options); - - try - { - // Event goes through the full DoSendEvent pipeline and is captured successfully. - // DoSendEvent sets @event.IsCaptured = true after CaptureEnvelope succeeds. - var capturedId = SentrySdk.CaptureMessage("test message"); - Assert.AreNotEqual(SentryId.Empty, capturedId, "Sanity check: event should be captured"); - - // Wait for the screenshot coroutine to complete - yield return null; - yield return null; + var eventId = SentryId.Create(); + processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); - // Screenshot envelope should reach the transport - var screenshotRequest = httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(2)); - Assert.IsNotEmpty(screenshotRequest, - "Screenshot attachment should be sent when the event is captured successfully"); - } - finally - { - SentrySdk.Close(); - } + Assert.NotNull(receivedScreenshot); + Assert.AreSame(cache.TransformInput, receivedScreenshot); + Assert.NotNull(receivedEvent); + Assert.AreEqual(eventId, receivedEvent!.EventId); } [UnityTest] - public IEnumerator Process_EventDroppedByBeforeSend_ScreenshotAttachmentIsNotSent() + public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() { - // Full pipeline test: the event goes through DoSendEvent where before_send drops it. - // The screenshot coroutine (queued during ProcessEvent, before the drop decision) - // must check IsCaptured and skip — no orphaned attachment envelope. - - var httpHandler = new TestHttpClientHandler("ScreenshotBeforeSendTest"); - var sentryMonoBehaviour = GetTestMonoBehaviour(); - + // The screenshot must be an item on the event's own envelope. Sending it separately + // orphans the attachment whenever the event is dropped on ingestion. + var httpHandler = new TestHttpClientHandler("ScreenshotEnvelopeTest"); var options = new SentryUnityOptions(application: new TestApplication()) { Dsn = SentryTests.TestDsn, CreateHttpMessageHandler = () => httpHandler }; - - // Register test screenshot processor — called during DoSendEvent → ProcessEvent - options.AddEventProcessor(new RealCaptureScreenshotEventProcessor(options, sentryMonoBehaviour)); - - // Drop all events via before_send - options.SetBeforeSend((_, _) => null); + options.AddEventProcessor(new ScreenshotEventProcessor(options, new TestScreenshotCache(options))); SentrySdk.Init(options); try { - // CaptureMessage goes through the full DoSendEvent pipeline: - // ProcessEvent → screenshot processor queues coroutine with @event in closure - // DoBeforeSend → returns null → event dropped, IsCaptured stays false var capturedId = SentrySdk.CaptureMessage("test message"); - Assert.AreEqual(SentryId.Empty, capturedId, "Sanity check: before_send should drop events"); + Assert.AreNotEqual(SentryId.Empty, capturedId); - // Wait for the screenshot coroutine to complete - yield return null; yield return null; - // No screenshot envelope should reach the transport. - // GetEvent logs Debug.LogError on timeout — tell the test runner this is expected. - LogAssert.Expect(LogType.Error, new Regex("timed out")); - var screenshotRequest = httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(2)); - Assert.IsEmpty(screenshotRequest, - "Screenshot attachment should not be sent when before_send drops the event"); + var request = httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(2)); + Assert.IsNotEmpty(request); + StringAssert.Contains("test message", request); } finally { SentrySdk.Close(); } } - - private static TestSentryMonoBehaviour GetTestMonoBehaviour() - { - var gameObject = new GameObject("ScreenshotProcessorTest"); - var behaviour = gameObject.AddComponent(); - return behaviour; - } } From f78f4d425f5fb2330d36b0334f0e7cad29d67b66 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 14 Oct 2026 17:54:03 +0200 Subject: [PATCH 02/15] perf(screenshots): Read back asynchronously and defer the encode Attaching a screenshot blocked the thread the error happened on for the GPU readback and the JPG encode, ~35 ms per error on an iPhone at High quality. Read the cached frame back via AsyncGPUReadback and attach it as content that only encodes once the SDK serializes the envelope on its background worker. The screenshot still rides in the event's envelope. Errors on background threads now get a screenshot too where async readback is supported. Also stop the refresh coroutine once the cache is disposed, so Close no longer leaks a render texture or throws when called off the main thread. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 + docs/agent-guides/integrations.md | 3 +- .../ConfigurationWindow/EnrichmentTab.cs | 3 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 36 +- .../SentryMonoBehaviour.Screenshot.cs | 17 +- src/Sentry.Unity/SentryMonoBehaviour.cs | 2 +- .../SentryScreenshotAttachmentContent.cs | 82 +++ src/Sentry.Unity/SentryScreenshotCache.cs | 514 ++++++++++++++--- src/Sentry.Unity/SentryScreenshotFrame.cs | 141 +++++ .../ScreenshotEventProcessorTests.cs | 44 +- .../SentryScreenshotCacheTests.cs | 525 ++++++++++++++++++ 11 files changed, 1285 insertions(+), 95 deletions(-) create mode 100644 src/Sentry.Unity/SentryScreenshotAttachmentContent.cs create mode 100644 src/Sentry.Unity/SentryScreenshotFrame.cs create mode 100644 test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 337840da6..20506855f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Behavioral Changes + +- Screenshots are now attached to the error event itself and sent in the same envelope, instead of being sent separately after the fact. They no longer get orphaned when their event gets dropped, e.g. by sampling or rate limiting. The SDK keeps a recently captured frame around and attaches that, so a screenshot can be up to `ScreenshotCaptureInterval` old. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `SetBeforeCaptureScreenshot` now decides whether the screenshot gets attached and can be invoked on a background thread. Events captured on background threads now get a screenshot as well, on devices that support async GPU readback. With `SetBeforeSendScreenshot` set, screenshots only get attached to events captured on the main thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) + +### Features + +- Added `ScreenshotCaptureInterval` to control how often the SDK refreshes the screenshot it attaches to events. It defaults to 250 ms. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) + +### Fixes + +- Attaching a screenshot no longer stalls the frame an error happens in. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event to disk or the network on its background worker. On an iPhone at `ScreenshotQuality.High`, this takes an error on the main thread from blocking it for ~35 ms down to no measurable overhead. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) + ### Dependencies - Bump .NET SDK from v6.11.0 to v6.12.0 ([#2870](https://github.com/getsentry/sentry-unity/pull/2870)) diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index 77c6d79e5..e147a4b47 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -21,7 +21,8 @@ - Startup transaction: `app.start`, name `runtime.initialization`; spans cover subsystem registration, assemblies, splash screen, and first scene. - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. -- Screenshot capture and view-hierarchy capture must run on Unity main thread. +- View-hierarchy capture must run on Unity main thread. +- Screenshots: `SentryScreenshotCache` refreshes a frame on the main thread at end of frame and reads it back via `AsyncGPUReadback`. The processor attaches it to the event's own envelope; `SentryScreenshotAttachmentContent` defers the JPG encode until the envelope is serialized on the SDK's worker. Without async readback or with `BeforeSendScreenshot` set, it encodes right away on the main thread. ## Tests diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs index 49769b583..dda6d4f0f 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs @@ -35,7 +35,8 @@ internal static void Display(ScriptableSentryUnityOptions options) new GUIContent("Attach Screenshots to Events", "Try to attach current screenshot on events.\n" + "This is an early-access feature and may not work on all platforms (it is explicitly disabled on WebGL).\n" + "The SDK periodically captures the screen and attaches the most recent capture to an event.\n" + - "A screenshot might not be able to be attached, for example when the error happens on a background thread."), + "A screenshot might not be able to be attached, for example when the error happens on a background thread\n" + + "on a device without async GPU readback support, or with a BeforeSendScreenshot callback set."), options.AttachScreenshot); EditorGUI.indentLevel++; diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index 10cd94aa4..ebf2465cd 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,9 +1,13 @@ +using System; using Sentry.Extensibility; +using Unity.Profiling; namespace Sentry.Unity; public class ScreenshotEventProcessor : ISentryEventProcessorWithHint { + private static readonly ProfilerMarker ProcessMarker = new("Sentry.Screenshot.Process"); + private readonly SentryUnityOptions _options; private readonly SentryScreenshotCache _cache; @@ -33,30 +37,38 @@ private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options public SentryEvent? Process(SentryEvent @event, SentryHint hint) { - // Reading the cached frame back off the GPU is a main thread only operation. - if (!MainThreadData.IsMainThread()) + using var _ = ProcessMarker.Auto(); + + // A processor throwing drops the whole event, so a failing callback must only cost the screenshot. + try { - _options.LogDebug("Screenshot capture skipped. Can't capture screenshots on other than the main thread."); - return @event; + if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) + { + _options.LogInfo("Screenshot capture skipped by BeforeCaptureScreenshot callback."); + return @event; + } } - - if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) + catch (Exception e) { - _options.LogInfo("Screenshot capture skipped by BeforeCaptureScreenshot callback."); + _options.LogError(e, "The BeforeCaptureScreenshot callback threw an exception. Skipping the screenshot."); return @event; } + // Without a callback to apply, the encode gets deferred until the envelope is serialized on the SDK's + // background worker. The callback operates on a Texture2D, which ties it to encoding right away. var beforeSend = _options.BeforeSendScreenshotInternal; - var screenshotBytes = _cache.TryEncodeLatest(beforeSend is null - ? null - : screenshot => beforeSend(screenshot, @event)); + var content = beforeSend is null + ? _cache.TryGetLatestContent() + : _cache.TryEncodeLatest(screenshot => beforeSend(screenshot, @event)) is { } bytes + ? new ByteAttachmentContent(bytes) + : null; - if (screenshotBytes is null) + if (content is null) { return @event; } - hint.AddAttachment(screenshotBytes, "screenshot.jpg", AttachmentType.Default, "image/jpeg"); + hint.Attachments.Add(new SentryAttachment(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg")); _options.LogDebug("Screenshot attached to event {0}", @event.EventId); return @event; diff --git a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs index 9b1c1fa0d..989a6128d 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs @@ -12,23 +12,26 @@ public partial class SentryMonoBehaviour private Coroutine? _screenshotCoroutine; internal void StartScreenshotCache(SentryScreenshotCache cache, TimeSpan interval) + => StartScreenshotCache(cache, interval, new WaitForEndOfFrame()); + + // For testing - WaitForEndOfFrame never resumes in batch mode. + internal void StartScreenshotCache(SentryScreenshotCache cache, TimeSpan interval, YieldInstruction? endOfFrame) { if (_screenshotCoroutine is not null) { StopCoroutine(_screenshotCoroutine); } - _screenshotCoroutine = StartCoroutine(ScreenshotCacheCoroutine(cache, interval)); + _screenshotCoroutine = StartCoroutine(ScreenshotCacheCoroutine(cache, interval, endOfFrame)); } - private static IEnumerator ScreenshotCacheCoroutine(SentryScreenshotCache cache, TimeSpan interval) + private static IEnumerator ScreenshotCacheCoroutine(SentryScreenshotCache cache, TimeSpan interval, YieldInstruction? endOfFrame) { // Reusing a WaitForSecondsRealtime is not an option - it captures its deadline on construction, // so every wait after the first returns immediately. Tracking the deadline here avoids both that // and an allocation per refresh. var intervalSeconds = (float)interval.TotalSeconds; var nextRefresh = 0f; - var endOfFrame = new WaitForEndOfFrame(); while (true) { @@ -37,6 +40,14 @@ private static IEnumerator ScreenshotCacheCoroutine(SentryScreenshotCache cache, // empty rather than holding a blank image. yield return endOfFrame; + // The SDK can get closed from any thread, so instead of being stopped from the outside the + // coroutine ends itself. GPU resources can only be released here, on the main thread. + if (cache.IsDisposed) + { + cache.ReleaseResources(); + yield break; + } + if (Time.realtimeSinceStartup < nextRefresh) { continue; diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs index 2d93d8f0d..1eb1fb8f8 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.cs @@ -59,7 +59,7 @@ public void StartAwakeSpan(MonoBehaviour monoBehaviour) /// public partial class SentryMonoBehaviour { - // Unbounded queue - used by the ScreenshotEventProcessor where capture is already limited by Interlocked to 1/frame + // Unbounded queue - used by the UnityWebRequestTransport to start sends from background threads private readonly ConcurrentQueue _coroutineQueue = new(); public void QueueCoroutine(IEnumerator routine) diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs new file mode 100644 index 000000000..06454c204 --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using Sentry.Extensibility; + +namespace Sentry.Unity; + +/// +/// Attachment content that encodes the screenshot only once it gets read. +/// +/// +/// The SDK reads attachment content when serializing the envelope on its background worker. Deferring the encode +/// until then keeps it off the thread that captured the event while the screenshot still goes out in the same +/// envelope as the event. Events that get dropped before being sent - e.g. sampled out - never pay for the encode. +/// +internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent +{ + private readonly SentryScreenshotFrame _frame; + private readonly SentryUnityOptions _options; + + internal SentryScreenshotAttachmentContent(SentryScreenshotFrame frame, SentryUnityOptions options) + { + _frame = frame; + _options = options; + } + + internal SentryScreenshotFrame Frame => _frame; + + public Stream GetStream() => new EncodeOnReadStream(this); + + private byte[] Encode() + { + var bytes = _frame.GetEncoded(_options); + if (bytes is null) + { + _options.LogWarning("Screenshot attachment is empty. Encoding the screenshot failed."); + } + + return bytes ?? Array.Empty(); + } + + // Deliberately not seekable: the SDK checks the length of attachment streams when building the envelope, on the + // capturing thread. Reporting no length keeps the encode from happening right there. + private sealed class EncodeOnReadStream : Stream + { + private readonly SentryScreenshotAttachmentContent _content; + private MemoryStream? _encoded; + + public EncodeOnReadStream(SentryScreenshotAttachmentContent content) => _content = content; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + _encoded ??= new MemoryStream(_content.Encode(), writable: false); + return _encoded.Read(buffer, offset, count); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _encoded?.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/src/Sentry.Unity/SentryScreenshotCache.cs b/src/Sentry.Unity/SentryScreenshotCache.cs index 1774eb5cd..ef7f88488 100644 --- a/src/Sentry.Unity/SentryScreenshotCache.cs +++ b/src/Sentry.Unity/SentryScreenshotCache.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using Sentry.Extensibility; +using Unity.Profiling; using UnityEngine; +using UnityEngine.Rendering; namespace Sentry.Unity; @@ -8,24 +11,86 @@ namespace Sentry.Unity; /// Holds the most recently completed frame so that it can be attached to an event synchronously, /// from within the event processor pipeline, instead of being sent as a separate envelope. /// +/// +/// The frame gets captured on the GPU and, where supported, copied to the CPU via an asynchronous readback. +/// Attaching it to an event then hands out the frame, which only gets encoded once the SDK serializes the envelope +/// on its background worker. Without async readback support the frame stays on the GPU and has to be read back and +/// encoded synchronously, which limits attaching it to events captured on the main thread. +/// internal class SentryScreenshotCache : IDisposable { + // A readback completes within a few frames. Past this, it's considered lost so a missing callback + // can't block all further refreshes. + private const int ReadbackTimeoutFrames = 30; + + // Frames held on to by attachments waiting for their envelope to get serialized. Past this, attaching encodes + // right away, which bounds the memory pending screenshots can hold on to. + internal const int MaxPendingFrames = 2; + + private static readonly ProfilerMarker RefreshMarker = new("Sentry.Screenshot.Refresh"); + private static readonly ProfilerMarker ReadbackCompleteMarker = new("Sentry.Screenshot.ReadbackComplete"); + private static readonly ProfilerMarker EncodeMarker = new("Sentry.Screenshot.Encode"); + private readonly SentryUnityOptions _options; + private readonly bool _useAsyncReadback; + private readonly Action _onReadbackComplete; - private RenderTexture? _cache; + // GPU side - main thread only + private RenderTexture? _capture; + private RenderTexture? _oriented; private int _width; private int _height; + private bool _readbackInFlight; + private int _readbackRequestFrame; + + // CPU side - guarded by _lock + private readonly object _lock = new(); + private SentryScreenshotFrame? _frame; + private byte[]? _spare; + private readonly List> _pendingFrames = new(); - private int _generation; - private byte[]? _encoded; - private int _encodedGeneration = -1; + // Without async readback the frame only exists on the GPU - guarded by _lock + private bool _gpuHasContent; + private int _gpuGeneration; + private byte[]? _gpuEncoded; + private int _gpuEncodedGeneration = -1; + + private volatile bool _disposed; internal SentryScreenshotCache(SentryUnityOptions options) + : this(options, SystemInfo.supportsAsyncGPUReadback) { } + + internal SentryScreenshotCache(SentryUnityOptions options, bool useAsyncReadback) { _options = options; + _useAsyncReadback = useAsyncReadback; + _onReadbackComplete = OnReadbackComplete; + } + + internal bool HasContent + { + get + { + lock (_lock) + { + return _frame is not null || _gpuHasContent; + } + } } - internal bool HasContent => _generation > 0; + internal bool IsDisposed => _disposed; + + // For testing + internal SentryScreenshotFrame? CurrentFrame + { + get + { + lock (_lock) + { + return _frame; + } + } + } /// /// Captures the current back buffer into the cache. Must run on the main thread at end of frame - @@ -33,6 +98,13 @@ internal SentryScreenshotCache(SentryUnityOptions options) /// internal virtual void Refresh() { + if (_disposed) + { + return; + } + + using var _ = RefreshMarker.Auto(); + var screenWidth = Screen.width; var screenHeight = Screen.height; if (screenWidth <= 0 || screenHeight <= 0) @@ -41,15 +113,34 @@ internal virtual void Refresh() } var (targetWidth, targetHeight) = SentryScreenshot.GetTargetSize(_options.ScreenshotQuality, screenWidth, screenHeight); - EnsureCache(targetWidth, targetHeight); + EnsureTextures(targetWidth, targetHeight); var previous = RenderTexture.active; try { - // Capturing straight into the downscaled cache keeps this - the only recurring cost of - // attaching screenshots - to a single operation. Mirroring is handled on read instead. - ScreenCapture.CaptureScreenshotIntoRenderTexture(_cache); - _generation++; + // Capturing straight into the downscaled texture keeps this - the only recurring cost of + // attaching screenshots - cheap. + CaptureScreen(_capture!); + + var source = _capture!; + if (_oriented != null) + { + Graphics.Blit(_capture, _oriented, new Vector2(1, -1), new Vector2(0, 1)); + source = _oriented; + } + + if (_useAsyncReadback) + { + RequestReadback(source); + } + else + { + lock (_lock) + { + _gpuGeneration++; + _gpuHasContent = true; + } + } } catch (Exception e) { @@ -61,65 +152,291 @@ internal virtual void Refresh() } } - /// - /// Encodes the cached frame as JPG. Must run on the main thread. Returns null when the cache has - /// not been populated yet or the transform discarded the screenshot. - /// - internal virtual byte[]? TryEncodeLatest(Func? transform = null) + // For testing + internal virtual void CaptureScreen(RenderTexture target) => ScreenCapture.CaptureScreenshotIntoRenderTexture(target); + + private void RequestReadback(RenderTexture source) { - if (!HasContent) + if (_readbackInFlight) { - _options.LogDebug("Screenshot cache is empty. Skipping."); - return null; + // Skipping this refresh means the cache trails by one interval at most, instead of piling + // up readbacks on a slow GPU. + if (Time.frameCount - _readbackRequestFrame < ReadbackTimeoutFrames) + { + return; + } + + _options.LogDebug("Screenshot readback did not complete in time. Requesting a new one."); } - // Every event between two refreshes sees the same frame, so the encode is only worth doing once. - // A transform may return a different image per event, which makes the cached bytes unusable. - if (transform is null && _encodedGeneration == _generation) + _readbackInFlight = true; + _readbackRequestFrame = Time.frameCount; + AsyncGPUReadback.Request(source, 0, TextureFormat.RGBA32, _onReadbackComplete); + } + + private void OnReadbackComplete(AsyncGPUReadbackRequest request) + { + _readbackInFlight = false; + + if (_disposed || request.hasError) { - return _encoded; + return; } - Texture2D? screenshot = null; + using var _ = ReadbackCompleteMarker.Auto(); + try { - screenshot = ReadCache(); + var data = request.GetData(); - if (transform is not null) + // The new frame goes into a buffer no one else can see, so attachments holding on to the current + // frame are never affected + byte[]? buffer; + lock (_lock) + { + buffer = _spare is not null && _spare.Length == data.Length ? _spare : null; + _spare = null; + } + + buffer ??= new byte[data.Length]; + data.CopyTo(buffer); + + lock (_lock) { - var transformed = transform.Invoke(screenshot); - if (transformed == null) + if (_disposed) { - return null; + return; } - if (transformed != screenshot) + var previous = _frame; + _frame = new SentryScreenshotFrame(buffer, request.width, request.height); + + // Only a frame nothing holds on to hands back its buffer. A buffer gets allocated whenever an + // event attached the previous frame. + _spare = previous?.Retire(); + } + } + catch (Exception e) + { + _options.LogError(e, "Failed to read back the screenshot."); + } + } + + /// + /// Returns the cached frame as attachment content. Returns null when the cache has not been populated yet or the + /// frame can't be attached on the calling thread. + /// + /// + /// With async readback support this runs on any thread and the content defers encoding until it gets read. + /// + internal virtual IAttachmentContent? TryGetLatestContent() + { + if (_disposed) + { + return null; + } + + if (!_useAsyncReadback) + { + return TryEncodeLatest() is { } bytes ? new ByteAttachmentContent(bytes) : null; + } + + SentryScreenshotFrame? frame; + bool deferEncode; + lock (_lock) + { + frame = _frame; + if (frame is null) + { + _options.LogDebug("Screenshot cache is empty. Skipping."); + return null; + } + + // A frame that is already pinned costs nothing extra to hand out again + deferEncode = frame.Pinned || CountPendingFrames() < MaxPendingFrames; + if (!frame.Pinned) + { + frame.Pinned = true; + if (deferEncode) { - UnityEngine.Object.Destroy(screenshot); - screenshot = transformed; + _pendingFrames.Add(new WeakReference(frame)); } } + } + + if (deferEncode) + { + return new SentryScreenshotAttachmentContent(frame, _options); + } + + _options.LogDebug("Too many screenshots waiting to be sent. Encoding right away."); + return frame.GetEncoded(_options) is { } encoded ? new ByteAttachmentContent(encoded) : null; + } + + // Must be called while holding _lock + private int CountPendingFrames() + { + // Frames drop out once their encode started, or once the attachments holding on to them are gone - e.g. + // because the event got dropped before being sent + _pendingFrames.RemoveAll(reference => !reference.TryGetTarget(out var frame) || frame.IsEncodeStarted); + return _pendingFrames.Count; + } + + /// + /// Encodes the cached frame as JPG right away. Returns null when the cache has not been populated yet, the + /// transform discarded the screenshot, or the encode is not possible on the calling thread. + /// + /// + /// Without a transform, this can run on any thread when async GPU readback is supported. Applying a + /// transform requires the main thread since it operates on a . + /// + internal virtual byte[]? TryEncodeLatest(Func? transform = null) + { + if (_disposed) + { + return null; + } + + try + { + if (transform is not null) + { + return EncodeLatest(transform); + } + + return _useAsyncReadback ? EncodeLatestFrame() : EncodeLatestGpu(); + } + catch (Exception e) + { + _options.LogError(e, "Failed to encode the cached screenshot."); + return null; + } + } + + private byte[]? EncodeLatestFrame() + { + SentryScreenshotFrame? frame; + lock (_lock) + { + frame = _frame; + if (frame is null) + { + _options.LogDebug("Screenshot cache is empty. Skipping."); + return null; + } + + frame.Pinned = true; + } + + return frame.GetEncoded(_options); + } + + private byte[]? EncodeLatestGpu() + { + int generation; + lock (_lock) + { + if (!_gpuHasContent) + { + _options.LogDebug("Screenshot cache is empty. Skipping."); + return null; + } + + // Every event between two refreshes sees the same frame, so the encode is only worth doing once. + if (_gpuEncodedGeneration == _gpuGeneration) + { + return _gpuEncoded; + } + + generation = _gpuGeneration; + } + + // Without a CPU copy the frame has to be read back from the GPU, which is a main thread only operation. + if (!MainThreadData.IsMainThread()) + { + _options.LogDebug("Screenshot skipped. Without async GPU readback support, screenshots can only be attached on the main thread."); + return null; + } + + Texture2D? screenshot = null; + try + { + screenshot = ReadTexture(); + + byte[]? bytes; + using (EncodeMarker.Auto()) + { + bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); + } - var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); if (bytes is null || bytes.Length == 0) { _options.LogWarning("Screenshot encoding returned empty data."); return null; } - if (transform is null) + lock (_lock) { - _encoded = bytes; - _encodedGeneration = _generation; + _gpuEncoded = bytes; + _gpuEncodedGeneration = generation; } return bytes; } - catch (Exception e) + finally { - _options.LogError(e, "Failed to encode the cached screenshot."); + if (screenshot != null) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } + + private byte[]? EncodeLatest(Func transform) + { + if (!MainThreadData.IsMainThread()) + { + _options.LogDebug("Screenshot skipped. The BeforeSendScreenshot callback can only be invoked on the main thread."); return null; } + + Texture2D? screenshot = null; + try + { + screenshot = CreateTexture(); + if (screenshot == null) + { + _options.LogDebug("Screenshot cache is empty. Skipping."); + return null; + } + + // A transform may return a different image per event, so its result does not get cached. + var transformed = transform.Invoke(screenshot); + if (transformed == null) + { + return null; + } + + if (transformed != screenshot) + { + UnityEngine.Object.Destroy(screenshot); + screenshot = transformed; + } + + byte[]? bytes; + using (EncodeMarker.Auto()) + { + bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); + } + + if (bytes is null || bytes.Length == 0) + { + _options.LogWarning("Screenshot encoding returned empty data."); + return null; + } + + return bytes; + } finally { if (screenshot != null) @@ -129,25 +446,38 @@ internal virtual void Refresh() } } - private Texture2D ReadCache() + // Main thread only + private Texture2D? CreateTexture() { - var previous = RenderTexture.active; - RenderTexture? mirrored = null; - try + SentryScreenshotFrame? frame; + lock (_lock) { - var source = _cache!; - - // The image may be mirrored on some platforms - mirror it back. - // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. - // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. - if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) + if (!_useAsyncReadback) { - mirrored = RenderTexture.GetTemporary(_width, _height); - Graphics.Blit(source, mirrored, new Vector2(1, -1), new Vector2(0, 1)); - source = mirrored; + return _gpuHasContent ? ReadTexture() : null; } - RenderTexture.active = source; + frame = _frame; + } + + // The current frame's pixels only get replaced on the main thread, by the readback callback + if (frame?.Pixels is not { } pixels) + { + return null; + } + + var texture = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false); + texture.LoadRawTextureData(pixels); + texture.Apply(); + return texture; + } + + private Texture2D ReadTexture() + { + var previous = RenderTexture.active; + try + { + RenderTexture.active = _oriented != null ? _oriented : _capture; var screenshot = new Texture2D(_width, _height, TextureFormat.RGB24, false); screenshot.ReadPixels(new Rect(0, 0, _width, _height), 0, 0); screenshot.Apply(); @@ -156,53 +486,89 @@ private Texture2D ReadCache() finally { RenderTexture.active = previous; - - if (mirrored) - { - RenderTexture.ReleaseTemporary(mirrored); - } } } - private void EnsureCache(int width, int height) + private void EnsureTextures(int width, int height) { - if (_cache != null && _width == width && _height == height) + if (_capture != null && _width == width && _height == height) { return; } - ReleaseCache(); + ReleaseResources(); + + _capture = CreateRenderTexture(width, height, "Sentry.ScreenshotCapture"); + + // The captured image is mirrored on some platforms. Mirroring it back right away on the GPU means + // every consumer of the cache gets it in the right orientation. + // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. + // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. + if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) + { + _oriented = CreateRenderTexture(width, height, "Sentry.ScreenshotOriented"); + } - _cache = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = "Sentry.ScreenshotCache" }; - _cache.Create(); _width = width; _height = height; - // The previous frame is gone and the new texture is uninitialized. - _generation = 0; - _encodedGeneration = -1; - _encoded = null; + // A frame read back to the CPU stays valid, if sized differently. A frame on the GPU is gone. + lock (_lock) + { + _gpuHasContent = false; + } _options.LogDebug("Screenshot cache allocated at {0}x{1}.", width, height); } - private void ReleaseCache() + private static RenderTexture CreateRenderTexture(int width, int height, string name) { - if (_cache == null) + var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = name }; + texture.Create(); + return texture; + } + + /// + /// Releases the GPU resources. Must run on the main thread. + /// + internal void ReleaseResources() + { + ReleaseRenderTexture(ref _capture); + ReleaseRenderTexture(ref _oriented); + } + + private static void ReleaseRenderTexture(ref RenderTexture? texture) + { + if (texture == null) { return; } - _cache.Release(); - UnityEngine.Object.Destroy(_cache); - _cache = null; + texture.Release(); + UnityEngine.Object.Destroy(texture); + texture = null; } public void Dispose() { - ReleaseCache(); - _encoded = null; - _encodedGeneration = -1; - _generation = 0; + _disposed = true; + + // Attachments still holding on to a frame can encode it regardless + lock (_lock) + { + _frame = null; + _spare = null; + _pendingFrames.Clear(); + _gpuHasContent = false; + _gpuEncoded = null; + _gpuEncodedGeneration = -1; + } + + // Off the main thread, the refresh coroutine releases the GPU resources once it notices the + // cache got disposed. + if (MainThreadData.IsMainThread()) + { + ReleaseResources(); + } } } diff --git a/src/Sentry.Unity/SentryScreenshotFrame.cs b/src/Sentry.Unity/SentryScreenshotFrame.cs new file mode 100644 index 000000000..93c09de73 --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotFrame.cs @@ -0,0 +1,141 @@ +using System; +using System.Diagnostics; +using Sentry.Extensibility; +using Unity.Profiling; +using UnityEngine; +using UnityEngine.Experimental.Rendering; + +namespace Sentry.Unity; + +/// +/// A frame read back from the GPU. Its pixels never change once the cache published it, so it can get encoded on +/// any thread. +/// +/// +/// The frame gets encoded at most once. The first consumer pays for it and every other event attaching the same frame +/// reuses the result. The pixels are dropped as soon as they are no longer needed: once the frame got encoded and the +/// cache moved on to a newer one. +/// +internal sealed class SentryScreenshotFrame +{ + private static readonly ProfilerMarker EncodeMarker = new("Sentry.Screenshot.Encode"); + + // Serializes encoders. Retiring never takes it, so the main thread can't get blocked by an encode in progress. + private readonly object _encodeLock = new(); + private volatile byte[]? _pixels; + private byte[]? _encoded; + private volatile bool _encodeStarted; + private volatile bool _retired; + + internal SentryScreenshotFrame(byte[] pixels, int width, int height) + { + _pixels = pixels; + Width = width; + Height = height; + } + + internal int Width { get; } + internal int Height { get; } + + /// + /// Set once something holds on to the frame beyond the cache, e.g. an attachment. The cache never reuses the + /// pixel buffer of a pinned frame. Guarded by the cache's lock. + /// + internal bool Pinned { get; set; } + + /// + /// Whether the encode has started. Readable without blocking on an encode in progress. + /// + internal bool IsEncodeStarted => _encodeStarted; + + /// + /// Pixels in RGBA32, bottom row first. Null once dropped. + /// + internal byte[]? Pixels => _pixels; + + /// + /// Returns the frame encoded as JPG, encoding it on the calling thread if no one did so yet. Returns null if the + /// encode failed. + /// + internal byte[]? GetEncoded(SentryUnityOptions options) + { + lock (_encodeLock) + { + if (_encodeStarted) + { + return _encoded; + } + + // Taking the reference before flagging the encode as started means Retire, which drops the pixels of + // started encodes, can't pull them away from under this one + var pixels = _pixels; + _encodeStarted = true; + + if (pixels is null) + { + return null; + } + + try + { + using var _ = EncodeMarker.Auto(); + var start = Stopwatch.GetTimestamp(); + + // Thread safe, so it can run wherever the attachment gets serialized + var bytes = ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, + (uint)Width, (uint)Height, 0, options.ScreenshotCompression); + + if (bytes is { Length: > 0 }) + { + _encoded = bytes; + + // The profiler under-reports long samples on background threads, so the duration gets logged + var elapsedMs = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency; + options.LogDebug("Screenshot encoded in {0} ms to {1} bytes.", elapsedMs, bytes.Length); + } + else + { + options.LogWarning("Screenshot encoding returned empty data."); + } + } + catch (Exception e) + { + options.LogError(e, "Failed to encode the screenshot."); + } + finally + { + if (_retired) + { + _pixels = null; + } + } + + return _encoded; + } + } + + /// + /// Called by the cache, under its lock, once a newer frame replaced this one. Hands back the pixel buffer for + /// reuse if nothing else holds on to the frame. + /// + internal byte[]? Retire() + { + _retired = true; + + var pixels = _pixels; + if (!Pinned) + { + // Nothing can encode an unpinned frame, and pinning happens under the cache's lock as well + _pixels = null; + return pixels; + } + + // A pinned frame keeps its pixels until its encode started. An encode in progress holds its own reference. + if (_encodeStarted) + { + _pixels = null; + } + + return null; + } +} diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 5bd1b00a3..10773643d 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; +using Sentry.Extensibility; using Sentry.Unity.Tests.Stubs; using UnityEngine; using UnityEngine.TestTools; @@ -12,7 +14,7 @@ public class ScreenshotEventProcessorTests { private class TestScreenshotCache : SentryScreenshotCache { - public int EncodeCallCount { get; private set; } + public int CacheCallCount { get; private set; } public byte[]? Bytes { get; set; } = { 1, 2, 3 }; public Texture2D? TransformInput { get; set; } @@ -20,9 +22,15 @@ public TestScreenshotCache(SentryUnityOptions options) : base(options) { } internal override void Refresh() { } + internal override IAttachmentContent? TryGetLatestContent() + { + CacheCallCount++; + return Bytes is null ? null : new ByteAttachmentContent(Bytes); + } + internal override byte[]? TryEncodeLatest(Func? transform = null) { - EncodeCallCount++; + CacheCallCount++; if (transform is null) { @@ -91,7 +99,37 @@ public void Process_BeforeCaptureScreenshotReturnsFalse_SkipsTheCacheEntirely() processor.Process(new SentryEvent(), hint); - Assert.AreEqual(0, cache.EncodeCallCount); + Assert.AreEqual(0, cache.CacheCallCount); + Assert.IsEmpty(hint.Attachments); + } + + [Test] + public void Process_OffTheMainThread_AttachesToHint() + { + // Errors raised on background threads must not lose their screenshot - the cache decides + // whether it can encode on the calling thread. + var (processor, cache) = GetSut(); + var hint = new SentryHint(); + + Task.Run(() => processor.Process(new SentryEvent(), hint)).Wait(); + + Assert.AreEqual(1, cache.CacheCallCount); + Assert.AreEqual(1, hint.Attachments.Count); + } + + [Test] + public void Process_BeforeCaptureScreenshotThrows_ReturnsEventWithoutAttachment() + { + var options = new SentryUnityOptions(); + options.SetBeforeCaptureScreenshot(_ => throw new InvalidOperationException("test")); + var (processor, cache) = GetSut(options); + var hint = new SentryHint(); + var @event = new SentryEvent(); + + var processed = processor.Process(@event, hint); + + Assert.AreSame(@event, processed); + Assert.AreEqual(0, cache.CacheCallCount); Assert.IsEmpty(hint.Attachments); } diff --git a/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs new file mode 100644 index 000000000..6d78690b8 --- /dev/null +++ b/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Sentry.Extensibility; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace Sentry.Unity.Tests; + +public class SentryScreenshotCacheTests +{ + // Captures a known image instead of the screen. Top half red, bottom half blue, so that a mirrored + // readback is detectable. + private class KnownImageCache : SentryScreenshotCache + { + public Texture2D Source { get; set; } + + public int RefreshCount { get; private set; } + + public KnownImageCache(SentryUnityOptions options, Texture2D source, bool useAsyncReadback) + : base(options, useAsyncReadback) + { + Source = source; + } + + internal override void Refresh() + { + RefreshCount++; + base.Refresh(); + } + + internal override void CaptureScreen(RenderTexture target) => Graphics.Blit(Source, target); + } + + private Texture2D _source = null!; + private int? _originalMainThreadId; + + [SetUp] + public void SetUp() + { + _originalMainThreadId = MainThreadData.MainThreadId; + MainThreadData.MainThreadId = Thread.CurrentThread.ManagedThreadId; + + _source = new Texture2D(64, 64, TextureFormat.RGBA32, false); + var pixels = new Color32[64 * 64]; + for (var y = 0; y < 64; y++) + { + for (var x = 0; x < 64; x++) + { + // Texture2D rows start at the bottom + pixels[y * 64 + x] = y >= 32 ? new Color32(255, 0, 0, 255) : new Color32(0, 0, 255, 255); + } + } + _source.SetPixels32(pixels); + _source.Apply(); + } + + [TearDown] + public void TearDown() + { + MainThreadData.MainThreadId = _originalMainThreadId; + Object.Destroy(_source); + } + + private static SentryUnityOptions GetOptions() => new() { ScreenshotQuality = ScreenshotQuality.Low }; + + // WaitForEndOfFrame never resumes in batch mode. Capturing the known image doesn't depend on the frame + // being complete, so refreshing on a regular frame is fine here. + private static IEnumerator RefreshUntilPopulated(SentryScreenshotCache cache) + { + yield return null; + cache.Refresh(); + + for (var i = 0; i < 30 && !cache.HasContent; i++) + { + yield return null; + } + + Assert.IsTrue(cache.HasContent, "The cache did not get populated."); + } + + private static IEnumerator RefreshUntilNewFrame(SentryScreenshotCache cache) + { + var previous = cache.CurrentFrame; + yield return null; + cache.Refresh(); + + for (var i = 0; i < 30 && ReferenceEquals(cache.CurrentFrame, previous); i++) + { + yield return null; + } + + Assert.AreNotSame(previous, cache.CurrentFrame, "The cache did not pick up a new frame."); + } + + private static Texture2D CreateSolid(Color32 color) + { + var texture = new Texture2D(64, 64, TextureFormat.RGBA32, false); + texture.SetPixels32(Enumerable.Repeat(color, 64 * 64).ToArray()); + texture.Apply(); + return texture; + } + + private static byte[] ReadAll(IAttachmentContent content) + { + using var stream = content.GetStream(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private static Texture2D Decode(byte[]? bytes) + { + Assert.IsNotNull(bytes); + Assert.IsNotEmpty(bytes); + + var texture = new Texture2D(2, 2); + Assert.IsTrue(texture.LoadImage(bytes)); + return texture; + } + + private static void AssertTopIsRedAndBottomIsBlue(Texture2D image, string label) + { + var top = image.GetPixel(image.width / 2, image.height * 3 / 4); + var bottom = image.GetPixel(image.width / 2, image.height / 4); + + // JPG is lossy, so only the dominant channel is compared + Assert.Greater(top.r, top.b, $"{label}: expected the top half to be red but got {top}."); + Assert.Greater(bottom.b, bottom.r, $"{label}: expected the bottom half to be blue but got {bottom}."); + } + + [Test] + public void TryEncodeLatest_CacheEmpty_ReturnsNull() + { + var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + + Assert.IsNull(cache.TryEncodeLatest()); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_SynchronousReadback_ReturnsFrameInOrientationOfTheCapture() + { + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(cache); + + var image = Decode(cache.TryEncodeLatest()); + + // The orientation fix on UV-starts-at-top platforms is applied to whatever gets captured, so the + // known image itself comes out mirrored there. What matters is that both readbacks agree. + if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) + { + FlipVertically(image); + } + + AssertTopIsRedAndBottomIsBlue(image, "synchronous readback"); + Object.Destroy(image); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_AsyncReadback_MatchesSynchronousReadback() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var asyncCache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + using var syncCache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(asyncCache); + yield return RefreshUntilPopulated(syncCache); + + var asyncImage = Decode(asyncCache.TryEncodeLatest()); + var syncImage = Decode(syncCache.TryEncodeLatest()); + + var asyncTop = asyncImage.GetPixel(asyncImage.width / 2, asyncImage.height * 3 / 4); + var syncTop = syncImage.GetPixel(syncImage.width / 2, syncImage.height * 3 / 4); + Assert.AreEqual(syncTop.r > syncTop.b, asyncTop.r > asyncTop.b, + $"Async readback is mirrored compared to the synchronous one. Async top: {asyncTop}, sync top: {syncTop}."); + + Object.Destroy(asyncImage); + Object.Destroy(syncImage); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_AsyncReadback_EncodesOffTheMainThread() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + var task = Task.Run(() => cache.TryEncodeLatest()); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsNotNull(task.Result); + Assert.IsNotEmpty(task.Result); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_SynchronousReadbackOffTheMainThread_ReturnsNull() + { + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(cache); + + var task = Task.Run(() => cache.TryEncodeLatest()); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsNull(task.Result); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_TransformOffTheMainThread_ReturnsNull() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + var transformInvoked = false; + var task = Task.Run(() => cache.TryEncodeLatest(texture => + { + transformInvoked = true; + return texture; + })); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsNull(task.Result); + Assert.IsFalse(transformInvoked); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_SameFrame_EncodesOnce() + { + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(cache); + + var first = cache.TryEncodeLatest(); + var second = cache.TryEncodeLatest(); + + Assert.IsNotNull(first); + Assert.AreSame(first, second); + } + + [UnityTest] + public IEnumerator TryEncodeLatest_WithTransform_PassesTheFrameAndEncodesTheResult() + { + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(cache); + + Texture2D? received = null; + var bytes = cache.TryEncodeLatest(texture => + { + received = texture; + return texture; + }); + + Assert.IsNotNull(received); + Assert.IsNotNull(bytes); + Assert.IsNotEmpty(bytes); + } + + [UnityTest] + public IEnumerator Dispose_ClearsTheCache() + { + var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + yield return RefreshUntilPopulated(cache); + + cache.Dispose(); + cache.Refresh(); + + Assert.IsTrue(cache.IsDisposed); + Assert.IsFalse(cache.HasContent); + Assert.IsNull(cache.TryEncodeLatest()); + } + + [UnityTest] + public IEnumerator Dispose_StopsTheRefreshCoroutine() + { + var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + var gameObject = new GameObject("ScreenshotCacheTest"); + var behaviour = gameObject.AddComponent(); + + try + { + behaviour.StartScreenshotCache(cache, System.TimeSpan.Zero, endOfFrame: null); + yield return WaitFor(() => cache.RefreshCount > 0); + + cache.Dispose(); + yield return null; + var refreshCount = cache.RefreshCount; + + for (var i = 0; i < 5; i++) + { + yield return null; + } + + Assert.AreEqual(refreshCount, cache.RefreshCount); + } + finally + { + Object.Destroy(gameObject); + } + } + + [UnityTest] + public IEnumerator Dispose_OffTheMainThread_DefersReleaseToTheRefreshCoroutine() + { + var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); + var gameObject = new GameObject("ScreenshotCacheTest"); + var behaviour = gameObject.AddComponent(); + + try + { + behaviour.StartScreenshotCache(cache, System.TimeSpan.Zero, endOfFrame: null); + yield return WaitFor(() => cache.RefreshCount > 0); + + // Releasing GPU resources off the main thread throws, which would abort the SDK's Close(). + var task = Task.Run(cache.Dispose); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsNull(task.Exception); + Assert.IsTrue(cache.IsDisposed); + } + finally + { + Object.Destroy(gameObject); + } + } + + private static IEnumerator WaitFor(System.Func condition, int maxFrames = 300) + { + for (var i = 0; i < maxFrames && !condition(); i++) + { + yield return null; + } + + Assert.IsTrue(condition(), "Timed out waiting for the condition."); + } + + [UnityTest] + public IEnumerator TryGetLatestContent_AsyncReadback_DefersEncodeUntilRead() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + var content = cache.TryGetLatestContent(); + + var deferred = content as SentryScreenshotAttachmentContent; + Assert.IsNotNull(deferred); + Assert.IsFalse(deferred!.Frame.IsEncodeStarted, "Attaching must not encode."); + + // The SDK checks the length when building the envelope. Reporting one would force the encode right there. + using (var stream = content!.GetStream()) + { + Assert.IsFalse(stream.CanSeek); + Assert.Throws(() => _ = stream.Length); + } + Assert.IsFalse(deferred.Frame.IsEncodeStarted, "Opening the stream must not encode."); + + // Reading happens on the SDK's background worker + var task = Task.Run(() => ReadAll(content)); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsTrue(deferred.Frame.IsEncodeStarted); + var image = Decode(task.Result); + Object.Destroy(image); + } + + [UnityTest] + public IEnumerator TryGetLatestContent_SameFrame_EncodesOnce() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + var first = (SentryScreenshotAttachmentContent)cache.TryGetLatestContent()!; + var second = (SentryScreenshotAttachmentContent)cache.TryGetLatestContent()!; + ReadAll(first); + + Assert.AreSame(first.Frame, second.Frame); + Assert.AreSame(first.Frame.GetEncoded(GetOptions()), second.Frame.GetEncoded(GetOptions())); + CollectionAssert.AreEqual(ReadAll(first), ReadAll(second)); + } + + [UnityTest] + public IEnumerator TryGetLatestContent_LaterReadbacks_DoNotOverwriteAttachedFrame() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + var red = CreateSolid(new Color32(255, 0, 0, 255)); + var green = CreateSolid(new Color32(0, 255, 0, 255)); + using var cache = new KnownImageCache(GetOptions(), red, useAsyncReadback: true); + + try + { + yield return RefreshUntilPopulated(cache); + var attached = cache.TryGetLatestContent()!; + + // Readbacks reuse buffers of frames nothing holds on to. Two of them cycle through every spare buffer. + cache.Source = green; + yield return RefreshUntilNewFrame(cache); + yield return RefreshUntilNewFrame(cache); + + var image = Decode(ReadAll(attached)); + var pixel = image.GetPixel(image.width / 2, image.height / 2); + Assert.Greater(pixel.r, pixel.g, $"Expected the attached frame to stay red but got {pixel}."); + Object.Destroy(image); + } + finally + { + Object.Destroy(red); + Object.Destroy(green); + } + } + + [UnityTest] + public IEnumerator TryGetLatestContent_TooManyPendingFrames_EncodesRightAway() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + // Kept alive, as the cache stops counting frames no attachment holds on to anymore + var pending = new List(); + for (var i = 0; i < SentryScreenshotCache.MaxPendingFrames; i++) + { + pending.Add(cache.TryGetLatestContent()!); + Assert.IsInstanceOf(pending[i]); + yield return RefreshUntilNewFrame(cache); + } + + var content = cache.TryGetLatestContent(); + + Assert.IsInstanceOf(content); + Assert.IsNotEmpty(((ByteAttachmentContent)content!).Bytes); + GC.KeepAlive(pending); + } + + [UnityTest] + public IEnumerator TryGetLatestContent_ThroughTheSdk_EncodedScreenshotRidesAlongTheEvent() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + + var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); + yield return RefreshUntilPopulated(cache); + + var httpHandler = new TestHttpClientHandler("DeferredScreenshotEnvelopeTest"); + var options = new SentryUnityOptions(application: new TestApplication()) + { + Dsn = SentryTests.TestDsn, + CreateHttpMessageHandler = () => httpHandler + }; + options.AddEventProcessor(new ScreenshotEventProcessor(options, cache)); + + SentrySdk.Init(options); + + try + { + SentrySdk.CaptureMessage("deferred screenshot"); + + var task = Task.Run(() => httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(5))); + yield return WaitFor(() => task.IsCompleted, 600); + + var request = task.Result; + StringAssert.Contains("deferred screenshot", request); + + // The item header carries the length of the payload, which is only known once encoded + var header = Regex.Match(request, @"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); + Assert.IsTrue(header.Success, "No screenshot item header found."); + var length = Regex.Match(header.Value, @"""length"":(\d+)"); + Assert.IsTrue(length.Success, $"No length in the screenshot item header: {header.Value}"); + Assert.Greater(int.Parse(length.Groups[1].Value), 0); + } + finally + { + SentrySdk.Close(); + cache.Dispose(); + } + } + + private static void FlipVertically(Texture2D texture) + { + var pixels = texture.GetPixels32(); + var flipped = new Color32[pixels.Length]; + for (var y = 0; y < texture.height; y++) + { + System.Array.Copy(pixels, y * texture.width, flipped, (texture.height - 1 - y) * texture.width, texture.width); + } + texture.SetPixels32(flipped); + texture.Apply(); + } +} From e8b974e1932dd90eee4c901d85435a252c2916da Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 14 Oct 2026 17:54:03 +0200 Subject: [PATCH 03/15] test(screenshots): Add an iOS device benchmark for screenshot capture Builds a harness from the sample that runs screenshot configurations round-robin against a baseline with screenshots disabled, records frame timings, capture costs and encodes on the SDK's worker, and checks that sent events carry their screenshot. A report script summarizes a run against its baseline. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + .../Assets/Editor/ScreenshotPerfBuilder.cs | 83 +++ .../Editor/ScreenshotPerfBuilder.cs.meta | 2 + .../Assets/Scripts/Performance.meta | 8 + .../Performance/ScreenshotPerfHarness.cs | 604 ++++++++++++++++++ .../Performance/ScreenshotPerfHarness.cs.meta | 2 + .../Scripts/SentryOptionConfiguration.cs | 7 + scripts/screenshot-perf-ios.ps1 | 154 +++++ scripts/screenshot-perf-report.py | 242 +++++++ 9 files changed, 1105 insertions(+) create mode 100644 samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs create mode 100644 samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta create mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance.meta create mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs create mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta create mode 100644 scripts/screenshot-perf-ios.ps1 create mode 100644 scripts/screenshot-perf-report.py diff --git a/.gitignore b/.gitignore index 89dcca713..19c19794b 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ samples/unity-of-bugs/*IL2CPPStats/ # Gradle caches generated by the VS Code Gradle extension scanning test fixtures .gradle/ + +# Generated by the screenshot performance builder +samples/unity-of-bugs/Assets/Scenes/Generated* diff --git a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs new file mode 100644 index 000000000..3c4765924 --- /dev/null +++ b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace Editor +{ + /// + /// Builds a player that only contains the screenshot performance harness. + /// + public static class ScreenshotPerfBuilder + { + // Generated on every build and ignored by git + private const string ScenePath = "Assets/Scenes/Generated/" + ScreenshotPerfHarness.SceneName + ".unity"; + + public static void Build() + { + var arguments = Environment.GetCommandLineArgs(); + var target = (BuildTarget)Enum.Parse(typeof(BuildTarget), GetArgument(arguments, "-buildTarget"), true); + var outputPath = GetArgument(arguments, "-buildOutput"); + + CreateScene(); + + // Development builds are required for the profiler recorders to report script markers. + // Frame timing stats provide the GPU frame time. + var frameTimingStats = PlayerSettings.enableFrameTimingStats; + PlayerSettings.enableFrameTimingStats = true; + + try + { + var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = new[] { ScenePath }, + locationPathName = outputPath, + target = target, + targetGroup = BuildPipeline.GetBuildTargetGroup(target), + options = BuildOptions.Development + }); + + if (report.summary.result != BuildResult.Succeeded) + { + throw new Exception($"Build failed with {report.summary.totalErrors} error(s)."); + } + + Debug.Log($"Screenshot performance build succeeded: {report.summary.outputPath}"); + } + finally + { + PlayerSettings.enableFrameTimingStats = frameTimingStats; + AssetDatabase.SaveAssets(); + } + } + + private static void CreateScene() + { + Directory.CreateDirectory(Path.GetDirectoryName(ScenePath)!); + + // The harness bootstraps itself based on the scene name and builds its content at runtime + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + new GameObject(nameof(ScreenshotPerfHarness)).AddComponent(); + + if (!EditorSceneManager.SaveScene(scene, ScenePath)) + { + throw new Exception($"Failed to save the screenshot performance scene to {ScenePath}."); + } + } + + private static string GetArgument(string[] arguments, string name) + { + for (var i = 0; i < arguments.Length - 1; i++) + { + if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase)) + { + return arguments[i + 1]; + } + } + + throw new ArgumentException($"Required command line argument '{name}' was not provided."); + } + } +} diff --git a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta new file mode 100644 index 000000000..a6d24ad27 --- /dev/null +++ b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d8a94645afe61425c8c3cdfda4d7685a \ No newline at end of file diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance.meta b/samples/unity-of-bugs/Assets/Scripts/Performance.meta new file mode 100644 index 000000000..ea59fd469 --- /dev/null +++ b/samples/unity-of-bugs/Assets/Scripts/Performance.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d6eb2762c618446c19b9813c6e482e31 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs new file mode 100644 index 000000000..8b1da5d10 --- /dev/null +++ b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs @@ -0,0 +1,604 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Sentry; +using Sentry.Extensibility; +using Sentry.Unity; +using Unity.Profiling; +using UnityEngine; +using Debug = UnityEngine.Debug; + +/// +/// Measures the performance impact of attaching screenshots to events. +/// +/// +/// Runs every configuration in a single session, re-initializing the SDK for each, and writes the results to +/// `persistentDataPath/screenshot-perf/`. Configurations are repeated round-robin, so slow drift such as thermal +/// throttling spreads evenly across all of them instead of skewing the ones that run last. Only relies on the public SDK API so the same harness can +/// be built against different SDK versions for comparison. +/// +public class ScreenshotPerfHarness : MonoBehaviour +{ + public const string SceneName = "ScreenshotPerf"; + + // Events get sent to the sample's project, so the screenshot encode - deferred until the envelope gets + // serialized - actually happens. The DSN is read from the sample's options at runtime. + private const string ConfigTag = "screenshot_perf"; + + private const float WarmupSeconds = 5f; + private const float IdleSeconds = 20f; + private const float PeriodicSeconds = 20f; + private const float PeriodicErrorIntervalSeconds = 1f; + private const float BurstSettleSeconds = 3f; + private const int BurstSize = 50; + private const int CubeCount = 400; + private const int DefaultRounds = 3; + + // Optional filters, passed as environment variables on launch + private const string ConfigsVariable = "SCREENSHOT_PERF_CONFIGS"; + private const string RoundsVariable = "SCREENSHOT_PERF_ROUNDS"; + + private readonly struct Config + { + public readonly string Name; + public readonly bool AttachScreenshot; + public readonly ScreenshotQuality Quality; + public readonly int IntervalMs; + + public Config(string name, bool attachScreenshot, ScreenshotQuality quality, int intervalMs) + { + Name = name; + AttachScreenshot = attachScreenshot; + Quality = quality; + IntervalMs = intervalMs; + } + } + + private static readonly Config[] AllConfigs = + { + new("baseline", false, ScreenshotQuality.High, 250), + new("high-250ms", true, ScreenshotQuality.High, 250), + new("high-100ms", true, ScreenshotQuality.High, 100), + new("high-1000ms", true, ScreenshotQuality.High, 1000), + new("low-250ms", true, ScreenshotQuality.Low, 250), + new("full-250ms", true, ScreenshotQuality.Full, 250), + }; + + private struct FrameSample + { + public int PhaseIndex; + public float DeltaMs; + public float CpuMainMs; + public float CpuMainWaitMs; + public float CpuRenderMs; + public float GpuMs; + public long GcBytes; + public float RefreshMs; + public float ReadbackMs; + public float EncodeMs; + public float ProcessMs; + } + + private struct CaptureSample + { + public int ConfigIndex; + public int Round; + public int PhaseIndex; + public bool MainThread; + public double Ms; + public double BeforeProcessorsMs; + public double ProcessorsMs; + public double AfterProcessorsMs; + } + + // Splits each capture into segments, taken on the capturing thread. The harness' event processor gets + // registered before the SDK's own ones and BeforeSend runs once all processors are done. + // Filled from background threads: the SDK's worker logs encodes, the transport sends envelopes + private readonly ConcurrentQueue _encodes = new(); + private readonly ConcurrentQueue _sent = new(); + private string? _dsn; + + [ThreadStatic] private static long _processorsStart; + [ThreadStatic] private static long _processorsEnd; + + // Only picks up the screenshot encode timings and ignores everything else, so having debug logging on stays cheap + private sealed class EncodeLogCollector : IDiagnosticLogger + { + private readonly ScreenshotPerfHarness _harness; + + public EncodeLogCollector(ScreenshotPerfHarness harness) => _harness = harness; + + public bool IsEnabled(SentryLevel level) => level == SentryLevel.Debug; + + public void Log(SentryLevel logLevel, string message, Exception? exception = null, params object?[] args) + { + if (!message.StartsWith("Screenshot encoded in", StringComparison.Ordinal) || args.Length < 2) + { + return; + } + + // Encodes run on the SDK's worker shortly after the capture and get attributed to the current phase + _harness._encodes.Enqueue(string.Join(",", + _harness._configs[Math.Min(_harness._configIndex, _harness._configs.Length - 1)].Name, + Math.Min(_harness._round, _harness._rounds - 1), Phases[Math.Max(0, _harness._phaseIndex)], + FormatMs(Convert.ToDouble(args[0], CultureInfo.InvariantCulture)), args[1])); + } + } + + // Records whether events leave the device with their screenshot attached + private sealed class EnvelopeInspector : DelegatingHandler + { + private static readonly Regex ConfigTagPattern = new(@"""screenshot_perf"":""([^""/]+)/(\d+)"""); + private static readonly Regex ScreenshotHeaderPattern = new(@"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); + private static readonly Regex LengthPattern = new(@"""length"":(\d+)"); + + private readonly ScreenshotPerfHarness _harness; + + public EnvelopeInspector(ScreenshotPerfHarness harness) + { + _harness = harness; + InnerHandler = new HttpClientHandler(); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + try + { + if (request.Content is { } content) + { + await content.LoadIntoBufferAsync().ConfigureAwait(false); + var body = await content.ReadAsByteArrayAsync().ConfigureAwait(false); + if (content.Headers.ContentEncoding.Contains("gzip")) + { + using var compressed = new GZipStream(new MemoryStream(body), CompressionMode.Decompress); + using var decompressed = new MemoryStream(); + compressed.CopyTo(decompressed); + body = decompressed.ToArray(); + } + + Inspect(Encoding.UTF8.GetString(body)); + } + } + catch (Exception e) + { + Debug.LogWarning($"[ScreenshotPerf] Failed to inspect an envelope: {e.Message}"); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private void Inspect(string envelope) + { + var tag = ConfigTagPattern.Match(envelope); + if (!tag.Success) + { + return; + } + + var header = ScreenshotHeaderPattern.Match(envelope); + var length = header.Success ? LengthPattern.Match(header.Value) : Match.Empty; + _harness._sent.Enqueue(string.Join(",", tag.Groups[1].Value, tag.Groups[2].Value, + header.Success ? 1 : 0, length.Success ? length.Groups[1].Value : "0")); + } + } + + private sealed class TimestampProcessor : ISentryEventProcessor + { + public SentryEvent Process(SentryEvent @event) + { + _processorsStart = Stopwatch.GetTimestamp(); + return @event; + } + } + + private static readonly string[] Phases = { "warmup", "idle", "periodic", "burst-main", "burst-background" }; + + // Preallocated so the harness itself doesn't allocate while measuring + private readonly FrameSample[] _frames = new FrameSample[60 * 120]; + private int _frameCount; + private readonly ConcurrentQueue _captures = new(); + private readonly FrameTiming[] _frameTimings = new FrameTiming[1]; + private readonly StringBuilder _memory = new(); + + private ProfilerRecorder _gcAlloc; + private ProfilerRecorder _gfxMemory; + private ProfilerRecorder _refresh; + private ProfilerRecorder _readback; + private ProfilerRecorder _encode; + private ProfilerRecorder _process; + + private Config[] _configs = AllConfigs; + private int _rounds = DefaultRounds; + private int _configIndex; + private int _round; + private int _phaseIndex = -1; + private string _outputDirectory = null!; + private Transform[] _cubes = Array.Empty(); + private TextMesh? _status; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Bootstrap() + { + if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name == SceneName + && FindObjectOfType() == null) + { + new GameObject(nameof(ScreenshotPerfHarness)).AddComponent(); + } + } + + private void Awake() + { + Application.targetFrameRate = 60; + BuildScene(); + + _gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame"); + _gfxMemory = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "Gfx Used Memory"); + _refresh = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Refresh"); + _readback = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.ReadbackComplete"); + _encode = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Encode"); + _process = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Process"); + + ReadFilters(); + + _dsn = Resources.Load("Sentry/SentryOptions")?.Dsn; + if (string.IsNullOrEmpty(_dsn)) + { + Debug.LogError("[ScreenshotPerf] The sample's options contain no DSN. Envelopes won't get sent."); + } + + var runId = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + _outputDirectory = Path.Combine(Application.persistentDataPath, "screenshot-perf", runId); + Directory.CreateDirectory(_outputDirectory); + } + + private void OnDestroy() + { + _gcAlloc.Dispose(); + _gfxMemory.Dispose(); + _refresh.Dispose(); + _readback.Dispose(); + _encode.Dispose(); + _process.Dispose(); + } + + private IEnumerator Start() + { + // Let the auto-initialized SDK (if any) settle, then take over + yield return null; + SentrySdk.Close(); + + WriteHeaders(); + + for (_round = 0; _round < _rounds; _round++) + for (_configIndex = 0; _configIndex < _configs.Length; _configIndex++) + { + var config = _configs[_configIndex]; + SetStatus($"{config.Name} ({_configIndex + 1}/{_configs.Length}), round {_round + 1}/{_rounds}"); + Debug.Log($"[ScreenshotPerf] Round {_round + 1}/{_rounds}: starting configuration '{config.Name}'."); + + InitSdk(config); + SentrySdk.ConfigureScope(scope => scope.SetTag(ConfigTag, $"{config.Name}/{_round}")); + _frameCount = 0; + + yield return RunPhase(0, WarmupSeconds, captureInterval: 0f); + yield return RunPhase(1, IdleSeconds, captureInterval: 0f); + _memory.AppendLine(string.Join(",", config.Name, _round, ToMb(_gfxMemory.LastValue), ToMb(GC.GetTotalMemory(false)))); + yield return RunPhase(2, PeriodicSeconds, PeriodicErrorIntervalSeconds); + yield return RunBurst(3, mainThread: true); + yield return RunBurst(4, mainThread: false); + + SentrySdk.Close(); + AppendResults(config); + yield return new WaitForSecondsRealtime(1f); + } + + AppendLines("encodes.csv", _encodes); + AppendLines("sent.csv", _sent); + File.WriteAllText(Path.Combine(_outputDirectory, "memory.csv"), "config,round,gfx_used_mb,gc_used_mb\n" + _memory); + File.WriteAllText(Path.Combine(_outputDirectory, "done"), DateTime.UtcNow.ToString("O")); + SetStatus("Done"); + Debug.Log($"[ScreenshotPerf] Done. Results in {_outputDirectory}"); + + yield return new WaitForSecondsRealtime(1f); + Application.Quit(); + } + + private void ReadFilters() + { + var configs = Environment.GetEnvironmentVariable(ConfigsVariable); + if (!string.IsNullOrWhiteSpace(configs)) + { + var names = new HashSet(configs.Split(',').Select(name => name.Trim())); + _configs = AllConfigs.Where(config => names.Contains(config.Name)).ToArray(); + if (_configs.Length == 0) + { + Debug.LogError($"[ScreenshotPerf] No configuration matches '{configs}'. Running all of them."); + _configs = AllConfigs; + } + } + + if (int.TryParse(Environment.GetEnvironmentVariable(RoundsVariable), out var rounds) && rounds > 0) + { + _rounds = rounds; + } + + Debug.Log($"[ScreenshotPerf] Running {string.Join(", ", _configs.Select(config => config.Name))} for {_rounds} round(s)."); + } + + private void InitSdk(Config config) + { + SentrySdk.Init(options => + { + options.Dsn = _dsn; + + // Offline caching stays on, as it is by default. It serializes envelopes to disk on the SDK's worker, + // which is where the deferred screenshot encode happens. + options.Debug = true; + options.DiagnosticLevel = SentryLevel.Debug; + options.DiagnosticLogger = new EncodeLogCollector(this); + options.CreateHttpMessageHandler = () => new EnvelopeInspector(this); + + options.AddEventProcessor(new TimestampProcessor()); + options.SetBeforeSend((@event, _) => + { + _processorsEnd = Stopwatch.GetTimestamp(); + return @event; + }); + + options.AutoSessionTracking = false; + + // Native SDKs don't play a part in managed screenshots and re-initializing them per configuration + // would only add noise + options.IosNativeSupportEnabled = false; + options.AndroidNativeSupportEnabled = false; + + options.AttachScreenshot = config.AttachScreenshot; + options.ScreenshotQuality = config.Quality; + + // Not available in every SDK version + var interval = typeof(SentryUnityOptions).GetProperty("ScreenshotCaptureInterval", BindingFlags.Public | BindingFlags.Instance); + interval?.SetValue(options, TimeSpan.FromMilliseconds(config.IntervalMs)); + }); + } + + private IEnumerator RunPhase(int phaseIndex, float seconds, float captureInterval) + { + _phaseIndex = phaseIndex; + var end = Time.realtimeSinceStartup + seconds; + var nextCapture = Time.realtimeSinceStartup + captureInterval; + var mainThread = true; + + while (Time.realtimeSinceStartup < end) + { + if (captureInterval > 0 && Time.realtimeSinceStartup >= nextCapture) + { + Capture(mainThread); + mainThread = !mainThread; + nextCapture += captureInterval; + } + + yield return null; + } + } + + private IEnumerator RunBurst(int phaseIndex, bool mainThread) + { + _phaseIndex = phaseIndex; + yield return null; + + if (mainThread) + { + for (var i = 0; i < BurstSize; i++) + { + Capture(true); + } + } + else + { + // Parallel.For blocks the calling thread, so it gets kicked off from the thread pool + Task.Run(() => Parallel.For(0, BurstSize, _ => CaptureAndRecord(false))); + } + + var end = Time.realtimeSinceStartup + BurstSettleSeconds; + while (Time.realtimeSinceStartup < end) + { + yield return null; + } + } + + private void Capture(bool mainThread) + { + if (mainThread) + { + CaptureAndRecord(true); + } + else + { + Task.Run(() => CaptureAndRecord(false)); + } + } + + private void CaptureAndRecord(bool mainThread) + { + var configIndex = _configIndex; + var round = _round; + var phaseIndex = _phaseIndex; + _processorsStart = 0; + _processorsEnd = 0; + + var start = Stopwatch.GetTimestamp(); + SentrySdk.CaptureException(new InvalidOperationException("Screenshot performance test")); + var end = Stopwatch.GetTimestamp(); + + // Segments stay negative when a timestamp was not taken, e.g. the event got dropped before BeforeSend + var processorsStart = _processorsStart; + var processorsEnd = _processorsEnd; + var hasSegments = processorsStart != 0 && processorsEnd != 0; + + _captures.Enqueue(new CaptureSample + { + ConfigIndex = configIndex, + Round = round, + PhaseIndex = phaseIndex, + MainThread = mainThread, + Ms = TicksToMs(end - start), + BeforeProcessorsMs = hasSegments ? TicksToMs(processorsStart - start) : -1, + ProcessorsMs = hasSegments ? TicksToMs(processorsEnd - processorsStart) : -1, + AfterProcessorsMs = hasSegments ? TicksToMs(end - processorsEnd) : -1, + }); + } + + private void Update() + { + for (var i = 0; i < _cubes.Length; i++) + { + _cubes[i].Rotate(30f * Time.deltaTime, 45f * Time.deltaTime, 0f); + } + + if (_phaseIndex < 0 || _frameCount >= _frames.Length) + { + return; + } + + // Frame timings lag a few frames behind. The main thread's frame time already excludes waiting for present, + // which is reported separately. + FrameTimingManager.CaptureFrameTimings(); + var hasTiming = FrameTimingManager.GetLatestTimings(1, _frameTimings) > 0; + var timing = _frameTimings[0]; + + // Recorders report the previous frame + _frames[_frameCount++] = new FrameSample + { + PhaseIndex = _phaseIndex, + DeltaMs = Time.unscaledDeltaTime * 1000f, + CpuMainMs = hasTiming ? (float)timing.cpuMainThreadFrameTime : -1f, + CpuMainWaitMs = hasTiming ? (float)timing.cpuMainThreadPresentWaitTime : -1f, + CpuRenderMs = hasTiming ? (float)timing.cpuRenderThreadFrameTime : -1f, + GpuMs = hasTiming ? (float)timing.gpuFrameTime : -1f, + GcBytes = _gcAlloc.LastValue, + RefreshMs = ToMs(_refresh.LastValue), + ReadbackMs = ToMs(_readback.LastValue), + EncodeMs = ToMs(_encode.LastValue), + ProcessMs = ToMs(_process.LastValue), + }; + } + + private void WriteHeaders() + { + File.WriteAllText(Path.Combine(_outputDirectory, "frames.csv"), + "config,round,phase,dt_ms,cpu_main_ms,cpu_main_wait_ms,cpu_render_ms,gpu_ms,gc_bytes,refresh_ms,readback_ms,encode_ms,process_ms\n"); + File.WriteAllText(Path.Combine(_outputDirectory, "encodes.csv"), "config,round,phase,ms,bytes\n"); + File.WriteAllText(Path.Combine(_outputDirectory, "sent.csv"), "config,round,has_screenshot,screenshot_bytes\n"); + File.WriteAllText(Path.Combine(_outputDirectory, "captures.csv"), + "config,round,phase,thread,ms,before_processors_ms,processors_ms,after_processors_ms\n"); + + var device = new StringBuilder(); + device.AppendLine($"device_model,{SystemInfo.deviceModel}"); + device.AppendLine($"os,{SystemInfo.operatingSystem}"); + device.AppendLine($"graphics_api,{SystemInfo.graphicsDeviceType}"); + device.AppendLine($"gpu,{SystemInfo.graphicsDeviceName}"); + device.AppendLine($"screen,{Screen.width}x{Screen.height}"); + device.AppendLine($"async_gpu_readback,{SystemInfo.supportsAsyncGPUReadback}"); + device.AppendLine($"unity,{Application.unityVersion}"); + device.AppendLine($"development_build,{Debug.isDebugBuild}"); + device.AppendLine($"rounds,{_rounds}"); + File.WriteAllText(Path.Combine(_outputDirectory, "device.csv"), device.ToString()); + } + + private void AppendResults(Config config) + { + var frames = new StringBuilder(); + for (var i = 0; i < _frameCount; i++) + { + var f = _frames[i]; + frames.Append(config.Name).Append(',').Append(_round).Append(',').Append(Phases[f.PhaseIndex]).Append(',') + .Append(Format(f.DeltaMs)).Append(',').Append(Format(f.CpuMainMs)).Append(',') + .Append(Format(f.CpuMainWaitMs)).Append(',').Append(Format(f.CpuRenderMs)).Append(',') + .Append(Format(f.GpuMs)).Append(',').Append(f.GcBytes).Append(',') + .Append(Format(f.RefreshMs)).Append(',').Append(Format(f.ReadbackMs)).Append(',') + .Append(Format(f.EncodeMs)).Append(',').Append(Format(f.ProcessMs)).Append('\n'); + } + File.AppendAllText(Path.Combine(_outputDirectory, "frames.csv"), frames.ToString()); + + var captures = new StringBuilder(); + while (_captures.TryDequeue(out var c)) + { + captures.Append(_configs[c.ConfigIndex].Name).Append(',').Append(c.Round).Append(',').Append(Phases[c.PhaseIndex]).Append(',') + .Append(c.MainThread ? "main" : "background").Append(',') + .Append(FormatMs(c.Ms)).Append(',').Append(FormatMs(c.BeforeProcessorsMs)).Append(',') + .Append(FormatMs(c.ProcessorsMs)).Append(',').Append(FormatMs(c.AfterProcessorsMs)).Append('\n'); + } + File.AppendAllText(Path.Combine(_outputDirectory, "captures.csv"), captures.ToString()); + + AppendLines("encodes.csv", _encodes); + AppendLines("sent.csv", _sent); + } + + private void AppendLines(string fileName, ConcurrentQueue lines) + { + var text = new StringBuilder(); + while (lines.TryDequeue(out var line)) + { + text.Append(line).Append('\n'); + } + File.AppendAllText(Path.Combine(_outputDirectory, fileName), text.ToString()); + } + + private void BuildScene() + { + var camera = new GameObject("Camera").AddComponent(); + camera.transform.position = new Vector3(0f, 0f, -22f); + camera.backgroundColor = new Color(0.1f, 0.1f, 0.15f); + camera.clearFlags = CameraClearFlags.SolidColor; + + var light = new GameObject("Light").AddComponent(); + light.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(50f, -30f, 0f); + + var random = new System.Random(42); + _cubes = new Transform[CubeCount]; + for (var i = 0; i < CubeCount; i++) + { + var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); + cube.transform.position = new Vector3((i % 20 - 9.5f) * 1.6f, (i / 20 - 9.5f) * 1.6f, (float)random.NextDouble() * 4f); + cube.GetComponent().material.color = Color.HSVToRGB((float)random.NextDouble(), 0.7f, 0.9f); + _cubes[i] = cube.transform; + } + + var font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + var statusObject = new GameObject("Status"); + statusObject.AddComponent().sharedMaterial = font.material; + _status = statusObject.AddComponent(); + _status.font = font; + _status.transform.position = new Vector3(-14f, 17f, -2f); + _status.characterSize = 0.5f; + _status.fontSize = 48; + _status.color = Color.white; + } + + private void SetStatus(string text) + { + if (_status != null) + { + _status.text = $"Screenshot perf: {text}"; + } + } + + private static float ToMs(long nanoseconds) => nanoseconds / 1_000_000f; + private static double TicksToMs(long ticks) => ticks * 1000.0 / Stopwatch.Frequency; + private static string FormatMs(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); + private static string ToMb(long bytes) => (bytes / (1024f * 1024f)).ToString("0.0", CultureInfo.InvariantCulture); + private static string Format(float value) => value.ToString("0.###", CultureInfo.InvariantCulture); +} diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta new file mode 100644 index 000000000..246cced37 --- /dev/null +++ b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 435d0e2ef12ad45268b0a78302c42d32 \ No newline at end of file diff --git a/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs b/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs index c38dbffae..db7ba502d 100644 --- a/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs +++ b/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs @@ -19,6 +19,13 @@ public override void Configure(SentryUnityOptions options) options.Enabled = false; } + // The screenshot performance harness initializes the SDK itself, once per configuration + if (sceneName == ScreenshotPerfHarness.SceneName) + { + Debug.Log("Disabling the SDK auto-initialization for the screenshot performance harness."); + options.Enabled = false; + } + // BeforeSend is currently limited to C# code. Native errors - such as crashes in C/C++ code - are getting // captured by the native SDKs, but the native SDKs won't invoke this callback. options.SetBeforeSend((sentryEvent, _) => diff --git a/scripts/screenshot-perf-ios.ps1 b/scripts/screenshot-perf-ios.ps1 new file mode 100644 index 000000000..b7255caf9 --- /dev/null +++ b/scripts/screenshot-perf-ios.ps1 @@ -0,0 +1,154 @@ +<# +.SYNOPSIS + Measures the performance impact of screenshot attachments on a connected iOS device. + +.DESCRIPTION + Builds the screenshot performance harness from the local sample, installs it on the device, runs it to + completion and pulls the results. Close the Unity Editor on the sample before running - the build runs + headlessly through Unity CLI. + +.EXAMPLE + pwsh scripts/screenshot-perf-ios.ps1 + pwsh scripts/screenshot-perf-ios.ps1 -Device "iPhone Sentry" -Label branch + pwsh scripts/screenshot-perf-ios.ps1 -SkipBuild -Label rerun + pwsh scripts/screenshot-perf-ios.ps1 -SkipBuild -Configs baseline,high-250ms -Rounds 2 +#> + +param( + # Device name or identifier as listed by `xcrun devicectl list devices`. Defaults to the only connected iPhone. + [string] $Device, + # Distinguishes runs from each other, e.g. the SDK version under test + [string] $Label = "run", + [string] $BundleId = "com.DefaultCompany.unity-of-bugs", + # Configurations to run, by name (see ScreenshotPerfHarness). Defaults to all of them. + [string[]] $Configs, + # Rounds per configuration. Defaults to the harness' default. + [int] $Rounds, + [switch] $SkipBuild, + [int] $TimeoutMinutes = 30 +) + +Set-StrictMode -Version latest +$ErrorActionPreference = "Stop" + +. $PSScriptRoot/unity-pipeline.ps1 + +$repoRoot = (Resolve-Path "$PSScriptRoot/..").Path +$projectPath = Join-Path $repoRoot "samples/unity-of-bugs-local" +$buildRoot = Join-Path $projectPath "Builds/iOS-ScreenshotPerf" +$xcodeProjectPath = Join-Path $buildRoot "xcode" +$derivedDataPath = Join-Path $buildRoot "DerivedData" +$resultsRoot = Join-Path $repoRoot "artifacts/screenshot-perf" + +function Get-ConnectedDevice([string] $nameOrId) { + $jsonPath = [System.IO.Path]::GetTempFileName() + try { + xcrun devicectl list devices --json-output $jsonPath | Out-Null + $devices = @((Get-Content $jsonPath -Raw | ConvertFrom-Json).result.devices | Where-Object { + $_.hardwareProperties.platform -eq "iOS" -and + $_.hardwareProperties.reality -eq "physical" -and + $_.connectionProperties.tunnelState -ne "unavailable" + }) + } + finally { + Remove-Item $jsonPath -Force -ErrorAction Ignore + } + + if ($nameOrId) { + $devices = @($devices | Where-Object { $_.deviceProperties.name -eq $nameOrId -or $_.identifier -eq $nameOrId }) + } + + if ($devices.Count -eq 0) { + throw "No connected iOS device found$(if ($nameOrId) { " matching '$nameOrId'" }). Connect and unlock the device, then check 'xcrun devicectl list devices'." + } + if ($devices.Count -gt 1) { + throw "Multiple connected iOS devices found: $($devices.deviceProperties.name -join ', '). Pick one with -Device." + } + + return $devices[0] +} + +$targetDevice = Get-ConnectedDevice $Device +$deviceId = $targetDevice.identifier +Write-Host "Using device '$($targetDevice.deviceProperties.name)' ($($targetDevice.hardwareProperties.productType))" + +if (-not $SkipBuild) { + if ((Get-UnityExecutionMode -ProjectPath $projectPath) -eq "Pipeline") { + throw "A Unity Editor has the sample open. Close it so the build can run headlessly." + } + + Write-Host "Building the Xcode project..." + $run = Invoke-UnityCli -Arguments @( + "build", $projectPath, + "--target", "iOS", + "--execute-method", "Editor.ScreenshotPerfBuilder.Build", + "--output-path", $xcodeProjectPath + ) + if ($run.ExitCode -ne 0) { + throw "Unity build failed with exit code $($run.ExitCode):`n$($run.Output)" + } + + # Building for the device lets automatic signing register it with the team and include it in the profile + Write-Host "Building and signing the app..." + xcodebuild ` + -project (Join-Path $xcodeProjectPath "Unity-iPhone.xcodeproj") ` + -scheme Unity-iPhone ` + -configuration Release ` + -destination "id=$($targetDevice.hardwareProperties.udid)" ` + -derivedDataPath $derivedDataPath ` + -allowProvisioningUpdates ` + -quiet ` + build + if ($LASTEXITCODE -ne 0) { + throw "xcodebuild failed with exit code $LASTEXITCODE." + } +} + +$appPath = Get-ChildItem -Path (Join-Path $derivedDataPath "Build/Products/Release-iphoneos") -Filter "*.app" -Directory | + Select-Object -First 1 +if (-not $appPath) { + throw "No app found under $derivedDataPath. Run without -SkipBuild first." +} + +Write-Host "Installing $($appPath.Name)..." +xcrun devicectl device install app --device $deviceId $appPath.FullName --quiet +if ($LASTEXITCODE -ne 0) { + throw "Installing the app failed with exit code $LASTEXITCODE." +} + +# Clear results from earlier runs so the pull only contains this one +$emptyDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "screenshot-perf-empty" +New-Item -ItemType Directory -Force -Path $emptyDirectory | Out-Null +xcrun devicectl device copy to --device $deviceId --domain-type appDataContainer --domain-identifier $BundleId ` + --source $emptyDirectory --destination "Documents/screenshot-perf" --remove-existing-content true --quiet 2>$null + +$environment = @{} +if ($Configs) { $environment["SCREENSHOT_PERF_CONFIGS"] = $Configs -join "," } +if ($Rounds) { $environment["SCREENSHOT_PERF_ROUNDS"] = "$Rounds" } +$launchArguments = @("device", "process", "launch", "--device", $deviceId, "--terminate-existing", "--console", "--timeout", ($TimeoutMinutes * 60)) +if ($environment.Count) { + $launchArguments += "--environment-variables", ($environment | ConvertTo-Json -Compress) +} + +Write-Host "Running the harness. A full run takes about 17 minutes - keep the device unlocked and the app in the foreground." +xcrun devicectl @launchArguments $BundleId | + Select-String -Pattern "\[ScreenshotPerf\]" | ForEach-Object { Write-Host $_.Line } +if ($LASTEXITCODE -ne 0) { + throw "Launching the harness failed with exit code $LASTEXITCODE. Make sure the device is unlocked, then rerun with -SkipBuild." +} + +$resultsPath = Join-Path $resultsRoot "$(Get-Date -Format 'yyyyMMdd-HHmmss')-$Label" +New-Item -ItemType Directory -Force -Path $resultsPath | Out-Null +xcrun devicectl device copy from --device $deviceId --domain-type appDataContainer --domain-identifier $BundleId ` + --source "Documents/screenshot-perf" --destination $resultsPath --quiet +if ($LASTEXITCODE -ne 0) { + throw "Pulling the results failed with exit code $LASTEXITCODE." +} + +$runDirectory = Get-ChildItem -Path $resultsPath -Recurse -Filter "done" -File | Select-Object -First 1 +if (-not $runDirectory) { + throw "The harness did not complete. Partial results, if any, are in $resultsPath." +} + +Write-Host "Results: $($runDirectory.DirectoryName)" +python3 -I (Join-Path $PSScriptRoot "screenshot-perf-report.py") $runDirectory.DirectoryName diff --git a/scripts/screenshot-perf-report.py b/scripts/screenshot-perf-report.py new file mode 100644 index 000000000..69c077e94 --- /dev/null +++ b/scripts/screenshot-perf-report.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Summarizes a screenshot performance harness run. + +Usage: python3 scripts/screenshot-perf-report.py [] + +Every configuration is compared against the run's `baseline` configuration (screenshots disabled). Each metric is +computed per round and reported as the median across rounds, followed by the half-range spread across rounds. When +a second run directory is passed, its configurations are listed alongside for comparison, e.g. to compare SDK versions. +""" + +import csv +import statistics +import sys +from collections import defaultdict +from pathlib import Path + + +def percentile(values, p): + if not values: + return float("nan") + ordered = sorted(values) + index = min(len(ordered) - 1, max(0, round(p / 100 * (len(ordered) - 1)))) + return ordered[index] + + +SEGMENTS = ("before_processors_ms", "processors_ms", "after_processors_ms") + + +def median(values): + return statistics.median(values) if values else float("nan") + + +def mean(values): + return statistics.fmean(values) if values else float("nan") + + +def load(run_dir): + run_dir = Path(run_dir) + # config -> round -> phase -> rows + frames = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + with open(run_dir / "frames.csv", newline="") as f: + for row in csv.DictReader(f): + frames[row["config"]][row["round"]][row["phase"]].append( + {k: float(v) for k, v in row.items() if k not in ("config", "round", "phase")}) + + # config -> round -> (phase, thread) -> ms, and the same keyed with the segment name appended + captures = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) + with open(run_dir / "captures.csv", newline="") as f: + for row in csv.DictReader(f): + key = (row["phase"], row["thread"]) + captures[row["config"]][row["round"]][key].append(float(row["ms"])) + for segment in SEGMENTS: + value = float(row.get(segment) or -1) + if value >= 0: + captures[row["config"]][row["round"]][key + (segment,)].append(value) + + # config -> round -> (gfx MB, managed heap MB) + memory = defaultdict(dict) + with open(run_dir / "memory.csv", newline="") as f: + for row in csv.DictReader(f): + memory[row["config"]][row["round"]] = (float(row["gfx_used_mb"]), float(row["gc_used_mb"])) + + # config -> round -> encode ms on the SDK's worker, and sent events as (has screenshot, screenshot bytes) + encodes = defaultdict(lambda: defaultdict(list)) + sent = defaultdict(lambda: defaultdict(list)) + if (run_dir / "encodes.csv").exists(): + with open(run_dir / "encodes.csv", newline="") as f: + for row in csv.DictReader(f): + if row["phase"] == "periodic": + encodes[row["config"]][row["round"]].append(float(row["ms"])) + with open(run_dir / "sent.csv", newline="") as f: + for row in csv.DictReader(f): + sent[row["config"]][row["round"]].append((int(row["has_screenshot"]), int(row["screenshot_bytes"]))) + captures["__encodes__"] = encodes + captures["__sent__"] = sent + + device = {} + with open(run_dir / "device.csv", newline="") as f: + for line in f: + key, _, value = line.strip().partition(",") + device[key] = value + + return frames, captures, memory, device + + +def column(rows, name, only_positive=False): + values = [r[name] for r in rows] + # Negative values mark frames without timing data + return [v for v in values if v > 0] if only_positive else [v for v in values if v >= 0] + + +def summarize_round(frames, captures, memory_mb, encodes=(), sent=()): + idle = frames["idle"] + periodic = frames["periodic"] + burst_main = frames["burst-main"] + burst_background = frames["burst-background"] + + return { + "idle_cpu_main_mean": mean(column(idle, "cpu_main_ms")), + "idle_cpu_main_p99": percentile(column(idle, "cpu_main_ms"), 99), + "idle_dt_p99": percentile(column(idle, "dt_ms"), 99), + "idle_gpu_mean": mean(column(idle, "gpu_ms", only_positive=True)), + "idle_gc_per_frame": mean(column(idle, "gc_bytes")), + "refresh_mean": mean(column(idle, "refresh_ms", only_positive=True)), + "refresh_count": len(column(idle, "refresh_ms", only_positive=True)), + "readback_mean": mean(column(idle, "readback_ms", only_positive=True)), + "readback_max": max(column(idle, "readback_ms", only_positive=True), default=float("nan")), + "idle_frames": len(idle), + "periodic_dt_p99": percentile(column(periodic, "dt_ms"), 99), + "periodic_dt_max": max(column(periodic, "dt_ms"), default=float("nan")), + "periodic_cpu_main_max": max(column(periodic, "cpu_main_ms"), default=float("nan")), + "capture_main_median": statistics.median(captures[("periodic", "main")] or [float("nan")]), + "capture_main_p95": percentile(captures[("periodic", "main")], 95), + "capture_background_median": statistics.median(captures[("periodic", "background")] or [float("nan")]), + **{f"main_{segment}": median(captures[("periodic", "main", segment)]) for segment in SEGMENTS}, + "burst_main_total": sum(captures[("burst-main", "main")]), + "burst_main_dt_max": max(column(burst_main, "dt_ms"), default=float("nan")), + "burst_background_dt_max": max(column(burst_background, "dt_ms"), default=float("nan")), + "worker_encode_median": median(list(encodes)), + "worker_encode_count": len(encodes) if encodes else float("nan"), + "sent_events": len(sent) if sent else float("nan"), + "sent_with_screenshot_pct": 100 * sum(1 for has, size in sent if has and size > 0) / len(sent) if sent else float("nan"), + "sent_screenshot_kb": median([size / 1024 for has, size in sent if has and size > 0]), + "gfx_mb": memory_mb[0], + "managed_mb": memory_mb[1], + } + + +def summarize(frames, captures, memory, config): + """Median and half-range spread of every metric across rounds.""" + encodes = captures["__encodes__"][config] + sent = captures["__sent__"][config] + rounds = [summarize_round(frames[config][r], captures[config][r], memory[config].get(r, (float("nan"), float("nan"))), + encodes.get(r, []), sent.get(r, [])) + for r in sorted(frames[config].keys())] + result = {} + for key in rounds[0]: + values = [r[key] for r in rounds if r[key] == r[key]] + result[key] = (statistics.median(values), (max(values) - min(values)) / 2) if values else (float("nan"), 0.0) + result["rounds"] = (len(rounds), 0.0) + return result + + +ROWS = [ + ("Ongoing (idle, no errors)", None, None), + ("CPU main thread (busy), mean", "idle_cpu_main_mean", "ms"), + ("CPU main thread (busy), p99", "idle_cpu_main_p99", "ms"), + ("Frame time p99", "idle_dt_p99", "ms"), + ("GPU / frame, mean", "idle_gpu_mean", "ms"), + ("GC alloc / frame, mean", "idle_gc_per_frame", "B"), + ("Refresh, mean per call", "refresh_mean", "ms"), + ("Refresh calls", "refresh_count", ""), + ("Readback copy, mean", "readback_mean", "ms"), + ("Readback copy, max", "readback_max", "ms"), + ("Gfx memory", "gfx_mb", "MB"), + ("Managed heap", "managed_mb", "MB"), + ("Per error (1/s)", None, None), + ("Capture on main thread, median", "capture_main_median", "ms"), + ("Capture on main thread, p95", "capture_main_p95", "ms"), + ("Capture on background, median", "capture_background_median", "ms"), + (" main: before processors, median", "main_before_processors_ms", "ms"), + # Covers the screenshot encode. Per-frame marker values are not used for it, since the profiler only partially + # attributes work on background threads to frames. + (" main: processors, median", "main_processors_ms", "ms"), + (" main: after processors, median", "main_after_processors_ms", "ms"), + ("Frame time p99", "periodic_dt_p99", "ms"), + ("Frame time max", "periodic_dt_max", "ms"), + ("CPU main thread (busy), max", "periodic_cpu_main_max", "ms"), + ("Worker (deferred encode)", None, None), + ("Encode on worker, median", "worker_encode_median", "ms"), + ("Encodes during periodic phase", "worker_encode_count", ""), + ("Events sent", "sent_events", ""), + ("Sent events with screenshot", "sent_with_screenshot_pct", "%"), + ("Screenshot size, median", "sent_screenshot_kb", "KB"), + ("Burst (50 errors)", None, None), + ("Main thread: capture total", "burst_main_total", "ms"), + ("Main thread: worst frame", "burst_main_dt_max", "ms"), + ("Background: worst frame", "burst_background_dt_max", "ms"), +] + + +def fmt(value, unit): + if value != value: # NaN + return "-" + if unit == "B": + return f"{value:,.0f}" + if unit == "": + return f"{value:.0f}" + return f"{value:.2f}" + + +def fmt_spread(spread, unit): + return f" ±{fmt(spread, unit)}" if spread > 0 and unit not in ("",) else "" + + +def fmt_delta(value, spread, baseline, unit): + if value != value or baseline != baseline: + return fmt(value, unit) + fmt_spread(spread, unit) + delta = value - baseline + return f"{fmt(value, unit)} ({'+' if delta >= 0 else ''}{fmt(delta, unit)}){fmt_spread(spread, unit)}" + + +def print_report(run_dir, other_dir=None): + frames, captures, memory, device = load(run_dir) + configs = [c for c in frames.keys()] + summaries = {c: summarize(frames, captures, memory, c) for c in configs} + columns = [(c, summaries[c]) for c in configs] + + if other_dir: + o_frames, o_captures, o_memory, _ = load(other_dir) + columns += [(f"{c} [cmp]", summarize(o_frames, o_captures, o_memory, c)) for c in o_frames.keys() if c != "baseline"] + + baseline = summaries.get("baseline") + + print(f"Device: {device.get('device_model')} | {device.get('os')} | {device.get('graphics_api')} | " + f"screen {device.get('screen')} | async readback: {device.get('async_gpu_readback')} | " + f"Unity {device.get('unity')} | development build: {device.get('development_build')}") + print(f"Median of {summaries[configs[0]]['rounds'][0]} rounds. Values in parentheses are deltas against `baseline` " + "(screenshots disabled), ± is the half-range across rounds.\n") + + header = "| Metric | " + " | ".join(name for name, _ in columns) + " |" + print(header) + print("|" + "---|" * (len(columns) + 1)) + for label, key, unit in ROWS: + if key is None: + print(f"| **{label}** |" + " |" * len(columns)) + continue + cells = [] + for name, summary in columns: + value, spread = summary[key] + if baseline and name != "baseline": + cells.append(fmt_delta(value, spread, baseline[key][0], unit)) + else: + cells.append(fmt(value, unit) + fmt_spread(spread, unit)) + print(f"| {label} ({unit or 'n'}) | " + " | ".join(cells) + " |") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + print_report(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) From 20d6a72e38d12c6d6ed117a3c8c16023c5d74782 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 15 Oct 2026 11:08:30 +0200 Subject: [PATCH 04/15] ref(screenshots): Drop benchmark tooling and profiling instrumentation Keep the change focused on how screenshots get attached to events. The device benchmark, the profiler markers and the encode timing log only served measuring it. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 - CHANGELOG.md | 2 +- .../Assets/Editor/ScreenshotPerfBuilder.cs | 83 --- .../Editor/ScreenshotPerfBuilder.cs.meta | 2 - .../Assets/Scripts/Performance.meta | 8 - .../Performance/ScreenshotPerfHarness.cs | 604 ------------------ .../Performance/ScreenshotPerfHarness.cs.meta | 2 - .../Scripts/SentryOptionConfiguration.cs | 7 - scripts/screenshot-perf-ios.ps1 | 154 ----- scripts/screenshot-perf-report.py | 242 ------- src/Sentry.Unity/ScreenshotEventProcessor.cs | 5 - src/Sentry.Unity/SentryScreenshotCache.cs | 21 +- src/Sentry.Unity/SentryScreenshotFrame.cs | 11 - 13 files changed, 3 insertions(+), 1141 deletions(-) delete mode 100644 samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs delete mode 100644 samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta delete mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance.meta delete mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs delete mode 100644 samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta delete mode 100644 scripts/screenshot-perf-ios.ps1 delete mode 100644 scripts/screenshot-perf-report.py diff --git a/.gitignore b/.gitignore index 19c19794b..89dcca713 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,3 @@ samples/unity-of-bugs/*IL2CPPStats/ # Gradle caches generated by the VS Code Gradle extension scanning test fixtures .gradle/ - -# Generated by the screenshot performance builder -samples/unity-of-bugs/Assets/Scenes/Generated* diff --git a/CHANGELOG.md b/CHANGELOG.md index 20506855f..ea92ab16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ ### Fixes -- Attaching a screenshot no longer stalls the frame an error happens in. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event to disk or the network on its background worker. On an iPhone at `ScreenshotQuality.High`, this takes an error on the main thread from blocking it for ~35 ms down to no measurable overhead. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Attaching a screenshot no longer reads back and encodes the frame on the main thread. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Dependencies diff --git a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs deleted file mode 100644 index 3c4765924..000000000 --- a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using UnityEditor.Build.Reporting; -using UnityEditor.SceneManagement; -using UnityEngine; - -namespace Editor -{ - /// - /// Builds a player that only contains the screenshot performance harness. - /// - public static class ScreenshotPerfBuilder - { - // Generated on every build and ignored by git - private const string ScenePath = "Assets/Scenes/Generated/" + ScreenshotPerfHarness.SceneName + ".unity"; - - public static void Build() - { - var arguments = Environment.GetCommandLineArgs(); - var target = (BuildTarget)Enum.Parse(typeof(BuildTarget), GetArgument(arguments, "-buildTarget"), true); - var outputPath = GetArgument(arguments, "-buildOutput"); - - CreateScene(); - - // Development builds are required for the profiler recorders to report script markers. - // Frame timing stats provide the GPU frame time. - var frameTimingStats = PlayerSettings.enableFrameTimingStats; - PlayerSettings.enableFrameTimingStats = true; - - try - { - var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions - { - scenes = new[] { ScenePath }, - locationPathName = outputPath, - target = target, - targetGroup = BuildPipeline.GetBuildTargetGroup(target), - options = BuildOptions.Development - }); - - if (report.summary.result != BuildResult.Succeeded) - { - throw new Exception($"Build failed with {report.summary.totalErrors} error(s)."); - } - - Debug.Log($"Screenshot performance build succeeded: {report.summary.outputPath}"); - } - finally - { - PlayerSettings.enableFrameTimingStats = frameTimingStats; - AssetDatabase.SaveAssets(); - } - } - - private static void CreateScene() - { - Directory.CreateDirectory(Path.GetDirectoryName(ScenePath)!); - - // The harness bootstraps itself based on the scene name and builds its content at runtime - var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); - new GameObject(nameof(ScreenshotPerfHarness)).AddComponent(); - - if (!EditorSceneManager.SaveScene(scene, ScenePath)) - { - throw new Exception($"Failed to save the screenshot performance scene to {ScenePath}."); - } - } - - private static string GetArgument(string[] arguments, string name) - { - for (var i = 0; i < arguments.Length - 1; i++) - { - if (string.Equals(arguments[i], name, StringComparison.OrdinalIgnoreCase)) - { - return arguments[i + 1]; - } - } - - throw new ArgumentException($"Required command line argument '{name}' was not provided."); - } - } -} diff --git a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta b/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta deleted file mode 100644 index a6d24ad27..000000000 --- a/samples/unity-of-bugs/Assets/Editor/ScreenshotPerfBuilder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d8a94645afe61425c8c3cdfda4d7685a \ No newline at end of file diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance.meta b/samples/unity-of-bugs/Assets/Scripts/Performance.meta deleted file mode 100644 index ea59fd469..000000000 --- a/samples/unity-of-bugs/Assets/Scripts/Performance.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: d6eb2762c618446c19b9813c6e482e31 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs deleted file mode 100644 index 8b1da5d10..000000000 --- a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs +++ /dev/null @@ -1,604 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Sentry; -using Sentry.Extensibility; -using Sentry.Unity; -using Unity.Profiling; -using UnityEngine; -using Debug = UnityEngine.Debug; - -/// -/// Measures the performance impact of attaching screenshots to events. -/// -/// -/// Runs every configuration in a single session, re-initializing the SDK for each, and writes the results to -/// `persistentDataPath/screenshot-perf/`. Configurations are repeated round-robin, so slow drift such as thermal -/// throttling spreads evenly across all of them instead of skewing the ones that run last. Only relies on the public SDK API so the same harness can -/// be built against different SDK versions for comparison. -/// -public class ScreenshotPerfHarness : MonoBehaviour -{ - public const string SceneName = "ScreenshotPerf"; - - // Events get sent to the sample's project, so the screenshot encode - deferred until the envelope gets - // serialized - actually happens. The DSN is read from the sample's options at runtime. - private const string ConfigTag = "screenshot_perf"; - - private const float WarmupSeconds = 5f; - private const float IdleSeconds = 20f; - private const float PeriodicSeconds = 20f; - private const float PeriodicErrorIntervalSeconds = 1f; - private const float BurstSettleSeconds = 3f; - private const int BurstSize = 50; - private const int CubeCount = 400; - private const int DefaultRounds = 3; - - // Optional filters, passed as environment variables on launch - private const string ConfigsVariable = "SCREENSHOT_PERF_CONFIGS"; - private const string RoundsVariable = "SCREENSHOT_PERF_ROUNDS"; - - private readonly struct Config - { - public readonly string Name; - public readonly bool AttachScreenshot; - public readonly ScreenshotQuality Quality; - public readonly int IntervalMs; - - public Config(string name, bool attachScreenshot, ScreenshotQuality quality, int intervalMs) - { - Name = name; - AttachScreenshot = attachScreenshot; - Quality = quality; - IntervalMs = intervalMs; - } - } - - private static readonly Config[] AllConfigs = - { - new("baseline", false, ScreenshotQuality.High, 250), - new("high-250ms", true, ScreenshotQuality.High, 250), - new("high-100ms", true, ScreenshotQuality.High, 100), - new("high-1000ms", true, ScreenshotQuality.High, 1000), - new("low-250ms", true, ScreenshotQuality.Low, 250), - new("full-250ms", true, ScreenshotQuality.Full, 250), - }; - - private struct FrameSample - { - public int PhaseIndex; - public float DeltaMs; - public float CpuMainMs; - public float CpuMainWaitMs; - public float CpuRenderMs; - public float GpuMs; - public long GcBytes; - public float RefreshMs; - public float ReadbackMs; - public float EncodeMs; - public float ProcessMs; - } - - private struct CaptureSample - { - public int ConfigIndex; - public int Round; - public int PhaseIndex; - public bool MainThread; - public double Ms; - public double BeforeProcessorsMs; - public double ProcessorsMs; - public double AfterProcessorsMs; - } - - // Splits each capture into segments, taken on the capturing thread. The harness' event processor gets - // registered before the SDK's own ones and BeforeSend runs once all processors are done. - // Filled from background threads: the SDK's worker logs encodes, the transport sends envelopes - private readonly ConcurrentQueue _encodes = new(); - private readonly ConcurrentQueue _sent = new(); - private string? _dsn; - - [ThreadStatic] private static long _processorsStart; - [ThreadStatic] private static long _processorsEnd; - - // Only picks up the screenshot encode timings and ignores everything else, so having debug logging on stays cheap - private sealed class EncodeLogCollector : IDiagnosticLogger - { - private readonly ScreenshotPerfHarness _harness; - - public EncodeLogCollector(ScreenshotPerfHarness harness) => _harness = harness; - - public bool IsEnabled(SentryLevel level) => level == SentryLevel.Debug; - - public void Log(SentryLevel logLevel, string message, Exception? exception = null, params object?[] args) - { - if (!message.StartsWith("Screenshot encoded in", StringComparison.Ordinal) || args.Length < 2) - { - return; - } - - // Encodes run on the SDK's worker shortly after the capture and get attributed to the current phase - _harness._encodes.Enqueue(string.Join(",", - _harness._configs[Math.Min(_harness._configIndex, _harness._configs.Length - 1)].Name, - Math.Min(_harness._round, _harness._rounds - 1), Phases[Math.Max(0, _harness._phaseIndex)], - FormatMs(Convert.ToDouble(args[0], CultureInfo.InvariantCulture)), args[1])); - } - } - - // Records whether events leave the device with their screenshot attached - private sealed class EnvelopeInspector : DelegatingHandler - { - private static readonly Regex ConfigTagPattern = new(@"""screenshot_perf"":""([^""/]+)/(\d+)"""); - private static readonly Regex ScreenshotHeaderPattern = new(@"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); - private static readonly Regex LengthPattern = new(@"""length"":(\d+)"); - - private readonly ScreenshotPerfHarness _harness; - - public EnvelopeInspector(ScreenshotPerfHarness harness) - { - _harness = harness; - InnerHandler = new HttpClientHandler(); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - try - { - if (request.Content is { } content) - { - await content.LoadIntoBufferAsync().ConfigureAwait(false); - var body = await content.ReadAsByteArrayAsync().ConfigureAwait(false); - if (content.Headers.ContentEncoding.Contains("gzip")) - { - using var compressed = new GZipStream(new MemoryStream(body), CompressionMode.Decompress); - using var decompressed = new MemoryStream(); - compressed.CopyTo(decompressed); - body = decompressed.ToArray(); - } - - Inspect(Encoding.UTF8.GetString(body)); - } - } - catch (Exception e) - { - Debug.LogWarning($"[ScreenshotPerf] Failed to inspect an envelope: {e.Message}"); - } - - return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - } - - private void Inspect(string envelope) - { - var tag = ConfigTagPattern.Match(envelope); - if (!tag.Success) - { - return; - } - - var header = ScreenshotHeaderPattern.Match(envelope); - var length = header.Success ? LengthPattern.Match(header.Value) : Match.Empty; - _harness._sent.Enqueue(string.Join(",", tag.Groups[1].Value, tag.Groups[2].Value, - header.Success ? 1 : 0, length.Success ? length.Groups[1].Value : "0")); - } - } - - private sealed class TimestampProcessor : ISentryEventProcessor - { - public SentryEvent Process(SentryEvent @event) - { - _processorsStart = Stopwatch.GetTimestamp(); - return @event; - } - } - - private static readonly string[] Phases = { "warmup", "idle", "periodic", "burst-main", "burst-background" }; - - // Preallocated so the harness itself doesn't allocate while measuring - private readonly FrameSample[] _frames = new FrameSample[60 * 120]; - private int _frameCount; - private readonly ConcurrentQueue _captures = new(); - private readonly FrameTiming[] _frameTimings = new FrameTiming[1]; - private readonly StringBuilder _memory = new(); - - private ProfilerRecorder _gcAlloc; - private ProfilerRecorder _gfxMemory; - private ProfilerRecorder _refresh; - private ProfilerRecorder _readback; - private ProfilerRecorder _encode; - private ProfilerRecorder _process; - - private Config[] _configs = AllConfigs; - private int _rounds = DefaultRounds; - private int _configIndex; - private int _round; - private int _phaseIndex = -1; - private string _outputDirectory = null!; - private Transform[] _cubes = Array.Empty(); - private TextMesh? _status; - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] - private static void Bootstrap() - { - if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name == SceneName - && FindObjectOfType() == null) - { - new GameObject(nameof(ScreenshotPerfHarness)).AddComponent(); - } - } - - private void Awake() - { - Application.targetFrameRate = 60; - BuildScene(); - - _gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame"); - _gfxMemory = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "Gfx Used Memory"); - _refresh = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Refresh"); - _readback = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.ReadbackComplete"); - _encode = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Encode"); - _process = ProfilerRecorder.StartNew(ProfilerCategory.Scripts, "Sentry.Screenshot.Process"); - - ReadFilters(); - - _dsn = Resources.Load("Sentry/SentryOptions")?.Dsn; - if (string.IsNullOrEmpty(_dsn)) - { - Debug.LogError("[ScreenshotPerf] The sample's options contain no DSN. Envelopes won't get sent."); - } - - var runId = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); - _outputDirectory = Path.Combine(Application.persistentDataPath, "screenshot-perf", runId); - Directory.CreateDirectory(_outputDirectory); - } - - private void OnDestroy() - { - _gcAlloc.Dispose(); - _gfxMemory.Dispose(); - _refresh.Dispose(); - _readback.Dispose(); - _encode.Dispose(); - _process.Dispose(); - } - - private IEnumerator Start() - { - // Let the auto-initialized SDK (if any) settle, then take over - yield return null; - SentrySdk.Close(); - - WriteHeaders(); - - for (_round = 0; _round < _rounds; _round++) - for (_configIndex = 0; _configIndex < _configs.Length; _configIndex++) - { - var config = _configs[_configIndex]; - SetStatus($"{config.Name} ({_configIndex + 1}/{_configs.Length}), round {_round + 1}/{_rounds}"); - Debug.Log($"[ScreenshotPerf] Round {_round + 1}/{_rounds}: starting configuration '{config.Name}'."); - - InitSdk(config); - SentrySdk.ConfigureScope(scope => scope.SetTag(ConfigTag, $"{config.Name}/{_round}")); - _frameCount = 0; - - yield return RunPhase(0, WarmupSeconds, captureInterval: 0f); - yield return RunPhase(1, IdleSeconds, captureInterval: 0f); - _memory.AppendLine(string.Join(",", config.Name, _round, ToMb(_gfxMemory.LastValue), ToMb(GC.GetTotalMemory(false)))); - yield return RunPhase(2, PeriodicSeconds, PeriodicErrorIntervalSeconds); - yield return RunBurst(3, mainThread: true); - yield return RunBurst(4, mainThread: false); - - SentrySdk.Close(); - AppendResults(config); - yield return new WaitForSecondsRealtime(1f); - } - - AppendLines("encodes.csv", _encodes); - AppendLines("sent.csv", _sent); - File.WriteAllText(Path.Combine(_outputDirectory, "memory.csv"), "config,round,gfx_used_mb,gc_used_mb\n" + _memory); - File.WriteAllText(Path.Combine(_outputDirectory, "done"), DateTime.UtcNow.ToString("O")); - SetStatus("Done"); - Debug.Log($"[ScreenshotPerf] Done. Results in {_outputDirectory}"); - - yield return new WaitForSecondsRealtime(1f); - Application.Quit(); - } - - private void ReadFilters() - { - var configs = Environment.GetEnvironmentVariable(ConfigsVariable); - if (!string.IsNullOrWhiteSpace(configs)) - { - var names = new HashSet(configs.Split(',').Select(name => name.Trim())); - _configs = AllConfigs.Where(config => names.Contains(config.Name)).ToArray(); - if (_configs.Length == 0) - { - Debug.LogError($"[ScreenshotPerf] No configuration matches '{configs}'. Running all of them."); - _configs = AllConfigs; - } - } - - if (int.TryParse(Environment.GetEnvironmentVariable(RoundsVariable), out var rounds) && rounds > 0) - { - _rounds = rounds; - } - - Debug.Log($"[ScreenshotPerf] Running {string.Join(", ", _configs.Select(config => config.Name))} for {_rounds} round(s)."); - } - - private void InitSdk(Config config) - { - SentrySdk.Init(options => - { - options.Dsn = _dsn; - - // Offline caching stays on, as it is by default. It serializes envelopes to disk on the SDK's worker, - // which is where the deferred screenshot encode happens. - options.Debug = true; - options.DiagnosticLevel = SentryLevel.Debug; - options.DiagnosticLogger = new EncodeLogCollector(this); - options.CreateHttpMessageHandler = () => new EnvelopeInspector(this); - - options.AddEventProcessor(new TimestampProcessor()); - options.SetBeforeSend((@event, _) => - { - _processorsEnd = Stopwatch.GetTimestamp(); - return @event; - }); - - options.AutoSessionTracking = false; - - // Native SDKs don't play a part in managed screenshots and re-initializing them per configuration - // would only add noise - options.IosNativeSupportEnabled = false; - options.AndroidNativeSupportEnabled = false; - - options.AttachScreenshot = config.AttachScreenshot; - options.ScreenshotQuality = config.Quality; - - // Not available in every SDK version - var interval = typeof(SentryUnityOptions).GetProperty("ScreenshotCaptureInterval", BindingFlags.Public | BindingFlags.Instance); - interval?.SetValue(options, TimeSpan.FromMilliseconds(config.IntervalMs)); - }); - } - - private IEnumerator RunPhase(int phaseIndex, float seconds, float captureInterval) - { - _phaseIndex = phaseIndex; - var end = Time.realtimeSinceStartup + seconds; - var nextCapture = Time.realtimeSinceStartup + captureInterval; - var mainThread = true; - - while (Time.realtimeSinceStartup < end) - { - if (captureInterval > 0 && Time.realtimeSinceStartup >= nextCapture) - { - Capture(mainThread); - mainThread = !mainThread; - nextCapture += captureInterval; - } - - yield return null; - } - } - - private IEnumerator RunBurst(int phaseIndex, bool mainThread) - { - _phaseIndex = phaseIndex; - yield return null; - - if (mainThread) - { - for (var i = 0; i < BurstSize; i++) - { - Capture(true); - } - } - else - { - // Parallel.For blocks the calling thread, so it gets kicked off from the thread pool - Task.Run(() => Parallel.For(0, BurstSize, _ => CaptureAndRecord(false))); - } - - var end = Time.realtimeSinceStartup + BurstSettleSeconds; - while (Time.realtimeSinceStartup < end) - { - yield return null; - } - } - - private void Capture(bool mainThread) - { - if (mainThread) - { - CaptureAndRecord(true); - } - else - { - Task.Run(() => CaptureAndRecord(false)); - } - } - - private void CaptureAndRecord(bool mainThread) - { - var configIndex = _configIndex; - var round = _round; - var phaseIndex = _phaseIndex; - _processorsStart = 0; - _processorsEnd = 0; - - var start = Stopwatch.GetTimestamp(); - SentrySdk.CaptureException(new InvalidOperationException("Screenshot performance test")); - var end = Stopwatch.GetTimestamp(); - - // Segments stay negative when a timestamp was not taken, e.g. the event got dropped before BeforeSend - var processorsStart = _processorsStart; - var processorsEnd = _processorsEnd; - var hasSegments = processorsStart != 0 && processorsEnd != 0; - - _captures.Enqueue(new CaptureSample - { - ConfigIndex = configIndex, - Round = round, - PhaseIndex = phaseIndex, - MainThread = mainThread, - Ms = TicksToMs(end - start), - BeforeProcessorsMs = hasSegments ? TicksToMs(processorsStart - start) : -1, - ProcessorsMs = hasSegments ? TicksToMs(processorsEnd - processorsStart) : -1, - AfterProcessorsMs = hasSegments ? TicksToMs(end - processorsEnd) : -1, - }); - } - - private void Update() - { - for (var i = 0; i < _cubes.Length; i++) - { - _cubes[i].Rotate(30f * Time.deltaTime, 45f * Time.deltaTime, 0f); - } - - if (_phaseIndex < 0 || _frameCount >= _frames.Length) - { - return; - } - - // Frame timings lag a few frames behind. The main thread's frame time already excludes waiting for present, - // which is reported separately. - FrameTimingManager.CaptureFrameTimings(); - var hasTiming = FrameTimingManager.GetLatestTimings(1, _frameTimings) > 0; - var timing = _frameTimings[0]; - - // Recorders report the previous frame - _frames[_frameCount++] = new FrameSample - { - PhaseIndex = _phaseIndex, - DeltaMs = Time.unscaledDeltaTime * 1000f, - CpuMainMs = hasTiming ? (float)timing.cpuMainThreadFrameTime : -1f, - CpuMainWaitMs = hasTiming ? (float)timing.cpuMainThreadPresentWaitTime : -1f, - CpuRenderMs = hasTiming ? (float)timing.cpuRenderThreadFrameTime : -1f, - GpuMs = hasTiming ? (float)timing.gpuFrameTime : -1f, - GcBytes = _gcAlloc.LastValue, - RefreshMs = ToMs(_refresh.LastValue), - ReadbackMs = ToMs(_readback.LastValue), - EncodeMs = ToMs(_encode.LastValue), - ProcessMs = ToMs(_process.LastValue), - }; - } - - private void WriteHeaders() - { - File.WriteAllText(Path.Combine(_outputDirectory, "frames.csv"), - "config,round,phase,dt_ms,cpu_main_ms,cpu_main_wait_ms,cpu_render_ms,gpu_ms,gc_bytes,refresh_ms,readback_ms,encode_ms,process_ms\n"); - File.WriteAllText(Path.Combine(_outputDirectory, "encodes.csv"), "config,round,phase,ms,bytes\n"); - File.WriteAllText(Path.Combine(_outputDirectory, "sent.csv"), "config,round,has_screenshot,screenshot_bytes\n"); - File.WriteAllText(Path.Combine(_outputDirectory, "captures.csv"), - "config,round,phase,thread,ms,before_processors_ms,processors_ms,after_processors_ms\n"); - - var device = new StringBuilder(); - device.AppendLine($"device_model,{SystemInfo.deviceModel}"); - device.AppendLine($"os,{SystemInfo.operatingSystem}"); - device.AppendLine($"graphics_api,{SystemInfo.graphicsDeviceType}"); - device.AppendLine($"gpu,{SystemInfo.graphicsDeviceName}"); - device.AppendLine($"screen,{Screen.width}x{Screen.height}"); - device.AppendLine($"async_gpu_readback,{SystemInfo.supportsAsyncGPUReadback}"); - device.AppendLine($"unity,{Application.unityVersion}"); - device.AppendLine($"development_build,{Debug.isDebugBuild}"); - device.AppendLine($"rounds,{_rounds}"); - File.WriteAllText(Path.Combine(_outputDirectory, "device.csv"), device.ToString()); - } - - private void AppendResults(Config config) - { - var frames = new StringBuilder(); - for (var i = 0; i < _frameCount; i++) - { - var f = _frames[i]; - frames.Append(config.Name).Append(',').Append(_round).Append(',').Append(Phases[f.PhaseIndex]).Append(',') - .Append(Format(f.DeltaMs)).Append(',').Append(Format(f.CpuMainMs)).Append(',') - .Append(Format(f.CpuMainWaitMs)).Append(',').Append(Format(f.CpuRenderMs)).Append(',') - .Append(Format(f.GpuMs)).Append(',').Append(f.GcBytes).Append(',') - .Append(Format(f.RefreshMs)).Append(',').Append(Format(f.ReadbackMs)).Append(',') - .Append(Format(f.EncodeMs)).Append(',').Append(Format(f.ProcessMs)).Append('\n'); - } - File.AppendAllText(Path.Combine(_outputDirectory, "frames.csv"), frames.ToString()); - - var captures = new StringBuilder(); - while (_captures.TryDequeue(out var c)) - { - captures.Append(_configs[c.ConfigIndex].Name).Append(',').Append(c.Round).Append(',').Append(Phases[c.PhaseIndex]).Append(',') - .Append(c.MainThread ? "main" : "background").Append(',') - .Append(FormatMs(c.Ms)).Append(',').Append(FormatMs(c.BeforeProcessorsMs)).Append(',') - .Append(FormatMs(c.ProcessorsMs)).Append(',').Append(FormatMs(c.AfterProcessorsMs)).Append('\n'); - } - File.AppendAllText(Path.Combine(_outputDirectory, "captures.csv"), captures.ToString()); - - AppendLines("encodes.csv", _encodes); - AppendLines("sent.csv", _sent); - } - - private void AppendLines(string fileName, ConcurrentQueue lines) - { - var text = new StringBuilder(); - while (lines.TryDequeue(out var line)) - { - text.Append(line).Append('\n'); - } - File.AppendAllText(Path.Combine(_outputDirectory, fileName), text.ToString()); - } - - private void BuildScene() - { - var camera = new GameObject("Camera").AddComponent(); - camera.transform.position = new Vector3(0f, 0f, -22f); - camera.backgroundColor = new Color(0.1f, 0.1f, 0.15f); - camera.clearFlags = CameraClearFlags.SolidColor; - - var light = new GameObject("Light").AddComponent(); - light.type = LightType.Directional; - light.transform.rotation = Quaternion.Euler(50f, -30f, 0f); - - var random = new System.Random(42); - _cubes = new Transform[CubeCount]; - for (var i = 0; i < CubeCount; i++) - { - var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); - cube.transform.position = new Vector3((i % 20 - 9.5f) * 1.6f, (i / 20 - 9.5f) * 1.6f, (float)random.NextDouble() * 4f); - cube.GetComponent().material.color = Color.HSVToRGB((float)random.NextDouble(), 0.7f, 0.9f); - _cubes[i] = cube.transform; - } - - var font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); - var statusObject = new GameObject("Status"); - statusObject.AddComponent().sharedMaterial = font.material; - _status = statusObject.AddComponent(); - _status.font = font; - _status.transform.position = new Vector3(-14f, 17f, -2f); - _status.characterSize = 0.5f; - _status.fontSize = 48; - _status.color = Color.white; - } - - private void SetStatus(string text) - { - if (_status != null) - { - _status.text = $"Screenshot perf: {text}"; - } - } - - private static float ToMs(long nanoseconds) => nanoseconds / 1_000_000f; - private static double TicksToMs(long ticks) => ticks * 1000.0 / Stopwatch.Frequency; - private static string FormatMs(double value) => value.ToString("0.###", CultureInfo.InvariantCulture); - private static string ToMb(long bytes) => (bytes / (1024f * 1024f)).ToString("0.0", CultureInfo.InvariantCulture); - private static string Format(float value) => value.ToString("0.###", CultureInfo.InvariantCulture); -} diff --git a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta b/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta deleted file mode 100644 index 246cced37..000000000 --- a/samples/unity-of-bugs/Assets/Scripts/Performance/ScreenshotPerfHarness.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 435d0e2ef12ad45268b0a78302c42d32 \ No newline at end of file diff --git a/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs b/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs index db7ba502d..c38dbffae 100644 --- a/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs +++ b/samples/unity-of-bugs/Assets/Scripts/SentryOptionConfiguration.cs @@ -19,13 +19,6 @@ public override void Configure(SentryUnityOptions options) options.Enabled = false; } - // The screenshot performance harness initializes the SDK itself, once per configuration - if (sceneName == ScreenshotPerfHarness.SceneName) - { - Debug.Log("Disabling the SDK auto-initialization for the screenshot performance harness."); - options.Enabled = false; - } - // BeforeSend is currently limited to C# code. Native errors - such as crashes in C/C++ code - are getting // captured by the native SDKs, but the native SDKs won't invoke this callback. options.SetBeforeSend((sentryEvent, _) => diff --git a/scripts/screenshot-perf-ios.ps1 b/scripts/screenshot-perf-ios.ps1 deleted file mode 100644 index b7255caf9..000000000 --- a/scripts/screenshot-perf-ios.ps1 +++ /dev/null @@ -1,154 +0,0 @@ -<# -.SYNOPSIS - Measures the performance impact of screenshot attachments on a connected iOS device. - -.DESCRIPTION - Builds the screenshot performance harness from the local sample, installs it on the device, runs it to - completion and pulls the results. Close the Unity Editor on the sample before running - the build runs - headlessly through Unity CLI. - -.EXAMPLE - pwsh scripts/screenshot-perf-ios.ps1 - pwsh scripts/screenshot-perf-ios.ps1 -Device "iPhone Sentry" -Label branch - pwsh scripts/screenshot-perf-ios.ps1 -SkipBuild -Label rerun - pwsh scripts/screenshot-perf-ios.ps1 -SkipBuild -Configs baseline,high-250ms -Rounds 2 -#> - -param( - # Device name or identifier as listed by `xcrun devicectl list devices`. Defaults to the only connected iPhone. - [string] $Device, - # Distinguishes runs from each other, e.g. the SDK version under test - [string] $Label = "run", - [string] $BundleId = "com.DefaultCompany.unity-of-bugs", - # Configurations to run, by name (see ScreenshotPerfHarness). Defaults to all of them. - [string[]] $Configs, - # Rounds per configuration. Defaults to the harness' default. - [int] $Rounds, - [switch] $SkipBuild, - [int] $TimeoutMinutes = 30 -) - -Set-StrictMode -Version latest -$ErrorActionPreference = "Stop" - -. $PSScriptRoot/unity-pipeline.ps1 - -$repoRoot = (Resolve-Path "$PSScriptRoot/..").Path -$projectPath = Join-Path $repoRoot "samples/unity-of-bugs-local" -$buildRoot = Join-Path $projectPath "Builds/iOS-ScreenshotPerf" -$xcodeProjectPath = Join-Path $buildRoot "xcode" -$derivedDataPath = Join-Path $buildRoot "DerivedData" -$resultsRoot = Join-Path $repoRoot "artifacts/screenshot-perf" - -function Get-ConnectedDevice([string] $nameOrId) { - $jsonPath = [System.IO.Path]::GetTempFileName() - try { - xcrun devicectl list devices --json-output $jsonPath | Out-Null - $devices = @((Get-Content $jsonPath -Raw | ConvertFrom-Json).result.devices | Where-Object { - $_.hardwareProperties.platform -eq "iOS" -and - $_.hardwareProperties.reality -eq "physical" -and - $_.connectionProperties.tunnelState -ne "unavailable" - }) - } - finally { - Remove-Item $jsonPath -Force -ErrorAction Ignore - } - - if ($nameOrId) { - $devices = @($devices | Where-Object { $_.deviceProperties.name -eq $nameOrId -or $_.identifier -eq $nameOrId }) - } - - if ($devices.Count -eq 0) { - throw "No connected iOS device found$(if ($nameOrId) { " matching '$nameOrId'" }). Connect and unlock the device, then check 'xcrun devicectl list devices'." - } - if ($devices.Count -gt 1) { - throw "Multiple connected iOS devices found: $($devices.deviceProperties.name -join ', '). Pick one with -Device." - } - - return $devices[0] -} - -$targetDevice = Get-ConnectedDevice $Device -$deviceId = $targetDevice.identifier -Write-Host "Using device '$($targetDevice.deviceProperties.name)' ($($targetDevice.hardwareProperties.productType))" - -if (-not $SkipBuild) { - if ((Get-UnityExecutionMode -ProjectPath $projectPath) -eq "Pipeline") { - throw "A Unity Editor has the sample open. Close it so the build can run headlessly." - } - - Write-Host "Building the Xcode project..." - $run = Invoke-UnityCli -Arguments @( - "build", $projectPath, - "--target", "iOS", - "--execute-method", "Editor.ScreenshotPerfBuilder.Build", - "--output-path", $xcodeProjectPath - ) - if ($run.ExitCode -ne 0) { - throw "Unity build failed with exit code $($run.ExitCode):`n$($run.Output)" - } - - # Building for the device lets automatic signing register it with the team and include it in the profile - Write-Host "Building and signing the app..." - xcodebuild ` - -project (Join-Path $xcodeProjectPath "Unity-iPhone.xcodeproj") ` - -scheme Unity-iPhone ` - -configuration Release ` - -destination "id=$($targetDevice.hardwareProperties.udid)" ` - -derivedDataPath $derivedDataPath ` - -allowProvisioningUpdates ` - -quiet ` - build - if ($LASTEXITCODE -ne 0) { - throw "xcodebuild failed with exit code $LASTEXITCODE." - } -} - -$appPath = Get-ChildItem -Path (Join-Path $derivedDataPath "Build/Products/Release-iphoneos") -Filter "*.app" -Directory | - Select-Object -First 1 -if (-not $appPath) { - throw "No app found under $derivedDataPath. Run without -SkipBuild first." -} - -Write-Host "Installing $($appPath.Name)..." -xcrun devicectl device install app --device $deviceId $appPath.FullName --quiet -if ($LASTEXITCODE -ne 0) { - throw "Installing the app failed with exit code $LASTEXITCODE." -} - -# Clear results from earlier runs so the pull only contains this one -$emptyDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "screenshot-perf-empty" -New-Item -ItemType Directory -Force -Path $emptyDirectory | Out-Null -xcrun devicectl device copy to --device $deviceId --domain-type appDataContainer --domain-identifier $BundleId ` - --source $emptyDirectory --destination "Documents/screenshot-perf" --remove-existing-content true --quiet 2>$null - -$environment = @{} -if ($Configs) { $environment["SCREENSHOT_PERF_CONFIGS"] = $Configs -join "," } -if ($Rounds) { $environment["SCREENSHOT_PERF_ROUNDS"] = "$Rounds" } -$launchArguments = @("device", "process", "launch", "--device", $deviceId, "--terminate-existing", "--console", "--timeout", ($TimeoutMinutes * 60)) -if ($environment.Count) { - $launchArguments += "--environment-variables", ($environment | ConvertTo-Json -Compress) -} - -Write-Host "Running the harness. A full run takes about 17 minutes - keep the device unlocked and the app in the foreground." -xcrun devicectl @launchArguments $BundleId | - Select-String -Pattern "\[ScreenshotPerf\]" | ForEach-Object { Write-Host $_.Line } -if ($LASTEXITCODE -ne 0) { - throw "Launching the harness failed with exit code $LASTEXITCODE. Make sure the device is unlocked, then rerun with -SkipBuild." -} - -$resultsPath = Join-Path $resultsRoot "$(Get-Date -Format 'yyyyMMdd-HHmmss')-$Label" -New-Item -ItemType Directory -Force -Path $resultsPath | Out-Null -xcrun devicectl device copy from --device $deviceId --domain-type appDataContainer --domain-identifier $BundleId ` - --source "Documents/screenshot-perf" --destination $resultsPath --quiet -if ($LASTEXITCODE -ne 0) { - throw "Pulling the results failed with exit code $LASTEXITCODE." -} - -$runDirectory = Get-ChildItem -Path $resultsPath -Recurse -Filter "done" -File | Select-Object -First 1 -if (-not $runDirectory) { - throw "The harness did not complete. Partial results, if any, are in $resultsPath." -} - -Write-Host "Results: $($runDirectory.DirectoryName)" -python3 -I (Join-Path $PSScriptRoot "screenshot-perf-report.py") $runDirectory.DirectoryName diff --git a/scripts/screenshot-perf-report.py b/scripts/screenshot-perf-report.py deleted file mode 100644 index 69c077e94..000000000 --- a/scripts/screenshot-perf-report.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -"""Summarizes a screenshot performance harness run. - -Usage: python3 scripts/screenshot-perf-report.py [] - -Every configuration is compared against the run's `baseline` configuration (screenshots disabled). Each metric is -computed per round and reported as the median across rounds, followed by the half-range spread across rounds. When -a second run directory is passed, its configurations are listed alongside for comparison, e.g. to compare SDK versions. -""" - -import csv -import statistics -import sys -from collections import defaultdict -from pathlib import Path - - -def percentile(values, p): - if not values: - return float("nan") - ordered = sorted(values) - index = min(len(ordered) - 1, max(0, round(p / 100 * (len(ordered) - 1)))) - return ordered[index] - - -SEGMENTS = ("before_processors_ms", "processors_ms", "after_processors_ms") - - -def median(values): - return statistics.median(values) if values else float("nan") - - -def mean(values): - return statistics.fmean(values) if values else float("nan") - - -def load(run_dir): - run_dir = Path(run_dir) - # config -> round -> phase -> rows - frames = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - with open(run_dir / "frames.csv", newline="") as f: - for row in csv.DictReader(f): - frames[row["config"]][row["round"]][row["phase"]].append( - {k: float(v) for k, v in row.items() if k not in ("config", "round", "phase")}) - - # config -> round -> (phase, thread) -> ms, and the same keyed with the segment name appended - captures = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) - with open(run_dir / "captures.csv", newline="") as f: - for row in csv.DictReader(f): - key = (row["phase"], row["thread"]) - captures[row["config"]][row["round"]][key].append(float(row["ms"])) - for segment in SEGMENTS: - value = float(row.get(segment) or -1) - if value >= 0: - captures[row["config"]][row["round"]][key + (segment,)].append(value) - - # config -> round -> (gfx MB, managed heap MB) - memory = defaultdict(dict) - with open(run_dir / "memory.csv", newline="") as f: - for row in csv.DictReader(f): - memory[row["config"]][row["round"]] = (float(row["gfx_used_mb"]), float(row["gc_used_mb"])) - - # config -> round -> encode ms on the SDK's worker, and sent events as (has screenshot, screenshot bytes) - encodes = defaultdict(lambda: defaultdict(list)) - sent = defaultdict(lambda: defaultdict(list)) - if (run_dir / "encodes.csv").exists(): - with open(run_dir / "encodes.csv", newline="") as f: - for row in csv.DictReader(f): - if row["phase"] == "periodic": - encodes[row["config"]][row["round"]].append(float(row["ms"])) - with open(run_dir / "sent.csv", newline="") as f: - for row in csv.DictReader(f): - sent[row["config"]][row["round"]].append((int(row["has_screenshot"]), int(row["screenshot_bytes"]))) - captures["__encodes__"] = encodes - captures["__sent__"] = sent - - device = {} - with open(run_dir / "device.csv", newline="") as f: - for line in f: - key, _, value = line.strip().partition(",") - device[key] = value - - return frames, captures, memory, device - - -def column(rows, name, only_positive=False): - values = [r[name] for r in rows] - # Negative values mark frames without timing data - return [v for v in values if v > 0] if only_positive else [v for v in values if v >= 0] - - -def summarize_round(frames, captures, memory_mb, encodes=(), sent=()): - idle = frames["idle"] - periodic = frames["periodic"] - burst_main = frames["burst-main"] - burst_background = frames["burst-background"] - - return { - "idle_cpu_main_mean": mean(column(idle, "cpu_main_ms")), - "idle_cpu_main_p99": percentile(column(idle, "cpu_main_ms"), 99), - "idle_dt_p99": percentile(column(idle, "dt_ms"), 99), - "idle_gpu_mean": mean(column(idle, "gpu_ms", only_positive=True)), - "idle_gc_per_frame": mean(column(idle, "gc_bytes")), - "refresh_mean": mean(column(idle, "refresh_ms", only_positive=True)), - "refresh_count": len(column(idle, "refresh_ms", only_positive=True)), - "readback_mean": mean(column(idle, "readback_ms", only_positive=True)), - "readback_max": max(column(idle, "readback_ms", only_positive=True), default=float("nan")), - "idle_frames": len(idle), - "periodic_dt_p99": percentile(column(periodic, "dt_ms"), 99), - "periodic_dt_max": max(column(periodic, "dt_ms"), default=float("nan")), - "periodic_cpu_main_max": max(column(periodic, "cpu_main_ms"), default=float("nan")), - "capture_main_median": statistics.median(captures[("periodic", "main")] or [float("nan")]), - "capture_main_p95": percentile(captures[("periodic", "main")], 95), - "capture_background_median": statistics.median(captures[("periodic", "background")] or [float("nan")]), - **{f"main_{segment}": median(captures[("periodic", "main", segment)]) for segment in SEGMENTS}, - "burst_main_total": sum(captures[("burst-main", "main")]), - "burst_main_dt_max": max(column(burst_main, "dt_ms"), default=float("nan")), - "burst_background_dt_max": max(column(burst_background, "dt_ms"), default=float("nan")), - "worker_encode_median": median(list(encodes)), - "worker_encode_count": len(encodes) if encodes else float("nan"), - "sent_events": len(sent) if sent else float("nan"), - "sent_with_screenshot_pct": 100 * sum(1 for has, size in sent if has and size > 0) / len(sent) if sent else float("nan"), - "sent_screenshot_kb": median([size / 1024 for has, size in sent if has and size > 0]), - "gfx_mb": memory_mb[0], - "managed_mb": memory_mb[1], - } - - -def summarize(frames, captures, memory, config): - """Median and half-range spread of every metric across rounds.""" - encodes = captures["__encodes__"][config] - sent = captures["__sent__"][config] - rounds = [summarize_round(frames[config][r], captures[config][r], memory[config].get(r, (float("nan"), float("nan"))), - encodes.get(r, []), sent.get(r, [])) - for r in sorted(frames[config].keys())] - result = {} - for key in rounds[0]: - values = [r[key] for r in rounds if r[key] == r[key]] - result[key] = (statistics.median(values), (max(values) - min(values)) / 2) if values else (float("nan"), 0.0) - result["rounds"] = (len(rounds), 0.0) - return result - - -ROWS = [ - ("Ongoing (idle, no errors)", None, None), - ("CPU main thread (busy), mean", "idle_cpu_main_mean", "ms"), - ("CPU main thread (busy), p99", "idle_cpu_main_p99", "ms"), - ("Frame time p99", "idle_dt_p99", "ms"), - ("GPU / frame, mean", "idle_gpu_mean", "ms"), - ("GC alloc / frame, mean", "idle_gc_per_frame", "B"), - ("Refresh, mean per call", "refresh_mean", "ms"), - ("Refresh calls", "refresh_count", ""), - ("Readback copy, mean", "readback_mean", "ms"), - ("Readback copy, max", "readback_max", "ms"), - ("Gfx memory", "gfx_mb", "MB"), - ("Managed heap", "managed_mb", "MB"), - ("Per error (1/s)", None, None), - ("Capture on main thread, median", "capture_main_median", "ms"), - ("Capture on main thread, p95", "capture_main_p95", "ms"), - ("Capture on background, median", "capture_background_median", "ms"), - (" main: before processors, median", "main_before_processors_ms", "ms"), - # Covers the screenshot encode. Per-frame marker values are not used for it, since the profiler only partially - # attributes work on background threads to frames. - (" main: processors, median", "main_processors_ms", "ms"), - (" main: after processors, median", "main_after_processors_ms", "ms"), - ("Frame time p99", "periodic_dt_p99", "ms"), - ("Frame time max", "periodic_dt_max", "ms"), - ("CPU main thread (busy), max", "periodic_cpu_main_max", "ms"), - ("Worker (deferred encode)", None, None), - ("Encode on worker, median", "worker_encode_median", "ms"), - ("Encodes during periodic phase", "worker_encode_count", ""), - ("Events sent", "sent_events", ""), - ("Sent events with screenshot", "sent_with_screenshot_pct", "%"), - ("Screenshot size, median", "sent_screenshot_kb", "KB"), - ("Burst (50 errors)", None, None), - ("Main thread: capture total", "burst_main_total", "ms"), - ("Main thread: worst frame", "burst_main_dt_max", "ms"), - ("Background: worst frame", "burst_background_dt_max", "ms"), -] - - -def fmt(value, unit): - if value != value: # NaN - return "-" - if unit == "B": - return f"{value:,.0f}" - if unit == "": - return f"{value:.0f}" - return f"{value:.2f}" - - -def fmt_spread(spread, unit): - return f" ±{fmt(spread, unit)}" if spread > 0 and unit not in ("",) else "" - - -def fmt_delta(value, spread, baseline, unit): - if value != value or baseline != baseline: - return fmt(value, unit) + fmt_spread(spread, unit) - delta = value - baseline - return f"{fmt(value, unit)} ({'+' if delta >= 0 else ''}{fmt(delta, unit)}){fmt_spread(spread, unit)}" - - -def print_report(run_dir, other_dir=None): - frames, captures, memory, device = load(run_dir) - configs = [c for c in frames.keys()] - summaries = {c: summarize(frames, captures, memory, c) for c in configs} - columns = [(c, summaries[c]) for c in configs] - - if other_dir: - o_frames, o_captures, o_memory, _ = load(other_dir) - columns += [(f"{c} [cmp]", summarize(o_frames, o_captures, o_memory, c)) for c in o_frames.keys() if c != "baseline"] - - baseline = summaries.get("baseline") - - print(f"Device: {device.get('device_model')} | {device.get('os')} | {device.get('graphics_api')} | " - f"screen {device.get('screen')} | async readback: {device.get('async_gpu_readback')} | " - f"Unity {device.get('unity')} | development build: {device.get('development_build')}") - print(f"Median of {summaries[configs[0]]['rounds'][0]} rounds. Values in parentheses are deltas against `baseline` " - "(screenshots disabled), ± is the half-range across rounds.\n") - - header = "| Metric | " + " | ".join(name for name, _ in columns) + " |" - print(header) - print("|" + "---|" * (len(columns) + 1)) - for label, key, unit in ROWS: - if key is None: - print(f"| **{label}** |" + " |" * len(columns)) - continue - cells = [] - for name, summary in columns: - value, spread = summary[key] - if baseline and name != "baseline": - cells.append(fmt_delta(value, spread, baseline[key][0], unit)) - else: - cells.append(fmt(value, unit) + fmt_spread(spread, unit)) - print(f"| {label} ({unit or 'n'}) | " + " | ".join(cells) + " |") - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print(__doc__) - sys.exit(1) - print_report(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index ebf2465cd..cb9df3ac0 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,13 +1,10 @@ using System; using Sentry.Extensibility; -using Unity.Profiling; namespace Sentry.Unity; public class ScreenshotEventProcessor : ISentryEventProcessorWithHint { - private static readonly ProfilerMarker ProcessMarker = new("Sentry.Screenshot.Process"); - private readonly SentryUnityOptions _options; private readonly SentryScreenshotCache _cache; @@ -37,8 +34,6 @@ private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options public SentryEvent? Process(SentryEvent @event, SentryHint hint) { - using var _ = ProcessMarker.Auto(); - // A processor throwing drops the whole event, so a failing callback must only cost the screenshot. try { diff --git a/src/Sentry.Unity/SentryScreenshotCache.cs b/src/Sentry.Unity/SentryScreenshotCache.cs index ef7f88488..191946957 100644 --- a/src/Sentry.Unity/SentryScreenshotCache.cs +++ b/src/Sentry.Unity/SentryScreenshotCache.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Sentry.Extensibility; -using Unity.Profiling; using UnityEngine; using UnityEngine.Rendering; @@ -27,10 +26,6 @@ internal class SentryScreenshotCache : IDisposable // right away, which bounds the memory pending screenshots can hold on to. internal const int MaxPendingFrames = 2; - private static readonly ProfilerMarker RefreshMarker = new("Sentry.Screenshot.Refresh"); - private static readonly ProfilerMarker ReadbackCompleteMarker = new("Sentry.Screenshot.ReadbackComplete"); - private static readonly ProfilerMarker EncodeMarker = new("Sentry.Screenshot.Encode"); - private readonly SentryUnityOptions _options; private readonly bool _useAsyncReadback; private readonly Action _onReadbackComplete; @@ -103,8 +98,6 @@ internal virtual void Refresh() return; } - using var _ = RefreshMarker.Auto(); - var screenWidth = Screen.width; var screenHeight = Screen.height; if (screenWidth <= 0 || screenHeight <= 0) @@ -183,8 +176,6 @@ private void OnReadbackComplete(AsyncGPUReadbackRequest request) return; } - using var _ = ReadbackCompleteMarker.Auto(); - try { var data = request.GetData(); @@ -363,11 +354,7 @@ private int CountPendingFrames() { screenshot = ReadTexture(); - byte[]? bytes; - using (EncodeMarker.Auto()) - { - bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - } + var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); if (bytes is null || bytes.Length == 0) { @@ -423,11 +410,7 @@ private int CountPendingFrames() screenshot = transformed; } - byte[]? bytes; - using (EncodeMarker.Auto()) - { - bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - } + var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); if (bytes is null || bytes.Length == 0) { diff --git a/src/Sentry.Unity/SentryScreenshotFrame.cs b/src/Sentry.Unity/SentryScreenshotFrame.cs index 93c09de73..cad4197df 100644 --- a/src/Sentry.Unity/SentryScreenshotFrame.cs +++ b/src/Sentry.Unity/SentryScreenshotFrame.cs @@ -1,7 +1,5 @@ using System; -using System.Diagnostics; using Sentry.Extensibility; -using Unity.Profiling; using UnityEngine; using UnityEngine.Experimental.Rendering; @@ -18,8 +16,6 @@ namespace Sentry.Unity; /// internal sealed class SentryScreenshotFrame { - private static readonly ProfilerMarker EncodeMarker = new("Sentry.Screenshot.Encode"); - // Serializes encoders. Retiring never takes it, so the main thread can't get blocked by an encode in progress. private readonly object _encodeLock = new(); private volatile byte[]? _pixels; @@ -78,9 +74,6 @@ internal SentryScreenshotFrame(byte[] pixels, int width, int height) try { - using var _ = EncodeMarker.Auto(); - var start = Stopwatch.GetTimestamp(); - // Thread safe, so it can run wherever the attachment gets serialized var bytes = ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, (uint)Width, (uint)Height, 0, options.ScreenshotCompression); @@ -88,10 +81,6 @@ internal SentryScreenshotFrame(byte[] pixels, int width, int height) if (bytes is { Length: > 0 }) { _encoded = bytes; - - // The profiler under-reports long samples on background threads, so the duration gets logged - var elapsedMs = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency; - options.LogDebug("Screenshot encoded in {0} ms to {1} bytes.", elapsedMs, bytes.Length); } else { From b53a5865a9d81dc99c5154cb9961f40766152dab Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 16 Oct 2026 11:10:43 +0200 Subject: [PATCH 05/15] fix(screenshots): Take screenshots at the end of the error's frame Replace the periodically refreshed frame cache with screenshots requested by the event and taken at the end of the frame it was captured in. Idle frames no longer pay for screenshots, and the screenshot shows the frame the error happened in. Events in quick succession share a screenshot. Read back into pooled native buffers and encode them on the SDK's worker, so the main thread only issues the GPU work. The capture now happens at screen resolution and gets scaled down in a blit, as capturing straight into a smaller target cropped the screenshot. Add SetBeforeSendScreenshotPixels, which edits the raw pixels on the worker right before encoding. Deprecate SetBeforeSendScreenshot, whose Texture2D ties encoding to the main thread, and apply it once per screenshot. Default ScreenshotQuality to Low. Refs #2835 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +- docs/agent-guides/integrations.md | 2 +- .../ConfigurationWindow/EnrichmentTab.cs | 11 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 39 +- .../ScriptableSentryUnityOptions.cs | 4 +- .../SentryMonoBehaviour.Screenshot.cs | 36 +- .../SentryScreenshotAttachmentContent.cs | 55 +- src/Sentry.Unity/SentryScreenshotCache.cs | 557 ------------- src/Sentry.Unity/SentryScreenshotCapturer.cs | 566 +++++++++++++ src/Sentry.Unity/SentryScreenshotFrame.cs | 191 +++-- src/Sentry.Unity/SentryScreenshotPixels.cs | 35 + src/Sentry.Unity/SentryScreenshotRequest.cs | 169 ++++ src/Sentry.Unity/SentryUnityOptions.cs | 40 +- src/Sentry.Unity/SentryUnitySdk.cs | 20 +- .../ScreenshotEventProcessorTests.cs | 132 ++-- .../SentryScreenshotCacheTests.cs | 525 ------------ .../SentryScreenshotCapturerTests.cs | 746 ++++++++++++++++++ 17 files changed, 1850 insertions(+), 1289 deletions(-) delete mode 100644 src/Sentry.Unity/SentryScreenshotCache.cs create mode 100644 src/Sentry.Unity/SentryScreenshotCapturer.cs create mode 100644 src/Sentry.Unity/SentryScreenshotPixels.cs create mode 100644 src/Sentry.Unity/SentryScreenshotRequest.cs delete mode 100644 test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs create mode 100644 test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ea92ab16b..83926aa25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,19 @@ ### Behavioral Changes -- Screenshots are now attached to the error event itself and sent in the same envelope, instead of being sent separately after the fact. They no longer get orphaned when their event gets dropped, e.g. by sampling or rate limiting. The SDK keeps a recently captured frame around and attaches that, so a screenshot can be up to `ScreenshotCaptureInterval` old. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `SetBeforeCaptureScreenshot` now decides whether the screenshot gets attached and can be invoked on a background thread. Events captured on background threads now get a screenshot as well, on devices that support async GPU readback. With `SetBeforeSendScreenshot` set, screenshots only get attached to events captured on the main thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are now attached to the error event itself and sent in the same envelope, instead of being sent separately after the fact. They no longer get orphaned when their event gets dropped, e.g. by sampling or rate limiting. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `SetBeforeCaptureScreenshot` now decides whether the screenshot gets attached and can be invoked on a background thread. Events captured on background threads now get a screenshot as well. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Events captured in quick succession share a screenshot. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `ScreenshotQuality` now defaults to `Low`. Taking a screenshot at higher resolutions can make the frame it gets taken in miss its display refresh. Configurations that set the quality explicitly keep it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `SetBeforeSendScreenshot` is deprecated in favor of `SetBeforeSendScreenshotPixels`. Operating on a `Texture2D` ties compressing the screenshot to the main thread, which stalls it. The callback now gets invoked once per screenshot, with the first of the events sharing it, on the main thread for events captured on any thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Features -- Added `ScreenshotCaptureInterval` to control how often the SDK refreshes the screenshot it attaches to events. It defaults to 250 ms. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Added `SetBeforeSendScreenshotPixels` to modify or discard screenshots before they are sent. It operates on the screenshot's raw pixels and gets invoked on a background thread right before the screenshot gets compressed, so it doesn't stall the main thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Fixes -- Attaching a screenshot no longer reads back and encodes the frame on the main thread. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Attaching a screenshot no longer reads back and encodes the frame on the main thread. The screenshot gets taken at the end of the frame the error happened in, read back from the GPU asynchronously, and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Dependencies diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index e147a4b47..2b549d4d6 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -22,7 +22,7 @@ - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. - View-hierarchy capture must run on Unity main thread. -- Screenshots: `SentryScreenshotCache` refreshes a frame on the main thread at end of frame and reads it back via `AsyncGPUReadback`. The processor attaches it to the event's own envelope; `SentryScreenshotAttachmentContent` defers the JPG encode until the envelope is serialized on the SDK's worker. Without async readback or with `BeforeSendScreenshot` set, it encodes right away on the main thread. +- Screenshots: the processor requests one from `SentryScreenshotCapturer` and attaches `SentryScreenshotAttachmentContent` to the event's own envelope. Events in quick succession share a request. The capturer takes it at end of frame on the main thread: it captures the screen at full resolution (`CaptureScreenshotIntoRenderTexture` copies, it doesn't scale), blits it down to the quality's size and reads it back (`AsyncGPUReadback`, else `ReadPixels`) into a pooled `NativeArray`. The obsolete `BeforeSendScreenshot` gets applied on the main thread, once per screenshot. The content waits for the screenshot and encodes it when the envelope is serialized on the SDK's worker, applying `BeforeSendScreenshotPixels` right before; it never waits on the main thread and times out if the main thread stops rendering. ## Tests diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs index dda6d4f0f..a0d1170b4 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs @@ -34,9 +34,8 @@ internal static void Display(ScriptableSentryUnityOptions options) options.AttachScreenshot = EditorGUILayout.BeginToggleGroup( new GUIContent("Attach Screenshots to Events", "Try to attach current screenshot on events.\n" + "This is an early-access feature and may not work on all platforms (it is explicitly disabled on WebGL).\n" + - "The SDK periodically captures the screen and attaches the most recent capture to an event.\n" + - "A screenshot might not be able to be attached, for example when the error happens on a background thread\n" + - "on a device without async GPU readback support, or with a BeforeSendScreenshot callback set."), + "The screenshot is taken at the end of the frame the error happened in.\n" + + "Errors in quick succession share a screenshot."), options.AttachScreenshot); EditorGUI.indentLevel++; @@ -52,12 +51,6 @@ internal static void Display(ScriptableSentryUnityOptions options) new GUIContent("Compression", "The compression of the screenshot."), options.ScreenshotCompression, 1, 100); - options.ScreenshotCaptureIntervalMilliseconds = EditorGUILayout.IntSlider( - new GUIContent("Capture Interval (ms)", "How often the SDK captures the screen.\n" + - "This is the upper bound on how stale an attached screenshot can be.\n" + - "Lower values increase the per-frame rendering cost."), - options.ScreenshotCaptureIntervalMilliseconds, 0, 1000); - EditorGUI.indentLevel--; EditorGUILayout.EndToggleGroup(); } diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index cb9df3ac0..a74dc9bf8 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -6,28 +6,28 @@ namespace Sentry.Unity; public class ScreenshotEventProcessor : ISentryEventProcessorWithHint { private readonly SentryUnityOptions _options; - private readonly SentryScreenshotCache _cache; + private readonly SentryScreenshotCapturer _capturer; public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) - : this(sentryOptions, GetOrCreateCache(sentryOptions)) { } + : this(sentryOptions, GetOrCreateCapturer(sentryOptions)) { } - internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, SentryScreenshotCache cache) + internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, SentryScreenshotCapturer capturer) { _options = sentryOptions; - _cache = cache; + _capturer = capturer; } - private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options) + private static SentryScreenshotCapturer GetOrCreateCapturer(SentryUnityOptions options) { - if (options.ScreenshotCache is { } cache) + if (options.ScreenshotCapturer is { } capturer) { - return cache; + return capturer; } - cache = new SentryScreenshotCache(options); - SentryMonoBehaviour.Instance.StartScreenshotCache(cache, options.ScreenshotCaptureInterval); - options.ScreenshotCache = cache; - return cache; + capturer = new SentryScreenshotCapturer(options); + SentryMonoBehaviour.Instance.StartScreenshotCapturer(capturer); + options.ScreenshotCapturer = capturer; + return capturer; } public SentryEvent? Process(SentryEvent @event) => @event; @@ -49,14 +49,13 @@ private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options return @event; } - // Without a callback to apply, the encode gets deferred until the envelope is serialized on the SDK's - // background worker. The callback operates on a Texture2D, which ties it to encoding right away. - var beforeSend = _options.BeforeSendScreenshotInternal; - var content = beforeSend is null - ? _cache.TryGetLatestContent() - : _cache.TryEncodeLatest(screenshot => beforeSend(screenshot, @event)) is { } bytes - ? new ByteAttachmentContent(bytes) - : null; + // The screenshot gets taken at the end of this frame. A callback to its pixels gets applied on the SDK's worker + // right before encoding. The obsolete Texture2D callback ties applying it - and encoding - to the main thread. + var content = _options.BeforeSendScreenshotPixelsInternal is { } beforeSendPixels + ? _capturer.TryRequest(beforeEncode: pixels => beforeSendPixels(pixels, @event)) + : _options.BeforeSendScreenshotInternal is { } beforeSend + ? _capturer.TryRequest(screenshot => beforeSend(screenshot, @event)) + : _capturer.TryRequest(); if (content is null) { @@ -64,7 +63,7 @@ private static SentryScreenshotCache GetOrCreateCache(SentryUnityOptions options } hint.Attachments.Add(new SentryAttachment(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg")); - _options.LogDebug("Screenshot attached to event {0}", @event.EventId); + _options.LogDebug("Screenshot requested for event {0}", @event.EventId); return @event; } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index ebbe40afb..ea13f7a14 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -66,9 +66,8 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public string EnvironmentOverride { get; set; } = string.Empty; [field: SerializeField] public bool AttachStacktrace { get; set; } [field: SerializeField] public bool AttachScreenshot { get; set; } - [field: SerializeField] public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.High; + [field: SerializeField] public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.Low; [field: SerializeField] public int ScreenshotCompression { get; set; } = 75; - [field: SerializeField] public int ScreenshotCaptureIntervalMilliseconds { get; set; } = 250; [field: SerializeField] public bool AttachViewHierarchy { get; set; } = false; [field: SerializeField] public int MaxViewHierarchyRootObjects { get; set; } = 100; @@ -217,7 +216,6 @@ internal SentryUnityOptions ToSentryUnityOptions( AttachScreenshot = AttachScreenshot, ScreenshotQuality = ScreenshotQuality, ScreenshotCompression = ScreenshotCompression, - ScreenshotCaptureInterval = TimeSpan.FromMilliseconds(ScreenshotCaptureIntervalMilliseconds), AttachViewHierarchy = AttachViewHierarchy, MaxViewHierarchyRootObjects = MaxViewHierarchyRootObjects, MaxViewHierarchyObjectChildCount = MaxViewHierarchyObjectChildCount, diff --git a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs index 989a6128d..54ea3e431 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs @@ -1,60 +1,46 @@ -using System; using System.Collections; using UnityEngine; namespace Sentry.Unity; /// -/// Drives the refresh of the screenshot cache. +/// Drives taking the screenshots requested by events. /// public partial class SentryMonoBehaviour { private Coroutine? _screenshotCoroutine; - internal void StartScreenshotCache(SentryScreenshotCache cache, TimeSpan interval) - => StartScreenshotCache(cache, interval, new WaitForEndOfFrame()); + internal void StartScreenshotCapturer(SentryScreenshotCapturer capturer) + => StartScreenshotCapturer(capturer, new WaitForEndOfFrame()); // For testing - WaitForEndOfFrame never resumes in batch mode. - internal void StartScreenshotCache(SentryScreenshotCache cache, TimeSpan interval, YieldInstruction? endOfFrame) + internal void StartScreenshotCapturer(SentryScreenshotCapturer capturer, YieldInstruction? endOfFrame) { if (_screenshotCoroutine is not null) { StopCoroutine(_screenshotCoroutine); } - _screenshotCoroutine = StartCoroutine(ScreenshotCacheCoroutine(cache, interval, endOfFrame)); + _screenshotCoroutine = StartCoroutine(ScreenshotCapturerCoroutine(capturer, endOfFrame)); } - private static IEnumerator ScreenshotCacheCoroutine(SentryScreenshotCache cache, TimeSpan interval, YieldInstruction? endOfFrame) + private static IEnumerator ScreenshotCapturerCoroutine(SentryScreenshotCapturer capturer, YieldInstruction? endOfFrame) { - // Reusing a WaitForSecondsRealtime is not an option - it captures its deadline on construction, - // so every wait after the first returns immediately. Tracking the deadline here avoids both that - // and an allocation per refresh. - var intervalSeconds = (float)interval.TotalSeconds; - var nextRefresh = 0f; - while (true) { - // Capturing mid-frame yields an incomplete image, so the cache is only ever refreshed once - // the frame has been rendered. This never resumes in headless mode, which leaves the cache - // empty rather than holding a blank image. + // Capturing mid-frame yields an incomplete image, so screenshots are only ever taken once the frame has + // been rendered. This never resumes in headless mode, where waiting for a screenshot times out instead. yield return endOfFrame; // The SDK can get closed from any thread, so instead of being stopped from the outside the // coroutine ends itself. GPU resources can only be released here, on the main thread. - if (cache.IsDisposed) + if (capturer.IsDisposed) { - cache.ReleaseResources(); + capturer.ReleaseResources(); yield break; } - if (Time.realtimeSinceStartup < nextRefresh) - { - continue; - } - - cache.Refresh(); - nextRefresh = Time.realtimeSinceStartup + intervalSeconds; + capturer.OnEndOfFrame(); } } } diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index 06454c204..28398e8c8 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -1,45 +1,68 @@ using System; using System.IO; +using System.Threading; +using System.Threading.Tasks; using Sentry.Extensibility; namespace Sentry.Unity; /// -/// Attachment content that encodes the screenshot only once it gets read. +/// Attachment content for a screenshot that gets taken at the end of the frame the event was captured in. /// /// -/// The SDK reads attachment content when serializing the envelope on its background worker. Deferring the encode -/// until then keeps it off the thread that captured the event while the screenshot still goes out in the same -/// envelope as the event. Events that get dropped before being sent - e.g. sampled out - never pay for the encode. +/// The SDK reads attachment content when serializing the envelope on its background worker. Waiting for the request +/// and encoding it only then keeps both off the thread that captured the event, while the screenshot still goes out in +/// the same envelope as the event. Events that get dropped before being sent - e.g. sampled out - never pay for the +/// encode. /// internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent { - private readonly SentryScreenshotFrame _frame; + private readonly SentryScreenshotCapturer _capturer; + private readonly SentryScreenshotRequest _request; + private readonly Task? _transformed; private readonly SentryUnityOptions _options; - internal SentryScreenshotAttachmentContent(SentryScreenshotFrame frame, SentryUnityOptions options) + internal SentryScreenshotAttachmentContent(SentryScreenshotCapturer capturer, SentryScreenshotRequest request, + Task? transformed, SentryUnityOptions options) { - _frame = frame; + _capturer = capturer; + _request = request; + _transformed = transformed; _options = options; } - internal SentryScreenshotFrame Frame => _frame; + internal SentryScreenshotRequest Request => _request; public Stream GetStream() => new EncodeOnReadStream(this); - private byte[] Encode() + internal async Task GetEncodedAsync() { - var bytes = _frame.GetEncoded(_options); + if (!_request.IsCompleted) + { + // Taking the screenshot needs the main thread to reach the end of the frame. Waiting on it there would deadlock. + if (MainThreadData.IsMainThread()) + { + _options.LogDebug("Screenshot not taken yet and the attachment is read on the main thread. Skipping."); + return Array.Empty(); + } + + await _capturer.WaitAsync(_request).ConfigureAwait(false); + } + + var bytes = _transformed is not null + ? await _transformed.ConfigureAwait(false) + : (await _request.Frame.ConfigureAwait(false))?.GetEncoded(_options, _request.BeforeEncode); + if (bytes is null) { - _options.LogWarning("Screenshot attachment is empty. Encoding the screenshot failed."); + _options.LogWarning("Screenshot attachment is empty. Taking or encoding the screenshot failed."); } return bytes ?? Array.Empty(); } // Deliberately not seekable: the SDK checks the length of attachment streams when building the envelope, on the - // capturing thread. Reporting no length keeps the encode from happening right there. + // capturing thread. Reporting no length keeps the wait and the encode from happening right there. private sealed class EncodeOnReadStream : Stream { private readonly SentryScreenshotAttachmentContent _content; @@ -60,7 +83,13 @@ public override long Position public override int Read(byte[] buffer, int offset, int count) { - _encoded ??= new MemoryStream(_content.Encode(), writable: false); + _encoded ??= new MemoryStream(_content.GetEncodedAsync().GetAwaiter().GetResult(), writable: false); + return _encoded.Read(buffer, offset, count); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _encoded ??= new MemoryStream(await _content.GetEncodedAsync().ConfigureAwait(false), writable: false); return _encoded.Read(buffer, offset, count); } diff --git a/src/Sentry.Unity/SentryScreenshotCache.cs b/src/Sentry.Unity/SentryScreenshotCache.cs deleted file mode 100644 index 191946957..000000000 --- a/src/Sentry.Unity/SentryScreenshotCache.cs +++ /dev/null @@ -1,557 +0,0 @@ -using System; -using System.Collections.Generic; -using Sentry.Extensibility; -using UnityEngine; -using UnityEngine.Rendering; - -namespace Sentry.Unity; - -/// -/// Holds the most recently completed frame so that it can be attached to an event synchronously, -/// from within the event processor pipeline, instead of being sent as a separate envelope. -/// -/// -/// The frame gets captured on the GPU and, where supported, copied to the CPU via an asynchronous readback. -/// Attaching it to an event then hands out the frame, which only gets encoded once the SDK serializes the envelope -/// on its background worker. Without async readback support the frame stays on the GPU and has to be read back and -/// encoded synchronously, which limits attaching it to events captured on the main thread. -/// -internal class SentryScreenshotCache : IDisposable -{ - // A readback completes within a few frames. Past this, it's considered lost so a missing callback - // can't block all further refreshes. - private const int ReadbackTimeoutFrames = 30; - - // Frames held on to by attachments waiting for their envelope to get serialized. Past this, attaching encodes - // right away, which bounds the memory pending screenshots can hold on to. - internal const int MaxPendingFrames = 2; - - private readonly SentryUnityOptions _options; - private readonly bool _useAsyncReadback; - private readonly Action _onReadbackComplete; - - // GPU side - main thread only - private RenderTexture? _capture; - private RenderTexture? _oriented; - private int _width; - private int _height; - private bool _readbackInFlight; - private int _readbackRequestFrame; - - // CPU side - guarded by _lock - private readonly object _lock = new(); - private SentryScreenshotFrame? _frame; - private byte[]? _spare; - private readonly List> _pendingFrames = new(); - - // Without async readback the frame only exists on the GPU - guarded by _lock - private bool _gpuHasContent; - private int _gpuGeneration; - private byte[]? _gpuEncoded; - private int _gpuEncodedGeneration = -1; - - private volatile bool _disposed; - - internal SentryScreenshotCache(SentryUnityOptions options) - : this(options, SystemInfo.supportsAsyncGPUReadback) { } - - internal SentryScreenshotCache(SentryUnityOptions options, bool useAsyncReadback) - { - _options = options; - _useAsyncReadback = useAsyncReadback; - _onReadbackComplete = OnReadbackComplete; - } - - internal bool HasContent - { - get - { - lock (_lock) - { - return _frame is not null || _gpuHasContent; - } - } - } - - internal bool IsDisposed => _disposed; - - // For testing - internal SentryScreenshotFrame? CurrentFrame - { - get - { - lock (_lock) - { - return _frame; - } - } - } - - /// - /// Captures the current back buffer into the cache. Must run on the main thread at end of frame - - /// capturing mid-frame yields an incomplete image. - /// - internal virtual void Refresh() - { - if (_disposed) - { - return; - } - - var screenWidth = Screen.width; - var screenHeight = Screen.height; - if (screenWidth <= 0 || screenHeight <= 0) - { - return; - } - - var (targetWidth, targetHeight) = SentryScreenshot.GetTargetSize(_options.ScreenshotQuality, screenWidth, screenHeight); - EnsureTextures(targetWidth, targetHeight); - - var previous = RenderTexture.active; - try - { - // Capturing straight into the downscaled texture keeps this - the only recurring cost of - // attaching screenshots - cheap. - CaptureScreen(_capture!); - - var source = _capture!; - if (_oriented != null) - { - Graphics.Blit(_capture, _oriented, new Vector2(1, -1), new Vector2(0, 1)); - source = _oriented; - } - - if (_useAsyncReadback) - { - RequestReadback(source); - } - else - { - lock (_lock) - { - _gpuGeneration++; - _gpuHasContent = true; - } - } - } - catch (Exception e) - { - _options.LogError(e, "Failed to refresh the screenshot cache."); - } - finally - { - RenderTexture.active = previous; - } - } - - // For testing - internal virtual void CaptureScreen(RenderTexture target) => ScreenCapture.CaptureScreenshotIntoRenderTexture(target); - - private void RequestReadback(RenderTexture source) - { - if (_readbackInFlight) - { - // Skipping this refresh means the cache trails by one interval at most, instead of piling - // up readbacks on a slow GPU. - if (Time.frameCount - _readbackRequestFrame < ReadbackTimeoutFrames) - { - return; - } - - _options.LogDebug("Screenshot readback did not complete in time. Requesting a new one."); - } - - _readbackInFlight = true; - _readbackRequestFrame = Time.frameCount; - AsyncGPUReadback.Request(source, 0, TextureFormat.RGBA32, _onReadbackComplete); - } - - private void OnReadbackComplete(AsyncGPUReadbackRequest request) - { - _readbackInFlight = false; - - if (_disposed || request.hasError) - { - return; - } - - try - { - var data = request.GetData(); - - // The new frame goes into a buffer no one else can see, so attachments holding on to the current - // frame are never affected - byte[]? buffer; - lock (_lock) - { - buffer = _spare is not null && _spare.Length == data.Length ? _spare : null; - _spare = null; - } - - buffer ??= new byte[data.Length]; - data.CopyTo(buffer); - - lock (_lock) - { - if (_disposed) - { - return; - } - - var previous = _frame; - _frame = new SentryScreenshotFrame(buffer, request.width, request.height); - - // Only a frame nothing holds on to hands back its buffer. A buffer gets allocated whenever an - // event attached the previous frame. - _spare = previous?.Retire(); - } - } - catch (Exception e) - { - _options.LogError(e, "Failed to read back the screenshot."); - } - } - - /// - /// Returns the cached frame as attachment content. Returns null when the cache has not been populated yet or the - /// frame can't be attached on the calling thread. - /// - /// - /// With async readback support this runs on any thread and the content defers encoding until it gets read. - /// - internal virtual IAttachmentContent? TryGetLatestContent() - { - if (_disposed) - { - return null; - } - - if (!_useAsyncReadback) - { - return TryEncodeLatest() is { } bytes ? new ByteAttachmentContent(bytes) : null; - } - - SentryScreenshotFrame? frame; - bool deferEncode; - lock (_lock) - { - frame = _frame; - if (frame is null) - { - _options.LogDebug("Screenshot cache is empty. Skipping."); - return null; - } - - // A frame that is already pinned costs nothing extra to hand out again - deferEncode = frame.Pinned || CountPendingFrames() < MaxPendingFrames; - if (!frame.Pinned) - { - frame.Pinned = true; - if (deferEncode) - { - _pendingFrames.Add(new WeakReference(frame)); - } - } - } - - if (deferEncode) - { - return new SentryScreenshotAttachmentContent(frame, _options); - } - - _options.LogDebug("Too many screenshots waiting to be sent. Encoding right away."); - return frame.GetEncoded(_options) is { } encoded ? new ByteAttachmentContent(encoded) : null; - } - - // Must be called while holding _lock - private int CountPendingFrames() - { - // Frames drop out once their encode started, or once the attachments holding on to them are gone - e.g. - // because the event got dropped before being sent - _pendingFrames.RemoveAll(reference => !reference.TryGetTarget(out var frame) || frame.IsEncodeStarted); - return _pendingFrames.Count; - } - - /// - /// Encodes the cached frame as JPG right away. Returns null when the cache has not been populated yet, the - /// transform discarded the screenshot, or the encode is not possible on the calling thread. - /// - /// - /// Without a transform, this can run on any thread when async GPU readback is supported. Applying a - /// transform requires the main thread since it operates on a . - /// - internal virtual byte[]? TryEncodeLatest(Func? transform = null) - { - if (_disposed) - { - return null; - } - - try - { - if (transform is not null) - { - return EncodeLatest(transform); - } - - return _useAsyncReadback ? EncodeLatestFrame() : EncodeLatestGpu(); - } - catch (Exception e) - { - _options.LogError(e, "Failed to encode the cached screenshot."); - return null; - } - } - - private byte[]? EncodeLatestFrame() - { - SentryScreenshotFrame? frame; - lock (_lock) - { - frame = _frame; - if (frame is null) - { - _options.LogDebug("Screenshot cache is empty. Skipping."); - return null; - } - - frame.Pinned = true; - } - - return frame.GetEncoded(_options); - } - - private byte[]? EncodeLatestGpu() - { - int generation; - lock (_lock) - { - if (!_gpuHasContent) - { - _options.LogDebug("Screenshot cache is empty. Skipping."); - return null; - } - - // Every event between two refreshes sees the same frame, so the encode is only worth doing once. - if (_gpuEncodedGeneration == _gpuGeneration) - { - return _gpuEncoded; - } - - generation = _gpuGeneration; - } - - // Without a CPU copy the frame has to be read back from the GPU, which is a main thread only operation. - if (!MainThreadData.IsMainThread()) - { - _options.LogDebug("Screenshot skipped. Without async GPU readback support, screenshots can only be attached on the main thread."); - return null; - } - - Texture2D? screenshot = null; - try - { - screenshot = ReadTexture(); - - var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - - if (bytes is null || bytes.Length == 0) - { - _options.LogWarning("Screenshot encoding returned empty data."); - return null; - } - - lock (_lock) - { - _gpuEncoded = bytes; - _gpuEncodedGeneration = generation; - } - - return bytes; - } - finally - { - if (screenshot != null) - { - UnityEngine.Object.Destroy(screenshot); - } - } - } - - private byte[]? EncodeLatest(Func transform) - { - if (!MainThreadData.IsMainThread()) - { - _options.LogDebug("Screenshot skipped. The BeforeSendScreenshot callback can only be invoked on the main thread."); - return null; - } - - Texture2D? screenshot = null; - try - { - screenshot = CreateTexture(); - if (screenshot == null) - { - _options.LogDebug("Screenshot cache is empty. Skipping."); - return null; - } - - // A transform may return a different image per event, so its result does not get cached. - var transformed = transform.Invoke(screenshot); - if (transformed == null) - { - return null; - } - - if (transformed != screenshot) - { - UnityEngine.Object.Destroy(screenshot); - screenshot = transformed; - } - - var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - - if (bytes is null || bytes.Length == 0) - { - _options.LogWarning("Screenshot encoding returned empty data."); - return null; - } - - return bytes; - } - finally - { - if (screenshot != null) - { - UnityEngine.Object.Destroy(screenshot); - } - } - } - - // Main thread only - private Texture2D? CreateTexture() - { - SentryScreenshotFrame? frame; - lock (_lock) - { - if (!_useAsyncReadback) - { - return _gpuHasContent ? ReadTexture() : null; - } - - frame = _frame; - } - - // The current frame's pixels only get replaced on the main thread, by the readback callback - if (frame?.Pixels is not { } pixels) - { - return null; - } - - var texture = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false); - texture.LoadRawTextureData(pixels); - texture.Apply(); - return texture; - } - - private Texture2D ReadTexture() - { - var previous = RenderTexture.active; - try - { - RenderTexture.active = _oriented != null ? _oriented : _capture; - var screenshot = new Texture2D(_width, _height, TextureFormat.RGB24, false); - screenshot.ReadPixels(new Rect(0, 0, _width, _height), 0, 0); - screenshot.Apply(); - return screenshot; - } - finally - { - RenderTexture.active = previous; - } - } - - private void EnsureTextures(int width, int height) - { - if (_capture != null && _width == width && _height == height) - { - return; - } - - ReleaseResources(); - - _capture = CreateRenderTexture(width, height, "Sentry.ScreenshotCapture"); - - // The captured image is mirrored on some platforms. Mirroring it back right away on the GPU means - // every consumer of the cache gets it in the right orientation. - // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. - // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. - if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) - { - _oriented = CreateRenderTexture(width, height, "Sentry.ScreenshotOriented"); - } - - _width = width; - _height = height; - - // A frame read back to the CPU stays valid, if sized differently. A frame on the GPU is gone. - lock (_lock) - { - _gpuHasContent = false; - } - - _options.LogDebug("Screenshot cache allocated at {0}x{1}.", width, height); - } - - private static RenderTexture CreateRenderTexture(int width, int height, string name) - { - var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = name }; - texture.Create(); - return texture; - } - - /// - /// Releases the GPU resources. Must run on the main thread. - /// - internal void ReleaseResources() - { - ReleaseRenderTexture(ref _capture); - ReleaseRenderTexture(ref _oriented); - } - - private static void ReleaseRenderTexture(ref RenderTexture? texture) - { - if (texture == null) - { - return; - } - - texture.Release(); - UnityEngine.Object.Destroy(texture); - texture = null; - } - - public void Dispose() - { - _disposed = true; - - // Attachments still holding on to a frame can encode it regardless - lock (_lock) - { - _frame = null; - _spare = null; - _pendingFrames.Clear(); - _gpuHasContent = false; - _gpuEncoded = null; - _gpuEncodedGeneration = -1; - } - - // Off the main thread, the refresh coroutine releases the GPU resources once it notices the - // cache got disposed. - if (MainThreadData.IsMainThread()) - { - ReleaseResources(); - } - } -} diff --git a/src/Sentry.Unity/SentryScreenshotCapturer.cs b/src/Sentry.Unity/SentryScreenshotCapturer.cs new file mode 100644 index 000000000..e39b5d211 --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotCapturer.cs @@ -0,0 +1,566 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Sentry.Extensibility; +using Unity.Collections; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Sentry.Unity; + +/// +/// Takes screenshots on demand, at the end of the frame an event was captured in, and hands them out as attachment +/// content that resolves once the SDK serializes the event's envelope. +/// +/// +/// Requesting a screenshot is cheap and works on any thread. The main thread pays for taking it at the end of the frame +/// - a GPU copy and, where supported, an asynchronous readback into a pooled buffer. Encoding happens on the SDK's +/// background worker, straight from that buffer. Events captured close together share a screenshot. +/// +internal class SentryScreenshotCapturer : IDisposable +{ + // Events within this window of a taken screenshot share it instead of requesting a new one. Bounds the cost of + // an error on every frame. + internal static readonly TimeSpan MinCaptureInterval = TimeSpan.FromMilliseconds(100); + + // Past this many screenshots waiting for their encode, events share the latest one instead. + internal const int MaxPendingRequests = 2; + + // How old a shared screenshot may be before no screenshot is the better choice. A stale one misleads. + internal static readonly TimeSpan MaxSharedAge = TimeSpan.FromSeconds(1); + + // How long the worker waits for the main thread to take a screenshot, e.g. while it's hanging or paused. + internal static readonly TimeSpan CaptureTimeout = TimeSpan.FromSeconds(1); + + // Readback buffers kept for reuse: one per pending screenshot plus the one being taken. Buffers rented past this, + // e.g. while dropped events wait to get collected, get disposed once returned. + internal const int MaxBuffers = MaxPendingRequests + 1; + + private readonly SentryUnityOptions _options; + private readonly bool _useAsyncReadback; + + // GPU side - main thread only + private RenderTexture? _capture; + private bool _flip; + private int _width; + private int _height; + + // Guarded by _lock + private readonly object _lock = new(); + private SentryScreenshotRequest? _requested; + private SentryScreenshotRequest? _latest; + private readonly List> _pending = new(); + private bool _stalled; + + // Readback buffers, guarded by _lock. In-use ones are tracked with the request holding them, so a buffer gets + // reclaimed even if its event is dropped before the frame gets encoded. + private readonly Stack> _freeBuffers = new(); + private readonly List<(WeakReference Owner, NativeArray Buffer)> _usedBuffers = new(); + private int _bufferLength; + + private volatile bool _disposed; + + internal SentryScreenshotCapturer(SentryUnityOptions options) + : this(options, SystemInfo.supportsAsyncGPUReadback) { } + + internal SentryScreenshotCapturer(SentryUnityOptions options, bool useAsyncReadback) + { + _options = options; + _useAsyncReadback = useAsyncReadback; + } + + internal bool IsDisposed => _disposed; + + // For testing + internal int FreeBufferCount + { + get + { + lock (_lock) + { + ReclaimBuffers(); + return _freeBuffers.Count; + } + } + } + + // For testing + internal int UsedBufferCount + { + get + { + lock (_lock) + { + ReclaimBuffers(); + return _usedBuffers.Count; + } + } + } + + // For testing + internal bool HasRequest + { + get + { + lock (_lock) + { + return _requested is not null; + } + } + } + + /// + /// Requests a screenshot of the current frame. Returns null when none can be attached, e.g. because the main + /// thread is not rendering. + /// + /// + /// Runs on any thread. A transform gets applied on the main thread once the screenshot is taken. A callback to the + /// pixels gets applied on the SDK's worker right before encoding. + /// + internal virtual IAttachmentContent? TryRequest(Func? transform = null, + Func? beforeEncode = null) + { + if (_disposed) + { + return null; + } + + lock (_lock) + { + if (_stalled) + { + _options.LogDebug("Screenshot skipped. The main thread did not take the last one in time."); + return null; + } + + if (_requested is { } requested) + { + return Attach(requested, transform, beforeEncode); + } + + if (_latest is { } recent && IsShareable(recent, MinCaptureInterval) && CanServe(recent, transform)) + { + return Attach(recent, transform, beforeEncode); + } + + if (CountPending() >= MaxPendingRequests) + { + if (_latest is { } latest && IsShareable(latest, MaxSharedAge) && CanServe(latest, transform)) + { + return Attach(latest, transform, beforeEncode); + } + + _options.LogDebug("Screenshot skipped. Too many screenshots are waiting to be sent."); + return null; + } + + _requested = new SentryScreenshotRequest(); + return Attach(_requested, transform, beforeEncode); + } + } + + // Must be called while holding _lock + private IAttachmentContent Attach(SentryScreenshotRequest request, Func? transform, + Func? beforeEncode) + { + Task? transformed = null; + if (transform is null) + { + request.HasFrameConsumers = true; + if (beforeEncode is not null) + { + request.TrySetBeforeEncode(beforeEncode); + } + } + else + { + // Not yet taken or already transformed, see CanServe + transformed = request.TryAddTransform(transform); + } + + return new SentryScreenshotAttachmentContent(this, request, transformed, _options); + } + + // Whether a screenshot that was already taken can serve another event. A transform only runs once per + // screenshot, so with one set, only a transformed screenshot can be shared. Without, the frame must still be + // encodable - its pixels get released right away when only the transform uses them. + private static bool CanServe(SentryScreenshotRequest request, Func? transform) + { + if (transform is not null) + { + return request.HasTransform; + } + + return !request.IsCompleted || request.Frame.Result is not { } frame || frame.IsEncodable; + } + + // Must be called while holding _lock + private int CountPending() + { + // Captures drop out once nothing is left to encode, or once the attachments holding on to them are gone - + // e.g. because the event got dropped before being sent + _pending.RemoveAll(reference => !reference.TryGetTarget(out var request) || request.IsSettled); + ReclaimBuffers(); + return _pending.Count; + } + + // Must be called while holding _lock + private NativeArray RentBuffer(SentryScreenshotRequest owner, int length) + { + ReclaimBuffers(); + + if (_bufferLength != length) + { + // The resolution changed. Buffers in use get disposed once returned. + while (_freeBuffers.Count > 0) + { + _freeBuffers.Pop().Dispose(); + } + + _bufferLength = length; + } + + var buffer = _freeBuffers.Count > 0 + ? _freeBuffers.Pop() + : new NativeArray(length, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + + _usedBuffers.Add((new WeakReference(owner), buffer)); + return buffer; + } + + // Called by the frame once its pixels are no longer needed, on whichever thread encoded or released them + private void ReturnBuffer(NativeArray buffer) + { + lock (_lock) + { + var index = _usedBuffers.FindIndex(used => used.Buffer.Equals(buffer)); + if (index < 0) + { + // Already reclaimed + return; + } + + _usedBuffers.RemoveAt(index); + Recycle(buffer); + } + } + + // Must be called while holding _lock. A request that got collected took its frame with it, so nothing can return + // its buffer anymore. + private void ReclaimBuffers() + { + for (var i = _usedBuffers.Count - 1; i >= 0; i--) + { + if (!_usedBuffers[i].Owner.TryGetTarget(out _)) + { + var buffer = _usedBuffers[i].Buffer; + _usedBuffers.RemoveAt(i); + Recycle(buffer); + } + } + } + + // Must be called while holding _lock + private void Recycle(NativeArray buffer) + { + if (_disposed || buffer.Length != _bufferLength || _freeBuffers.Count >= MaxBuffers) + { + buffer.Dispose(); + } + else + { + _freeBuffers.Push(buffer); + } + } + + private static bool IsShareable(SentryScreenshotRequest request, TimeSpan maxAge) + { + if (request.IsCompleted && request.Frame.Result is null) + { + return false; + } + + var age = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - request.TakenTimestamp) / (double)Stopwatch.Frequency); + return age < maxAge; + } + + /// + /// Waits for the screenshot to be taken. Gives up after , which also skips screenshots + /// until the main thread reaches the end of a frame again. + /// + internal async Task WaitAsync(SentryScreenshotRequest request) + { + var completed = await Task.WhenAny(request.Frame, Task.Delay(CaptureTimeout)).ConfigureAwait(false); + if (completed == request.Frame) + { + return; + } + + _options.LogDebug("Screenshot was not taken in time. Skipping screenshots until the main thread catches up."); + + lock (_lock) + { + _stalled = true; + if (ReferenceEquals(_requested, request)) + { + _requested = null; + } + } + + request.Abandon(); + } + + /// + /// Takes the requested screenshot, if any. Must run on the main thread at end of frame - capturing mid-frame + /// yields an incomplete image. + /// + internal virtual void OnEndOfFrame() + { + if (_disposed) + { + return; + } + + SentryScreenshotRequest? request; + lock (_lock) + { + _stalled = false; + request = _requested; + _requested = null; + + if (request is null) + { + return; + } + + request.TakenTimestamp = Stopwatch.GetTimestamp(); + _latest = request; + _pending.Add(new WeakReference(request)); + } + + Take(request); + } + + private void Take(SentryScreenshotRequest request) + { + var (screenWidth, screenHeight) = GetScreenSize(); + if (screenWidth <= 0 || screenHeight <= 0) + { + request.Abandon(); + return; + } + + var (targetWidth, targetHeight) = SentryScreenshot.GetTargetSize(_options.ScreenshotQuality, screenWidth, screenHeight); + EnsureTextures(targetWidth, targetHeight); + + var previous = RenderTexture.active; + + // The capture copies the screen into the target as is instead of scaling it, so it needs one at the + // screen's resolution. It's only needed for the duration of the frame, and the pool hands it out again on + // the next screenshot. + var screen = RenderTexture.GetTemporary(screenWidth, screenHeight, 0, RenderTextureFormat.ARGB32); + try + { + CaptureScreen(screen); + + // Scales the capture down to the target size. The captured image is mirrored on some platforms, which + // gets fixed in the same pass so every consumer gets it in the right orientation. + // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. + // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. + if (_flip) + { + Graphics.Blit(screen, _capture, new Vector2(1, -1), new Vector2(0, 1)); + } + else + { + Graphics.Blit(screen, _capture); + } + + if (_useAsyncReadback) + { + // The readback copies the texture as of now, so later captures into it don't affect this one + RequestReadback(request, _capture!); + } + else + { + // Stalls the main thread until the GPU caught up, but only on frames that had an error + request.Complete(ReadPixels(request, _capture!), _options); + } + } + catch (Exception e) + { + _options.LogError(e, "Failed to take the screenshot."); + request.Abandon(); + } + finally + { + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(screen); + } + } + + // For testing + internal virtual (int Width, int Height) GetScreenSize() => (Screen.width, Screen.height); + + // For testing + internal virtual void CaptureScreen(RenderTexture target) => ScreenCapture.CaptureScreenshotIntoRenderTexture(target); + + private void RequestReadback(SentryScreenshotRequest request, RenderTexture source) + { + NativeArray buffer; + lock (_lock) + { + buffer = RentBuffer(request, _width * _height * 4); + } + + try + { + // Reads back straight into the buffer, which then gets encoded as is - nothing gets copied on the main thread + AsyncGPUReadback.RequestIntoNativeArray(ref buffer, source, 0, TextureFormat.RGBA32, + readback => OnReadbackComplete(request, readback, buffer)); + } + catch + { + ReturnBuffer(buffer); + throw; + } + } + + private void OnReadbackComplete(SentryScreenshotRequest request, AsyncGPUReadbackRequest readback, NativeArray buffer) + { + if (_disposed || readback.hasError) + { + ReturnBuffer(buffer); + request.Abandon(); + return; + } + + try + { + request.Complete(new SentryScreenshotFrame(buffer, readback.width, readback.height, ReturnBuffer), _options); + } + catch (Exception e) + { + _options.LogError(e, "Failed to read back the screenshot."); + ReturnBuffer(buffer); + request.Abandon(); + } + } + + private SentryScreenshotFrame ReadPixels(SentryScreenshotRequest request, RenderTexture source) + { + NativeArray buffer; + lock (_lock) + { + buffer = RentBuffer(request, _width * _height * 4); + } + + var texture = new Texture2D(_width, _height, TextureFormat.RGBA32, false); + try + { + RenderTexture.active = source; + texture.ReadPixels(new Rect(0, 0, _width, _height), 0, 0); + buffer.CopyFrom(texture.GetRawTextureData()); + return new SentryScreenshotFrame(buffer, _width, _height, ReturnBuffer); + } + catch + { + ReturnBuffer(buffer); + throw; + } + finally + { + UnityEngine.Object.Destroy(texture); + } + } + + private void EnsureTextures(int width, int height) + { + if (_capture != null && _width == width && _height == height) + { + return; + } + + ReleaseResources(); + + _capture = CreateRenderTexture(width, height, "Sentry.ScreenshotCapture"); + _flip = SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true; + _width = width; + _height = height; + + _options.LogDebug("Screenshot textures allocated at {0}x{1}.", width, height); + } + + private static RenderTexture CreateRenderTexture(int width, int height, string name) + { + var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = name }; + texture.Create(); + return texture; + } + + /// + /// Releases the GPU resources. Must run on the main thread. + /// + internal void ReleaseResources() + { + ReleaseRenderTexture(ref _capture); + } + + private static void ReleaseRenderTexture(ref RenderTexture? texture) + { + if (texture == null) + { + return; + } + + texture.Release(); + UnityEngine.Object.Destroy(texture); + texture = null; + } + + public void Dispose() + { + _disposed = true; + + // Releases everything waiting, so that flushing on close doesn't wait for screenshots that won't be taken + var waiting = new List(); + lock (_lock) + { + if (_requested is not null) + { + waiting.Add(_requested); + } + + foreach (var reference in _pending) + { + if (reference.TryGetTarget(out var request)) + { + waiting.Add(request); + } + } + + _requested = null; + _latest = null; + _pending.Clear(); + + // Buffers in use get disposed once returned, or here if their request is gone already + ReclaimBuffers(); + while (_freeBuffers.Count > 0) + { + _freeBuffers.Pop().Dispose(); + } + } + + foreach (var request in waiting) + { + request.Abandon(); + } + + // Off the main thread, the coroutine releases the GPU resources once it notices the cache got disposed. + if (MainThreadData.IsMainThread()) + { + ReleaseResources(); + } + } +} diff --git a/src/Sentry.Unity/SentryScreenshotFrame.cs b/src/Sentry.Unity/SentryScreenshotFrame.cs index cad4197df..6644a38fb 100644 --- a/src/Sentry.Unity/SentryScreenshotFrame.cs +++ b/src/Sentry.Unity/SentryScreenshotFrame.cs @@ -1,31 +1,38 @@ using System; using Sentry.Extensibility; +using Unity.Collections; using UnityEngine; using UnityEngine.Experimental.Rendering; namespace Sentry.Unity; /// -/// A frame read back from the GPU. Its pixels never change once the cache published it, so it can get encoded on -/// any thread. +/// A frame read back from the GPU into a buffer rented from the capturer. Its pixels only change through the +/// BeforeSendScreenshotPixels callback right before encoding, so it can get encoded on any thread. /// /// /// The frame gets encoded at most once. The first consumer pays for it and every other event attaching the same frame -/// reuses the result. The pixels are dropped as soon as they are no longer needed: once the frame got encoded and the -/// cache moved on to a newer one. +/// reuses the result. The buffer goes back to the capturer once encoded or released. /// internal sealed class SentryScreenshotFrame { - // Serializes encoders. Retiring never takes it, so the main thread can't get blocked by an encode in progress. - private readonly object _encodeLock = new(); - private volatile byte[]? _pixels; + // Serializes encoding and releasing. The buffer gets handed back only after leaving it, so the capturer's lock is + // never taken while holding this one. + private readonly object _lock = new(); + private readonly Action> _returnBuffer; + private NativeArray _buffer; private byte[]? _encoded; private volatile bool _encodeStarted; - private volatile bool _retired; + private volatile bool _released; - internal SentryScreenshotFrame(byte[] pixels, int width, int height) + /// + /// Wraps a rented buffer holding RGBA32 pixels, bottom row first, which gets handed to + /// once released. + /// + internal SentryScreenshotFrame(NativeArray buffer, int width, int height, Action> returnBuffer) { - _pixels = pixels; + _buffer = buffer; + _returnBuffer = returnBuffer; Width = width; Height = height; } @@ -34,97 +41,159 @@ internal SentryScreenshotFrame(byte[] pixels, int width, int height) internal int Height { get; } /// - /// Set once something holds on to the frame beyond the cache, e.g. an attachment. The cache never reuses the - /// pixel buffer of a pinned frame. Guarded by the cache's lock. + /// Whether the encode has started. Readable without blocking on an encode in progress. /// - internal bool Pinned { get; set; } + internal bool IsEncodeStarted => _encodeStarted; /// - /// Whether the encode has started. Readable without blocking on an encode in progress. + /// Whether the frame can still be encoded, i.e. it either was already or still holds its pixels. Readable without + /// blocking on an encode in progress. /// - internal bool IsEncodeStarted => _encodeStarted; + internal bool IsEncodable => _encodeStarted || !_released; /// - /// Pixels in RGBA32, bottom row first. Null once dropped. + /// Loads the pixels into the texture. Returns false once they're released. /// - internal byte[]? Pixels => _pixels; + internal bool TryLoadInto(Texture2D texture) + { + lock (_lock) + { + if (_released) + { + return false; + } + + texture.LoadRawTextureData(_buffer); + return true; + } + } /// /// Returns the frame encoded as JPG, encoding it on the calling thread if no one did so yet. Returns null if the - /// encode failed. + /// encode failed, the callback discarded the screenshot, or the pixels got released before. /// - internal byte[]? GetEncoded(SentryUnityOptions options) + /// The options. + /// + /// Applied to the pixels right before the first encode. Returning false discards the screenshot. + /// + internal byte[]? GetEncoded(SentryUnityOptions options, Func? beforeEncode = null) { - lock (_encodeLock) + var released = default(NativeArray); + try { - if (_encodeStarted) + lock (_lock) { - return _encoded; + return EncodeLocked(options, beforeEncode, ref released); } + } + finally + { + if (released.IsCreated) + { + _returnBuffer(released); + } + } + } + + // Must be called while holding _lock + private byte[]? EncodeLocked(SentryUnityOptions options, Func? beforeEncode, + ref NativeArray released) + { + if (_encodeStarted) + { + return _encoded; + } - // Taking the reference before flagging the encode as started means Retire, which drops the pixels of - // started encodes, can't pull them away from under this one - var pixels = _pixels; - _encodeStarted = true; + _encodeStarted = true; - if (pixels is null) + if (_released) + { + return null; + } + + try + { + if (beforeEncode is not null && !ApplyBeforeEncode(options, beforeEncode)) { return null; } - try + // Thread safe, so it runs wherever the attachment gets serialized + using var encoded = ImageConversion.EncodeNativeArrayToJPG(_buffer, GraphicsFormat.R8G8B8A8_UNorm, + (uint)Width, (uint)Height, 0, options.ScreenshotCompression); + var bytes = encoded.ToArray(); + + if (bytes is { Length: > 0 }) { - // Thread safe, so it can run wherever the attachment gets serialized - var bytes = ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, - (uint)Width, (uint)Height, 0, options.ScreenshotCompression); - - if (bytes is { Length: > 0 }) - { - _encoded = bytes; - } - else - { - options.LogWarning("Screenshot encoding returned empty data."); - } + _encoded = bytes; } - catch (Exception e) + else { - options.LogError(e, "Failed to encode the screenshot."); + options.LogWarning("Screenshot encoding returned empty data."); } - finally + } + catch (Exception e) + { + options.LogError(e, "Failed to encode the screenshot."); + } + finally + { + released = ReleaseLocked(); + } + + return _encoded; + } + + // Must be called while holding _lock + private bool ApplyBeforeEncode(SentryUnityOptions options, Func beforeEncode) + { + try + { + if (beforeEncode(new SentryScreenshotPixels(_buffer, Width, Height))) { - if (_retired) - { - _pixels = null; - } + return true; } - return _encoded; + options.LogInfo("Screenshot discarded by BeforeSendScreenshotPixels callback."); + return false; + } + catch (Exception e) + { + // The callback might have been about to redact something, so sending the screenshot as is isn't an option + options.LogError(e, "The BeforeSendScreenshotPixels callback threw an exception. Discarding the screenshot."); + return false; } } /// - /// Called by the cache, under its lock, once a newer frame replaced this one. Hands back the pixel buffer for - /// reuse if nothing else holds on to the frame. + /// Drops the pixels, handing the buffer back for reuse. Later encodes return null. /// - internal byte[]? Retire() + internal void ReleasePixels() { - _retired = true; + NativeArray released; + lock (_lock) + { + released = ReleaseLocked(); + } - var pixels = _pixels; - if (!Pinned) + if (released.IsCreated) { - // Nothing can encode an unpinned frame, and pinning happens under the cache's lock as well - _pixels = null; - return pixels; + _returnBuffer(released); } + } - // A pinned frame keeps its pixels until its encode started. An encode in progress holds its own reference. - if (_encodeStarted) + // Must be called while holding _lock + private NativeArray ReleaseLocked() + { + if (_released) { - _pixels = null; + return default; } - return null; + _released = true; + + var buffer = _buffer; + _buffer = default; + return buffer; } } diff --git a/src/Sentry.Unity/SentryScreenshotPixels.cs b/src/Sentry.Unity/SentryScreenshotPixels.cs new file mode 100644 index 000000000..53e38413c --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotPixels.cs @@ -0,0 +1,35 @@ +using Unity.Collections; + +namespace Sentry.Unity; + +/// +/// The pixels of a screenshot before it gets compressed and attached to an event. +/// +/// +/// Only valid for the duration of the callback it gets handed to - don't hold on to beyond it. +/// +public readonly struct SentryScreenshotPixels +{ + internal SentryScreenshotPixels(NativeArray data, int width, int height) + { + Data = data; + Width = width; + Height = height; + } + + /// + /// The width of the screenshot in pixels. + /// + public int Width { get; } + + /// + /// The height of the screenshot in pixels. + /// + public int Height { get; } + + /// + /// The pixels in RGBA32: four bytes per pixel - red, green, blue and alpha - with rows ordered from the bottom of the + /// screen to the top. Edits made to it end up in the attached screenshot. + /// + public NativeArray Data { get; } +} diff --git a/src/Sentry.Unity/SentryScreenshotRequest.cs b/src/Sentry.Unity/SentryScreenshotRequest.cs new file mode 100644 index 000000000..e6bc83895 --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotRequest.cs @@ -0,0 +1,169 @@ +using System; +using System.Threading.Tasks; +using Sentry.Extensibility; +using UnityEngine; + +namespace Sentry.Unity; + +/// +/// A screenshot requested by one or more events, taken at the end of the frame they were captured in. +/// +/// +/// Attachments hold on to the request and wait for it only once the SDK serializes their envelope on its background +/// worker. Completion runs on the main thread, so continuations are forced off of it. +/// +internal sealed class SentryScreenshotRequest +{ + private readonly TaskCompletionSource _frame = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + // Set under the capturer's lock until taken, read on the main thread after + private Func? _transform; + private TaskCompletionSource? _transformed; + private volatile bool _hasFrameConsumers; + + /// + /// When the frame was taken, in ticks. Zero until then. + /// + internal long TakenTimestamp { get; set; } + + /// + /// Whether an attachment encodes the frame itself, as opposed to only through the transform. + /// + internal bool HasFrameConsumers + { + get => _hasFrameConsumers; + set => _hasFrameConsumers = value; + } + + /// + /// Whether a transform got registered, so its result can be shared. + /// + internal bool HasTransform => _transformed is not null; + + /// + /// Applied to the pixels on the SDK's worker right before the frame gets encoded. Like the transform, it runs once + /// per screenshot: the first event's callback gets applied. + /// + internal Func? BeforeEncode { get; private set; } + + /// + /// Registers the callback applied to the pixels before encoding, unless one already is. Must be called under the + /// capturer's lock. + /// + internal void TrySetBeforeEncode(Func beforeEncode) => BeforeEncode ??= beforeEncode; + + internal Task Frame => _frame.Task; + + internal bool IsCompleted => _frame.Task.IsCompleted; + + /// + /// Whether the request no longer holds on to memory waiting for an encode. + /// + internal bool IsSettled => _frame.Task.IsCompleted && + (!HasFrameConsumers || _frame.Task.Result is not { } frame || frame.IsEncodeStarted); + + /// + /// Registers a transform that gets applied, and its result encoded, on the main thread once the frame is taken. + /// Must be called under the capturer's lock. + /// + /// + /// Applying and encoding it costs the main thread, so it happens once per screenshot: the first event's transform + /// gets applied and every event attaching the screenshot shares the result. Returns null once it's too late to + /// register one, i.e. the frame was taken without. + /// + internal Task? TryAddTransform(Func transform) + { + if (_transformed is { } transformed) + { + return transformed.Task; + } + + if (TakenTimestamp != 0) + { + return null; + } + + _transform = transform; + _transformed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return _transformed.Task; + } + + /// + /// Publishes the frame. Must run on the main thread, as it applies the transforms. + /// + internal void Complete(SentryScreenshotFrame frame, SentryUnityOptions options) + { + // The transform completes before the frame does, so that anything waiting on the frame finds it done and no + // encode can release the pixels from under it. + if (_transform is not null) + { + _transformed!.TrySetResult(ApplyTransform(frame, _transform, options)); + + // Nothing encodes the frame itself, so its pixels - possibly a pooled buffer - can go right away + if (!HasFrameConsumers) + { + frame.ReleasePixels(); + } + } + + // Abandoned in the meantime, e.g. because the worker stopped waiting. Nothing will encode the frame. + if (!_frame.TrySetResult(frame)) + { + frame.ReleasePixels(); + } + } + + /// + /// Releases everything waiting on the request without a screenshot. + /// + internal void Abandon() + { + _transformed?.TrySetResult(null); + _frame.TrySetResult(null); + } + + private static byte[]? ApplyTransform(SentryScreenshotFrame frame, Func transform, + SentryUnityOptions options) + { + Texture2D? screenshot = null; + try + { + screenshot = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false); + if (!frame.TryLoadInto(screenshot)) + { + return null; + } + + screenshot.Apply(); + + var transformed = transform.Invoke(screenshot); + if (transformed == null) + { + options.LogInfo("Screenshot discarded by BeforeSendScreenshot callback."); + return null; + } + + if (transformed != screenshot) + { + UnityEngine.Object.Destroy(screenshot); + screenshot = transformed; + } + + var bytes = screenshot.EncodeToJPG(options.ScreenshotCompression); + return bytes is { Length: > 0 } ? bytes : null; + } + catch (Exception e) + { + options.LogError(e, "The BeforeSendScreenshot callback threw an exception. Skipping the screenshot."); + return null; + } + finally + { + if (screenshot != null) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } +} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 7ee86349b..4a9483f62 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -183,21 +183,18 @@ public sealed class SentryUnityOptions : SentryOptions /// /// The quality of the attached screenshot /// - public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.High; + /// + /// Defaults to . Taking a screenshot at higher resolutions can make the frame it + /// gets taken in miss its display refresh. + /// + public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.Low; /// /// The JPG compression quality of the attached screenshot /// public int ScreenshotCompression { get; set; } = 75; - /// - /// How often the SDK refreshes the screenshot it attaches to error events. Errors are attached the - /// most recently captured frame, so this doubles as the upper bound on how stale that frame can be. - /// Lowering it increases the per-frame rendering cost. Set to zero to capture every frame. - /// - public TimeSpan ScreenshotCaptureInterval { get; set; } = TimeSpan.FromMilliseconds(250); - - internal SentryScreenshotCache? ScreenshotCache { get; set; } + internal SentryScreenshotCapturer? ScreenshotCapturer { get; set; } /// /// Controls whether structured logs should be captured for each Unity log type. @@ -430,11 +427,36 @@ public void SetBeforeCaptureScreenshot(Func beforeAttachScree /// You can modify the texture (blur areas, redact PII, etc.) and return it, or return null to discard. /// /// The callback function to invoke before sending screenshots. + [Obsolete("Operating on a Texture2D ties compressing the screenshot to the main thread, which stalls it. " + + "Use SetBeforeSendScreenshotPixels instead.")] public void SetBeforeSendScreenshot(Func beforeSendScreenshot) { BeforeSendScreenshotInternal = beforeSendScreenshot; } + internal Func? BeforeSendScreenshotPixelsInternal { get; private set; } + + /// + /// Configures a callback to modify or discard screenshots before they are sent, operating on their raw pixels. + /// + /// + /// The callback receives the screenshot's pixels right before they get compressed. Edit them in place - e.g. to + /// redact areas - and return true to send the screenshot, or false to discard it. + /// + /// It gets invoked on a background thread, so it doesn't stall the main thread. Don't call Unity APIs that are + /// restricted to the main thread from it, and don't hold on to the pixels beyond the call. + /// + /// + /// Events captured close together share a screenshot. The callback gets invoked once per screenshot, with the first + /// of those events. Takes precedence over . + /// + /// + /// The callback function to invoke before sending screenshots. + public void SetBeforeSendScreenshotPixels(Func beforeSendScreenshotPixels) + { + BeforeSendScreenshotPixelsInternal = beforeSendScreenshotPixels; + } + internal Func? BeforeCaptureViewHierarchyInternal { get; private set; } internal Func? BeforeSendViewHierarchyInternal { get; private set; } diff --git a/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 4b619a621..2761de02e 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -67,8 +67,8 @@ public void Close() try { ApplicationAdapter.Instance.Quitting -= Close; - _options.ScreenshotCache?.Dispose(); - _options.ScreenshotCache = null; + _options.ScreenshotCapturer?.Dispose(); + _options.ScreenshotCapturer = null; _options.DisposeGameMetricAttributes(); _options.NativeSupportCloseCallback?.Invoke(); _options.NativeSupportCloseCallback = null; @@ -127,13 +127,12 @@ public SentryId CaptureFeedback(string message, string? email, string? name, boo SentryHint? hint = null; if (addScreenshot) { - var screenshotBytes = CaptureFeedbackScreenshot(); - if (screenshotBytes is { Length: > 0 }) + if (CaptureFeedbackScreenshot() is { } content) { hint = SentryHint.WithAttachments( new SentryAttachment( AttachmentType.Default, - new ByteAttachmentContent(screenshotBytes), + content, "screenshot.jpg", "image/jpeg")); } @@ -142,20 +141,21 @@ public SentryId CaptureFeedback(string message, string? email, string? name, boo return Sentry.SentrySdk.CurrentHub.CaptureFeedback(message, email, name, hint: hint); } - private byte[]? CaptureFeedbackScreenshot() + private IAttachmentContent? CaptureFeedbackScreenshot() { - // The cache holds a fully rendered frame. Capturing here instead would happen mid-frame, + // Taken at the end of the frame. Capturing here instead would happen mid-frame, // i.e. while handling the input that submitted the feedback, and yield an incomplete image. - if (_options.ScreenshotCache is { HasContent: true } cache) + if (_options.ScreenshotCapturer is { } capturer) { - return cache.TryEncodeLatest(); + return capturer.TryRequest(); } Texture2D? screenshot = null; try { screenshot = SentryScreenshot.CreateNewScreenshotTexture2D(_options); - return screenshot.EncodeToJPG(_options.ScreenshotCompression); + var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); + return bytes is { Length: > 0 } ? new ByteAttachmentContent(bytes) : null; } finally { diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 10773643d..37c1032f6 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -12,45 +12,34 @@ namespace Sentry.Unity.Tests; public class ScreenshotEventProcessorTests { - private class TestScreenshotCache : SentryScreenshotCache + private class TestScreenshotCapturer : SentryScreenshotCapturer { - public int CacheCallCount { get; private set; } - public byte[]? Bytes { get; set; } = { 1, 2, 3 }; - public Texture2D? TransformInput { get; set; } + public int RequestCount { get; private set; } + public bool Available { get; set; } = true; + public Func? Transform { get; private set; } + public Func? BeforeEncode { get; private set; } - public TestScreenshotCache(SentryUnityOptions options) : base(options) { } + public TestScreenshotCapturer(SentryUnityOptions options) : base(options, useAsyncReadback: false) { } - internal override void Refresh() { } - - internal override IAttachmentContent? TryGetLatestContent() + internal override IAttachmentContent? TryRequest(Func? transform = null, + Func? beforeEncode = null) { - CacheCallCount++; - return Bytes is null ? null : new ByteAttachmentContent(Bytes); - } - - internal override byte[]? TryEncodeLatest(Func? transform = null) - { - CacheCallCount++; - - if (transform is null) - { - return Bytes; - } - - var input = TransformInput ??= new Texture2D(1, 1); - return transform.Invoke(input) is null ? null : Bytes; + RequestCount++; + Transform = transform; + BeforeEncode = beforeEncode; + return Available ? new ByteAttachmentContent(new byte[] { 1, 2, 3 }) : null; } } - private static (ScreenshotEventProcessor Processor, TestScreenshotCache Cache) GetSut(SentryUnityOptions? options = null) + private static (ScreenshotEventProcessor Processor, TestScreenshotCapturer Capturer) GetSut(SentryUnityOptions? options = null) { options ??= new SentryUnityOptions(); - var cache = new TestScreenshotCache(options); - return (new ScreenshotEventProcessor(options, cache), cache); + var capturer = new TestScreenshotCapturer(options); + return (new ScreenshotEventProcessor(options, capturer), capturer); } [Test] - public void Process_CachedScreenshotAvailable_AttachesToHint() + public void Process_ScreenshotAvailable_AttachesToHint() { var (processor, _) = GetSut(); var hint = new SentryHint(); @@ -64,10 +53,10 @@ public void Process_CachedScreenshotAvailable_AttachesToHint() } [Test] - public void Process_CacheReturnsNothing_DoesNotAttach() + public void Process_CapturerReturnsNothing_DoesNotAttach() { - var (processor, cache) = GetSut(); - cache.Bytes = null; + var (processor, capturer) = GetSut(); + capturer.Available = false; var hint = new SentryHint(); processor.Process(new SentryEvent(), hint); @@ -90,30 +79,29 @@ public void Process_MultipleEventsInTheSameFrame_EachGetsAnAttachment() } [Test] - public void Process_BeforeCaptureScreenshotReturnsFalse_SkipsTheCacheEntirely() + public void Process_BeforeCaptureScreenshotReturnsFalse_SkipsTheCapturerEntirely() { var options = new SentryUnityOptions(); options.SetBeforeCaptureScreenshot(_ => false); - var (processor, cache) = GetSut(options); + var (processor, capturer) = GetSut(options); var hint = new SentryHint(); processor.Process(new SentryEvent(), hint); - Assert.AreEqual(0, cache.CacheCallCount); + Assert.AreEqual(0, capturer.RequestCount); Assert.IsEmpty(hint.Attachments); } [Test] public void Process_OffTheMainThread_AttachesToHint() { - // Errors raised on background threads must not lose their screenshot - the cache decides - // whether it can encode on the calling thread. - var (processor, cache) = GetSut(); + // Errors raised on background threads must not lose their screenshot + var (processor, capturer) = GetSut(); var hint = new SentryHint(); Task.Run(() => processor.Process(new SentryEvent(), hint)).Wait(); - Assert.AreEqual(1, cache.CacheCallCount); + Assert.AreEqual(1, capturer.RequestCount); Assert.AreEqual(1, hint.Attachments.Count); } @@ -122,14 +110,14 @@ public void Process_BeforeCaptureScreenshotThrows_ReturnsEventWithoutAttachment( { var options = new SentryUnityOptions(); options.SetBeforeCaptureScreenshot(_ => throw new InvalidOperationException("test")); - var (processor, cache) = GetSut(options); + var (processor, capturer) = GetSut(options); var hint = new SentryHint(); var @event = new SentryEvent(); var processed = processor.Process(@event, hint); Assert.AreSame(@event, processed); - Assert.AreEqual(0, cache.CacheCallCount); + Assert.AreEqual(0, capturer.RequestCount); Assert.IsEmpty(hint.Attachments); } @@ -153,39 +141,41 @@ public void Process_BeforeCaptureScreenshotReceivesEvent() } [Test] - public void Process_BeforeSendScreenshotReturnsNull_DoesNotAttach() + public void Process_NoBeforeSendScreenshot_RequestsWithoutTransform() { - var options = new SentryUnityOptions(); - options.SetBeforeSendScreenshot((_, _) => null); - var (processor, _) = GetSut(options); - var hint = new SentryHint(); + var (processor, capturer) = GetSut(); - processor.Process(new SentryEvent(), hint); + processor.Process(new SentryEvent(), new SentryHint()); - Assert.IsEmpty(hint.Attachments); + Assert.IsNull(capturer.Transform); } [Test] - public void Process_BeforeSendScreenshotReceivesScreenshotAndEvent() + public void Process_BeforeSendScreenshot_PassesTheScreenshotAndEventThroughTheTransform() { var options = new SentryUnityOptions(); Texture2D? receivedScreenshot = null; SentryEvent? receivedEvent = null; +#pragma warning disable CS0618 // Still supported until removed options.SetBeforeSendScreenshot((screenshot, @event) => { receivedScreenshot = screenshot; receivedEvent = @event; return screenshot; }); - var (processor, cache) = GetSut(options); +#pragma warning restore CS0618 + var (processor, capturer) = GetSut(options); var eventId = SentryId.Create(); processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); - Assert.NotNull(receivedScreenshot); - Assert.AreSame(cache.TransformInput, receivedScreenshot); - Assert.NotNull(receivedEvent); + // The capturer applies the transform once the screenshot is taken + Assert.IsNotNull(capturer.Transform); + var screenshot = new Texture2D(1, 1); + Assert.AreSame(screenshot, capturer.Transform!(screenshot)); + Assert.AreSame(screenshot, receivedScreenshot); Assert.AreEqual(eventId, receivedEvent!.EventId); + UnityEngine.Object.Destroy(screenshot); } [UnityTest] @@ -199,7 +189,7 @@ public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() Dsn = SentryTests.TestDsn, CreateHttpMessageHandler = () => httpHandler }; - options.AddEventProcessor(new ScreenshotEventProcessor(options, new TestScreenshotCache(options))); + options.AddEventProcessor(new ScreenshotEventProcessor(options, new TestScreenshotCapturer(options))); SentrySdk.Init(options); @@ -219,4 +209,42 @@ public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() SentrySdk.Close(); } } + + [Test] + public void Process_BeforeSendScreenshotPixelsSet_PassesItToTheCapturerWithTheEvent() + { + var options = new SentryUnityOptions(); + SentryEvent? receivedEvent = null; + options.SetBeforeSendScreenshotPixels((_, @event) => + { + receivedEvent = @event; + return true; + }); + var (processor, capturer) = GetSut(options); + + var eventId = SentryId.Create(); + processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); + + // Applied by the frame right before encoding, on the SDK's worker + Assert.IsNotNull(capturer.BeforeEncode); + Assert.IsNull(capturer.Transform); + Assert.IsTrue(capturer.BeforeEncode!(default)); + Assert.AreEqual(eventId, receivedEvent!.EventId); + } + + [Test] + public void Process_BothScreenshotCallbacksSet_PrefersThePixelsCallback() + { + var options = new SentryUnityOptions(); +#pragma warning disable CS0618 // Still supported until removed + options.SetBeforeSendScreenshot((screenshot, _) => screenshot); +#pragma warning restore CS0618 + options.SetBeforeSendScreenshotPixels((_, _) => true); + var (processor, capturer) = GetSut(options); + + processor.Process(new SentryEvent(), new SentryHint()); + + Assert.IsNotNull(capturer.BeforeEncode); + Assert.IsNull(capturer.Transform); + } } diff --git a/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs deleted file mode 100644 index 6d78690b8..000000000 --- a/test/Sentry.Unity.Tests/SentryScreenshotCacheTests.cs +++ /dev/null @@ -1,525 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using Sentry.Extensibility; -using Sentry.Unity.Tests.Stubs; -using UnityEngine; -using UnityEngine.TestTools; -using Object = UnityEngine.Object; - -namespace Sentry.Unity.Tests; - -public class SentryScreenshotCacheTests -{ - // Captures a known image instead of the screen. Top half red, bottom half blue, so that a mirrored - // readback is detectable. - private class KnownImageCache : SentryScreenshotCache - { - public Texture2D Source { get; set; } - - public int RefreshCount { get; private set; } - - public KnownImageCache(SentryUnityOptions options, Texture2D source, bool useAsyncReadback) - : base(options, useAsyncReadback) - { - Source = source; - } - - internal override void Refresh() - { - RefreshCount++; - base.Refresh(); - } - - internal override void CaptureScreen(RenderTexture target) => Graphics.Blit(Source, target); - } - - private Texture2D _source = null!; - private int? _originalMainThreadId; - - [SetUp] - public void SetUp() - { - _originalMainThreadId = MainThreadData.MainThreadId; - MainThreadData.MainThreadId = Thread.CurrentThread.ManagedThreadId; - - _source = new Texture2D(64, 64, TextureFormat.RGBA32, false); - var pixels = new Color32[64 * 64]; - for (var y = 0; y < 64; y++) - { - for (var x = 0; x < 64; x++) - { - // Texture2D rows start at the bottom - pixels[y * 64 + x] = y >= 32 ? new Color32(255, 0, 0, 255) : new Color32(0, 0, 255, 255); - } - } - _source.SetPixels32(pixels); - _source.Apply(); - } - - [TearDown] - public void TearDown() - { - MainThreadData.MainThreadId = _originalMainThreadId; - Object.Destroy(_source); - } - - private static SentryUnityOptions GetOptions() => new() { ScreenshotQuality = ScreenshotQuality.Low }; - - // WaitForEndOfFrame never resumes in batch mode. Capturing the known image doesn't depend on the frame - // being complete, so refreshing on a regular frame is fine here. - private static IEnumerator RefreshUntilPopulated(SentryScreenshotCache cache) - { - yield return null; - cache.Refresh(); - - for (var i = 0; i < 30 && !cache.HasContent; i++) - { - yield return null; - } - - Assert.IsTrue(cache.HasContent, "The cache did not get populated."); - } - - private static IEnumerator RefreshUntilNewFrame(SentryScreenshotCache cache) - { - var previous = cache.CurrentFrame; - yield return null; - cache.Refresh(); - - for (var i = 0; i < 30 && ReferenceEquals(cache.CurrentFrame, previous); i++) - { - yield return null; - } - - Assert.AreNotSame(previous, cache.CurrentFrame, "The cache did not pick up a new frame."); - } - - private static Texture2D CreateSolid(Color32 color) - { - var texture = new Texture2D(64, 64, TextureFormat.RGBA32, false); - texture.SetPixels32(Enumerable.Repeat(color, 64 * 64).ToArray()); - texture.Apply(); - return texture; - } - - private static byte[] ReadAll(IAttachmentContent content) - { - using var stream = content.GetStream(); - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return memory.ToArray(); - } - - private static Texture2D Decode(byte[]? bytes) - { - Assert.IsNotNull(bytes); - Assert.IsNotEmpty(bytes); - - var texture = new Texture2D(2, 2); - Assert.IsTrue(texture.LoadImage(bytes)); - return texture; - } - - private static void AssertTopIsRedAndBottomIsBlue(Texture2D image, string label) - { - var top = image.GetPixel(image.width / 2, image.height * 3 / 4); - var bottom = image.GetPixel(image.width / 2, image.height / 4); - - // JPG is lossy, so only the dominant channel is compared - Assert.Greater(top.r, top.b, $"{label}: expected the top half to be red but got {top}."); - Assert.Greater(bottom.b, bottom.r, $"{label}: expected the bottom half to be blue but got {bottom}."); - } - - [Test] - public void TryEncodeLatest_CacheEmpty_ReturnsNull() - { - var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - - Assert.IsNull(cache.TryEncodeLatest()); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_SynchronousReadback_ReturnsFrameInOrientationOfTheCapture() - { - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(cache); - - var image = Decode(cache.TryEncodeLatest()); - - // The orientation fix on UV-starts-at-top platforms is applied to whatever gets captured, so the - // known image itself comes out mirrored there. What matters is that both readbacks agree. - if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) - { - FlipVertically(image); - } - - AssertTopIsRedAndBottomIsBlue(image, "synchronous readback"); - Object.Destroy(image); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_AsyncReadback_MatchesSynchronousReadback() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var asyncCache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - using var syncCache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(asyncCache); - yield return RefreshUntilPopulated(syncCache); - - var asyncImage = Decode(asyncCache.TryEncodeLatest()); - var syncImage = Decode(syncCache.TryEncodeLatest()); - - var asyncTop = asyncImage.GetPixel(asyncImage.width / 2, asyncImage.height * 3 / 4); - var syncTop = syncImage.GetPixel(syncImage.width / 2, syncImage.height * 3 / 4); - Assert.AreEqual(syncTop.r > syncTop.b, asyncTop.r > asyncTop.b, - $"Async readback is mirrored compared to the synchronous one. Async top: {asyncTop}, sync top: {syncTop}."); - - Object.Destroy(asyncImage); - Object.Destroy(syncImage); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_AsyncReadback_EncodesOffTheMainThread() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - var task = Task.Run(() => cache.TryEncodeLatest()); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsNotNull(task.Result); - Assert.IsNotEmpty(task.Result); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_SynchronousReadbackOffTheMainThread_ReturnsNull() - { - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(cache); - - var task = Task.Run(() => cache.TryEncodeLatest()); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsNull(task.Result); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_TransformOffTheMainThread_ReturnsNull() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - var transformInvoked = false; - var task = Task.Run(() => cache.TryEncodeLatest(texture => - { - transformInvoked = true; - return texture; - })); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsNull(task.Result); - Assert.IsFalse(transformInvoked); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_SameFrame_EncodesOnce() - { - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(cache); - - var first = cache.TryEncodeLatest(); - var second = cache.TryEncodeLatest(); - - Assert.IsNotNull(first); - Assert.AreSame(first, second); - } - - [UnityTest] - public IEnumerator TryEncodeLatest_WithTransform_PassesTheFrameAndEncodesTheResult() - { - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(cache); - - Texture2D? received = null; - var bytes = cache.TryEncodeLatest(texture => - { - received = texture; - return texture; - }); - - Assert.IsNotNull(received); - Assert.IsNotNull(bytes); - Assert.IsNotEmpty(bytes); - } - - [UnityTest] - public IEnumerator Dispose_ClearsTheCache() - { - var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - yield return RefreshUntilPopulated(cache); - - cache.Dispose(); - cache.Refresh(); - - Assert.IsTrue(cache.IsDisposed); - Assert.IsFalse(cache.HasContent); - Assert.IsNull(cache.TryEncodeLatest()); - } - - [UnityTest] - public IEnumerator Dispose_StopsTheRefreshCoroutine() - { - var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - var gameObject = new GameObject("ScreenshotCacheTest"); - var behaviour = gameObject.AddComponent(); - - try - { - behaviour.StartScreenshotCache(cache, System.TimeSpan.Zero, endOfFrame: null); - yield return WaitFor(() => cache.RefreshCount > 0); - - cache.Dispose(); - yield return null; - var refreshCount = cache.RefreshCount; - - for (var i = 0; i < 5; i++) - { - yield return null; - } - - Assert.AreEqual(refreshCount, cache.RefreshCount); - } - finally - { - Object.Destroy(gameObject); - } - } - - [UnityTest] - public IEnumerator Dispose_OffTheMainThread_DefersReleaseToTheRefreshCoroutine() - { - var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: false); - var gameObject = new GameObject("ScreenshotCacheTest"); - var behaviour = gameObject.AddComponent(); - - try - { - behaviour.StartScreenshotCache(cache, System.TimeSpan.Zero, endOfFrame: null); - yield return WaitFor(() => cache.RefreshCount > 0); - - // Releasing GPU resources off the main thread throws, which would abort the SDK's Close(). - var task = Task.Run(cache.Dispose); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsNull(task.Exception); - Assert.IsTrue(cache.IsDisposed); - } - finally - { - Object.Destroy(gameObject); - } - } - - private static IEnumerator WaitFor(System.Func condition, int maxFrames = 300) - { - for (var i = 0; i < maxFrames && !condition(); i++) - { - yield return null; - } - - Assert.IsTrue(condition(), "Timed out waiting for the condition."); - } - - [UnityTest] - public IEnumerator TryGetLatestContent_AsyncReadback_DefersEncodeUntilRead() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - var content = cache.TryGetLatestContent(); - - var deferred = content as SentryScreenshotAttachmentContent; - Assert.IsNotNull(deferred); - Assert.IsFalse(deferred!.Frame.IsEncodeStarted, "Attaching must not encode."); - - // The SDK checks the length when building the envelope. Reporting one would force the encode right there. - using (var stream = content!.GetStream()) - { - Assert.IsFalse(stream.CanSeek); - Assert.Throws(() => _ = stream.Length); - } - Assert.IsFalse(deferred.Frame.IsEncodeStarted, "Opening the stream must not encode."); - - // Reading happens on the SDK's background worker - var task = Task.Run(() => ReadAll(content)); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsTrue(deferred.Frame.IsEncodeStarted); - var image = Decode(task.Result); - Object.Destroy(image); - } - - [UnityTest] - public IEnumerator TryGetLatestContent_SameFrame_EncodesOnce() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - var first = (SentryScreenshotAttachmentContent)cache.TryGetLatestContent()!; - var second = (SentryScreenshotAttachmentContent)cache.TryGetLatestContent()!; - ReadAll(first); - - Assert.AreSame(first.Frame, second.Frame); - Assert.AreSame(first.Frame.GetEncoded(GetOptions()), second.Frame.GetEncoded(GetOptions())); - CollectionAssert.AreEqual(ReadAll(first), ReadAll(second)); - } - - [UnityTest] - public IEnumerator TryGetLatestContent_LaterReadbacks_DoNotOverwriteAttachedFrame() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - var red = CreateSolid(new Color32(255, 0, 0, 255)); - var green = CreateSolid(new Color32(0, 255, 0, 255)); - using var cache = new KnownImageCache(GetOptions(), red, useAsyncReadback: true); - - try - { - yield return RefreshUntilPopulated(cache); - var attached = cache.TryGetLatestContent()!; - - // Readbacks reuse buffers of frames nothing holds on to. Two of them cycle through every spare buffer. - cache.Source = green; - yield return RefreshUntilNewFrame(cache); - yield return RefreshUntilNewFrame(cache); - - var image = Decode(ReadAll(attached)); - var pixel = image.GetPixel(image.width / 2, image.height / 2); - Assert.Greater(pixel.r, pixel.g, $"Expected the attached frame to stay red but got {pixel}."); - Object.Destroy(image); - } - finally - { - Object.Destroy(red); - Object.Destroy(green); - } - } - - [UnityTest] - public IEnumerator TryGetLatestContent_TooManyPendingFrames_EncodesRightAway() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - using var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - // Kept alive, as the cache stops counting frames no attachment holds on to anymore - var pending = new List(); - for (var i = 0; i < SentryScreenshotCache.MaxPendingFrames; i++) - { - pending.Add(cache.TryGetLatestContent()!); - Assert.IsInstanceOf(pending[i]); - yield return RefreshUntilNewFrame(cache); - } - - var content = cache.TryGetLatestContent(); - - Assert.IsInstanceOf(content); - Assert.IsNotEmpty(((ByteAttachmentContent)content!).Bytes); - GC.KeepAlive(pending); - } - - [UnityTest] - public IEnumerator TryGetLatestContent_ThroughTheSdk_EncodedScreenshotRidesAlongTheEvent() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - - var cache = new KnownImageCache(GetOptions(), _source, useAsyncReadback: true); - yield return RefreshUntilPopulated(cache); - - var httpHandler = new TestHttpClientHandler("DeferredScreenshotEnvelopeTest"); - var options = new SentryUnityOptions(application: new TestApplication()) - { - Dsn = SentryTests.TestDsn, - CreateHttpMessageHandler = () => httpHandler - }; - options.AddEventProcessor(new ScreenshotEventProcessor(options, cache)); - - SentrySdk.Init(options); - - try - { - SentrySdk.CaptureMessage("deferred screenshot"); - - var task = Task.Run(() => httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(5))); - yield return WaitFor(() => task.IsCompleted, 600); - - var request = task.Result; - StringAssert.Contains("deferred screenshot", request); - - // The item header carries the length of the payload, which is only known once encoded - var header = Regex.Match(request, @"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); - Assert.IsTrue(header.Success, "No screenshot item header found."); - var length = Regex.Match(header.Value, @"""length"":(\d+)"); - Assert.IsTrue(length.Success, $"No length in the screenshot item header: {header.Value}"); - Assert.Greater(int.Parse(length.Groups[1].Value), 0); - } - finally - { - SentrySdk.Close(); - cache.Dispose(); - } - } - - private static void FlipVertically(Texture2D texture) - { - var pixels = texture.GetPixels32(); - var flipped = new Color32[pixels.Length]; - for (var y = 0; y < texture.height; y++) - { - System.Array.Copy(pixels, y * texture.width, flipped, (texture.height - 1 - y) * texture.width, texture.width); - } - texture.SetPixels32(flipped); - texture.Apply(); - } -} diff --git a/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs new file mode 100644 index 000000000..adf41ddbc --- /dev/null +++ b/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs @@ -0,0 +1,746 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Sentry.Extensibility; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace Sentry.Unity.Tests; + +public class SentryScreenshotCapturerTests +{ + // Captures a known image instead of the screen. Top half red, bottom half blue, so that a mirrored + // readback is detectable. + private class KnownImageCapturer : SentryScreenshotCapturer + { + public Texture2D Source { get; set; } + + public int CaptureCount { get; private set; } + + public Vector2Int CaptureSize { get; private set; } + + public Vector2Int? ScreenSize { get; set; } + + internal override (int Width, int Height) GetScreenSize() => + ScreenSize is { } size ? (size.x, size.y) : base.GetScreenSize(); + + public KnownImageCapturer(SentryUnityOptions options, Texture2D source, bool useAsyncReadback) + : base(options, useAsyncReadback) + { + Source = source; + } + + internal override void CaptureScreen(RenderTexture target) + { + CaptureCount++; + CaptureSize = new Vector2Int(target.width, target.height); + Graphics.Blit(Source, target); + } + } + + private Texture2D _source = null!; + private int? _originalMainThreadId; + + [SetUp] + public void SetUp() + { + _originalMainThreadId = MainThreadData.MainThreadId; + MainThreadData.MainThreadId = Thread.CurrentThread.ManagedThreadId; + + _source = new Texture2D(64, 64, TextureFormat.RGBA32, false); + var pixels = new Color32[64 * 64]; + for (var y = 0; y < 64; y++) + { + for (var x = 0; x < 64; x++) + { + // Texture2D rows start at the bottom + pixels[y * 64 + x] = y >= 32 ? new Color32(255, 0, 0, 255) : new Color32(0, 0, 255, 255); + } + } + _source.SetPixels32(pixels); + _source.Apply(); + } + + [TearDown] + public void TearDown() + { + MainThreadData.MainThreadId = _originalMainThreadId; + Object.Destroy(_source); + } + + private static SentryUnityOptions GetOptions() => new() { ScreenshotQuality = ScreenshotQuality.Low }; + + private static void RequireAsyncReadback() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + Assert.Ignore("Async GPU readback is not supported on this graphics device."); + } + } + + private static SentryScreenshotAttachmentContent Request(SentryScreenshotCapturer capturer, + Func? transform = null, Func? beforeEncode = null) + { + var content = capturer.TryRequest(transform, beforeEncode); + Assert.IsInstanceOf(content); + return (SentryScreenshotAttachmentContent)content!; + } + + // WaitForEndOfFrame never resumes in batch mode. Capturing the known image doesn't depend on the frame + // being complete, so taking it on a regular frame is fine here. + private static IEnumerator TakeScreenshot(SentryScreenshotCapturer capturer, params SentryScreenshotAttachmentContent[] contents) + { + yield return null; + capturer.OnEndOfFrame(); + + yield return WaitFor(() => contents.All(content => content.Request.IsCompleted)); + } + + // Reading happens on the SDK's background worker + private static IEnumerator ReadOnWorker(IAttachmentContent content, Action onRead) + { + var task = Task.Run(() => ReadAll(content)); + yield return WaitFor(() => task.IsCompleted, 600); + onRead(task.Result); + } + + private static Texture2D CreateSolid(Color32 color) + { + var texture = new Texture2D(64, 64, TextureFormat.RGBA32, false); + texture.SetPixels32(Enumerable.Repeat(color, 64 * 64).ToArray()); + texture.Apply(); + return texture; + } + + private static byte[] ReadAll(IAttachmentContent content) + { + using var stream = content.GetStream(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private static Texture2D Decode(byte[]? bytes) + { + Assert.IsNotNull(bytes); + Assert.IsNotEmpty(bytes); + + var texture = new Texture2D(2, 2); + Assert.IsTrue(texture.LoadImage(bytes)); + return texture; + } + + private static void AssertTopIsRedAndBottomIsBlue(Texture2D image, string label) + { + var top = image.GetPixel(image.width / 2, image.height * 3 / 4); + var bottom = image.GetPixel(image.width / 2, image.height / 4); + + // JPG is lossy, so only the dominant channel is compared + Assert.Greater(top.r, top.b, $"{label}: expected the top half to be red but got {top}."); + Assert.Greater(bottom.b, bottom.r, $"{label}: expected the bottom half to be blue but got {bottom}."); + } + + private static IEnumerator WaitFor(Func condition, int maxFrames = 300) + { + for (var i = 0; i < maxFrames && !condition(); i++) + { + yield return null; + } + + Assert.IsTrue(condition(), "Timed out waiting for the condition."); + } + + [Test] + public void TryRequest_DoesNotTakeTheScreenshotRightAway() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + var content = Request(capturer); + + Assert.IsTrue(capturer.HasRequest); + Assert.IsFalse(content.Request.IsCompleted); + Assert.AreEqual(0, capturer.CaptureCount); + } + + [UnityTest] + public IEnumerator TryRequest_ScreenLargerThanTarget_CapturesAtScreenResolutionAndScalesDown() + { + // The capture copies the screen into the target without scaling it, so capturing into a target smaller than + // the screen would crop the screenshot + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false) + { + ScreenSize = new Vector2Int(1000, 2000) + }; + var content = Request(capturer); + yield return TakeScreenshot(capturer, content); + + byte[] bytes = null!; + yield return ReadOnWorker(content, read => bytes = read); + var image = Decode(bytes); + + Assert.AreEqual(new Vector2Int(1000, 2000), capturer.CaptureSize); + Assert.AreEqual(SentryScreenshot.GetTargetSize(ScreenshotQuality.Low, 1000, 2000), + (image.width, image.height)); + Object.Destroy(image); + } + + [UnityTest] + public IEnumerator TryRequest_SynchronousReadback_ReturnsFrameInOrientationOfTheCapture() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + yield return TakeScreenshot(capturer, content); + + byte[] bytes = null!; + yield return ReadOnWorker(content, read => bytes = read); + var image = Decode(bytes); + + // The orientation fix on UV-starts-at-top platforms is applied to whatever gets captured, so the + // known image itself comes out mirrored there. What matters is that both readbacks agree. + if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) + { + FlipVertically(image); + } + + AssertTopIsRedAndBottomIsBlue(image, "synchronous readback"); + Object.Destroy(image); + } + + [UnityTest] + public IEnumerator TryRequest_AsyncReadback_MatchesSynchronousReadback() + { + RequireAsyncReadback(); + + using var asyncCapturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); + using var syncCapturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var asyncContent = Request(asyncCapturer); + var syncContent = Request(syncCapturer); + yield return TakeScreenshot(asyncCapturer, asyncContent); + yield return TakeScreenshot(syncCapturer, syncContent); + + byte[] asyncBytes = null!; + byte[] syncBytes = null!; + yield return ReadOnWorker(asyncContent, read => asyncBytes = read); + yield return ReadOnWorker(syncContent, read => syncBytes = read); + var asyncImage = Decode(asyncBytes); + var syncImage = Decode(syncBytes); + + var asyncTop = asyncImage.GetPixel(asyncImage.width / 2, asyncImage.height * 3 / 4); + var syncTop = syncImage.GetPixel(syncImage.width / 2, syncImage.height * 3 / 4); + Assert.AreEqual(syncTop.r > syncTop.b, asyncTop.r > asyncTop.b, + $"Async readback is mirrored compared to the synchronous one. Async top: {asyncTop}, sync top: {syncTop}."); + + Object.Destroy(asyncImage); + Object.Destroy(syncImage); + } + + [UnityTest] + public IEnumerator TryRequest_DefersEncodeUntilRead() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + yield return TakeScreenshot(capturer, content); + + var frame = content.Request.Frame.Result!; + Assert.IsFalse(frame.IsEncodeStarted, "Taking the screenshot must not encode."); + + // The SDK checks the length when building the envelope. Reporting one would force the wait and the encode. + using (var stream = content.GetStream()) + { + Assert.IsFalse(stream.CanSeek); + Assert.Throws(() => _ = stream.Length); + } + Assert.IsFalse(frame.IsEncodeStarted, "Opening the stream must not encode."); + + byte[] bytes = null!; + yield return ReadOnWorker(content, read => bytes = read); + + Assert.IsTrue(frame.IsEncodeStarted); + Object.Destroy(Decode(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_ReadOnTheWorkerBeforeTaken_WaitsForTheEndOfFrame() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + + var task = Task.Run(() => ReadAll(content)); + yield return null; + Assert.IsFalse(task.IsCompleted, "Reading must wait for the screenshot to be taken."); + + capturer.OnEndOfFrame(); + yield return WaitFor(() => task.IsCompleted, 600); + + Object.Destroy(Decode(task.Result)); + } + + [Test] + public void TryRequest_ReadOnTheMainThreadBeforeTaken_ReturnsEmptyWithoutWaiting() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + + // Waiting here would deadlock, as taking the screenshot needs the main thread + Assert.IsEmpty(ReadAll(content)); + } + + [UnityTest] + public IEnumerator TryRequest_SameFrame_SharesTheScreenshotAndEncodesOnce() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var first = Request(capturer); + var second = Request(capturer); + yield return TakeScreenshot(capturer, first, second); + + Assert.AreSame(first.Request, second.Request); + Assert.AreEqual(1, capturer.CaptureCount); + + byte[] firstBytes = null!; + byte[] secondBytes = null!; + yield return ReadOnWorker(first, read => firstBytes = read); + yield return ReadOnWorker(second, read => secondBytes = read); + CollectionAssert.AreEqual(firstBytes, secondBytes); + } + + [UnityTest] + public IEnumerator TryRequest_WithinMinCaptureInterval_SharesTheLatestScreenshot() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var first = Request(capturer); + yield return TakeScreenshot(capturer, first); + + var second = Request(capturer); + + Assert.AreSame(first.Request, second.Request); + Assert.IsFalse(capturer.HasRequest); + } + + [UnityTest] + public IEnumerator TryRequest_AfterMinCaptureInterval_TakesANewScreenshot() + { + var red = CreateSolid(new Color32(255, 0, 0, 255)); + var green = CreateSolid(new Color32(0, 255, 0, 255)); + using var capturer = new KnownImageCapturer(GetOptions(), red, useAsyncReadback: false); + + try + { + var first = Request(capturer); + yield return TakeScreenshot(capturer, first); + + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); + capturer.Source = green; + var second = Request(capturer); + yield return TakeScreenshot(capturer, second); + + Assert.AreNotSame(first.Request, second.Request); + + // The first screenshot stays as it was taken + byte[] bytes = null!; + yield return ReadOnWorker(first, read => bytes = read); + var image = Decode(bytes); + var pixel = image.GetPixel(image.width / 2, image.height / 2); + Assert.Greater(pixel.r, pixel.g, $"Expected the first screenshot to stay red but got {pixel}."); + Object.Destroy(image); + } + finally + { + Object.Destroy(red); + Object.Destroy(green); + } + } + + [UnityTest] + public IEnumerator TryRequest_TooManyPendingScreenshots_SharesTheLatest() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + // Kept alive, as screenshots no attachment holds on to anymore don't count + var pending = new List(); + for (var i = 0; i < SentryScreenshotCapturer.MaxPendingRequests; i++) + { + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); + pending.Add(Request(capturer)); + yield return TakeScreenshot(capturer, pending[i]); + } + + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); + var content = Request(capturer); + + Assert.AreSame(pending.Last().Request, content.Request); + Assert.IsFalse(capturer.HasRequest); + GC.KeepAlive(pending); + } + + [UnityTest] + public IEnumerator TryRequest_NotTakenInTime_ResolvesEmptyAndSkipsUntilTheNextEndOfFrame() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + + var task = Task.Run(() => ReadAll(content)); + var deadline = Time.realtimeSinceStartup + (float)SentryScreenshotCapturer.CaptureTimeout.TotalSeconds * 3; + while (!task.IsCompleted && Time.realtimeSinceStartup < deadline) + { + yield return null; + } + + Assert.IsTrue(task.IsCompleted, "Reading must give up once the screenshot didn't get taken in time."); + Assert.IsEmpty(task.Result); + Assert.IsNull(capturer.TryRequest(), "While the main thread doesn't render, no screenshot can be taken."); + + capturer.OnEndOfFrame(); + + Assert.IsNotNull(capturer.TryRequest()); + } + + [UnityTest] + public IEnumerator TryRequest_WithTransform_AppliesItWhenTakenAndEncodesTheResult() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + Texture2D? received = null; + var transformedOnMainThread = false; + var content = Request(capturer, texture => + { + received = texture; + transformedOnMainThread = MainThreadData.IsMainThread(); + return texture; + }); + Assert.IsNull(received, "The transform must wait for the screenshot to be taken."); + + yield return TakeScreenshot(capturer, content); + + Assert.IsNotNull(received); + Assert.IsTrue(transformedOnMainThread); + + byte[] bytes = null!; + yield return ReadOnWorker(content, read => bytes = read); + Object.Destroy(Decode(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_TransformDiscardsTheScreenshot_ResolvesEmpty() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var discarded = Request(capturer, _ => null); + var kept = Request(capturer); + yield return TakeScreenshot(capturer, discarded, kept); + + byte[] discardedBytes = null!; + byte[] keptBytes = null!; + yield return ReadOnWorker(discarded, read => discardedBytes = read); + yield return ReadOnWorker(kept, read => keptBytes = read); + + Assert.IsEmpty(discardedBytes); + Assert.IsNotEmpty(keptBytes, "A transform only affects the event it was applied for."); + } + + [UnityTest] + public IEnumerator TryRequest_TransformInTheSameFrame_RunsOnceAndSharesTheResult() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var calls = 0; + Texture2D? Transform(Texture2D texture) + { + calls++; + return texture; + } + + // Applying and encoding the transform costs the main thread, so a burst of events must not multiply it + var first = Request(capturer, Transform); + var second = Request(capturer, Transform); + yield return TakeScreenshot(capturer, first, second); + + byte[]? firstBytes = null; + byte[]? secondBytes = null; + yield return ReadOnWorker(first, bytes => firstBytes = bytes); + yield return ReadOnWorker(second, bytes => secondBytes = bytes); + + Assert.AreEqual(1, calls); + Assert.IsNotEmpty(firstBytes); + CollectionAssert.AreEqual(firstBytes, secondBytes); + } + + [UnityTest] + public IEnumerator TryRequest_TransformWithinMinCaptureInterval_SharesTheTransformedScreenshot() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var calls = 0; + Texture2D? Transform(Texture2D texture) + { + calls++; + return texture; + } + + var first = Request(capturer, Transform); + yield return TakeScreenshot(capturer, first); + + var second = Request(capturer, Transform); + + Assert.AreSame(first.Request, second.Request); + Assert.IsFalse(capturer.HasRequest); + Assert.AreEqual(1, calls); + } + + [UnityTest] + public IEnumerator TryRequest_AsyncReadback_ReturnsTheBufferOnceEncodedAndReusesIt() + { + RequireAsyncReadback(); + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); + + var first = Request(capturer); + yield return TakeScreenshot(capturer, first); + Assert.AreEqual(1, capturer.UsedBufferCount, "The frame holds its buffer until encoded."); + + yield return ReadOnWorker(first, bytes => Assert.IsNotEmpty(bytes)); + Assert.AreEqual(0, capturer.UsedBufferCount); + Assert.AreEqual(1, capturer.FreeBufferCount); + + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds + 0.05f); + var second = Request(capturer); + Assert.AreNotSame(first.Request, second.Request); + yield return TakeScreenshot(capturer, second); + + // Reused rather than allocating another one + Assert.AreEqual(1, capturer.UsedBufferCount); + Assert.AreEqual(0, capturer.FreeBufferCount); + + byte[]? secondBytes = null; + yield return ReadOnWorker(second, bytes => secondBytes = bytes); + var image = Decode(secondBytes); + Object.Destroy(image); + Assert.AreEqual(1, capturer.FreeBufferCount); + } + + [UnityTest] + public IEnumerator TryRequest_AsyncReadbackWithOnlyATransform_ReturnsTheBufferRightAway() + { + RequireAsyncReadback(); + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); + + var content = Request(capturer, texture => texture); + yield return TakeScreenshot(capturer, content); + + // The transform already encoded what gets sent, nothing else needs the pixels + Assert.AreEqual(0, capturer.UsedBufferCount); + Assert.AreEqual(1, capturer.FreeBufferCount); + yield return ReadOnWorker(content, bytes => Assert.IsNotEmpty(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_BeforeEncode_RunsOnTheWorkerAndItsEditsGetEncoded() + { + RequireAsyncReadback(); + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); + var mainThreadId = Thread.CurrentThread.ManagedThreadId; + int? callbackThreadId = null; + + var content = Request(capturer, beforeEncode: pixels => + { + callbackThreadId = Thread.CurrentThread.ManagedThreadId; + + // Paints the whole screenshot green, as a redaction would paint over an area + var data = pixels.Data; + for (var i = 0; i < data.Length; i += 4) + { + data[i] = 0; + data[i + 1] = 255; + data[i + 2] = 0; + } + + return true; + }); + yield return TakeScreenshot(capturer, content); + + Assert.IsNull(callbackThreadId, "The callback must not run before the attachment gets read."); + + byte[]? bytes = null; + yield return ReadOnWorker(content, read => bytes = read); + + Assert.IsNotNull(callbackThreadId); + Assert.AreNotEqual(mainThreadId, callbackThreadId); + var image = Decode(bytes); + var pixel = image.GetPixel(image.width / 2, image.height / 2); + Assert.Greater(pixel.g, pixel.r, $"Expected the edited screenshot to be green but got {pixel}."); + Object.Destroy(image); + } + + [UnityTest] + public IEnumerator TryRequest_BeforeEncodeReturnsFalse_ResolvesEmpty() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + var content = Request(capturer, beforeEncode: _ => false); + yield return TakeScreenshot(capturer, content); + + yield return ReadOnWorker(content, bytes => Assert.IsEmpty(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_BeforeEncodeThrows_ResolvesEmpty() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + // It might have been about to redact something, so the screenshot must not go out as is + var content = Request(capturer, beforeEncode: _ => throw new InvalidOperationException("test")); + yield return TakeScreenshot(capturer, content); + + yield return ReadOnWorker(content, bytes => Assert.IsEmpty(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_BeforeEncodeInTheSameFrame_RunsOnce() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var calls = 0; + bool BeforeEncode(SentryScreenshotPixels _) + { + Interlocked.Increment(ref calls); + return true; + } + + var first = Request(capturer, beforeEncode: BeforeEncode); + var second = Request(capturer, beforeEncode: BeforeEncode); + yield return TakeScreenshot(capturer, first, second); + + yield return ReadOnWorker(first, bytes => Assert.IsNotEmpty(bytes)); + yield return ReadOnWorker(second, bytes => Assert.IsNotEmpty(bytes)); + Assert.AreEqual(1, calls); + } + + [UnityTest] + public IEnumerator Dispose_ReleasesPendingRequests() + { + var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer); + + capturer.Dispose(); + + byte[] bytes = null!; + yield return ReadOnWorker(content, read => bytes = read); + Assert.IsEmpty(bytes); + Assert.IsTrue(capturer.IsDisposed); + Assert.IsNull(capturer.TryRequest()); + } + + [UnityTest] + public IEnumerator Dispose_StopsTheCoroutine() + { + var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var gameObject = new GameObject("ScreenshotCapturerTest"); + var behaviour = gameObject.AddComponent(); + + try + { + behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); + Request(capturer); + yield return WaitFor(() => capturer.CaptureCount > 0); + + capturer.Dispose(); + yield return null; + capturer.TryRequest(); + + for (var i = 0; i < 5; i++) + { + yield return null; + } + + Assert.AreEqual(1, capturer.CaptureCount); + } + finally + { + Object.Destroy(gameObject); + } + } + + [UnityTest] + public IEnumerator Dispose_OffTheMainThread_DefersReleaseToTheCoroutine() + { + var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var gameObject = new GameObject("ScreenshotCapturerTest"); + var behaviour = gameObject.AddComponent(); + + try + { + behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); + Request(capturer); + yield return WaitFor(() => capturer.CaptureCount > 0); + + // Releasing GPU resources off the main thread throws, which would abort the SDK's Close(). + var task = Task.Run(capturer.Dispose); + yield return WaitFor(() => task.IsCompleted); + + Assert.IsNull(task.Exception); + Assert.IsTrue(capturer.IsDisposed); + } + finally + { + Object.Destroy(gameObject); + } + } + + [UnityTest] + public IEnumerator TryRequest_ThroughTheSdk_ScreenshotRidesAlongTheEvent() + { + RequireAsyncReadback(); + + var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); + var httpHandler = new TestHttpClientHandler("DeferredScreenshotEnvelopeTest"); + var options = new SentryUnityOptions(application: new TestApplication()) + { + Dsn = SentryTests.TestDsn, + CreateHttpMessageHandler = () => httpHandler + }; + options.AddEventProcessor(new ScreenshotEventProcessor(options, capturer)); + + SentrySdk.Init(options); + + try + { + SentrySdk.CaptureMessage("deferred screenshot"); + + // The worker is now waiting for the screenshot + yield return null; + capturer.OnEndOfFrame(); + + var task = Task.Run(() => httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(5))); + yield return WaitFor(() => task.IsCompleted, 600); + + var request = task.Result; + StringAssert.Contains("deferred screenshot", request); + + // The item header carries the length of the payload, which is only known once encoded + var header = Regex.Match(request, @"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); + Assert.IsTrue(header.Success, "No screenshot item header found."); + var length = Regex.Match(header.Value, @"""length"":(\d+)"); + Assert.IsTrue(length.Success, $"No length in the screenshot item header: {header.Value}"); + Assert.Greater(int.Parse(length.Groups[1].Value), 0); + } + finally + { + SentrySdk.Close(); + capturer.Dispose(); + } + } + + private static void FlipVertically(Texture2D texture) + { + var pixels = texture.GetPixels32(); + var flipped = new Color32[pixels.Length]; + for (var y = 0; y < texture.height; y++) + { + Array.Copy(pixels, y * texture.width, flipped, (texture.height - 1 - y) * texture.width, texture.width); + } + texture.SetPixels32(flipped); + texture.Apply(); + } +} From a83f1c1c17aab4fb93682144ced76670e889837d Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Tue, 20 Oct 2026 17:24:49 +0200 Subject: [PATCH 06/15] skip screenshots of dropped events and warm up capture at startup --- CHANGELOG.md | 14 +- docs/agent-guides/integrations.md | 2 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 6 + .../SentryMonoBehaviour.Screenshot.cs | 6 + .../SentryScreenshotAttachmentContent.cs | 18 +- src/Sentry.Unity/SentryScreenshotCapturer.cs | 72 +++++++- src/Sentry.Unity/SentryScreenshotRequest.cs | 18 ++ src/Sentry.Unity/SentryUnitySdk.cs | 10 +- src/Sentry.Unity/UnityWebRequestTransport.cs | 9 + .../ScreenshotEventProcessorTests.cs | 14 ++ .../SentryScreenshotCapturerTests.cs | 172 +++++++++++++++++- 11 files changed, 319 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83926aa25..b106404a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,19 @@ ### Behavioral Changes -- Screenshots are now attached to the error event itself and sent in the same envelope, instead of being sent separately after the fact. They no longer get orphaned when their event gets dropped, e.g. by sampling or rate limiting. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `SetBeforeCaptureScreenshot` now decides whether the screenshot gets attached and can be invoked on a background thread. Events captured on background threads now get a screenshot as well. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- Events captured in quick succession share a screenshot. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `ScreenshotQuality` now defaults to `Low`. Taking a screenshot at higher resolutions can make the frame it gets taken in miss its display refresh. Configurations that set the quality explicitly keep it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `SetBeforeSendScreenshot` is deprecated in favor of `SetBeforeSendScreenshotPixels`. Operating on a `Texture2D` ties compressing the screenshot to the main thread, which stalls it. The callback now gets invoked once per screenshot, with the first of the events sharing it, on the main thread for events captured on any thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in quick succession share one. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are taken at the end of the frame the error happened in. Events whose frame takes longer than a second to finish, e.g. while synchronously loading a scene, get sent without one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `SetBeforeCaptureScreenshot` can now get invoked on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `ScreenshotQuality` now defaults to `Low`, as higher resolutions can make the frame the screenshot is taken in miss its display refresh. Configurations that set the quality explicitly keep it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- `SetBeforeSendScreenshot` is deprecated in favor of `SetBeforeSendScreenshotPixels`, as operating on a `Texture2D` ties compressing the screenshot to the main thread. It now gets invoked on the main thread once per screenshot, with the first of the events sharing it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Features -- Added `SetBeforeSendScreenshotPixels` to modify or discard screenshots before they are sent. It operates on the screenshot's raw pixels and gets invoked on a background thread right before the screenshot gets compressed, so it doesn't stall the main thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Added `SetBeforeSendScreenshotPixels` to modify or discard screenshots before they are sent. It operates on the raw pixels on a background thread, right before the screenshot gets compressed. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Fixes -- Attaching a screenshot no longer reads back and encodes the frame on the main thread. The screenshot gets taken at the end of the frame the error happened in, read back from the GPU asynchronously, and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Attaching a screenshot no longer stalls the main thread. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. Everything needed to take screenshots gets set up at the end of the first frame after initializing the SDK, so the first screenshot doesn't stall either. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Dependencies diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index 2b549d4d6..ab1ff608c 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -22,7 +22,7 @@ - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. - View-hierarchy capture must run on Unity main thread. -- Screenshots: the processor requests one from `SentryScreenshotCapturer` and attaches `SentryScreenshotAttachmentContent` to the event's own envelope. Events in quick succession share a request. The capturer takes it at end of frame on the main thread: it captures the screen at full resolution (`CaptureScreenshotIntoRenderTexture` copies, it doesn't scale), blits it down to the quality's size and reads it back (`AsyncGPUReadback`, else `ReadPixels`) into a pooled `NativeArray`. The obsolete `BeforeSendScreenshot` gets applied on the main thread, once per screenshot. The content waits for the screenshot and encodes it when the envelope is serialized on the SDK's worker, applying `BeforeSendScreenshotPixels` right before; it never waits on the main thread and times out if the main thread stops rendering. +- Screenshots: the processor requests one from `SentryScreenshotCapturer` and attaches `SentryScreenshotAttachmentContent` to the event's own envelope. Events in quick succession share a request. A request only gets taken if one of its events reached `Envelope.FromEvent` (the content's `GetStream` claims it), so events dropped by sampling or `BeforeSend` cost nothing; requests from background threads get taken regardless. The coroutine warms the capturer up with a throwaway screenshot at the first end of frame, so the first real one doesn't pay for allocations and driver setup. The capturer takes it at end of frame on the main thread: it captures the screen at full resolution (`CaptureScreenshotIntoRenderTexture` copies, it doesn't scale), blits it down to the quality's size and reads it back (`AsyncGPUReadback`, else `ReadPixels`) into a pooled `NativeArray`. The obsolete `BeforeSendScreenshot` gets applied on the main thread, once per screenshot. The content waits for the screenshot and encodes it when the envelope is serialized on the SDK's worker, applying `BeforeSendScreenshotPixels` right before; it never waits on the main thread and times out if the main thread stops rendering. ## Tests diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index a74dc9bf8..b33b4e750 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -34,6 +34,12 @@ private static SentryScreenshotCapturer GetOrCreateCapturer(SentryUnityOptions o public SentryEvent? Process(SentryEvent @event, SentryHint hint) { + // Feedback attaches a screenshot only when asked to, see SentryUnitySdk.CaptureFeedback + if (@event.Contexts.Feedback is not null) + { + return @event; + } + // A processor throwing drops the whole event, so a failing callback must only cost the screenshot. try { diff --git a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs index 54ea3e431..cf2ebda13 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs @@ -26,6 +26,12 @@ internal void StartScreenshotCapturer(SentryScreenshotCapturer capturer, YieldIn private static IEnumerator ScreenshotCapturerCoroutine(SentryScreenshotCapturer capturer, YieldInstruction? endOfFrame) { + yield return endOfFrame; + if (!capturer.IsDisposed) + { + capturer.Warmup(); + } + while (true) { // Capturing mid-frame yields an incomplete image, so screenshots are only ever taken once the frame has diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index 28398e8c8..bb0870f7e 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -33,7 +33,12 @@ internal SentryScreenshotAttachmentContent(SentryScreenshotCapturer capturer, Se internal SentryScreenshotRequest Request => _request; - public Stream GetStream() => new EncodeOnReadStream(this); + // The SDK gets the stream once the event made it past sampling and BeforeSend, when building its envelope + public Stream GetStream() + { + _request.Claim(); + return new EncodeOnReadStream(this); + } internal async Task GetEncodedAsync() { @@ -67,6 +72,7 @@ private sealed class EncodeOnReadStream : Stream { private readonly SentryScreenshotAttachmentContent _content; private MemoryStream? _encoded; + private bool _disposed; public EncodeOnReadStream(SentryScreenshotAttachmentContent content) => _content = content; @@ -100,8 +106,16 @@ public override void Flush() { } protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_disposed) { + _disposed = true; + + // Disposed without being read, e.g. because the envelope got dropped from a full queue + if (_encoded is null) + { + _content._request.Unclaim(); + } + _encoded?.Dispose(); } diff --git a/src/Sentry.Unity/SentryScreenshotCapturer.cs b/src/Sentry.Unity/SentryScreenshotCapturer.cs index e39b5d211..f2c8ebb16 100644 --- a/src/Sentry.Unity/SentryScreenshotCapturer.cs +++ b/src/Sentry.Unity/SentryScreenshotCapturer.cs @@ -164,6 +164,11 @@ internal bool HasRequest private IAttachmentContent Attach(SentryScreenshotRequest request, Func? transform, Func? beforeEncode) { + if (!MainThreadData.IsMainThread()) + { + request.RequestedOffMainThread = true; + } + Task? transformed = null; if (transform is null) { @@ -192,17 +197,34 @@ private static bool CanServe(SentryScreenshotRequest request, Func !reference.TryGetTarget(out var request) || request.IsSettled); ReclaimBuffers(); - return _pending.Count; + + // Screenshots of events that got dropped are never going to be encoded, but only get collected eventually. + // Counting them would keep the events that do get sent from getting a screenshot of their own. + var count = 0; + foreach (var reference in _pending) + { + if (reference.TryGetTarget(out var request) && request.IsClaimed) + { + count++; + } + } + + return count; } // Must be called while holding _lock @@ -323,6 +345,7 @@ internal virtual void OnEndOfFrame() } SentryScreenshotRequest? request; + var dropped = false; lock (_lock) { _stalled = false; @@ -334,14 +357,48 @@ internal virtual void OnEndOfFrame() return; } - request.TakenTimestamp = Stopwatch.GetTimestamp(); - _latest = request; - _pending.Add(new WeakReference(request)); + // Events on the main thread are done being captured by now. If none of them made it into an envelope, + // e.g. because they got sampled out, nothing is going to send the screenshot. + if (!request.IsClaimed && !request.RequestedOffMainThread) + { + _options.LogDebug("Screenshot skipped. The events requesting it got dropped."); + dropped = true; + } + else + { + request.TakenTimestamp = Stopwatch.GetTimestamp(); + _latest = request; + _pending.Add(new WeakReference(request)); + } + } + + if (dropped) + { + request.Abandon(); + return; } Take(request); } + /// + /// Takes a screenshot nothing reads, so the first one requested by an event doesn't pay for allocating the + /// textures and buffers, or for the graphics driver setting up the capture. Must run on the main thread at end of + /// frame. + /// + internal void Warmup() + { + var request = new SentryScreenshotRequest(); + Take(request); + + // Hands the buffer back to the pool, whether the readback is still pending or already done + request.Abandon(); + if (request.Frame.Result is { } frame) + { + frame.ReleasePixels(); + } + } + private void Take(SentryScreenshotRequest request) { var (screenWidth, screenHeight) = GetScreenSize(); @@ -377,7 +434,8 @@ private void Take(SentryScreenshotRequest request) Graphics.Blit(screen, _capture); } - if (_useAsyncReadback) + // The fallback worker reads the attachment on the main thread, where it can't wait for a readback + if (_useAsyncReadback && _options.BackgroundWorker is not WebBackgroundWorker) { // The readback copies the texture as of now, so later captures into it don't affect this one RequestReadback(request, _capture!); diff --git a/src/Sentry.Unity/SentryScreenshotRequest.cs b/src/Sentry.Unity/SentryScreenshotRequest.cs index e6bc83895..17ab20fcc 100644 --- a/src/Sentry.Unity/SentryScreenshotRequest.cs +++ b/src/Sentry.Unity/SentryScreenshotRequest.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Sentry.Extensibility; using UnityEngine; @@ -21,6 +22,7 @@ internal sealed class SentryScreenshotRequest private Func? _transform; private TaskCompletionSource? _transformed; private volatile bool _hasFrameConsumers; + private int _claims; /// /// When the frame was taken, in ticks. Zero until then. @@ -36,6 +38,22 @@ internal bool HasFrameConsumers set => _hasFrameConsumers = value; } + /// + /// Whether an event attaching the screenshot made it into an envelope. Events that get dropped before, e.g. by + /// sampling or BeforeSend, never get there. + /// + internal bool IsClaimed => Volatile.Read(ref _claims) > 0; + + /// + /// Whether an event requested it off the main thread. Those might only reach their envelope after the end of the + /// frame, so whether they get dropped isn't known in time. + /// + internal bool RequestedOffMainThread { get; set; } + + internal void Claim() => Interlocked.Increment(ref _claims); + + internal void Unclaim() => Interlocked.Decrement(ref _claims); + /// /// Whether a transform got registered, so its result can be shared. /// diff --git a/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 2761de02e..994c0ce6d 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -199,7 +199,15 @@ internal static void AddIntegrations(SentryUnityOptions options) } if (!ApplicationAdapter.Instance.IsEditor && options.AttachScreenshot) { - options.AddEventProcessor(new ScreenshotEventProcessor(options)); + // Headless, the end of the frame never comes and every event would wait for a screenshot in vain + if (Application.isBatchMode) + { + options.LogDebug("Running in batch mode. Not attaching screenshots."); + } + else + { + options.AddEventProcessor(new ScreenshotEventProcessor(options)); + } } if (!ApplicationAdapter.Instance.IsEditor && diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index 1e92d5d46..863386a53 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -17,12 +17,14 @@ namespace Sentry.Unity; internal class WebBackgroundWorker : IBackgroundWorker { + private readonly SentryUnityOptions _options; private readonly SentryMonoBehaviour _behaviour; private readonly UnityWebRequestTransport _transport; private int _pendingItems; public WebBackgroundWorker(SentryUnityOptions options, SentryMonoBehaviour behaviour) { + _options = options; _behaviour = behaviour; _transport = new UnityWebRequestTransport(options); } @@ -38,6 +40,13 @@ private IEnumerator SendAndTrack(Envelope envelope) { try { + // Serializing the envelope reads its screenshot right here on the main thread, so it has to be taken first. + // It gets taken at the end of the frame, before the next one starts. + if (_options.ScreenshotCapturer is not null) + { + yield return null; + } + yield return _transport.SendEnvelopeAsync(envelope); } finally diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 37c1032f6..2802379a0 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -121,6 +121,20 @@ public void Process_BeforeCaptureScreenshotThrows_ReturnsEventWithoutAttachment( Assert.IsEmpty(hint.Attachments); } + [Test] + public void Process_Feedback_DoesNotAttach() + { + var (processor, capturer) = GetSut(); + var feedback = new SentryEvent(); + feedback.Contexts.Feedback = new SentryFeedback("message"); + var hint = new SentryHint(); + + processor.Process(feedback, hint); + + Assert.AreEqual(0, capturer.RequestCount); + Assert.IsEmpty(hint.Attachments); + } + [Test] public void Process_BeforeCaptureScreenshotReceivesEvent() { diff --git a/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs index adf41ddbc..6cae54744 100644 --- a/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs +++ b/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs @@ -86,12 +86,20 @@ private static void RequireAsyncReadback() } } + // By default, the event makes it into its envelope, as it would through the SDK private static SentryScreenshotAttachmentContent Request(SentryScreenshotCapturer capturer, - Func? transform = null, Func? beforeEncode = null) + Func? transform = null, Func? beforeEncode = null, + bool claim = true) { var content = capturer.TryRequest(transform, beforeEncode); Assert.IsInstanceOf(content); - return (SentryScreenshotAttachmentContent)content!; + var screenshot = (SentryScreenshotAttachmentContent)content!; + if (claim) + { + screenshot.Request.Claim(); + } + + return screenshot; } // WaitForEndOfFrame never resumes in batch mode. Capturing the known image doesn't depend on the frame @@ -380,6 +388,86 @@ public IEnumerator TryRequest_TooManyPendingScreenshots_SharesTheLatest() GC.KeepAlive(pending); } + [Test] + public void OnEndOfFrame_FallbackWorker_ReadsBackSynchronouslyForTheMainThreadToRead() + { + var options = GetOptions(); + options.BackgroundWorker = new WebBackgroundWorker(options, SentryMonoBehaviour.Instance); + using var capturer = new KnownImageCapturer(options, _source, useAsyncReadback: true); + var content = Request(capturer); + + capturer.OnEndOfFrame(); + + Assert.IsTrue(content.Request.IsCompleted); + Object.Destroy(Decode(ReadAll(content))); + } + + [Test] + public void OnEndOfFrame_EventsDroppedBeforeTheirEnvelope_DoesNotTakeTheScreenshot() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var content = Request(capturer, claim: false); + + capturer.OnEndOfFrame(); + + Assert.AreEqual(0, capturer.CaptureCount); + Assert.IsFalse(capturer.HasRequest); + Assert.IsTrue(content.Request.IsCompleted); + Assert.IsNull(content.Request.Frame.Result); + } + + [UnityTest] + public IEnumerator OnEndOfFrame_RequestedOffTheMainThread_TakesTheScreenshotBeforeTheEnvelope() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + // The event might only reach its envelope after the end of the frame + var task = Task.Run(() => capturer.TryRequest()); + yield return WaitFor(() => task.IsCompleted); + + capturer.OnEndOfFrame(); + + Assert.AreEqual(1, capturer.CaptureCount); + } + + [UnityTest] + public IEnumerator TryRequest_OneOfTheEventsMadeItIntoAnEnvelope_TakesTheScreenshot() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + Request(capturer, claim: false); + var sent = Request(capturer); + yield return TakeScreenshot(capturer, sent); + + Assert.AreEqual(1, capturer.CaptureCount); + yield return ReadOnWorker(sent, bytes => Assert.IsNotEmpty(bytes)); + } + + [UnityTest] + public IEnumerator TryRequest_ScreenshotsOfDroppedEnvelopes_DoNotCountAsPending() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + // Kept alive, so that only their envelopes getting dropped makes them not count + var dropped = new List(); + for (var i = 0; i < SentryScreenshotCapturer.MaxPendingRequests; i++) + { + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); + dropped.Add(Request(capturer, claim: false)); + var stream = dropped[i].GetStream(); + yield return TakeScreenshot(capturer, dropped[i]); + + // e.g. by a full queue or rate limiting, without ever being read + stream.Dispose(); + } + + yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); + var content = Request(capturer); + + Assert.AreNotSame(dropped.Last().Request, content.Request); + Assert.IsTrue(capturer.HasRequest); + GC.KeepAlive(dropped); + } + [UnityTest] public IEnumerator TryRequest_NotTakenInTime_ResolvesEmptyAndSkipsUntilTheNextEndOfFrame() { @@ -444,6 +532,22 @@ public IEnumerator TryRequest_TransformDiscardsTheScreenshot_ResolvesEmpty() Assert.IsNotEmpty(keptBytes, "A transform only affects the event it was applied for."); } + [UnityTest] + public IEnumerator TryRequest_WithoutTransformAfterATransformOnlyScreenshot_DoesNotShareIt() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + + var transformed = Request(capturer, texture => texture); + capturer.OnEndOfFrame(); + + // Within the interval, but its pixels go once the transform ran, which leaves nothing to encode + var plain = Request(capturer); + yield return TakeScreenshot(capturer, transformed, plain); + + Assert.AreNotSame(transformed.Request, plain.Request); + yield return ReadOnWorker(plain, bytes => Assert.IsNotEmpty(bytes)); + } + [UnityTest] public IEnumerator TryRequest_TransformInTheSameFrame_RunsOnceAndSharesTheResult() { @@ -632,6 +736,34 @@ public IEnumerator Dispose_ReleasesPendingRequests() Assert.IsNull(capturer.TryRequest()); } + [UnityTest] + public IEnumerator StartScreenshotCapturer_WarmsUpAtTheFirstEndOfFrame() + { + using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var gameObject = new GameObject("ScreenshotCapturerTest"); + var behaviour = gameObject.AddComponent(); + + try + { + behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); + yield return WaitFor(() => capturer.CaptureCount > 0); + + // The first event's screenshot reuses what the warmup allocated + Assert.AreEqual(0, capturer.UsedBufferCount); + Assert.AreEqual(1, capturer.FreeBufferCount); + Assert.IsFalse(capturer.HasRequest); + + var content = Request(capturer); + yield return TakeScreenshot(capturer, content); + Assert.AreEqual(0, capturer.FreeBufferCount); + yield return ReadOnWorker(content, bytes => Assert.IsNotEmpty(bytes)); + } + finally + { + Object.Destroy(gameObject); + } + } + [UnityTest] public IEnumerator Dispose_StopsTheCoroutine() { @@ -642,8 +774,9 @@ public IEnumerator Dispose_StopsTheCoroutine() try { behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); - Request(capturer); yield return WaitFor(() => capturer.CaptureCount > 0); + Request(capturer); + yield return WaitFor(() => capturer.CaptureCount > 1); capturer.Dispose(); yield return null; @@ -654,7 +787,7 @@ public IEnumerator Dispose_StopsTheCoroutine() yield return null; } - Assert.AreEqual(1, capturer.CaptureCount); + Assert.AreEqual(2, capturer.CaptureCount); } finally { @@ -688,6 +821,37 @@ public IEnumerator Dispose_OffTheMainThread_DefersReleaseToTheCoroutine() } } + [UnityTest] + public IEnumerator TryRequest_ThroughTheSdk_DroppedEventDoesNotTakeTheScreenshot() + { + var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); + var options = new SentryUnityOptions(application: new TestApplication()) + { + Dsn = SentryTests.TestDsn, + CreateHttpMessageHandler = () => new TestHttpClientHandler("DroppedScreenshotTest") + }; + options.SetBeforeSend((_, _) => null); + options.AddEventProcessor(new ScreenshotEventProcessor(options, capturer)); + + SentrySdk.Init(options); + + try + { + SentrySdk.CaptureMessage("dropped"); + Assert.IsTrue(capturer.HasRequest, "The screenshot gets requested before BeforeSend and sampling."); + + yield return null; + capturer.OnEndOfFrame(); + + Assert.AreEqual(0, capturer.CaptureCount); + } + finally + { + SentrySdk.Close(); + capturer.Dispose(); + } + } + [UnityTest] public IEnumerator TryRequest_ThroughTheSdk_ScreenshotRidesAlongTheEvent() { From cc514342319d1947509b2f5d254ed8cdbbe10564 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 21 Oct 2026 13:34:01 +0200 Subject: [PATCH 07/15] stripped down --- CHANGELOG.md | 12 +- docs/agent-guides/integrations.md | 4 +- .../ConfigurationWindow/EnrichmentTab.cs | 4 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 134 ++- .../ScriptableSentryUnityOptions.cs | 2 +- .../SentryMonoBehaviour.Screenshot.cs | 52 - src/Sentry.Unity/SentryMonoBehaviour.cs | 2 +- src/Sentry.Unity/SentryScreenshot.cs | 122 ++- .../SentryScreenshotAttachmentContent.cs | 65 +- src/Sentry.Unity/SentryScreenshotCapturer.cs | 624 ------------ src/Sentry.Unity/SentryScreenshotFrame.cs | 199 ---- src/Sentry.Unity/SentryScreenshotPixels.cs | 35 - src/Sentry.Unity/SentryScreenshotRequest.cs | 187 ---- src/Sentry.Unity/SentryUnityOptions.cs | 33 +- src/Sentry.Unity/SentryUnitySdk.cs | 56 +- src/Sentry.Unity/UnityWebRequestTransport.cs | 2 +- .../ScreenshotEventProcessorTests.cs | 240 ++--- .../SentryScreenshotCapturerTests.cs | 910 ------------------ 18 files changed, 337 insertions(+), 2346 deletions(-) delete mode 100644 src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs delete mode 100644 src/Sentry.Unity/SentryScreenshotCapturer.cs delete mode 100644 src/Sentry.Unity/SentryScreenshotFrame.cs delete mode 100644 src/Sentry.Unity/SentryScreenshotPixels.cs delete mode 100644 src/Sentry.Unity/SentryScreenshotRequest.cs delete mode 100644 test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b106404a9..0c5851d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,11 @@ ### Behavioral Changes -- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in quick succession share one. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- Screenshots are taken at the end of the frame the error happened in. Events whose frame takes longer than a second to finish, e.g. while synchronously loading a scene, get sent without one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `SetBeforeCaptureScreenshot` can now get invoked on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `ScreenshotQuality` now defaults to `Low`, as higher resolutions can make the frame the screenshot is taken in miss its display refresh. Configurations that set the quality explicitly keep it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) -- `SetBeforeSendScreenshot` is deprecated in favor of `SetBeforeSendScreenshotPixels`, as operating on a `Texture2D` ties compressing the screenshot to the main thread. It now gets invoked on the main thread once per screenshot, with the first of the events sharing it. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) - -### Features - -- Added `SetBeforeSendScreenshotPixels` to modify or discard screenshots before they are sent. It operates on the raw pixels on a background thread, right before the screenshot gets compressed. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in the same frame share one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Fixes -- Attaching a screenshot no longer stalls the main thread. The frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. Everything needed to take screenshots gets set up at the end of the first frame after initializing the SDK, so the first screenshot doesn't stall either. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Dependencies diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index ab1ff608c..f6277ddad 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -21,8 +21,8 @@ - Startup transaction: `app.start`, name `runtime.initialization`; spans cover subsystem registration, assemblies, splash screen, and first scene. - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. -- View-hierarchy capture must run on Unity main thread. -- Screenshots: the processor requests one from `SentryScreenshotCapturer` and attaches `SentryScreenshotAttachmentContent` to the event's own envelope. Events in quick succession share a request. A request only gets taken if one of its events reached `Envelope.FromEvent` (the content's `GetStream` claims it), so events dropped by sampling or `BeforeSend` cost nothing; requests from background threads get taken regardless. The coroutine warms the capturer up with a throwaway screenshot at the first end of frame, so the first real one doesn't pay for allocations and driver setup. The capturer takes it at end of frame on the main thread: it captures the screen at full resolution (`CaptureScreenshotIntoRenderTexture` copies, it doesn't scale), blits it down to the quality's size and reads it back (`AsyncGPUReadback`, else `ReadPixels`) into a pooled `NativeArray`. The obsolete `BeforeSendScreenshot` gets applied on the main thread, once per screenshot. The content waits for the screenshot and encodes it when the envelope is serialized on the SDK's worker, applying `BeforeSendScreenshotPixels` right before; it never waits on the main thread and times out if the main thread stops rendering. +- Screenshot capture and view-hierarchy capture must run on Unity main thread. +- Screenshots get attached to the event's hint as `SentryScreenshotAttachmentContent`, so they ride along the event's own envelope. Events in the same frame share one, taken at end of frame. The content waits for it and encodes it when the envelope gets serialized on the SDK's worker; it never waits on the main thread and times out if the main thread stops rendering. ## Tests diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs index a0d1170b4..0d5a29783 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs @@ -34,8 +34,8 @@ internal static void Display(ScriptableSentryUnityOptions options) options.AttachScreenshot = EditorGUILayout.BeginToggleGroup( new GUIContent("Attach Screenshots to Events", "Try to attach current screenshot on events.\n" + "This is an early-access feature and may not work on all platforms (it is explicitly disabled on WebGL).\n" + - "The screenshot is taken at the end of the frame the error happened in.\n" + - "Errors in quick succession share a screenshot."), + "Additionally, the screenshot is captured mid-frame, when an event happens, so it may be incomplete.\n" + + "A screenshot might not be able to be attached, for example when the error happens on a background thread."), options.AttachScreenshot); EditorGUI.indentLevel++; diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index b33b4e750..2c351c839 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,33 +1,26 @@ using System; +using System.Collections; +using System.Threading.Tasks; using Sentry.Extensibility; +using UnityEngine; namespace Sentry.Unity; public class ScreenshotEventProcessor : ISentryEventProcessorWithHint { private readonly SentryUnityOptions _options; - private readonly SentryScreenshotCapturer _capturer; + private readonly ISentryMonoBehaviour _sentryMonoBehaviour; - public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) - : this(sentryOptions, GetOrCreateCapturer(sentryOptions)) { } + // Events in the same frame share a screenshot + private readonly object _lock = new(); + private TaskCompletionSource?>? _pending; - internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, SentryScreenshotCapturer capturer) - { - _options = sentryOptions; - _capturer = capturer; - } + public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } - private static SentryScreenshotCapturer GetOrCreateCapturer(SentryUnityOptions options) + internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoBehaviour sentryMonoBehaviour) { - if (options.ScreenshotCapturer is { } capturer) - { - return capturer; - } - - capturer = new SentryScreenshotCapturer(options); - SentryMonoBehaviour.Instance.StartScreenshotCapturer(capturer); - options.ScreenshotCapturer = capturer; - return capturer; + _options = sentryOptions; + _sentryMonoBehaviour = sentryMonoBehaviour; } public SentryEvent? Process(SentryEvent @event) => @event; @@ -40,12 +33,10 @@ private static SentryScreenshotCapturer GetOrCreateCapturer(SentryUnityOptions o return @event; } - // A processor throwing drops the whole event, so a failing callback must only cost the screenshot. try { if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) { - _options.LogInfo("Screenshot capture skipped by BeforeCaptureScreenshot callback."); return @event; } } @@ -55,22 +46,101 @@ private static SentryScreenshotCapturer GetOrCreateCapturer(SentryUnityOptions o return @event; } - // The screenshot gets taken at the end of this frame. A callback to its pixels gets applied on the SDK's worker - // right before encoding. The obsolete Texture2D callback ties applying it - and encoding - to the main thread. - var content = _options.BeforeSendScreenshotPixelsInternal is { } beforeSendPixels - ? _capturer.TryRequest(beforeEncode: pixels => beforeSendPixels(pixels, @event)) - : _options.BeforeSendScreenshotInternal is { } beforeSend - ? _capturer.TryRequest(screenshot => beforeSend(screenshot, @event)) - : _capturer.TryRequest(); - - if (content is null) + Task?> screenshot; + lock (_lock) { - return @event; + if (_pending is null) + { + _pending = new TaskCompletionSource?>(TaskCreationOptions.RunContinuationsAsynchronously); + _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, @event)); + } + + screenshot = _pending.Task; } - hint.Attachments.Add(new SentryAttachment(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg")); - _options.LogDebug("Screenshot requested for event {0}", @event.EventId); + // The screenshot gets read, and encoded, when the SDK serializes the event's envelope on its worker + hint.Attachments.Add(new SentryAttachment( + AttachmentType.Default, + new SentryScreenshotAttachmentContent(screenshot, _options), + "screenshot.jpg", + "image/jpeg")); return @event; } + + private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource?> screenshot, SentryEvent @event) + { + // Capturing mid-frame yields an incomplete image + yield return WaitForEndOfFrame(); + + lock (_lock) + { + _pending = null; + } + + try + { + // The Texture2D callback and the main thread worker need the screenshot right away + if (_options.BeforeSendScreenshotInternal is null && CanReadBackAsync()) + { + SentryScreenshot.CaptureAsync(_options, result => screenshot.TrySetResult(result)); + } + else + { + screenshot.TrySetResult(CaptureScreenshot(@event)); + } + } + catch (Exception e) + { + _options.LogError(e, "Failed to capture screenshot."); + screenshot.TrySetResult(null); + } + } + + private Lazy? CaptureScreenshot(SentryEvent @event) + { + Texture2D? screenshot = null; + try + { + screenshot = CreateNewScreenshotTexture2D(_options); + + if (_options.BeforeSendScreenshotInternal != null) + { + var modifiedScreenshot = _options.BeforeSendScreenshotInternal(screenshot, @event); + + if (modifiedScreenshot == null) + { + _options.LogInfo("Screenshot discarded by BeforeSendScreenshot callback."); + return null; + } + + // Clean up - If the user returned a new texture object and did not modify the passed in one + if (modifiedScreenshot != screenshot) + { + _options.LogDebug("Applying modified screenshot."); + UnityEngine.Object.Destroy(screenshot); + screenshot = modifiedScreenshot; + } + } + + var screenshotBytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); + return new Lazy(() => screenshotBytes); + } + finally + { + if (screenshot != null) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } + + internal virtual bool CanReadBackAsync() + => SystemInfo.supportsAsyncGPUReadback && _options.BackgroundWorker is not WebBackgroundWorker; + + internal virtual Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) + => SentryScreenshot.CreateNewScreenshotTexture2D(options); + + internal virtual YieldInstruction? WaitForEndOfFrame() + => new WaitForEndOfFrame(); } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index ea13f7a14..6bfb920f7 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -66,7 +66,7 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public string EnvironmentOverride { get; set; } = string.Empty; [field: SerializeField] public bool AttachStacktrace { get; set; } [field: SerializeField] public bool AttachScreenshot { get; set; } - [field: SerializeField] public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.Low; + [field: SerializeField] public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.High; [field: SerializeField] public int ScreenshotCompression { get; set; } = 75; [field: SerializeField] public bool AttachViewHierarchy { get; set; } = false; diff --git a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs b/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs deleted file mode 100644 index cf2ebda13..000000000 --- a/src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections; -using UnityEngine; - -namespace Sentry.Unity; - -/// -/// Drives taking the screenshots requested by events. -/// -public partial class SentryMonoBehaviour -{ - private Coroutine? _screenshotCoroutine; - - internal void StartScreenshotCapturer(SentryScreenshotCapturer capturer) - => StartScreenshotCapturer(capturer, new WaitForEndOfFrame()); - - // For testing - WaitForEndOfFrame never resumes in batch mode. - internal void StartScreenshotCapturer(SentryScreenshotCapturer capturer, YieldInstruction? endOfFrame) - { - if (_screenshotCoroutine is not null) - { - StopCoroutine(_screenshotCoroutine); - } - - _screenshotCoroutine = StartCoroutine(ScreenshotCapturerCoroutine(capturer, endOfFrame)); - } - - private static IEnumerator ScreenshotCapturerCoroutine(SentryScreenshotCapturer capturer, YieldInstruction? endOfFrame) - { - yield return endOfFrame; - if (!capturer.IsDisposed) - { - capturer.Warmup(); - } - - while (true) - { - // Capturing mid-frame yields an incomplete image, so screenshots are only ever taken once the frame has - // been rendered. This never resumes in headless mode, where waiting for a screenshot times out instead. - yield return endOfFrame; - - // The SDK can get closed from any thread, so instead of being stopped from the outside the - // coroutine ends itself. GPU resources can only be released here, on the main thread. - if (capturer.IsDisposed) - { - capturer.ReleaseResources(); - yield break; - } - - capturer.OnEndOfFrame(); - } - } -} diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs index 1eb1fb8f8..2d93d8f0d 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.cs @@ -59,7 +59,7 @@ public void StartAwakeSpan(MonoBehaviour monoBehaviour) /// public partial class SentryMonoBehaviour { - // Unbounded queue - used by the UnityWebRequestTransport to start sends from background threads + // Unbounded queue - used by the ScreenshotEventProcessor where capture is already limited by Interlocked to 1/frame private readonly ConcurrentQueue _coroutineQueue = new(); public void QueueCoroutine(IEnumerator routine) diff --git a/src/Sentry.Unity/SentryScreenshot.cs b/src/Sentry.Unity/SentryScreenshot.cs index 9e35633e6..a1a33dfee 100644 --- a/src/Sentry.Unity/SentryScreenshot.cs +++ b/src/Sentry.Unity/SentryScreenshot.cs @@ -1,5 +1,8 @@ +using System; using Sentry.Extensibility; using UnityEngine; +using UnityEngine.Experimental.Rendering; +using UnityEngine.Rendering; namespace Sentry.Unity; @@ -16,49 +19,90 @@ internal static int GetTargetResolution(ScreenshotQuality quality) }; } + public static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => + CreateNewScreenshotTexture2D(options, Screen.width, Screen.height); + + // For testing + internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options, int width, int height) + { + var renderTexture = CaptureIntoRenderTexture(options, width, height); + var previousRenderTexture = RenderTexture.active; + + try + { + // Captures the current screenshot synchronously. + var screenshot = new Texture2D(renderTexture.width, renderTexture.height, TextureFormat.RGB24, false); + RenderTexture.active = renderTexture; + screenshot.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0); + screenshot.Apply(); + + options.LogDebug("Screenshot captured at {0}x{1}.", renderTexture.width, renderTexture.height); + + return screenshot; + } + finally + { + RenderTexture.active = previousRenderTexture; + RenderTexture.ReleaseTemporary(renderTexture); + } + } + /// - /// Scales the given size down to the quality's target resolution while conserving the original ratio, - /// based on which, width or height, is the smaller. + /// Reads the current screenshot back from the GPU without stalling the main thread. The result gets encoded to JPG + /// on first access, on the accessing thread. /// - internal static (int Width, int Height) GetTargetSize(ScreenshotQuality quality, int width, int height) + internal static void CaptureAsync(SentryUnityOptions options, Action?> onCaptured) { - if (quality is ScreenshotQuality.Full) + var renderTexture = CaptureIntoRenderTexture(options, Screen.width, Screen.height); + var (width, height) = ((uint)renderTexture.width, (uint)renderTexture.height); + + try { - return (width, height); - } + // The readback copies the texture as of now, so it can go back to the pool right away + AsyncGPUReadback.Request(renderTexture, 0, TextureFormat.RGBA32, readback => + { + if (readback.hasError) + { + options.LogError("Failed to read back the screenshot."); + onCaptured(null); + return; + } - var targetResolution = GetTargetResolution(quality); - var ratioW = targetResolution / (float)width; - var ratioH = targetResolution / (float)height; - var ratio = Mathf.Min(ratioH, ratioW); - if (ratio is > 0.0f and < 1.0f) + var pixels = readback.GetData().ToArray(); + onCaptured(new Lazy(() => + ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, width, height, 0, + options.ScreenshotCompression))); + }); + } + finally { - width = Mathf.FloorToInt(width * ratio); - height = Mathf.FloorToInt(height * ratio); + RenderTexture.ReleaseTemporary(renderTexture); } - - return (width, height); } - public static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => - CreateNewScreenshotTexture2D(options, Screen.width, Screen.height); - - // For testing - internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options, int width, int height) + // Returns a temporary render texture holding the screenshot, scaled down to the configured quality + private static RenderTexture CaptureIntoRenderTexture(SentryUnityOptions options, int width, int height) { - (width, height) = GetTargetSize(options.ScreenshotQuality, width, height); - - RenderTexture? renderTextureFull = null; - RenderTexture? renderTextureResized = null; - var previousRenderTexture = RenderTexture.active; + // Make sure the screenshot size does not exceed the target size by scaling the image while conserving the + // original ratio based on which, width or height, is the smaller + if (options.ScreenshotQuality is not ScreenshotQuality.Full) + { + var targetResolution = GetTargetResolution(options.ScreenshotQuality); + var ratioW = targetResolution / (float)width; + var ratioH = targetResolution / (float)height; + var ratio = Mathf.Min(ratioH, ratioW); + if (ratio is > 0.0f and < 1.0f) + { + width = Mathf.FloorToInt(width * ratio); + height = Mathf.FloorToInt(height * ratio); + } + } + var renderTextureFull = RenderTexture.GetTemporary(Screen.width, Screen.height); try { - // Captures the current screenshot synchronously. - var screenshot = new Texture2D(width, height, TextureFormat.RGB24, false); - renderTextureFull = RenderTexture.GetTemporary(Screen.width, Screen.height); ScreenCapture.CaptureScreenshotIntoRenderTexture(renderTextureFull); - renderTextureResized = RenderTexture.GetTemporary(width, height); + var renderTextureResized = RenderTexture.GetTemporary(width, height); // The image may be mirrored on some platforms - mirror it back. // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. @@ -73,27 +117,11 @@ internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions option Graphics.Blit(renderTextureFull, renderTextureResized); } - RenderTexture.active = renderTextureResized; - screenshot.ReadPixels(new Rect(0, 0, width, height), 0, 0); - screenshot.Apply(); - - options.LogDebug("Screenshot captured at {0}x{1}.", width, height); - - return screenshot; + return renderTextureResized; } finally { - RenderTexture.active = previousRenderTexture; - - if (renderTextureFull) - { - RenderTexture.ReleaseTemporary(renderTextureFull); - } - - if (renderTextureResized) - { - RenderTexture.ReleaseTemporary(renderTextureResized); - } + RenderTexture.ReleaseTemporary(renderTextureFull); } } } diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index bb0870f7e..1a7d902b8 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -10,60 +10,54 @@ namespace Sentry.Unity; /// Attachment content for a screenshot that gets taken at the end of the frame the event was captured in. /// /// -/// The SDK reads attachment content when serializing the envelope on its background worker. Waiting for the request +/// The SDK reads attachment content when serializing the envelope on its background worker. Waiting for the screenshot /// and encoding it only then keeps both off the thread that captured the event, while the screenshot still goes out in -/// the same envelope as the event. Events that get dropped before being sent - e.g. sampled out - never pay for the -/// encode. +/// the same envelope as the event. /// internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent { - private readonly SentryScreenshotCapturer _capturer; - private readonly SentryScreenshotRequest _request; - private readonly Task? _transformed; + // How long the worker waits for the main thread to take the screenshot, e.g. while it's hanging or paused + internal static readonly TimeSpan CaptureTimeout = TimeSpan.FromSeconds(1); + + private readonly Task?> _screenshot; private readonly SentryUnityOptions _options; - internal SentryScreenshotAttachmentContent(SentryScreenshotCapturer capturer, SentryScreenshotRequest request, - Task? transformed, SentryUnityOptions options) + internal SentryScreenshotAttachmentContent(Task?> screenshot, SentryUnityOptions options) { - _capturer = capturer; - _request = request; - _transformed = transformed; + _screenshot = screenshot; _options = options; } - internal SentryScreenshotRequest Request => _request; - - // The SDK gets the stream once the event made it past sampling and BeforeSend, when building its envelope - public Stream GetStream() - { - _request.Claim(); - return new EncodeOnReadStream(this); - } + public Stream GetStream() => new EncodeOnReadStream(this); internal async Task GetEncodedAsync() { - if (!_request.IsCompleted) + if (!_screenshot.IsCompleted) { // Taking the screenshot needs the main thread to reach the end of the frame. Waiting on it there would deadlock. if (MainThreadData.IsMainThread()) { - _options.LogDebug("Screenshot not taken yet and the attachment is read on the main thread. Skipping."); + _options.LogWarning("Screenshot not taken yet and the attachment is read on the main thread. Skipping."); return Array.Empty(); } - await _capturer.WaitAsync(_request).ConfigureAwait(false); + if (await Task.WhenAny(_screenshot, Task.Delay(CaptureTimeout)).ConfigureAwait(false) != _screenshot) + { + _options.LogWarning("Screenshot was not taken in time. Skipping."); + return Array.Empty(); + } } - var bytes = _transformed is not null - ? await _transformed.ConfigureAwait(false) - : (await _request.Frame.ConfigureAwait(false))?.GetEncoded(_options, _request.BeforeEncode); - - if (bytes is null) + try { - _options.LogWarning("Screenshot attachment is empty. Taking or encoding the screenshot failed."); + var bytes = (await _screenshot.ConfigureAwait(false))?.Value; + return bytes is { Length: > 0 } ? bytes : Array.Empty(); + } + catch (Exception e) + { + _options.LogError(e, "Failed to encode the screenshot."); + return Array.Empty(); } - - return bytes ?? Array.Empty(); } // Deliberately not seekable: the SDK checks the length of attachment streams when building the envelope, on the @@ -72,7 +66,6 @@ private sealed class EncodeOnReadStream : Stream { private readonly SentryScreenshotAttachmentContent _content; private MemoryStream? _encoded; - private bool _disposed; public EncodeOnReadStream(SentryScreenshotAttachmentContent content) => _content = content; @@ -106,16 +99,8 @@ public override void Flush() { } protected override void Dispose(bool disposing) { - if (disposing && !_disposed) + if (disposing) { - _disposed = true; - - // Disposed without being read, e.g. because the envelope got dropped from a full queue - if (_encoded is null) - { - _content._request.Unclaim(); - } - _encoded?.Dispose(); } diff --git a/src/Sentry.Unity/SentryScreenshotCapturer.cs b/src/Sentry.Unity/SentryScreenshotCapturer.cs deleted file mode 100644 index f2c8ebb16..000000000 --- a/src/Sentry.Unity/SentryScreenshotCapturer.cs +++ /dev/null @@ -1,624 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; -using Sentry.Extensibility; -using Unity.Collections; -using UnityEngine; -using UnityEngine.Rendering; - -namespace Sentry.Unity; - -/// -/// Takes screenshots on demand, at the end of the frame an event was captured in, and hands them out as attachment -/// content that resolves once the SDK serializes the event's envelope. -/// -/// -/// Requesting a screenshot is cheap and works on any thread. The main thread pays for taking it at the end of the frame -/// - a GPU copy and, where supported, an asynchronous readback into a pooled buffer. Encoding happens on the SDK's -/// background worker, straight from that buffer. Events captured close together share a screenshot. -/// -internal class SentryScreenshotCapturer : IDisposable -{ - // Events within this window of a taken screenshot share it instead of requesting a new one. Bounds the cost of - // an error on every frame. - internal static readonly TimeSpan MinCaptureInterval = TimeSpan.FromMilliseconds(100); - - // Past this many screenshots waiting for their encode, events share the latest one instead. - internal const int MaxPendingRequests = 2; - - // How old a shared screenshot may be before no screenshot is the better choice. A stale one misleads. - internal static readonly TimeSpan MaxSharedAge = TimeSpan.FromSeconds(1); - - // How long the worker waits for the main thread to take a screenshot, e.g. while it's hanging or paused. - internal static readonly TimeSpan CaptureTimeout = TimeSpan.FromSeconds(1); - - // Readback buffers kept for reuse: one per pending screenshot plus the one being taken. Buffers rented past this, - // e.g. while dropped events wait to get collected, get disposed once returned. - internal const int MaxBuffers = MaxPendingRequests + 1; - - private readonly SentryUnityOptions _options; - private readonly bool _useAsyncReadback; - - // GPU side - main thread only - private RenderTexture? _capture; - private bool _flip; - private int _width; - private int _height; - - // Guarded by _lock - private readonly object _lock = new(); - private SentryScreenshotRequest? _requested; - private SentryScreenshotRequest? _latest; - private readonly List> _pending = new(); - private bool _stalled; - - // Readback buffers, guarded by _lock. In-use ones are tracked with the request holding them, so a buffer gets - // reclaimed even if its event is dropped before the frame gets encoded. - private readonly Stack> _freeBuffers = new(); - private readonly List<(WeakReference Owner, NativeArray Buffer)> _usedBuffers = new(); - private int _bufferLength; - - private volatile bool _disposed; - - internal SentryScreenshotCapturer(SentryUnityOptions options) - : this(options, SystemInfo.supportsAsyncGPUReadback) { } - - internal SentryScreenshotCapturer(SentryUnityOptions options, bool useAsyncReadback) - { - _options = options; - _useAsyncReadback = useAsyncReadback; - } - - internal bool IsDisposed => _disposed; - - // For testing - internal int FreeBufferCount - { - get - { - lock (_lock) - { - ReclaimBuffers(); - return _freeBuffers.Count; - } - } - } - - // For testing - internal int UsedBufferCount - { - get - { - lock (_lock) - { - ReclaimBuffers(); - return _usedBuffers.Count; - } - } - } - - // For testing - internal bool HasRequest - { - get - { - lock (_lock) - { - return _requested is not null; - } - } - } - - /// - /// Requests a screenshot of the current frame. Returns null when none can be attached, e.g. because the main - /// thread is not rendering. - /// - /// - /// Runs on any thread. A transform gets applied on the main thread once the screenshot is taken. A callback to the - /// pixels gets applied on the SDK's worker right before encoding. - /// - internal virtual IAttachmentContent? TryRequest(Func? transform = null, - Func? beforeEncode = null) - { - if (_disposed) - { - return null; - } - - lock (_lock) - { - if (_stalled) - { - _options.LogDebug("Screenshot skipped. The main thread did not take the last one in time."); - return null; - } - - if (_requested is { } requested) - { - return Attach(requested, transform, beforeEncode); - } - - if (_latest is { } recent && IsShareable(recent, MinCaptureInterval) && CanServe(recent, transform)) - { - return Attach(recent, transform, beforeEncode); - } - - if (CountPending() >= MaxPendingRequests) - { - if (_latest is { } latest && IsShareable(latest, MaxSharedAge) && CanServe(latest, transform)) - { - return Attach(latest, transform, beforeEncode); - } - - _options.LogDebug("Screenshot skipped. Too many screenshots are waiting to be sent."); - return null; - } - - _requested = new SentryScreenshotRequest(); - return Attach(_requested, transform, beforeEncode); - } - } - - // Must be called while holding _lock - private IAttachmentContent Attach(SentryScreenshotRequest request, Func? transform, - Func? beforeEncode) - { - if (!MainThreadData.IsMainThread()) - { - request.RequestedOffMainThread = true; - } - - Task? transformed = null; - if (transform is null) - { - request.HasFrameConsumers = true; - if (beforeEncode is not null) - { - request.TrySetBeforeEncode(beforeEncode); - } - } - else - { - // Not yet taken or already transformed, see CanServe - transformed = request.TryAddTransform(transform); - } - - return new SentryScreenshotAttachmentContent(this, request, transformed, _options); - } - - // Whether a screenshot that was already taken can serve another event. A transform only runs once per - // screenshot, so with one set, only a transformed screenshot can be shared. Without, the frame must still be - // encodable - its pixels get released right away when only the transform uses them. - private static bool CanServe(SentryScreenshotRequest request, Func? transform) - { - if (transform is not null) - { - return request.HasTransform; - } - - // Taken with only the transform reading the frame, its pixels go as soon as the transform ran - if (request.TakenTimestamp != 0 && !request.HasFrameConsumers) - { - return false; - } - - return !request.IsCompleted || request.Frame.Result is not { } frame || frame.IsEncodable; - } - - // Must be called while holding _lock - private int CountPending() - { - // Captures drop out once nothing is left to encode, or once the attachments holding on to them are gone - _pending.RemoveAll(reference => !reference.TryGetTarget(out var request) || request.IsSettled); - ReclaimBuffers(); - - // Screenshots of events that got dropped are never going to be encoded, but only get collected eventually. - // Counting them would keep the events that do get sent from getting a screenshot of their own. - var count = 0; - foreach (var reference in _pending) - { - if (reference.TryGetTarget(out var request) && request.IsClaimed) - { - count++; - } - } - - return count; - } - - // Must be called while holding _lock - private NativeArray RentBuffer(SentryScreenshotRequest owner, int length) - { - ReclaimBuffers(); - - if (_bufferLength != length) - { - // The resolution changed. Buffers in use get disposed once returned. - while (_freeBuffers.Count > 0) - { - _freeBuffers.Pop().Dispose(); - } - - _bufferLength = length; - } - - var buffer = _freeBuffers.Count > 0 - ? _freeBuffers.Pop() - : new NativeArray(length, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); - - _usedBuffers.Add((new WeakReference(owner), buffer)); - return buffer; - } - - // Called by the frame once its pixels are no longer needed, on whichever thread encoded or released them - private void ReturnBuffer(NativeArray buffer) - { - lock (_lock) - { - var index = _usedBuffers.FindIndex(used => used.Buffer.Equals(buffer)); - if (index < 0) - { - // Already reclaimed - return; - } - - _usedBuffers.RemoveAt(index); - Recycle(buffer); - } - } - - // Must be called while holding _lock. A request that got collected took its frame with it, so nothing can return - // its buffer anymore. - private void ReclaimBuffers() - { - for (var i = _usedBuffers.Count - 1; i >= 0; i--) - { - if (!_usedBuffers[i].Owner.TryGetTarget(out _)) - { - var buffer = _usedBuffers[i].Buffer; - _usedBuffers.RemoveAt(i); - Recycle(buffer); - } - } - } - - // Must be called while holding _lock - private void Recycle(NativeArray buffer) - { - if (_disposed || buffer.Length != _bufferLength || _freeBuffers.Count >= MaxBuffers) - { - buffer.Dispose(); - } - else - { - _freeBuffers.Push(buffer); - } - } - - private static bool IsShareable(SentryScreenshotRequest request, TimeSpan maxAge) - { - if (request.IsCompleted && request.Frame.Result is null) - { - return false; - } - - var age = TimeSpan.FromSeconds((Stopwatch.GetTimestamp() - request.TakenTimestamp) / (double)Stopwatch.Frequency); - return age < maxAge; - } - - /// - /// Waits for the screenshot to be taken. Gives up after , which also skips screenshots - /// until the main thread reaches the end of a frame again. - /// - internal async Task WaitAsync(SentryScreenshotRequest request) - { - var completed = await Task.WhenAny(request.Frame, Task.Delay(CaptureTimeout)).ConfigureAwait(false); - if (completed == request.Frame) - { - return; - } - - _options.LogDebug("Screenshot was not taken in time. Skipping screenshots until the main thread catches up."); - - lock (_lock) - { - _stalled = true; - if (ReferenceEquals(_requested, request)) - { - _requested = null; - } - } - - request.Abandon(); - } - - /// - /// Takes the requested screenshot, if any. Must run on the main thread at end of frame - capturing mid-frame - /// yields an incomplete image. - /// - internal virtual void OnEndOfFrame() - { - if (_disposed) - { - return; - } - - SentryScreenshotRequest? request; - var dropped = false; - lock (_lock) - { - _stalled = false; - request = _requested; - _requested = null; - - if (request is null) - { - return; - } - - // Events on the main thread are done being captured by now. If none of them made it into an envelope, - // e.g. because they got sampled out, nothing is going to send the screenshot. - if (!request.IsClaimed && !request.RequestedOffMainThread) - { - _options.LogDebug("Screenshot skipped. The events requesting it got dropped."); - dropped = true; - } - else - { - request.TakenTimestamp = Stopwatch.GetTimestamp(); - _latest = request; - _pending.Add(new WeakReference(request)); - } - } - - if (dropped) - { - request.Abandon(); - return; - } - - Take(request); - } - - /// - /// Takes a screenshot nothing reads, so the first one requested by an event doesn't pay for allocating the - /// textures and buffers, or for the graphics driver setting up the capture. Must run on the main thread at end of - /// frame. - /// - internal void Warmup() - { - var request = new SentryScreenshotRequest(); - Take(request); - - // Hands the buffer back to the pool, whether the readback is still pending or already done - request.Abandon(); - if (request.Frame.Result is { } frame) - { - frame.ReleasePixels(); - } - } - - private void Take(SentryScreenshotRequest request) - { - var (screenWidth, screenHeight) = GetScreenSize(); - if (screenWidth <= 0 || screenHeight <= 0) - { - request.Abandon(); - return; - } - - var (targetWidth, targetHeight) = SentryScreenshot.GetTargetSize(_options.ScreenshotQuality, screenWidth, screenHeight); - EnsureTextures(targetWidth, targetHeight); - - var previous = RenderTexture.active; - - // The capture copies the screen into the target as is instead of scaling it, so it needs one at the - // screen's resolution. It's only needed for the duration of the frame, and the pool hands it out again on - // the next screenshot. - var screen = RenderTexture.GetTemporary(screenWidth, screenHeight, 0, RenderTextureFormat.ARGB32); - try - { - CaptureScreen(screen); - - // Scales the capture down to the target size. The captured image is mirrored on some platforms, which - // gets fixed in the same pass so every consumer gets it in the right orientation. - // See https://docs.unity3d.com/2019.4/Documentation/Manual/SL-PlatformDifferences.html for more info. - // Note, we can't use the `UNITY_UV_STARTS_AT_TOP` macro because it's only available in shaders. - if (_flip) - { - Graphics.Blit(screen, _capture, new Vector2(1, -1), new Vector2(0, 1)); - } - else - { - Graphics.Blit(screen, _capture); - } - - // The fallback worker reads the attachment on the main thread, where it can't wait for a readback - if (_useAsyncReadback && _options.BackgroundWorker is not WebBackgroundWorker) - { - // The readback copies the texture as of now, so later captures into it don't affect this one - RequestReadback(request, _capture!); - } - else - { - // Stalls the main thread until the GPU caught up, but only on frames that had an error - request.Complete(ReadPixels(request, _capture!), _options); - } - } - catch (Exception e) - { - _options.LogError(e, "Failed to take the screenshot."); - request.Abandon(); - } - finally - { - RenderTexture.active = previous; - RenderTexture.ReleaseTemporary(screen); - } - } - - // For testing - internal virtual (int Width, int Height) GetScreenSize() => (Screen.width, Screen.height); - - // For testing - internal virtual void CaptureScreen(RenderTexture target) => ScreenCapture.CaptureScreenshotIntoRenderTexture(target); - - private void RequestReadback(SentryScreenshotRequest request, RenderTexture source) - { - NativeArray buffer; - lock (_lock) - { - buffer = RentBuffer(request, _width * _height * 4); - } - - try - { - // Reads back straight into the buffer, which then gets encoded as is - nothing gets copied on the main thread - AsyncGPUReadback.RequestIntoNativeArray(ref buffer, source, 0, TextureFormat.RGBA32, - readback => OnReadbackComplete(request, readback, buffer)); - } - catch - { - ReturnBuffer(buffer); - throw; - } - } - - private void OnReadbackComplete(SentryScreenshotRequest request, AsyncGPUReadbackRequest readback, NativeArray buffer) - { - if (_disposed || readback.hasError) - { - ReturnBuffer(buffer); - request.Abandon(); - return; - } - - try - { - request.Complete(new SentryScreenshotFrame(buffer, readback.width, readback.height, ReturnBuffer), _options); - } - catch (Exception e) - { - _options.LogError(e, "Failed to read back the screenshot."); - ReturnBuffer(buffer); - request.Abandon(); - } - } - - private SentryScreenshotFrame ReadPixels(SentryScreenshotRequest request, RenderTexture source) - { - NativeArray buffer; - lock (_lock) - { - buffer = RentBuffer(request, _width * _height * 4); - } - - var texture = new Texture2D(_width, _height, TextureFormat.RGBA32, false); - try - { - RenderTexture.active = source; - texture.ReadPixels(new Rect(0, 0, _width, _height), 0, 0); - buffer.CopyFrom(texture.GetRawTextureData()); - return new SentryScreenshotFrame(buffer, _width, _height, ReturnBuffer); - } - catch - { - ReturnBuffer(buffer); - throw; - } - finally - { - UnityEngine.Object.Destroy(texture); - } - } - - private void EnsureTextures(int width, int height) - { - if (_capture != null && _width == width && _height == height) - { - return; - } - - ReleaseResources(); - - _capture = CreateRenderTexture(width, height, "Sentry.ScreenshotCapture"); - _flip = SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true; - _width = width; - _height = height; - - _options.LogDebug("Screenshot textures allocated at {0}x{1}.", width, height); - } - - private static RenderTexture CreateRenderTexture(int width, int height, string name) - { - var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) { name = name }; - texture.Create(); - return texture; - } - - /// - /// Releases the GPU resources. Must run on the main thread. - /// - internal void ReleaseResources() - { - ReleaseRenderTexture(ref _capture); - } - - private static void ReleaseRenderTexture(ref RenderTexture? texture) - { - if (texture == null) - { - return; - } - - texture.Release(); - UnityEngine.Object.Destroy(texture); - texture = null; - } - - public void Dispose() - { - _disposed = true; - - // Releases everything waiting, so that flushing on close doesn't wait for screenshots that won't be taken - var waiting = new List(); - lock (_lock) - { - if (_requested is not null) - { - waiting.Add(_requested); - } - - foreach (var reference in _pending) - { - if (reference.TryGetTarget(out var request)) - { - waiting.Add(request); - } - } - - _requested = null; - _latest = null; - _pending.Clear(); - - // Buffers in use get disposed once returned, or here if their request is gone already - ReclaimBuffers(); - while (_freeBuffers.Count > 0) - { - _freeBuffers.Pop().Dispose(); - } - } - - foreach (var request in waiting) - { - request.Abandon(); - } - - // Off the main thread, the coroutine releases the GPU resources once it notices the cache got disposed. - if (MainThreadData.IsMainThread()) - { - ReleaseResources(); - } - } -} diff --git a/src/Sentry.Unity/SentryScreenshotFrame.cs b/src/Sentry.Unity/SentryScreenshotFrame.cs deleted file mode 100644 index 6644a38fb..000000000 --- a/src/Sentry.Unity/SentryScreenshotFrame.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using Sentry.Extensibility; -using Unity.Collections; -using UnityEngine; -using UnityEngine.Experimental.Rendering; - -namespace Sentry.Unity; - -/// -/// A frame read back from the GPU into a buffer rented from the capturer. Its pixels only change through the -/// BeforeSendScreenshotPixels callback right before encoding, so it can get encoded on any thread. -/// -/// -/// The frame gets encoded at most once. The first consumer pays for it and every other event attaching the same frame -/// reuses the result. The buffer goes back to the capturer once encoded or released. -/// -internal sealed class SentryScreenshotFrame -{ - // Serializes encoding and releasing. The buffer gets handed back only after leaving it, so the capturer's lock is - // never taken while holding this one. - private readonly object _lock = new(); - private readonly Action> _returnBuffer; - private NativeArray _buffer; - private byte[]? _encoded; - private volatile bool _encodeStarted; - private volatile bool _released; - - /// - /// Wraps a rented buffer holding RGBA32 pixels, bottom row first, which gets handed to - /// once released. - /// - internal SentryScreenshotFrame(NativeArray buffer, int width, int height, Action> returnBuffer) - { - _buffer = buffer; - _returnBuffer = returnBuffer; - Width = width; - Height = height; - } - - internal int Width { get; } - internal int Height { get; } - - /// - /// Whether the encode has started. Readable without blocking on an encode in progress. - /// - internal bool IsEncodeStarted => _encodeStarted; - - /// - /// Whether the frame can still be encoded, i.e. it either was already or still holds its pixels. Readable without - /// blocking on an encode in progress. - /// - internal bool IsEncodable => _encodeStarted || !_released; - - /// - /// Loads the pixels into the texture. Returns false once they're released. - /// - internal bool TryLoadInto(Texture2D texture) - { - lock (_lock) - { - if (_released) - { - return false; - } - - texture.LoadRawTextureData(_buffer); - return true; - } - } - - /// - /// Returns the frame encoded as JPG, encoding it on the calling thread if no one did so yet. Returns null if the - /// encode failed, the callback discarded the screenshot, or the pixels got released before. - /// - /// The options. - /// - /// Applied to the pixels right before the first encode. Returning false discards the screenshot. - /// - internal byte[]? GetEncoded(SentryUnityOptions options, Func? beforeEncode = null) - { - var released = default(NativeArray); - try - { - lock (_lock) - { - return EncodeLocked(options, beforeEncode, ref released); - } - } - finally - { - if (released.IsCreated) - { - _returnBuffer(released); - } - } - } - - // Must be called while holding _lock - private byte[]? EncodeLocked(SentryUnityOptions options, Func? beforeEncode, - ref NativeArray released) - { - if (_encodeStarted) - { - return _encoded; - } - - _encodeStarted = true; - - if (_released) - { - return null; - } - - try - { - if (beforeEncode is not null && !ApplyBeforeEncode(options, beforeEncode)) - { - return null; - } - - // Thread safe, so it runs wherever the attachment gets serialized - using var encoded = ImageConversion.EncodeNativeArrayToJPG(_buffer, GraphicsFormat.R8G8B8A8_UNorm, - (uint)Width, (uint)Height, 0, options.ScreenshotCompression); - var bytes = encoded.ToArray(); - - if (bytes is { Length: > 0 }) - { - _encoded = bytes; - } - else - { - options.LogWarning("Screenshot encoding returned empty data."); - } - } - catch (Exception e) - { - options.LogError(e, "Failed to encode the screenshot."); - } - finally - { - released = ReleaseLocked(); - } - - return _encoded; - } - - // Must be called while holding _lock - private bool ApplyBeforeEncode(SentryUnityOptions options, Func beforeEncode) - { - try - { - if (beforeEncode(new SentryScreenshotPixels(_buffer, Width, Height))) - { - return true; - } - - options.LogInfo("Screenshot discarded by BeforeSendScreenshotPixels callback."); - return false; - } - catch (Exception e) - { - // The callback might have been about to redact something, so sending the screenshot as is isn't an option - options.LogError(e, "The BeforeSendScreenshotPixels callback threw an exception. Discarding the screenshot."); - return false; - } - } - - /// - /// Drops the pixels, handing the buffer back for reuse. Later encodes return null. - /// - internal void ReleasePixels() - { - NativeArray released; - lock (_lock) - { - released = ReleaseLocked(); - } - - if (released.IsCreated) - { - _returnBuffer(released); - } - } - - // Must be called while holding _lock - private NativeArray ReleaseLocked() - { - if (_released) - { - return default; - } - - _released = true; - - var buffer = _buffer; - _buffer = default; - return buffer; - } -} diff --git a/src/Sentry.Unity/SentryScreenshotPixels.cs b/src/Sentry.Unity/SentryScreenshotPixels.cs deleted file mode 100644 index 53e38413c..000000000 --- a/src/Sentry.Unity/SentryScreenshotPixels.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Unity.Collections; - -namespace Sentry.Unity; - -/// -/// The pixels of a screenshot before it gets compressed and attached to an event. -/// -/// -/// Only valid for the duration of the callback it gets handed to - don't hold on to beyond it. -/// -public readonly struct SentryScreenshotPixels -{ - internal SentryScreenshotPixels(NativeArray data, int width, int height) - { - Data = data; - Width = width; - Height = height; - } - - /// - /// The width of the screenshot in pixels. - /// - public int Width { get; } - - /// - /// The height of the screenshot in pixels. - /// - public int Height { get; } - - /// - /// The pixels in RGBA32: four bytes per pixel - red, green, blue and alpha - with rows ordered from the bottom of the - /// screen to the top. Edits made to it end up in the attached screenshot. - /// - public NativeArray Data { get; } -} diff --git a/src/Sentry.Unity/SentryScreenshotRequest.cs b/src/Sentry.Unity/SentryScreenshotRequest.cs deleted file mode 100644 index 17ab20fcc..000000000 --- a/src/Sentry.Unity/SentryScreenshotRequest.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Sentry.Extensibility; -using UnityEngine; - -namespace Sentry.Unity; - -/// -/// A screenshot requested by one or more events, taken at the end of the frame they were captured in. -/// -/// -/// Attachments hold on to the request and wait for it only once the SDK serializes their envelope on its background -/// worker. Completion runs on the main thread, so continuations are forced off of it. -/// -internal sealed class SentryScreenshotRequest -{ - private readonly TaskCompletionSource _frame = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - // Set under the capturer's lock until taken, read on the main thread after - private Func? _transform; - private TaskCompletionSource? _transformed; - private volatile bool _hasFrameConsumers; - private int _claims; - - /// - /// When the frame was taken, in ticks. Zero until then. - /// - internal long TakenTimestamp { get; set; } - - /// - /// Whether an attachment encodes the frame itself, as opposed to only through the transform. - /// - internal bool HasFrameConsumers - { - get => _hasFrameConsumers; - set => _hasFrameConsumers = value; - } - - /// - /// Whether an event attaching the screenshot made it into an envelope. Events that get dropped before, e.g. by - /// sampling or BeforeSend, never get there. - /// - internal bool IsClaimed => Volatile.Read(ref _claims) > 0; - - /// - /// Whether an event requested it off the main thread. Those might only reach their envelope after the end of the - /// frame, so whether they get dropped isn't known in time. - /// - internal bool RequestedOffMainThread { get; set; } - - internal void Claim() => Interlocked.Increment(ref _claims); - - internal void Unclaim() => Interlocked.Decrement(ref _claims); - - /// - /// Whether a transform got registered, so its result can be shared. - /// - internal bool HasTransform => _transformed is not null; - - /// - /// Applied to the pixels on the SDK's worker right before the frame gets encoded. Like the transform, it runs once - /// per screenshot: the first event's callback gets applied. - /// - internal Func? BeforeEncode { get; private set; } - - /// - /// Registers the callback applied to the pixels before encoding, unless one already is. Must be called under the - /// capturer's lock. - /// - internal void TrySetBeforeEncode(Func beforeEncode) => BeforeEncode ??= beforeEncode; - - internal Task Frame => _frame.Task; - - internal bool IsCompleted => _frame.Task.IsCompleted; - - /// - /// Whether the request no longer holds on to memory waiting for an encode. - /// - internal bool IsSettled => _frame.Task.IsCompleted && - (!HasFrameConsumers || _frame.Task.Result is not { } frame || frame.IsEncodeStarted); - - /// - /// Registers a transform that gets applied, and its result encoded, on the main thread once the frame is taken. - /// Must be called under the capturer's lock. - /// - /// - /// Applying and encoding it costs the main thread, so it happens once per screenshot: the first event's transform - /// gets applied and every event attaching the screenshot shares the result. Returns null once it's too late to - /// register one, i.e. the frame was taken without. - /// - internal Task? TryAddTransform(Func transform) - { - if (_transformed is { } transformed) - { - return transformed.Task; - } - - if (TakenTimestamp != 0) - { - return null; - } - - _transform = transform; - _transformed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - return _transformed.Task; - } - - /// - /// Publishes the frame. Must run on the main thread, as it applies the transforms. - /// - internal void Complete(SentryScreenshotFrame frame, SentryUnityOptions options) - { - // The transform completes before the frame does, so that anything waiting on the frame finds it done and no - // encode can release the pixels from under it. - if (_transform is not null) - { - _transformed!.TrySetResult(ApplyTransform(frame, _transform, options)); - - // Nothing encodes the frame itself, so its pixels - possibly a pooled buffer - can go right away - if (!HasFrameConsumers) - { - frame.ReleasePixels(); - } - } - - // Abandoned in the meantime, e.g. because the worker stopped waiting. Nothing will encode the frame. - if (!_frame.TrySetResult(frame)) - { - frame.ReleasePixels(); - } - } - - /// - /// Releases everything waiting on the request without a screenshot. - /// - internal void Abandon() - { - _transformed?.TrySetResult(null); - _frame.TrySetResult(null); - } - - private static byte[]? ApplyTransform(SentryScreenshotFrame frame, Func transform, - SentryUnityOptions options) - { - Texture2D? screenshot = null; - try - { - screenshot = new Texture2D(frame.Width, frame.Height, TextureFormat.RGBA32, false); - if (!frame.TryLoadInto(screenshot)) - { - return null; - } - - screenshot.Apply(); - - var transformed = transform.Invoke(screenshot); - if (transformed == null) - { - options.LogInfo("Screenshot discarded by BeforeSendScreenshot callback."); - return null; - } - - if (transformed != screenshot) - { - UnityEngine.Object.Destroy(screenshot); - screenshot = transformed; - } - - var bytes = screenshot.EncodeToJPG(options.ScreenshotCompression); - return bytes is { Length: > 0 } ? bytes : null; - } - catch (Exception e) - { - options.LogError(e, "The BeforeSendScreenshot callback threw an exception. Skipping the screenshot."); - return null; - } - finally - { - if (screenshot != null) - { - UnityEngine.Object.Destroy(screenshot); - } - } - } -} diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 4a9483f62..6cd8fc854 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -183,19 +183,13 @@ public sealed class SentryUnityOptions : SentryOptions /// /// The quality of the attached screenshot /// - /// - /// Defaults to . Taking a screenshot at higher resolutions can make the frame it - /// gets taken in miss its display refresh. - /// - public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.Low; + public ScreenshotQuality ScreenshotQuality { get; set; } = ScreenshotQuality.High; /// /// The JPG compression quality of the attached screenshot /// public int ScreenshotCompression { get; set; } = 75; - internal SentryScreenshotCapturer? ScreenshotCapturer { get; set; } - /// /// Controls whether structured logs should be captured for each Unity log type. /// @@ -427,36 +421,11 @@ public void SetBeforeCaptureScreenshot(Func beforeAttachScree /// You can modify the texture (blur areas, redact PII, etc.) and return it, or return null to discard. /// /// The callback function to invoke before sending screenshots. - [Obsolete("Operating on a Texture2D ties compressing the screenshot to the main thread, which stalls it. " + - "Use SetBeforeSendScreenshotPixels instead.")] public void SetBeforeSendScreenshot(Func beforeSendScreenshot) { BeforeSendScreenshotInternal = beforeSendScreenshot; } - internal Func? BeforeSendScreenshotPixelsInternal { get; private set; } - - /// - /// Configures a callback to modify or discard screenshots before they are sent, operating on their raw pixels. - /// - /// - /// The callback receives the screenshot's pixels right before they get compressed. Edit them in place - e.g. to - /// redact areas - and return true to send the screenshot, or false to discard it. - /// - /// It gets invoked on a background thread, so it doesn't stall the main thread. Don't call Unity APIs that are - /// restricted to the main thread from it, and don't hold on to the pixels beyond the call. - /// - /// - /// Events captured close together share a screenshot. The callback gets invoked once per screenshot, with the first - /// of those events. Takes precedence over . - /// - /// - /// The callback function to invoke before sending screenshots. - public void SetBeforeSendScreenshotPixels(Func beforeSendScreenshotPixels) - { - BeforeSendScreenshotPixelsInternal = beforeSendScreenshotPixels; - } - internal Func? BeforeCaptureViewHierarchyInternal { get; private set; } internal Func? BeforeSendViewHierarchyInternal { get; private set; } diff --git a/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 994c0ce6d..1464120da 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -67,8 +67,6 @@ public void Close() try { ApplicationAdapter.Instance.Quitting -= Close; - _options.ScreenshotCapturer?.Dispose(); - _options.ScreenshotCapturer = null; _options.DisposeGameMetricAttributes(); _options.NativeSupportCloseCallback?.Invoke(); _options.NativeSupportCloseCallback = null; @@ -127,43 +125,33 @@ public SentryId CaptureFeedback(string message, string? email, string? name, boo SentryHint? hint = null; if (addScreenshot) { - if (CaptureFeedbackScreenshot() is { } content) - { - hint = SentryHint.WithAttachments( - new SentryAttachment( - AttachmentType.Default, - content, - "screenshot.jpg", - "image/jpeg")); - } - } - - return Sentry.SentrySdk.CurrentHub.CaptureFeedback(message, email, name, hint: hint); - } + Texture2D? screenshot = null; - private IAttachmentContent? CaptureFeedbackScreenshot() - { - // Taken at the end of the frame. Capturing here instead would happen mid-frame, - // i.e. while handling the input that submitted the feedback, and yield an incomplete image. - if (_options.ScreenshotCapturer is { } capturer) - { - return capturer.TryRequest(); - } + try + { + screenshot = SentryScreenshot.CreateNewScreenshotTexture2D(_options); + var screenshotBytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - Texture2D? screenshot = null; - try - { - screenshot = SentryScreenshot.CreateNewScreenshotTexture2D(_options); - var bytes = screenshot.EncodeToJPG(_options.ScreenshotCompression); - return bytes is { Length: > 0 } ? new ByteAttachmentContent(bytes) : null; - } - finally - { - if (screenshot) + if (screenshotBytes.Length > 0) + { + hint = SentryHint.WithAttachments( + new SentryAttachment( + AttachmentType.Default, + new ByteAttachmentContent(screenshotBytes), + "screenshot.jpg", + "image/jpeg")); + } + } + finally { - UnityEngine.Object.Destroy(screenshot); + if (screenshot) + { + UnityEngine.Object.Destroy(screenshot); + } } } + + return Sentry.SentrySdk.CurrentHub.CaptureFeedback(message, email, name, hint: hint); } internal static void SetUpWindowsPlayerCaching(SentryUnitySdk unitySdk, SentryUnityOptions options) diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs index 863386a53..dea5a6545 100644 --- a/src/Sentry.Unity/UnityWebRequestTransport.cs +++ b/src/Sentry.Unity/UnityWebRequestTransport.cs @@ -42,7 +42,7 @@ private IEnumerator SendAndTrack(Envelope envelope) { // Serializing the envelope reads its screenshot right here on the main thread, so it has to be taken first. // It gets taken at the end of the frame, before the next one starts. - if (_options.ScreenshotCapturer is not null) + if (_options.AttachScreenshot) { yield return null; } diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 2802379a0..9069b8654 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -1,9 +1,9 @@ using System; using System.Collections; +using System.IO; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; -using Sentry.Extensibility; using Sentry.Unity.Tests.Stubs; using UnityEngine; using UnityEngine.TestTools; @@ -12,39 +12,47 @@ namespace Sentry.Unity.Tests; public class ScreenshotEventProcessorTests { - private class TestScreenshotCapturer : SentryScreenshotCapturer + private class TestScreenshotEventProcessor : ScreenshotEventProcessor { - public int RequestCount { get; private set; } - public bool Available { get; set; } = true; - public Func? Transform { get; private set; } - public Func? BeforeEncode { get; private set; } + public Func CreateScreenshotFunc { get; set; } - public TestScreenshotCapturer(SentryUnityOptions options) : base(options, useAsyncReadback: false) { } - - internal override IAttachmentContent? TryRequest(Func? transform = null, - Func? beforeEncode = null) + public TestScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) + : base(options, sentryMonoBehaviour) { - RequestCount++; - Transform = transform; - BeforeEncode = beforeEncode; - return Available ? new ByteAttachmentContent(new byte[] { 1, 2, 3 }) : null; + CreateScreenshotFunc = _ => new Texture2D(1, 1); } + + internal override bool CanReadBackAsync() => false; + + internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) + => CreateScreenshotFunc.Invoke(options); + + // WaitForEndOfFrame never resumes in batch mode + internal override YieldInstruction? WaitForEndOfFrame() => null; } - private static (ScreenshotEventProcessor Processor, TestScreenshotCapturer Capturer) GetSut(SentryUnityOptions? options = null) + private static TestScreenshotEventProcessor GetSut(SentryUnityOptions? options = null) { - options ??= new SentryUnityOptions(); - var capturer = new TestScreenshotCapturer(options); - return (new ScreenshotEventProcessor(options, capturer), capturer); + var gameObject = new GameObject("ScreenshotProcessorTest"); + var behaviour = gameObject.AddComponent(); + return new TestScreenshotEventProcessor(options ?? new SentryUnityOptions(), behaviour); } + // The SDK reads attachments on its worker, where reading waits for the screenshot + private static Task ReadOnWorker(SentryAttachment attachment) => Task.Run(() => + { + using var stream = attachment.Content.GetStream(); + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return memoryStream.ToArray(); + }); + [Test] - public void Process_ScreenshotAvailable_AttachesToHint() + public void Process_AttachesScreenshotToHint() { - var (processor, _) = GetSut(); var hint = new SentryHint(); - processor.Process(new SentryEvent(), hint); + GetSut().Process(new SentryEvent(), hint); var attachment = hint.Attachments.Single(); Assert.AreEqual("screenshot.jpg", attachment.FileName); @@ -52,158 +60,152 @@ public void Process_ScreenshotAvailable_AttachesToHint() Assert.AreEqual(AttachmentType.Default, attachment.Type); } - [Test] - public void Process_CapturerReturnsNothing_DoesNotAttach() - { - var (processor, capturer) = GetSut(); - capturer.Available = false; - var hint = new SentryHint(); - - processor.Process(new SentryEvent(), hint); - - Assert.IsEmpty(hint.Attachments); - } - - [Test] - public void Process_MultipleEventsInTheSameFrame_EachGetsAnAttachment() + [UnityTest] + public IEnumerator Process_EventsInTheSameFrame_ShareOneScreenshot() { - var (processor, _) = GetSut(); + var processor = GetSut(); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => + { + captureCount++; + return new Texture2D(1, 1); + }; var firstHint = new SentryHint(); var secondHint = new SentryHint(); processor.Process(new SentryEvent(), firstHint); processor.Process(new SentryEvent(), secondHint); - Assert.AreEqual(1, firstHint.Attachments.Count); - Assert.AreEqual(1, secondHint.Attachments.Count); + var first = ReadOnWorker(firstHint.Attachments.Single()); + var second = ReadOnWorker(secondHint.Attachments.Single()); + yield return new WaitUntil(() => first.IsCompleted && second.IsCompleted); + + Assert.AreEqual(1, captureCount); + Assert.IsNotEmpty(first.Result); + CollectionAssert.AreEqual(first.Result, second.Result); } [Test] - public void Process_BeforeCaptureScreenshotReturnsFalse_SkipsTheCapturerEntirely() + public void Process_BeforeCaptureScreenshotReturnsFalse_DoesNotAttach() { var options = new SentryUnityOptions(); options.SetBeforeCaptureScreenshot(_ => false); - var (processor, capturer) = GetSut(options); var hint = new SentryHint(); - processor.Process(new SentryEvent(), hint); + GetSut(options).Process(new SentryEvent(), hint); - Assert.AreEqual(0, capturer.RequestCount); Assert.IsEmpty(hint.Attachments); } - [Test] - public void Process_OffTheMainThread_AttachesToHint() - { - // Errors raised on background threads must not lose their screenshot - var (processor, capturer) = GetSut(); - var hint = new SentryHint(); - - Task.Run(() => processor.Process(new SentryEvent(), hint)).Wait(); - - Assert.AreEqual(1, capturer.RequestCount); - Assert.AreEqual(1, hint.Attachments.Count); - } - [Test] public void Process_BeforeCaptureScreenshotThrows_ReturnsEventWithoutAttachment() { var options = new SentryUnityOptions(); options.SetBeforeCaptureScreenshot(_ => throw new InvalidOperationException("test")); - var (processor, capturer) = GetSut(options); var hint = new SentryHint(); var @event = new SentryEvent(); - var processed = processor.Process(@event, hint); + var processed = GetSut(options).Process(@event, hint); Assert.AreSame(@event, processed); - Assert.AreEqual(0, capturer.RequestCount); Assert.IsEmpty(hint.Attachments); } [Test] public void Process_Feedback_DoesNotAttach() { - var (processor, capturer) = GetSut(); var feedback = new SentryEvent(); feedback.Contexts.Feedback = new SentryFeedback("message"); var hint = new SentryHint(); - processor.Process(feedback, hint); + GetSut().Process(feedback, hint); - Assert.AreEqual(0, capturer.RequestCount); Assert.IsEmpty(hint.Attachments); } - [Test] - public void Process_BeforeCaptureScreenshotReceivesEvent() + [UnityTest] + public IEnumerator Process_ScreenshotCaptureThrows_AttachmentIsEmpty() + { + var processor = GetSut(); + processor.CreateScreenshotFunc = _ => throw new Exception("Screenshot capture failed"); + var hint = new SentryHint(); + + processor.Process(new SentryEvent(), hint); + + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.IsEmpty(read.Result); + } + + [UnityTest] + public IEnumerator Process_BeforeSendScreenshot_ReceivesScreenshotAndEvent() { var options = new SentryUnityOptions(); + Texture2D? receivedScreenshot = null; SentryEvent? receivedEvent = null; - options.SetBeforeCaptureScreenshot(@event => + options.SetBeforeSendScreenshot((screenshot, @event) => { + receivedScreenshot = screenshot; receivedEvent = @event; - return true; + return screenshot; }); - var (processor, _) = GetSut(options); - + var hint = new SentryHint(); var eventId = SentryId.Create(); - processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); - Assert.NotNull(receivedEvent); + GetSut(options).Process(new SentryEvent(eventId: eventId), hint); + + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.NotNull(receivedScreenshot); Assert.AreEqual(eventId, receivedEvent!.EventId); + Assert.IsNotEmpty(read.Result); } - [Test] - public void Process_NoBeforeSendScreenshot_RequestsWithoutTransform() + [UnityTest] + public IEnumerator Process_BeforeSendScreenshotReturnsNull_AttachmentIsEmpty() { - var (processor, capturer) = GetSut(); + var options = new SentryUnityOptions(); + options.SetBeforeSendScreenshot((_, _) => null); + var hint = new SentryHint(); + + GetSut(options).Process(new SentryEvent(), hint); - processor.Process(new SentryEvent(), new SentryHint()); + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - Assert.IsNull(capturer.Transform); + Assert.IsEmpty(read.Result); } - [Test] - public void Process_BeforeSendScreenshot_PassesTheScreenshotAndEventThroughTheTransform() + [UnityTest] + public IEnumerator Process_BeforeSendScreenshotReturnsNewTexture_AttachesNewTexture() { var options = new SentryUnityOptions(); - Texture2D? receivedScreenshot = null; - SentryEvent? receivedEvent = null; -#pragma warning disable CS0618 // Still supported until removed - options.SetBeforeSendScreenshot((screenshot, @event) => - { - receivedScreenshot = screenshot; - receivedEvent = @event; - return screenshot; - }); -#pragma warning restore CS0618 - var (processor, capturer) = GetSut(options); + var newTexture = new Texture2D(10, 10); + var newTextureBytes = newTexture.EncodeToJPG(options.ScreenshotCompression); + options.SetBeforeSendScreenshot((_, _) => newTexture); + var hint = new SentryHint(); - var eventId = SentryId.Create(); - processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); + GetSut(options).Process(new SentryEvent(), hint); - // The capturer applies the transform once the screenshot is taken - Assert.IsNotNull(capturer.Transform); - var screenshot = new Texture2D(1, 1); - Assert.AreSame(screenshot, capturer.Transform!(screenshot)); - Assert.AreSame(screenshot, receivedScreenshot); - Assert.AreEqual(eventId, receivedEvent!.EventId); - UnityEngine.Object.Destroy(screenshot); + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.AreEqual(newTextureBytes.Length, read.Result.Length); } [UnityTest] public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() { - // The screenshot must be an item on the event's own envelope. Sending it separately - // orphans the attachment whenever the event is dropped on ingestion. + // Sending the screenshot separately orphans it whenever the event gets dropped on ingestion var httpHandler = new TestHttpClientHandler("ScreenshotEnvelopeTest"); var options = new SentryUnityOptions(application: new TestApplication()) { Dsn = SentryTests.TestDsn, CreateHttpMessageHandler = () => httpHandler }; - options.AddEventProcessor(new ScreenshotEventProcessor(options, new TestScreenshotCapturer(options))); + options.AddEventProcessor(GetSut(options)); SentrySdk.Init(options); @@ -212,6 +214,8 @@ public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() var capturedId = SentrySdk.CaptureMessage("test message"); Assert.AreNotEqual(SentryId.Empty, capturedId); + // Lets the screenshot get taken before blocking the main thread on the request + yield return null; yield return null; var request = httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(2)); @@ -223,42 +227,4 @@ public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() SentrySdk.Close(); } } - - [Test] - public void Process_BeforeSendScreenshotPixelsSet_PassesItToTheCapturerWithTheEvent() - { - var options = new SentryUnityOptions(); - SentryEvent? receivedEvent = null; - options.SetBeforeSendScreenshotPixels((_, @event) => - { - receivedEvent = @event; - return true; - }); - var (processor, capturer) = GetSut(options); - - var eventId = SentryId.Create(); - processor.Process(new SentryEvent(eventId: eventId), new SentryHint()); - - // Applied by the frame right before encoding, on the SDK's worker - Assert.IsNotNull(capturer.BeforeEncode); - Assert.IsNull(capturer.Transform); - Assert.IsTrue(capturer.BeforeEncode!(default)); - Assert.AreEqual(eventId, receivedEvent!.EventId); - } - - [Test] - public void Process_BothScreenshotCallbacksSet_PrefersThePixelsCallback() - { - var options = new SentryUnityOptions(); -#pragma warning disable CS0618 // Still supported until removed - options.SetBeforeSendScreenshot((screenshot, _) => screenshot); -#pragma warning restore CS0618 - options.SetBeforeSendScreenshotPixels((_, _) => true); - var (processor, capturer) = GetSut(options); - - processor.Process(new SentryEvent(), new SentryHint()); - - Assert.IsNotNull(capturer.BeforeEncode); - Assert.IsNull(capturer.Transform); - } } diff --git a/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs deleted file mode 100644 index 6cae54744..000000000 --- a/test/Sentry.Unity.Tests/SentryScreenshotCapturerTests.cs +++ /dev/null @@ -1,910 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using Sentry.Extensibility; -using Sentry.Unity.Tests.Stubs; -using UnityEngine; -using UnityEngine.TestTools; -using Object = UnityEngine.Object; - -namespace Sentry.Unity.Tests; - -public class SentryScreenshotCapturerTests -{ - // Captures a known image instead of the screen. Top half red, bottom half blue, so that a mirrored - // readback is detectable. - private class KnownImageCapturer : SentryScreenshotCapturer - { - public Texture2D Source { get; set; } - - public int CaptureCount { get; private set; } - - public Vector2Int CaptureSize { get; private set; } - - public Vector2Int? ScreenSize { get; set; } - - internal override (int Width, int Height) GetScreenSize() => - ScreenSize is { } size ? (size.x, size.y) : base.GetScreenSize(); - - public KnownImageCapturer(SentryUnityOptions options, Texture2D source, bool useAsyncReadback) - : base(options, useAsyncReadback) - { - Source = source; - } - - internal override void CaptureScreen(RenderTexture target) - { - CaptureCount++; - CaptureSize = new Vector2Int(target.width, target.height); - Graphics.Blit(Source, target); - } - } - - private Texture2D _source = null!; - private int? _originalMainThreadId; - - [SetUp] - public void SetUp() - { - _originalMainThreadId = MainThreadData.MainThreadId; - MainThreadData.MainThreadId = Thread.CurrentThread.ManagedThreadId; - - _source = new Texture2D(64, 64, TextureFormat.RGBA32, false); - var pixels = new Color32[64 * 64]; - for (var y = 0; y < 64; y++) - { - for (var x = 0; x < 64; x++) - { - // Texture2D rows start at the bottom - pixels[y * 64 + x] = y >= 32 ? new Color32(255, 0, 0, 255) : new Color32(0, 0, 255, 255); - } - } - _source.SetPixels32(pixels); - _source.Apply(); - } - - [TearDown] - public void TearDown() - { - MainThreadData.MainThreadId = _originalMainThreadId; - Object.Destroy(_source); - } - - private static SentryUnityOptions GetOptions() => new() { ScreenshotQuality = ScreenshotQuality.Low }; - - private static void RequireAsyncReadback() - { - if (!SystemInfo.supportsAsyncGPUReadback) - { - Assert.Ignore("Async GPU readback is not supported on this graphics device."); - } - } - - // By default, the event makes it into its envelope, as it would through the SDK - private static SentryScreenshotAttachmentContent Request(SentryScreenshotCapturer capturer, - Func? transform = null, Func? beforeEncode = null, - bool claim = true) - { - var content = capturer.TryRequest(transform, beforeEncode); - Assert.IsInstanceOf(content); - var screenshot = (SentryScreenshotAttachmentContent)content!; - if (claim) - { - screenshot.Request.Claim(); - } - - return screenshot; - } - - // WaitForEndOfFrame never resumes in batch mode. Capturing the known image doesn't depend on the frame - // being complete, so taking it on a regular frame is fine here. - private static IEnumerator TakeScreenshot(SentryScreenshotCapturer capturer, params SentryScreenshotAttachmentContent[] contents) - { - yield return null; - capturer.OnEndOfFrame(); - - yield return WaitFor(() => contents.All(content => content.Request.IsCompleted)); - } - - // Reading happens on the SDK's background worker - private static IEnumerator ReadOnWorker(IAttachmentContent content, Action onRead) - { - var task = Task.Run(() => ReadAll(content)); - yield return WaitFor(() => task.IsCompleted, 600); - onRead(task.Result); - } - - private static Texture2D CreateSolid(Color32 color) - { - var texture = new Texture2D(64, 64, TextureFormat.RGBA32, false); - texture.SetPixels32(Enumerable.Repeat(color, 64 * 64).ToArray()); - texture.Apply(); - return texture; - } - - private static byte[] ReadAll(IAttachmentContent content) - { - using var stream = content.GetStream(); - using var memory = new MemoryStream(); - stream.CopyTo(memory); - return memory.ToArray(); - } - - private static Texture2D Decode(byte[]? bytes) - { - Assert.IsNotNull(bytes); - Assert.IsNotEmpty(bytes); - - var texture = new Texture2D(2, 2); - Assert.IsTrue(texture.LoadImage(bytes)); - return texture; - } - - private static void AssertTopIsRedAndBottomIsBlue(Texture2D image, string label) - { - var top = image.GetPixel(image.width / 2, image.height * 3 / 4); - var bottom = image.GetPixel(image.width / 2, image.height / 4); - - // JPG is lossy, so only the dominant channel is compared - Assert.Greater(top.r, top.b, $"{label}: expected the top half to be red but got {top}."); - Assert.Greater(bottom.b, bottom.r, $"{label}: expected the bottom half to be blue but got {bottom}."); - } - - private static IEnumerator WaitFor(Func condition, int maxFrames = 300) - { - for (var i = 0; i < maxFrames && !condition(); i++) - { - yield return null; - } - - Assert.IsTrue(condition(), "Timed out waiting for the condition."); - } - - [Test] - public void TryRequest_DoesNotTakeTheScreenshotRightAway() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - var content = Request(capturer); - - Assert.IsTrue(capturer.HasRequest); - Assert.IsFalse(content.Request.IsCompleted); - Assert.AreEqual(0, capturer.CaptureCount); - } - - [UnityTest] - public IEnumerator TryRequest_ScreenLargerThanTarget_CapturesAtScreenResolutionAndScalesDown() - { - // The capture copies the screen into the target without scaling it, so capturing into a target smaller than - // the screen would crop the screenshot - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false) - { - ScreenSize = new Vector2Int(1000, 2000) - }; - var content = Request(capturer); - yield return TakeScreenshot(capturer, content); - - byte[] bytes = null!; - yield return ReadOnWorker(content, read => bytes = read); - var image = Decode(bytes); - - Assert.AreEqual(new Vector2Int(1000, 2000), capturer.CaptureSize); - Assert.AreEqual(SentryScreenshot.GetTargetSize(ScreenshotQuality.Low, 1000, 2000), - (image.width, image.height)); - Object.Destroy(image); - } - - [UnityTest] - public IEnumerator TryRequest_SynchronousReadback_ReturnsFrameInOrientationOfTheCapture() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - yield return TakeScreenshot(capturer, content); - - byte[] bytes = null!; - yield return ReadOnWorker(content, read => bytes = read); - var image = Decode(bytes); - - // The orientation fix on UV-starts-at-top platforms is applied to whatever gets captured, so the - // known image itself comes out mirrored there. What matters is that both readbacks agree. - if (SentrySystemInfoAdapter.Instance.GraphicsUVStartsAtTop ?? true) - { - FlipVertically(image); - } - - AssertTopIsRedAndBottomIsBlue(image, "synchronous readback"); - Object.Destroy(image); - } - - [UnityTest] - public IEnumerator TryRequest_AsyncReadback_MatchesSynchronousReadback() - { - RequireAsyncReadback(); - - using var asyncCapturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); - using var syncCapturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var asyncContent = Request(asyncCapturer); - var syncContent = Request(syncCapturer); - yield return TakeScreenshot(asyncCapturer, asyncContent); - yield return TakeScreenshot(syncCapturer, syncContent); - - byte[] asyncBytes = null!; - byte[] syncBytes = null!; - yield return ReadOnWorker(asyncContent, read => asyncBytes = read); - yield return ReadOnWorker(syncContent, read => syncBytes = read); - var asyncImage = Decode(asyncBytes); - var syncImage = Decode(syncBytes); - - var asyncTop = asyncImage.GetPixel(asyncImage.width / 2, asyncImage.height * 3 / 4); - var syncTop = syncImage.GetPixel(syncImage.width / 2, syncImage.height * 3 / 4); - Assert.AreEqual(syncTop.r > syncTop.b, asyncTop.r > asyncTop.b, - $"Async readback is mirrored compared to the synchronous one. Async top: {asyncTop}, sync top: {syncTop}."); - - Object.Destroy(asyncImage); - Object.Destroy(syncImage); - } - - [UnityTest] - public IEnumerator TryRequest_DefersEncodeUntilRead() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - yield return TakeScreenshot(capturer, content); - - var frame = content.Request.Frame.Result!; - Assert.IsFalse(frame.IsEncodeStarted, "Taking the screenshot must not encode."); - - // The SDK checks the length when building the envelope. Reporting one would force the wait and the encode. - using (var stream = content.GetStream()) - { - Assert.IsFalse(stream.CanSeek); - Assert.Throws(() => _ = stream.Length); - } - Assert.IsFalse(frame.IsEncodeStarted, "Opening the stream must not encode."); - - byte[] bytes = null!; - yield return ReadOnWorker(content, read => bytes = read); - - Assert.IsTrue(frame.IsEncodeStarted); - Object.Destroy(Decode(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_ReadOnTheWorkerBeforeTaken_WaitsForTheEndOfFrame() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - - var task = Task.Run(() => ReadAll(content)); - yield return null; - Assert.IsFalse(task.IsCompleted, "Reading must wait for the screenshot to be taken."); - - capturer.OnEndOfFrame(); - yield return WaitFor(() => task.IsCompleted, 600); - - Object.Destroy(Decode(task.Result)); - } - - [Test] - public void TryRequest_ReadOnTheMainThreadBeforeTaken_ReturnsEmptyWithoutWaiting() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - - // Waiting here would deadlock, as taking the screenshot needs the main thread - Assert.IsEmpty(ReadAll(content)); - } - - [UnityTest] - public IEnumerator TryRequest_SameFrame_SharesTheScreenshotAndEncodesOnce() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var first = Request(capturer); - var second = Request(capturer); - yield return TakeScreenshot(capturer, first, second); - - Assert.AreSame(first.Request, second.Request); - Assert.AreEqual(1, capturer.CaptureCount); - - byte[] firstBytes = null!; - byte[] secondBytes = null!; - yield return ReadOnWorker(first, read => firstBytes = read); - yield return ReadOnWorker(second, read => secondBytes = read); - CollectionAssert.AreEqual(firstBytes, secondBytes); - } - - [UnityTest] - public IEnumerator TryRequest_WithinMinCaptureInterval_SharesTheLatestScreenshot() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var first = Request(capturer); - yield return TakeScreenshot(capturer, first); - - var second = Request(capturer); - - Assert.AreSame(first.Request, second.Request); - Assert.IsFalse(capturer.HasRequest); - } - - [UnityTest] - public IEnumerator TryRequest_AfterMinCaptureInterval_TakesANewScreenshot() - { - var red = CreateSolid(new Color32(255, 0, 0, 255)); - var green = CreateSolid(new Color32(0, 255, 0, 255)); - using var capturer = new KnownImageCapturer(GetOptions(), red, useAsyncReadback: false); - - try - { - var first = Request(capturer); - yield return TakeScreenshot(capturer, first); - - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); - capturer.Source = green; - var second = Request(capturer); - yield return TakeScreenshot(capturer, second); - - Assert.AreNotSame(first.Request, second.Request); - - // The first screenshot stays as it was taken - byte[] bytes = null!; - yield return ReadOnWorker(first, read => bytes = read); - var image = Decode(bytes); - var pixel = image.GetPixel(image.width / 2, image.height / 2); - Assert.Greater(pixel.r, pixel.g, $"Expected the first screenshot to stay red but got {pixel}."); - Object.Destroy(image); - } - finally - { - Object.Destroy(red); - Object.Destroy(green); - } - } - - [UnityTest] - public IEnumerator TryRequest_TooManyPendingScreenshots_SharesTheLatest() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - // Kept alive, as screenshots no attachment holds on to anymore don't count - var pending = new List(); - for (var i = 0; i < SentryScreenshotCapturer.MaxPendingRequests; i++) - { - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); - pending.Add(Request(capturer)); - yield return TakeScreenshot(capturer, pending[i]); - } - - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); - var content = Request(capturer); - - Assert.AreSame(pending.Last().Request, content.Request); - Assert.IsFalse(capturer.HasRequest); - GC.KeepAlive(pending); - } - - [Test] - public void OnEndOfFrame_FallbackWorker_ReadsBackSynchronouslyForTheMainThreadToRead() - { - var options = GetOptions(); - options.BackgroundWorker = new WebBackgroundWorker(options, SentryMonoBehaviour.Instance); - using var capturer = new KnownImageCapturer(options, _source, useAsyncReadback: true); - var content = Request(capturer); - - capturer.OnEndOfFrame(); - - Assert.IsTrue(content.Request.IsCompleted); - Object.Destroy(Decode(ReadAll(content))); - } - - [Test] - public void OnEndOfFrame_EventsDroppedBeforeTheirEnvelope_DoesNotTakeTheScreenshot() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer, claim: false); - - capturer.OnEndOfFrame(); - - Assert.AreEqual(0, capturer.CaptureCount); - Assert.IsFalse(capturer.HasRequest); - Assert.IsTrue(content.Request.IsCompleted); - Assert.IsNull(content.Request.Frame.Result); - } - - [UnityTest] - public IEnumerator OnEndOfFrame_RequestedOffTheMainThread_TakesTheScreenshotBeforeTheEnvelope() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - // The event might only reach its envelope after the end of the frame - var task = Task.Run(() => capturer.TryRequest()); - yield return WaitFor(() => task.IsCompleted); - - capturer.OnEndOfFrame(); - - Assert.AreEqual(1, capturer.CaptureCount); - } - - [UnityTest] - public IEnumerator TryRequest_OneOfTheEventsMadeItIntoAnEnvelope_TakesTheScreenshot() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - Request(capturer, claim: false); - var sent = Request(capturer); - yield return TakeScreenshot(capturer, sent); - - Assert.AreEqual(1, capturer.CaptureCount); - yield return ReadOnWorker(sent, bytes => Assert.IsNotEmpty(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_ScreenshotsOfDroppedEnvelopes_DoNotCountAsPending() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - // Kept alive, so that only their envelopes getting dropped makes them not count - var dropped = new List(); - for (var i = 0; i < SentryScreenshotCapturer.MaxPendingRequests; i++) - { - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); - dropped.Add(Request(capturer, claim: false)); - var stream = dropped[i].GetStream(); - yield return TakeScreenshot(capturer, dropped[i]); - - // e.g. by a full queue or rate limiting, without ever being read - stream.Dispose(); - } - - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds * 1.5f); - var content = Request(capturer); - - Assert.AreNotSame(dropped.Last().Request, content.Request); - Assert.IsTrue(capturer.HasRequest); - GC.KeepAlive(dropped); - } - - [UnityTest] - public IEnumerator TryRequest_NotTakenInTime_ResolvesEmptyAndSkipsUntilTheNextEndOfFrame() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - - var task = Task.Run(() => ReadAll(content)); - var deadline = Time.realtimeSinceStartup + (float)SentryScreenshotCapturer.CaptureTimeout.TotalSeconds * 3; - while (!task.IsCompleted && Time.realtimeSinceStartup < deadline) - { - yield return null; - } - - Assert.IsTrue(task.IsCompleted, "Reading must give up once the screenshot didn't get taken in time."); - Assert.IsEmpty(task.Result); - Assert.IsNull(capturer.TryRequest(), "While the main thread doesn't render, no screenshot can be taken."); - - capturer.OnEndOfFrame(); - - Assert.IsNotNull(capturer.TryRequest()); - } - - [UnityTest] - public IEnumerator TryRequest_WithTransform_AppliesItWhenTakenAndEncodesTheResult() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - Texture2D? received = null; - var transformedOnMainThread = false; - var content = Request(capturer, texture => - { - received = texture; - transformedOnMainThread = MainThreadData.IsMainThread(); - return texture; - }); - Assert.IsNull(received, "The transform must wait for the screenshot to be taken."); - - yield return TakeScreenshot(capturer, content); - - Assert.IsNotNull(received); - Assert.IsTrue(transformedOnMainThread); - - byte[] bytes = null!; - yield return ReadOnWorker(content, read => bytes = read); - Object.Destroy(Decode(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_TransformDiscardsTheScreenshot_ResolvesEmpty() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var discarded = Request(capturer, _ => null); - var kept = Request(capturer); - yield return TakeScreenshot(capturer, discarded, kept); - - byte[] discardedBytes = null!; - byte[] keptBytes = null!; - yield return ReadOnWorker(discarded, read => discardedBytes = read); - yield return ReadOnWorker(kept, read => keptBytes = read); - - Assert.IsEmpty(discardedBytes); - Assert.IsNotEmpty(keptBytes, "A transform only affects the event it was applied for."); - } - - [UnityTest] - public IEnumerator TryRequest_WithoutTransformAfterATransformOnlyScreenshot_DoesNotShareIt() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - var transformed = Request(capturer, texture => texture); - capturer.OnEndOfFrame(); - - // Within the interval, but its pixels go once the transform ran, which leaves nothing to encode - var plain = Request(capturer); - yield return TakeScreenshot(capturer, transformed, plain); - - Assert.AreNotSame(transformed.Request, plain.Request); - yield return ReadOnWorker(plain, bytes => Assert.IsNotEmpty(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_TransformInTheSameFrame_RunsOnceAndSharesTheResult() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var calls = 0; - Texture2D? Transform(Texture2D texture) - { - calls++; - return texture; - } - - // Applying and encoding the transform costs the main thread, so a burst of events must not multiply it - var first = Request(capturer, Transform); - var second = Request(capturer, Transform); - yield return TakeScreenshot(capturer, first, second); - - byte[]? firstBytes = null; - byte[]? secondBytes = null; - yield return ReadOnWorker(first, bytes => firstBytes = bytes); - yield return ReadOnWorker(second, bytes => secondBytes = bytes); - - Assert.AreEqual(1, calls); - Assert.IsNotEmpty(firstBytes); - CollectionAssert.AreEqual(firstBytes, secondBytes); - } - - [UnityTest] - public IEnumerator TryRequest_TransformWithinMinCaptureInterval_SharesTheTransformedScreenshot() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var calls = 0; - Texture2D? Transform(Texture2D texture) - { - calls++; - return texture; - } - - var first = Request(capturer, Transform); - yield return TakeScreenshot(capturer, first); - - var second = Request(capturer, Transform); - - Assert.AreSame(first.Request, second.Request); - Assert.IsFalse(capturer.HasRequest); - Assert.AreEqual(1, calls); - } - - [UnityTest] - public IEnumerator TryRequest_AsyncReadback_ReturnsTheBufferOnceEncodedAndReusesIt() - { - RequireAsyncReadback(); - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); - - var first = Request(capturer); - yield return TakeScreenshot(capturer, first); - Assert.AreEqual(1, capturer.UsedBufferCount, "The frame holds its buffer until encoded."); - - yield return ReadOnWorker(first, bytes => Assert.IsNotEmpty(bytes)); - Assert.AreEqual(0, capturer.UsedBufferCount); - Assert.AreEqual(1, capturer.FreeBufferCount); - - yield return new WaitForSecondsRealtime((float)SentryScreenshotCapturer.MinCaptureInterval.TotalSeconds + 0.05f); - var second = Request(capturer); - Assert.AreNotSame(first.Request, second.Request); - yield return TakeScreenshot(capturer, second); - - // Reused rather than allocating another one - Assert.AreEqual(1, capturer.UsedBufferCount); - Assert.AreEqual(0, capturer.FreeBufferCount); - - byte[]? secondBytes = null; - yield return ReadOnWorker(second, bytes => secondBytes = bytes); - var image = Decode(secondBytes); - Object.Destroy(image); - Assert.AreEqual(1, capturer.FreeBufferCount); - } - - [UnityTest] - public IEnumerator TryRequest_AsyncReadbackWithOnlyATransform_ReturnsTheBufferRightAway() - { - RequireAsyncReadback(); - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); - - var content = Request(capturer, texture => texture); - yield return TakeScreenshot(capturer, content); - - // The transform already encoded what gets sent, nothing else needs the pixels - Assert.AreEqual(0, capturer.UsedBufferCount); - Assert.AreEqual(1, capturer.FreeBufferCount); - yield return ReadOnWorker(content, bytes => Assert.IsNotEmpty(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_BeforeEncode_RunsOnTheWorkerAndItsEditsGetEncoded() - { - RequireAsyncReadback(); - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); - var mainThreadId = Thread.CurrentThread.ManagedThreadId; - int? callbackThreadId = null; - - var content = Request(capturer, beforeEncode: pixels => - { - callbackThreadId = Thread.CurrentThread.ManagedThreadId; - - // Paints the whole screenshot green, as a redaction would paint over an area - var data = pixels.Data; - for (var i = 0; i < data.Length; i += 4) - { - data[i] = 0; - data[i + 1] = 255; - data[i + 2] = 0; - } - - return true; - }); - yield return TakeScreenshot(capturer, content); - - Assert.IsNull(callbackThreadId, "The callback must not run before the attachment gets read."); - - byte[]? bytes = null; - yield return ReadOnWorker(content, read => bytes = read); - - Assert.IsNotNull(callbackThreadId); - Assert.AreNotEqual(mainThreadId, callbackThreadId); - var image = Decode(bytes); - var pixel = image.GetPixel(image.width / 2, image.height / 2); - Assert.Greater(pixel.g, pixel.r, $"Expected the edited screenshot to be green but got {pixel}."); - Object.Destroy(image); - } - - [UnityTest] - public IEnumerator TryRequest_BeforeEncodeReturnsFalse_ResolvesEmpty() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - var content = Request(capturer, beforeEncode: _ => false); - yield return TakeScreenshot(capturer, content); - - yield return ReadOnWorker(content, bytes => Assert.IsEmpty(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_BeforeEncodeThrows_ResolvesEmpty() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - - // It might have been about to redact something, so the screenshot must not go out as is - var content = Request(capturer, beforeEncode: _ => throw new InvalidOperationException("test")); - yield return TakeScreenshot(capturer, content); - - yield return ReadOnWorker(content, bytes => Assert.IsEmpty(bytes)); - } - - [UnityTest] - public IEnumerator TryRequest_BeforeEncodeInTheSameFrame_RunsOnce() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var calls = 0; - bool BeforeEncode(SentryScreenshotPixels _) - { - Interlocked.Increment(ref calls); - return true; - } - - var first = Request(capturer, beforeEncode: BeforeEncode); - var second = Request(capturer, beforeEncode: BeforeEncode); - yield return TakeScreenshot(capturer, first, second); - - yield return ReadOnWorker(first, bytes => Assert.IsNotEmpty(bytes)); - yield return ReadOnWorker(second, bytes => Assert.IsNotEmpty(bytes)); - Assert.AreEqual(1, calls); - } - - [UnityTest] - public IEnumerator Dispose_ReleasesPendingRequests() - { - var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var content = Request(capturer); - - capturer.Dispose(); - - byte[] bytes = null!; - yield return ReadOnWorker(content, read => bytes = read); - Assert.IsEmpty(bytes); - Assert.IsTrue(capturer.IsDisposed); - Assert.IsNull(capturer.TryRequest()); - } - - [UnityTest] - public IEnumerator StartScreenshotCapturer_WarmsUpAtTheFirstEndOfFrame() - { - using var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var gameObject = new GameObject("ScreenshotCapturerTest"); - var behaviour = gameObject.AddComponent(); - - try - { - behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); - yield return WaitFor(() => capturer.CaptureCount > 0); - - // The first event's screenshot reuses what the warmup allocated - Assert.AreEqual(0, capturer.UsedBufferCount); - Assert.AreEqual(1, capturer.FreeBufferCount); - Assert.IsFalse(capturer.HasRequest); - - var content = Request(capturer); - yield return TakeScreenshot(capturer, content); - Assert.AreEqual(0, capturer.FreeBufferCount); - yield return ReadOnWorker(content, bytes => Assert.IsNotEmpty(bytes)); - } - finally - { - Object.Destroy(gameObject); - } - } - - [UnityTest] - public IEnumerator Dispose_StopsTheCoroutine() - { - var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var gameObject = new GameObject("ScreenshotCapturerTest"); - var behaviour = gameObject.AddComponent(); - - try - { - behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); - yield return WaitFor(() => capturer.CaptureCount > 0); - Request(capturer); - yield return WaitFor(() => capturer.CaptureCount > 1); - - capturer.Dispose(); - yield return null; - capturer.TryRequest(); - - for (var i = 0; i < 5; i++) - { - yield return null; - } - - Assert.AreEqual(2, capturer.CaptureCount); - } - finally - { - Object.Destroy(gameObject); - } - } - - [UnityTest] - public IEnumerator Dispose_OffTheMainThread_DefersReleaseToTheCoroutine() - { - var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var gameObject = new GameObject("ScreenshotCapturerTest"); - var behaviour = gameObject.AddComponent(); - - try - { - behaviour.StartScreenshotCapturer(capturer, endOfFrame: null); - Request(capturer); - yield return WaitFor(() => capturer.CaptureCount > 0); - - // Releasing GPU resources off the main thread throws, which would abort the SDK's Close(). - var task = Task.Run(capturer.Dispose); - yield return WaitFor(() => task.IsCompleted); - - Assert.IsNull(task.Exception); - Assert.IsTrue(capturer.IsDisposed); - } - finally - { - Object.Destroy(gameObject); - } - } - - [UnityTest] - public IEnumerator TryRequest_ThroughTheSdk_DroppedEventDoesNotTakeTheScreenshot() - { - var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: false); - var options = new SentryUnityOptions(application: new TestApplication()) - { - Dsn = SentryTests.TestDsn, - CreateHttpMessageHandler = () => new TestHttpClientHandler("DroppedScreenshotTest") - }; - options.SetBeforeSend((_, _) => null); - options.AddEventProcessor(new ScreenshotEventProcessor(options, capturer)); - - SentrySdk.Init(options); - - try - { - SentrySdk.CaptureMessage("dropped"); - Assert.IsTrue(capturer.HasRequest, "The screenshot gets requested before BeforeSend and sampling."); - - yield return null; - capturer.OnEndOfFrame(); - - Assert.AreEqual(0, capturer.CaptureCount); - } - finally - { - SentrySdk.Close(); - capturer.Dispose(); - } - } - - [UnityTest] - public IEnumerator TryRequest_ThroughTheSdk_ScreenshotRidesAlongTheEvent() - { - RequireAsyncReadback(); - - var capturer = new KnownImageCapturer(GetOptions(), _source, useAsyncReadback: true); - var httpHandler = new TestHttpClientHandler("DeferredScreenshotEnvelopeTest"); - var options = new SentryUnityOptions(application: new TestApplication()) - { - Dsn = SentryTests.TestDsn, - CreateHttpMessageHandler = () => httpHandler - }; - options.AddEventProcessor(new ScreenshotEventProcessor(options, capturer)); - - SentrySdk.Init(options); - - try - { - SentrySdk.CaptureMessage("deferred screenshot"); - - // The worker is now waiting for the screenshot - yield return null; - capturer.OnEndOfFrame(); - - var task = Task.Run(() => httpHandler.GetEvent("screenshot.jpg", TimeSpan.FromSeconds(5))); - yield return WaitFor(() => task.IsCompleted, 600); - - var request = task.Result; - StringAssert.Contains("deferred screenshot", request); - - // The item header carries the length of the payload, which is only known once encoded - var header = Regex.Match(request, @"\{[^\n]*""filename"":""screenshot\.jpg""[^\n]*\}"); - Assert.IsTrue(header.Success, "No screenshot item header found."); - var length = Regex.Match(header.Value, @"""length"":(\d+)"); - Assert.IsTrue(length.Success, $"No length in the screenshot item header: {header.Value}"); - Assert.Greater(int.Parse(length.Groups[1].Value), 0); - } - finally - { - SentrySdk.Close(); - capturer.Dispose(); - } - } - - private static void FlipVertically(Texture2D texture) - { - var pixels = texture.GetPixels32(); - var flipped = new Color32[pixels.Length]; - for (var y = 0; y < texture.height; y++) - { - Array.Copy(pixels, y * texture.width, flipped, (texture.height - 1 - y) * texture.width, texture.width); - } - texture.SetPixels32(flipped); - texture.Apply(); - } -} From ccbb34a0036cef6f5140213e7ef987cf8e305850 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:33:34 +0200 Subject: [PATCH 08/15] Encode screenshots right after the readback so raw frames don't pile up in the queue --- CHANGELOG.md | 2 +- docs/agent-guides/integrations.md | 2 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 41 +++++++++++-------- src/Sentry.Unity/SentryScreenshot.cs | 25 ++++++++--- .../SentryScreenshotAttachmentContent.cs | 28 +++++-------- 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5851d26..9b2f2f706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixes -- Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and only encoded once the SDK writes the event's envelope on its background worker. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Attaching a screenshot no longer stalls the main thread. Unless `SetBeforeSendScreenshot` is set, the frame gets read back from the GPU asynchronously and encoded on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Dependencies diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index f6277ddad..41d356f39 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -22,7 +22,7 @@ - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. - Screenshot capture and view-hierarchy capture must run on Unity main thread. -- Screenshots get attached to the event's hint as `SentryScreenshotAttachmentContent`, so they ride along the event's own envelope. Events in the same frame share one, taken at end of frame. The content waits for it and encodes it when the envelope gets serialized on the SDK's worker; it never waits on the main thread and times out if the main thread stops rendering. +- Screenshots get attached to the event's hint as `SentryScreenshotAttachmentContent`, so they ride along the event's own envelope. Events in the same frame share one, taken at end of frame. An async GPU readback gets encoded off the main thread right after it completes; until then, later events share the in-flight screenshot. The content waits for it when the envelope gets serialized on the SDK's worker; it never waits on the main thread and times out if the main thread stops rendering. ## Tests diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index 2c351c839..cefd8512b 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -11,9 +11,9 @@ public class ScreenshotEventProcessor : ISentryEventProcessorWithHint private readonly SentryUnityOptions _options; private readonly ISentryMonoBehaviour _sentryMonoBehaviour; - // Events in the same frame share a screenshot + // Events share a screenshot until it's done, so frames can't pile up behind a slow encoder private readonly object _lock = new(); - private TaskCompletionSource?>? _pending; + private TaskCompletionSource? _pending; public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } @@ -46,19 +46,19 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB return @event; } - Task?> screenshot; + Task screenshot; lock (_lock) { if (_pending is null) { - _pending = new TaskCompletionSource?>(TaskCreationOptions.RunContinuationsAsynchronously); + _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, @event)); } screenshot = _pending.Task; } - // The screenshot gets read, and encoded, when the SDK serializes the event's envelope on its worker + // The screenshot gets read when the SDK serializes the event's envelope on its worker hint.Attachments.Add(new SentryAttachment( AttachmentType.Default, new SentryScreenshotAttachmentContent(screenshot, _options), @@ -68,36 +68,44 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB return @event; } - private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource?> screenshot, SentryEvent @event) + private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource screenshot, SentryEvent @event) { // Capturing mid-frame yields an incomplete image yield return WaitForEndOfFrame(); - lock (_lock) - { - _pending = null; - } - try { // The Texture2D callback and the main thread worker need the screenshot right away if (_options.BeforeSendScreenshotInternal is null && CanReadBackAsync()) { - SentryScreenshot.CaptureAsync(_options, result => screenshot.TrySetResult(result)); + SentryScreenshot.CaptureAsync(_options, result => Complete(screenshot, result)); } else { - screenshot.TrySetResult(CaptureScreenshot(@event)); + Complete(screenshot, CaptureScreenshot(@event)); } } catch (Exception e) { _options.LogError(e, "Failed to capture screenshot."); - screenshot.TrySetResult(null); + Complete(screenshot, null); } } - private Lazy? CaptureScreenshot(SentryEvent @event) + private void Complete(TaskCompletionSource screenshot, byte[]? result) + { + lock (_lock) + { + if (_pending == screenshot) + { + _pending = null; + } + } + + screenshot.TrySetResult(result); + } + + private byte[]? CaptureScreenshot(SentryEvent @event) { Texture2D? screenshot = null; try @@ -123,8 +131,7 @@ private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource(() => screenshotBytes); + return screenshot.EncodeToJPG(_options.ScreenshotCompression); } finally { diff --git a/src/Sentry.Unity/SentryScreenshot.cs b/src/Sentry.Unity/SentryScreenshot.cs index a1a33dfee..26a9c6eeb 100644 --- a/src/Sentry.Unity/SentryScreenshot.cs +++ b/src/Sentry.Unity/SentryScreenshot.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Sentry.Extensibility; using UnityEngine; using UnityEngine.Experimental.Rendering; @@ -48,10 +49,10 @@ internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions option } /// - /// Reads the current screenshot back from the GPU without stalling the main thread. The result gets encoded to JPG - /// on first access, on the accessing thread. + /// Reads the current screenshot back from the GPU and encodes it to JPG on a background thread, without stalling + /// the main thread. /// - internal static void CaptureAsync(SentryUnityOptions options, Action?> onCaptured) + internal static void CaptureAsync(SentryUnityOptions options, Action onCaptured) { var renderTexture = CaptureIntoRenderTexture(options, Screen.width, Screen.height); var (width, height) = ((uint)renderTexture.width, (uint)renderTexture.height); @@ -69,9 +70,21 @@ internal static void CaptureAsync(SentryUnityOptions options, Action().ToArray(); - onCaptured(new Lazy(() => - ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, width, height, 0, - options.ScreenshotCompression))); + Task.Run(() => + { + byte[]? bytes = null; + try + { + bytes = ImageConversion.EncodeArrayToJPG(pixels, GraphicsFormat.R8G8B8A8_UNorm, width, height, + 0, options.ScreenshotCompression); + } + catch (Exception e) + { + options.LogError(e, "Failed to encode the screenshot."); + } + + onCaptured(bytes); + }); }); } finally diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index 1a7d902b8..26a3cc39d 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -11,24 +11,24 @@ namespace Sentry.Unity; /// /// /// The SDK reads attachment content when serializing the envelope on its background worker. Waiting for the screenshot -/// and encoding it only then keeps both off the thread that captured the event, while the screenshot still goes out in -/// the same envelope as the event. +/// only then keeps it off the thread that captured the event, while the screenshot still goes out in the same envelope +/// as the event. /// internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent { // How long the worker waits for the main thread to take the screenshot, e.g. while it's hanging or paused internal static readonly TimeSpan CaptureTimeout = TimeSpan.FromSeconds(1); - private readonly Task?> _screenshot; + private readonly Task _screenshot; private readonly SentryUnityOptions _options; - internal SentryScreenshotAttachmentContent(Task?> screenshot, SentryUnityOptions options) + internal SentryScreenshotAttachmentContent(Task screenshot, SentryUnityOptions options) { _screenshot = screenshot; _options = options; } - public Stream GetStream() => new EncodeOnReadStream(this); + public Stream GetStream() => new WaitOnReadStream(this); internal async Task GetEncodedAsync() { @@ -48,26 +48,18 @@ internal async Task GetEncodedAsync() } } - try - { - var bytes = (await _screenshot.ConfigureAwait(false))?.Value; - return bytes is { Length: > 0 } ? bytes : Array.Empty(); - } - catch (Exception e) - { - _options.LogError(e, "Failed to encode the screenshot."); - return Array.Empty(); - } + var bytes = await _screenshot.ConfigureAwait(false); + return bytes is { Length: > 0 } ? bytes : Array.Empty(); } // Deliberately not seekable: the SDK checks the length of attachment streams when building the envelope, on the - // capturing thread. Reporting no length keeps the wait and the encode from happening right there. - private sealed class EncodeOnReadStream : Stream + // capturing thread. Reporting no length keeps the wait from happening right there. + private sealed class WaitOnReadStream : Stream { private readonly SentryScreenshotAttachmentContent _content; private MemoryStream? _encoded; - public EncodeOnReadStream(SentryScreenshotAttachmentContent content) => _content = content; + public WaitOnReadStream(SentryScreenshotAttachmentContent content) => _content = content; public override bool CanRead => true; public override bool CanSeek => false; From be644f3dece313b79c2b447b13a4636ac3297b9c Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:35:18 +0200 Subject: [PATCH 09/15] Make the screenshot timeout a deadline and replace stale captures so a missing frame can't stall every envelope --- src/Sentry.Unity/ScreenshotEventProcessor.cs | 5 ++- .../SentryScreenshotAttachmentContent.cs | 11 ++++- .../ScreenshotEventProcessorTests.cs | 43 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index cefd8512b..e0fbc5085 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -14,6 +14,7 @@ public class ScreenshotEventProcessor : ISentryEventProcessorWithHint // Events share a screenshot until it's done, so frames can't pile up behind a slow encoder private readonly object _lock = new(); private TaskCompletionSource? _pending; + private DateTime _pendingSince; public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } @@ -49,9 +50,11 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB Task screenshot; lock (_lock) { - if (_pending is null) + // A capture that never completes, e.g. while the main thread hangs, must not hold up later events + if (_pending is null || DateTime.UtcNow - _pendingSince > SentryScreenshotAttachmentContent.CaptureTimeout) { _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingSince = DateTime.UtcNow; _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, @event)); } diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index 26a3cc39d..c3d665db3 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -21,11 +21,13 @@ internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent private readonly Task _screenshot; private readonly SentryUnityOptions _options; + private readonly DateTime _deadline; internal SentryScreenshotAttachmentContent(Task screenshot, SentryUnityOptions options) { _screenshot = screenshot; _options = options; + _deadline = DateTime.UtcNow + CaptureTimeout; } public Stream GetStream() => new WaitOnReadStream(this); @@ -41,7 +43,14 @@ internal async Task GetEncodedAsync() return Array.Empty(); } - if (await Task.WhenAny(_screenshot, Task.Delay(CaptureTimeout)).ConfigureAwait(false) != _screenshot) + // A deadline, so envelopes sharing a screenshot that never comes don't each wait the full timeout + var remaining = _deadline - DateTime.UtcNow; + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + + if (await Task.WhenAny(_screenshot, Task.Delay(remaining)).ConfigureAwait(false) != _screenshot) { _options.LogWarning("Screenshot was not taken in time. Skipping."); return Array.Empty(); diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 9069b8654..6853cc66b 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -15,6 +16,7 @@ public class ScreenshotEventProcessorTests private class TestScreenshotEventProcessor : ScreenshotEventProcessor { public Func CreateScreenshotFunc { get; set; } + public YieldInstruction? EndOfFrame { get; set; } public TestScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) : base(options, sentryMonoBehaviour) @@ -28,7 +30,7 @@ internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions opti => CreateScreenshotFunc.Invoke(options); // WaitForEndOfFrame never resumes in batch mode - internal override YieldInstruction? WaitForEndOfFrame() => null; + internal override YieldInstruction? WaitForEndOfFrame() => EndOfFrame; } private static TestScreenshotEventProcessor GetSut(SentryUnityOptions? options = null) @@ -195,6 +197,45 @@ public IEnumerator Process_BeforeSendScreenshotReturnsNewTexture_AttachesNewText Assert.AreEqual(newTextureBytes.Length, read.Result.Length); } + [UnityTest] + public IEnumerator Process_PendingCaptureTimedOut_StartsNewCapture() + { + var processor = GetSut(); + // Keeps the first capture from completing, as if the main thread stopped rendering + processor.EndOfFrame = new WaitForSeconds(60); + var staleHint = new SentryHint(); + processor.Process(new SentryEvent(), staleHint); + + yield return new WaitForSecondsRealtime((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.1f); + + processor.EndOfFrame = null; + var hint = new SentryHint(); + processor.Process(new SentryEvent(), hint); + + var stale = ReadOnWorker(staleHint.Attachments.Single()); + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => stale.IsCompleted && read.IsCompleted); + + Assert.IsEmpty(stale.Result); + Assert.IsNotEmpty(read.Result); + } + + [UnityTest] + public IEnumerator GetStream_ReadAfterTheCaptureTimeout_DoesNotWaitAgain() + { + var content = new SentryScreenshotAttachmentContent(new TaskCompletionSource().Task, new SentryUnityOptions()); + var attachment = new SentryAttachment(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg"); + + yield return new WaitForSecondsRealtime((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.1f); + + var stopwatch = Stopwatch.StartNew(); + var read = ReadOnWorker(attachment); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.Less(stopwatch.ElapsedMilliseconds, 500); + Assert.IsEmpty(read.Result); + } + [UnityTest] public IEnumerator Process_ThroughTheSdk_ScreenshotRidesAlongTheEventEnvelope() { From 06e8f78a9c90516b6926739c5e49de67dae2ee90 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:36:21 +0200 Subject: [PATCH 10/15] Skip the screenshot when none of the events sharing it got captured --- src/Sentry.Unity/ScreenshotEventProcessor.cs | 25 +++++++++++- .../ScreenshotEventProcessorTests.cs | 39 ++++++++++++++----- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index e0fbc5085..a8a1aea94 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Threading.Tasks; using Sentry.Extensibility; using UnityEngine; @@ -15,6 +16,7 @@ public class ScreenshotEventProcessor : ISentryEventProcessorWithHint private readonly object _lock = new(); private TaskCompletionSource? _pending; private DateTime _pendingSince; + private List _pendingEvents = new(); public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } @@ -55,9 +57,12 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB { _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingSince = DateTime.UtcNow; - _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, @event)); + _pendingEvents = new List(); + _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, _pendingEvents)); } + _pendingEvents.Add(@event); + screenshot = _pending.Task; } @@ -71,11 +76,27 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB return @event; } - private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource screenshot, SentryEvent @event) + private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource screenshot, List events) { // Capturing mid-frame yields an incomplete image yield return WaitForEndOfFrame(); + // Event processors run before BeforeSend and sampling, which may have dropped the events + SentryEvent @event; + bool anyCaptured; + lock (_lock) + { + @event = events[0]; + anyCaptured = events.Exists(e => e.IsCaptured); + } + + if (!anyCaptured) + { + _options.LogDebug("Skipping the screenshot. None of its events got captured."); + Complete(screenshot, null); + yield break; + } + try { // The Texture2D callback and the main thread worker need the screenshot right away diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 6853cc66b..3073ecda6 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -75,8 +75,8 @@ public IEnumerator Process_EventsInTheSameFrame_ShareOneScreenshot() var firstHint = new SentryHint(); var secondHint = new SentryHint(); - processor.Process(new SentryEvent(), firstHint); - processor.Process(new SentryEvent(), secondHint); + processor.Process(new SentryEvent { IsCaptured = true }, firstHint); + processor.Process(new SentryEvent { IsCaptured = true }, secondHint); var first = ReadOnWorker(firstHint.Attachments.Single()); var second = ReadOnWorker(secondHint.Attachments.Single()); @@ -87,6 +87,27 @@ public IEnumerator Process_EventsInTheSameFrame_ShareOneScreenshot() CollectionAssert.AreEqual(first.Result, second.Result); } + [UnityTest] + public IEnumerator Process_NoEventCaptured_SkipsScreenshot() + { + var processor = GetSut(); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => + { + captureCount++; + return new Texture2D(1, 1); + }; + var hint = new SentryHint(); + + processor.Process(new SentryEvent(), hint); + + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.AreEqual(0, captureCount); + Assert.IsEmpty(read.Result); + } + [Test] public void Process_BeforeCaptureScreenshotReturnsFalse_DoesNotAttach() { @@ -94,7 +115,7 @@ public void Process_BeforeCaptureScreenshotReturnsFalse_DoesNotAttach() options.SetBeforeCaptureScreenshot(_ => false); var hint = new SentryHint(); - GetSut(options).Process(new SentryEvent(), hint); + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); Assert.IsEmpty(hint.Attachments); } @@ -132,7 +153,7 @@ public IEnumerator Process_ScreenshotCaptureThrows_AttachmentIsEmpty() processor.CreateScreenshotFunc = _ => throw new Exception("Screenshot capture failed"); var hint = new SentryHint(); - processor.Process(new SentryEvent(), hint); + processor.Process(new SentryEvent { IsCaptured = true }, hint); var read = ReadOnWorker(hint.Attachments.Single()); yield return new WaitUntil(() => read.IsCompleted); @@ -155,7 +176,7 @@ public IEnumerator Process_BeforeSendScreenshot_ReceivesScreenshotAndEvent() var hint = new SentryHint(); var eventId = SentryId.Create(); - GetSut(options).Process(new SentryEvent(eventId: eventId), hint); + GetSut(options).Process(new SentryEvent(eventId: eventId) { IsCaptured = true }, hint); var read = ReadOnWorker(hint.Attachments.Single()); yield return new WaitUntil(() => read.IsCompleted); @@ -172,7 +193,7 @@ public IEnumerator Process_BeforeSendScreenshotReturnsNull_AttachmentIsEmpty() options.SetBeforeSendScreenshot((_, _) => null); var hint = new SentryHint(); - GetSut(options).Process(new SentryEvent(), hint); + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); var read = ReadOnWorker(hint.Attachments.Single()); yield return new WaitUntil(() => read.IsCompleted); @@ -189,7 +210,7 @@ public IEnumerator Process_BeforeSendScreenshotReturnsNewTexture_AttachesNewText options.SetBeforeSendScreenshot((_, _) => newTexture); var hint = new SentryHint(); - GetSut(options).Process(new SentryEvent(), hint); + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); var read = ReadOnWorker(hint.Attachments.Single()); yield return new WaitUntil(() => read.IsCompleted); @@ -204,13 +225,13 @@ public IEnumerator Process_PendingCaptureTimedOut_StartsNewCapture() // Keeps the first capture from completing, as if the main thread stopped rendering processor.EndOfFrame = new WaitForSeconds(60); var staleHint = new SentryHint(); - processor.Process(new SentryEvent(), staleHint); + processor.Process(new SentryEvent { IsCaptured = true }, staleHint); yield return new WaitForSecondsRealtime((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.1f); processor.EndOfFrame = null; var hint = new SentryHint(); - processor.Process(new SentryEvent(), hint); + processor.Process(new SentryEvent { IsCaptured = true }, hint); var stale = ReadOnWorker(staleHint.Attachments.Single()); var read = ReadOnWorker(hint.Attachments.Single()); From b63efa8cb2147a8187bbd195fd61c225346d265c Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:37:21 +0200 Subject: [PATCH 11/15] Pass the first captured event to BeforeSendScreenshot and document it runs once per shared screenshot --- CHANGELOG.md | 2 +- src/Sentry.Unity/ScreenshotEventProcessor.cs | 8 +++--- src/Sentry.Unity/SentryUnityOptions.cs | 2 ++ .../ScreenshotEventProcessorTests.cs | 25 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2f2f706..a2421774c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Behavioral Changes -- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in the same frame share one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in the same frame share one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. `SetBeforeSendScreenshot` now runs once per screenshot, receiving the first of the events sharing it that didn't get dropped. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Fixes diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index a8a1aea94..8eca0c337 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -82,15 +82,13 @@ private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource scr yield return WaitForEndOfFrame(); // Event processors run before BeforeSend and sampling, which may have dropped the events - SentryEvent @event; - bool anyCaptured; + SentryEvent? @event; lock (_lock) { - @event = events[0]; - anyCaptured = events.Exists(e => e.IsCaptured); + @event = events.Find(e => e.IsCaptured); } - if (!anyCaptured) + if (@event is null) { _options.LogDebug("Skipping the screenshot. None of its events got captured."); Complete(screenshot, null); diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 6cd8fc854..fed3adb51 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -419,6 +419,8 @@ public void SetBeforeCaptureScreenshot(Func beforeAttachScree /// /// This callback receives the captured screenshot as a Texture2D before JPEG compression. /// You can modify the texture (blur areas, redact PII, etc.) and return it, or return null to discard. + /// It runs once per screenshot. Events captured together share one, and the callback receives the first of them + /// that didn't get dropped. /// /// The callback function to invoke before sending screenshots. public void SetBeforeSendScreenshot(Func beforeSendScreenshot) diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 3073ecda6..ff2a944ff 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -186,6 +186,31 @@ public IEnumerator Process_BeforeSendScreenshot_ReceivesScreenshotAndEvent() Assert.IsNotEmpty(read.Result); } + [UnityTest] + public IEnumerator Process_BeforeSendScreenshot_ReceivesFirstCapturedEvent() + { + var options = new SentryUnityOptions(); + SentryEvent? receivedEvent = null; + options.SetBeforeSendScreenshot((screenshot, @event) => + { + receivedEvent = @event; + return screenshot; + }); + var processor = GetSut(options); + var dropped = new SentryEvent(); + var captured = new SentryEvent { IsCaptured = true }; + var droppedHint = new SentryHint(); + var capturedHint = new SentryHint(); + + processor.Process(dropped, droppedHint); + processor.Process(captured, capturedHint); + + var read = ReadOnWorker(capturedHint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); + + Assert.AreSame(captured, receivedEvent); + } + [UnityTest] public IEnumerator Process_BeforeSendScreenshotReturnsNull_AttachmentIsEmpty() { From da0fd4e6968ac71fd6e904d6a93d2ffe80925e81 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:37:35 +0200 Subject: [PATCH 12/15] Update screenshot docs and comments to match when captures happen --- src/Sentry.Unity/SentryMonoBehaviour.cs | 2 +- src/Sentry.Unity/SentryUnityOptions.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs index 2d93d8f0d..74d524ef9 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.cs @@ -59,7 +59,7 @@ public void StartAwakeSpan(MonoBehaviour monoBehaviour) /// public partial class SentryMonoBehaviour { - // Unbounded queue - used by the ScreenshotEventProcessor where capture is already limited by Interlocked to 1/frame + // Unbounded queue - used by the ScreenshotEventProcessor, which shares one capture across the events of a frame private readonly ConcurrentQueue _coroutineQueue = new(); public void QueueCoroutine(IEnumerator routine) diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index fed3adb51..e1c66dd2a 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -403,8 +403,8 @@ internal bool NativeAppHangTrackingEnabled /// Configures a callback function to be invoked before capturing and attaching a screenshot to an event. /// /// - /// This callback will get invoked right before a screenshot gets taken. If the screenshot should not - /// be taken return `false`. + /// This callback will get invoked when the event gets captured, possibly off the main thread, so don't use the Unity + /// API in it. If the screenshot should not be taken return `false`. /// public void SetBeforeCaptureScreenshot(Func beforeAttachScreenshot) { From 74f9a18c39e2bfabf91c3480f488015b1f89dfd7 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:40:57 +0200 Subject: [PATCH 13/15] Cancel replaced stale captures so a recovering main thread doesn't take them all at once --- src/Sentry.Unity/ScreenshotEventProcessor.cs | 7 +++++++ .../ScreenshotEventProcessorTests.cs | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index 8eca0c337..296b16fd0 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -55,6 +55,8 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB // A capture that never completes, e.g. while the main thread hangs, must not hold up later events if (_pending is null || DateTime.UtcNow - _pendingSince > SentryScreenshotAttachmentContent.CaptureTimeout) { + // Its envelopes are past their deadline, so it doesn't need taking anymore + _pending?.TrySetResult(null); _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingSince = DateTime.UtcNow; _pendingEvents = new List(); @@ -81,6 +83,11 @@ private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource scr // Capturing mid-frame yields an incomplete image yield return WaitForEndOfFrame(); + if (screenshot.Task.IsCompleted) + { + yield break; + } + // Event processors run before BeforeSend and sampling, which may have dropped the events SentryEvent? @event; lock (_lock) diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index ff2a944ff..fb61a74a4 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -247,8 +247,14 @@ public IEnumerator Process_BeforeSendScreenshotReturnsNewTexture_AttachesNewText public IEnumerator Process_PendingCaptureTimedOut_StartsNewCapture() { var processor = GetSut(); - // Keeps the first capture from completing, as if the main thread stopped rendering - processor.EndOfFrame = new WaitForSeconds(60); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => + { + captureCount++; + return new Texture2D(1, 1); + }; + // Holds the first capture past the timeout, as if the main thread stopped rendering + processor.EndOfFrame = new WaitForSeconds((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.5f); var staleHint = new SentryHint(); processor.Process(new SentryEvent { IsCaptured = true }, staleHint); @@ -261,9 +267,12 @@ public IEnumerator Process_PendingCaptureTimedOut_StartsNewCapture() var stale = ReadOnWorker(staleHint.Attachments.Single()); var read = ReadOnWorker(hint.Attachments.Single()); yield return new WaitUntil(() => stale.IsCompleted && read.IsCompleted); + // Lets the replaced capture reach its end of frame + yield return new WaitForSecondsRealtime(0.5f); Assert.IsEmpty(stale.Result); Assert.IsNotEmpty(read.Result); + Assert.AreEqual(1, captureCount); } [UnityTest] From f99970e65d419d3a749b9b2101558a56de0cef48 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:41:54 +0200 Subject: [PATCH 14/15] Time screenshot captures with a monotonic clock so wall clock jumps don't skew the timeout --- src/Sentry.Unity/ScreenshotEventProcessor.cs | 7 ++++--- src/Sentry.Unity/SentryScreenshotAttachmentContent.cs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index 296b16fd0..e704ff77c 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using Sentry.Extensibility; using UnityEngine; @@ -15,7 +16,7 @@ public class ScreenshotEventProcessor : ISentryEventProcessorWithHint // Events share a screenshot until it's done, so frames can't pile up behind a slow encoder private readonly object _lock = new(); private TaskCompletionSource? _pending; - private DateTime _pendingSince; + private readonly Stopwatch _pendingSince = new(); private List _pendingEvents = new(); public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } @@ -53,12 +54,12 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB lock (_lock) { // A capture that never completes, e.g. while the main thread hangs, must not hold up later events - if (_pending is null || DateTime.UtcNow - _pendingSince > SentryScreenshotAttachmentContent.CaptureTimeout) + if (_pending is null || _pendingSince.Elapsed > SentryScreenshotAttachmentContent.CaptureTimeout) { // Its envelopes are past their deadline, so it doesn't need taking anymore _pending?.TrySetResult(null); _pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pendingSince = DateTime.UtcNow; + _pendingSince.Restart(); _pendingEvents = new List(); _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, _pendingEvents)); } diff --git a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs index c3d665db3..adfc2b4ca 100644 --- a/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -21,13 +22,12 @@ internal sealed class SentryScreenshotAttachmentContent : IAttachmentContent private readonly Task _screenshot; private readonly SentryUnityOptions _options; - private readonly DateTime _deadline; + private readonly Stopwatch _sinceCreated = Stopwatch.StartNew(); internal SentryScreenshotAttachmentContent(Task screenshot, SentryUnityOptions options) { _screenshot = screenshot; _options = options; - _deadline = DateTime.UtcNow + CaptureTimeout; } public Stream GetStream() => new WaitOnReadStream(this); @@ -44,7 +44,7 @@ internal async Task GetEncodedAsync() } // A deadline, so envelopes sharing a screenshot that never comes don't each wait the full timeout - var remaining = _deadline - DateTime.UtcNow; + var remaining = CaptureTimeout - _sinceCreated.Elapsed; if (remaining < TimeSpan.Zero) { remaining = TimeSpan.Zero; From 974b177c6cefcbfde58ffed48ed978e6e163ab68 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 23 Oct 2026 21:42:47 +0200 Subject: [PATCH 15/15] Describe screenshots as shared while in flight rather than per frame --- CHANGELOG.md | 2 +- docs/agent-guides/integrations.md | 2 +- src/Sentry.Unity/SentryMonoBehaviour.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2421774c..6cebbd2f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Behavioral Changes -- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events in the same frame share one. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. `SetBeforeSendScreenshot` now runs once per screenshot, receiving the first of the events sharing it that didn't get dropped. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) +- Screenshots are now sent in the same envelope as their event instead of separately, so they no longer get orphaned when the event gets dropped, e.g. by sampling or rate limiting. Events captured on background threads now get a screenshot as well, and events captured while a screenshot is still being taken share it. Screenshots are no longer attached in batch mode, nor to user feedback unless requested via `addScreenshot`. `SetBeforeCaptureScreenshot` can now get invoked on a background thread. `SetBeforeSendScreenshot` now runs once per screenshot, receiving the first of the events sharing it that didn't get dropped. ([#2835](https://github.com/getsentry/sentry-unity/pull/2835)) ### Fixes diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index 41d356f39..2ba556dbc 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -22,7 +22,7 @@ - Scene tracing replaces global `SceneManagerAPI.overrideAPI`; it overwrites application customization and logs a warning. - C# ANR monitoring pauses while backgrounded. Cocoa/native app-hang tracking can disable it to prevent duplicate reports. - Screenshot capture and view-hierarchy capture must run on Unity main thread. -- Screenshots get attached to the event's hint as `SentryScreenshotAttachmentContent`, so they ride along the event's own envelope. Events in the same frame share one, taken at end of frame. An async GPU readback gets encoded off the main thread right after it completes; until then, later events share the in-flight screenshot. The content waits for it when the envelope gets serialized on the SDK's worker; it never waits on the main thread and times out if the main thread stops rendering. +- Screenshots get attached to the event's hint as `SentryScreenshotAttachmentContent`, so they ride along the event's own envelope. Events captured while a screenshot is still being taken share it; it gets taken at end of frame. An async GPU readback gets encoded off the main thread right after it completes. The content waits for it when the envelope gets serialized on the SDK's worker; it never waits on the main thread and times out if the main thread stops rendering. ## Tests diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs index 74d524ef9..4df01991e 100644 --- a/src/Sentry.Unity/SentryMonoBehaviour.cs +++ b/src/Sentry.Unity/SentryMonoBehaviour.cs @@ -59,7 +59,7 @@ public void StartAwakeSpan(MonoBehaviour monoBehaviour) /// public partial class SentryMonoBehaviour { - // Unbounded queue - used by the ScreenshotEventProcessor, which shares one capture across the events of a frame + // Unbounded queue - used by the ScreenshotEventProcessor, which shares a capture among events until it is done private readonly ConcurrentQueue _coroutineQueue = new(); public void QueueCoroutine(IEnumerator routine)