From 3d2ce290f30de2e8a00a0713cbb1f4d730dac9b1 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Fri, 4 Sep 2026 14:14:54 +0200 Subject: [PATCH] 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; - } }