diff --git a/CHANGELOG.md b/CHANGELOG.md index ee836469d..ef128bd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### 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 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 + +- 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)) + ## 4.11.0 ### Fixes diff --git a/docs/agent-guides/integrations.md b/docs/agent-guides/integrations.md index 77c6d79e5..2ba556dbc 100644 --- a/docs/agent-guides/integrations.md +++ b/docs/agent-guides/integrations.md @@ -22,6 +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 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/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index c7c854aba..e704ff77c 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -1,17 +1,23 @@ using System; using System.Collections; -using System.Threading; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; 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; + + // 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 readonly Stopwatch _pendingSince = new(); + private List _pendingEvents = new(); public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { } @@ -21,43 +27,119 @@ internal ScreenshotEventProcessor(SentryUnityOptions sentryOptions, ISentryMonoB _sentryMonoBehaviour = sentryMonoBehaviour; } - public SentryEvent Process(SentryEvent @event) + public SentryEvent? Process(SentryEvent @event) => @event; + + public SentryEvent? Process(SentryEvent @event, SentryHint hint) { - // Only ever capture one screenshot per frame - if (Interlocked.CompareExchange(ref _isCapturingScreenshot, 1, 0) == 0) + // Feedback attaches a screenshot only when asked to, see SentryUnitySdk.CaptureFeedback + if (@event.Contexts.Feedback is not null) + { + return @event; + } + + try + { + if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) + { + return @event; + } + } + catch (Exception e) { - _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)); + _options.LogError(e, "The BeforeCaptureScreenshot callback threw an exception. Skipping the screenshot."); + return @event; } + Task screenshot; + lock (_lock) + { + // A capture that never completes, e.g. while the main thread hangs, must not hold up later events + 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.Restart(); + _pendingEvents = new List(); + _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, _pendingEvents)); + } + + _pendingEvents.Add(@event); + + screenshot = _pending.Task; + } + + // 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), + "screenshot.jpg", + "image/jpeg")); + return @event; } - internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) + private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource screenshot, List events) { - _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 + // Capturing mid-frame yields an incomplete image yield return WaitForEndOfFrame(); - Texture2D? screenshot = null; + if (screenshot.Task.IsCompleted) + { + yield break; + } + + // Event processors run before BeforeSend and sampling, which may have dropped the events + SentryEvent? @event; + lock (_lock) + { + @event = events.Find(e => e.IsCaptured); + } + + if (@event is null) + { + _options.LogDebug("Skipping the screenshot. None of its events got captured."); + Complete(screenshot, null); + yield break; + } + try { - if (!@event.IsCaptured) + // The Texture2D callback and the main thread worker need the screenshot right away + if (_options.BeforeSendScreenshotInternal is null && CanReadBackAsync()) { - _options.LogDebug("Skipping screenshot for event {0}. Event was not captured.", @event.EventId); - yield break; + SentryScreenshot.CaptureAsync(_options, result => Complete(screenshot, result)); } + else + { + Complete(screenshot, CaptureScreenshot(@event)); + } + } + catch (Exception e) + { + _options.LogError(e, "Failed to capture screenshot."); + Complete(screenshot, null); + } + } - if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) + private void Complete(TaskCompletionSource screenshot, byte[]? result) + { + lock (_lock) + { + if (_pending == screenshot) { - yield break; + _pending = null; } + } + screenshot.TrySetResult(result); + } + + private byte[]? CaptureScreenshot(SentryEvent @event) + { + Texture2D? screenshot = null; + try + { screenshot = CreateNewScreenshotTexture2D(_options); if (_options.BeforeSendScreenshotInternal != null) @@ -67,7 +149,7 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) if (modifiedScreenshot == null) { _options.LogInfo("Screenshot discarded by BeforeSendScreenshot callback."); - yield break; + return null; } // Clean up - If the user returned a new texture object and did not modify the passed in one @@ -79,31 +161,10 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) } } - 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); - } - catch (Exception e) - { - _options.LogError(e, "Failed to capture screenshot."); + return screenshot.EncodeToJPG(_options.ScreenshotCompression); } finally { - Interlocked.Exchange(ref _isCapturingScreenshot, 0); - if (screenshot != null) { UnityEngine.Object.Destroy(screenshot); @@ -111,12 +172,12 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) } } + internal virtual bool CanReadBackAsync() + => SystemInfo.supportsAsyncGPUReadback && _options.BackgroundWorker is not WebBackgroundWorker; + internal virtual Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => SentryScreenshot.CreateNewScreenshotTexture2D(options); - internal virtual void CaptureAttachment(SentryId eventId, SentryAttachment attachment) - => (Sentry.SentrySdk.CurrentHub as Hub)?.CaptureAttachment(eventId, attachment); - - internal virtual YieldInstruction WaitForEndOfFrame() + internal virtual YieldInstruction? WaitForEndOfFrame() => new WaitForEndOfFrame(); } diff --git a/src/Sentry.Unity/SentryMonoBehaviour.cs b/src/Sentry.Unity/SentryMonoBehaviour.cs index 2d93d8f0d..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 where capture is already limited by Interlocked to 1/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) diff --git a/src/Sentry.Unity/SentryScreenshot.cs b/src/Sentry.Unity/SentryScreenshot.cs index 2e829d8ce..26a9c6eeb 100644 --- a/src/Sentry.Unity/SentryScreenshot.cs +++ b/src/Sentry.Unity/SentryScreenshot.cs @@ -1,5 +1,9 @@ +using System; +using System.Threading.Tasks; using Sentry.Extensibility; using UnityEngine; +using UnityEngine.Experimental.Rendering; +using UnityEngine.Rendering; namespace Sentry.Unity; @@ -21,6 +25,76 @@ public static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) // 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); + } + } + + /// + /// 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) + { + var renderTexture = CaptureIntoRenderTexture(options, Screen.width, Screen.height); + var (width, height) = ((uint)renderTexture.width, (uint)renderTexture.height); + + try + { + // 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 pixels = readback.GetData().ToArray(); + 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 + { + RenderTexture.ReleaseTemporary(renderTexture); + } + } + + // Returns a temporary render texture holding the screenshot, scaled down to the configured quality + private static RenderTexture CaptureIntoRenderTexture(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 @@ -37,17 +111,11 @@ internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions option } } - RenderTexture? renderTextureFull = null; - RenderTexture? renderTextureResized = null; - var previousRenderTexture = RenderTexture.active; - + 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. @@ -62,27 +130,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 new file mode 100644 index 000000000..adfc2b4ca --- /dev/null +++ b/src/Sentry.Unity/SentryScreenshotAttachmentContent.cs @@ -0,0 +1,111 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Sentry.Extensibility; + +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 screenshot +/// 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 SentryUnityOptions _options; + private readonly Stopwatch _sinceCreated = Stopwatch.StartNew(); + + internal SentryScreenshotAttachmentContent(Task screenshot, SentryUnityOptions options) + { + _screenshot = screenshot; + _options = options; + } + + public Stream GetStream() => new WaitOnReadStream(this); + + internal async Task GetEncodedAsync() + { + 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.LogWarning("Screenshot not taken yet and the attachment is read on the main thread. Skipping."); + return Array.Empty(); + } + + // A deadline, so envelopes sharing a screenshot that never comes don't each wait the full timeout + var remaining = CaptureTimeout - _sinceCreated.Elapsed; + 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(); + } + } + + 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 from happening right there. + private sealed class WaitOnReadStream : Stream + { + private readonly SentryScreenshotAttachmentContent _content; + private MemoryStream? _encoded; + + public WaitOnReadStream(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.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); + } + + 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/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 95249276a..f829f5689 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -409,8 +409,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) { @@ -425,6 +425,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/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 707483405..1464120da 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -187,7 +187,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..dea5a6545 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.AttachScreenshot) + { + 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 bb7f396f3..fb61a74a4 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.Diagnostics; using System.IO; -using System.Text.RegularExpressions; +using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using Sentry.Unity.Tests.Stubs; using UnityEngine; @@ -11,490 +13,314 @@ 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 - { - public RealCaptureScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) - : base(options, sentryMonoBehaviour) { } - - internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) - => new Texture2D(1, 1); - - internal override YieldInstruction WaitForEndOfFrame() - => new YieldInstruction(); - - // 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 YieldInstruction? EndOfFrame { get; set; } public TestScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) : base(options, sentryMonoBehaviour) { CreateScreenshotFunc = _ => new Texture2D(1, 1); - CaptureAttachmentAction = (_, _) => { }; - WaitForEndOfFrameFunc = () => new YieldInstruction(); } + internal override bool CanReadBackAsync() => false; + internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => CreateScreenshotFunc.Invoke(options); - internal override void CaptureAttachment(SentryId eventId, SentryAttachment attachment) - => CaptureAttachmentAction(eventId, attachment); + // WaitForEndOfFrame never resumes in batch mode + internal override YieldInstruction? WaitForEndOfFrame() => EndOfFrame; + } - internal override YieldInstruction WaitForEndOfFrame() - => WaitForEndOfFrameFunc.Invoke(); + private static TestScreenshotEventProcessor GetSut(SentryUnityOptions? options = null) + { + 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_FirstCallInAFrame_StartsCoroutine() + public void Process_AttachesScreenshotToHint() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); + var hint = new SentryHint(); - screenshotProcessor.Process(new SentryEvent()); + GetSut().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() + public IEnumerator Process_EventsInTheSameFrame_ShareOneScreenshot() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var capturedEventId = SentryId.Empty; - SentryAttachment? capturedAttachment = null; - screenshotProcessor.CaptureAttachmentAction = (eventId, attachment) => + var processor = GetSut(); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => { - capturedEventId = eventId; - capturedAttachment = attachment; + captureCount++; + return new Texture2D(1, 1); }; + var firstHint = new SentryHint(); + var secondHint = new SentryHint(); - var eventId = SentryId.Create(); - var sentryEvent = new SentryEvent(eventId: eventId) { IsCaptured = true }; - - screenshotProcessor.Process(sentryEvent); + processor.Process(new SentryEvent { IsCaptured = true }, firstHint); + processor.Process(new SentryEvent { IsCaptured = true }, secondHint); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; + var first = ReadOnWorker(firstHint.Attachments.Single()); + var second = ReadOnWorker(secondHint.Attachments.Single()); + yield return new WaitUntil(() => first.IsCompleted && second.IsCompleted); - 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.AreEqual(1, captureCount); + Assert.IsNotEmpty(first.Result); + CollectionAssert.AreEqual(first.Result, second.Result); } [UnityTest] - public IEnumerator Process_CalledMultipleTimesQuickly_OnlyExecutesScreenshotCaptureOnce() + public IEnumerator Process_NoEventCaptured_SkipsScreenshot() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => + var processor = GetSut(); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => { - screenshotCaptureCallCount++; + captureCount++; return new Texture2D(1, 1); }; + var hint = new SentryHint(); - 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 }); + processor.Process(new SentryEvent(), hint); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - Assert.AreEqual(1, screenshotCaptureCallCount); - Assert.AreEqual(1, attachmentCaptureCallCount); + Assert.AreEqual(0, captureCount); + Assert.IsEmpty(read.Result); } - [UnityTest] - public IEnumerator Process_ScreenshotCaptureThrowsException_HandlesGracefully() + [Test] + public void Process_BeforeCaptureScreenshotReturnsFalse_DoesNotAttach() { - 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 hint = new SentryHint(); - // Wait for the coroutine to complete - need to wait for processing - yield return null; - yield return null; + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); - Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); - Assert.AreEqual(0, attachmentCaptureCallCount); + Assert.IsEmpty(hint.Attachments); } - [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallback_ReceivesScreenshotAndEvent() + [Test] + public void Process_BeforeCaptureScreenshotThrows_ReturnsEventWithoutAttachment() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); + options.SetBeforeCaptureScreenshot(_ => throw new InvalidOperationException("test")); + var hint = new SentryHint(); + var @event = new SentryEvent(); - Texture2D? receivedScreenshot = null; - SentryEvent? receivedEvent = null; - - options.SetBeforeSendScreenshot((screenshot, @event) => - { - receivedScreenshot = screenshot; - receivedEvent = @event; - return screenshot; - }); + var processed = GetSut(options).Process(@event, hint); - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); - - var eventId = SentryId.Create(); - var sentryEvent = new SentryEvent(eventId: eventId) { IsCaptured = true }; + Assert.AreSame(@event, processed); + Assert.IsEmpty(hint.Attachments); + } - screenshotProcessor.Process(sentryEvent); + [Test] + public void Process_Feedback_DoesNotAttach() + { + var feedback = new SentryEvent(); + feedback.Contexts.Feedback = new SentryFeedback("message"); + var hint = new SentryHint(); - yield return null; - yield return null; + GetSut().Process(feedback, hint); - Assert.NotNull(receivedScreenshot); - Assert.NotNull(receivedEvent); - Assert.AreEqual(eventId, receivedEvent!.EventId); + Assert.IsEmpty(hint.Attachments); } [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallback_ReturnsNull_SkipsAttachment() + public IEnumerator Process_ScreenshotCaptureThrows_AttachmentIsEmpty() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var options = new SentryUnityOptions(); - - options.SetBeforeSendScreenshot((_, _) => null); - - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); + var processor = GetSut(); + processor.CreateScreenshotFunc = _ => throw new Exception("Screenshot capture failed"); + var hint = new SentryHint(); - var attachmentCaptureCallCount = 0; - screenshotProcessor.CaptureAttachmentAction = (_, _) => - { - attachmentCaptureCallCount++; - }; - - var sentryEvent = new SentryEvent { IsCaptured = true }; - screenshotProcessor.Process(sentryEvent); + processor.Process(new SentryEvent { IsCaptured = true }, hint); - yield return null; - yield return null; + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - Assert.AreEqual(0, attachmentCaptureCallCount); + Assert.IsEmpty(read.Result); } [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallbackReturnsNewTexture_AttachesNewTexture() + public IEnumerator Process_BeforeSendScreenshot_ReceivesScreenshotAndEvent() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); - - var newTexture = new Texture2D(10, 10); - var newTextureBytes = newTexture.EncodeToJPG(options.ScreenshotCompression); - var beforeSendInvoked = false; - - options.SetBeforeSendScreenshot((_, _) => + Texture2D? receivedScreenshot = null; + SentryEvent? receivedEvent = null; + options.SetBeforeSendScreenshot((screenshot, @event) => { - beforeSendInvoked = true; - return newTexture; + receivedScreenshot = screenshot; + receivedEvent = @event; + return screenshot; }); + var hint = new SentryHint(); + var eventId = SentryId.Create(); - 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; + GetSut(options).Process(new SentryEvent(eventId: eventId) { IsCaptured = true }, hint); - Assert.IsTrue(beforeSendInvoked); // Sanity Check - Assert.IsTrue(attachmentCaptured); // Sanity Check - Assert.NotNull(capturedBytes); - Assert.AreEqual(newTextureBytes.Length, capturedBytes!.Length); + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - UnityEngine.Object.Destroy(newTexture); + Assert.NotNull(receivedScreenshot); + Assert.AreEqual(eventId, receivedEvent!.EventId); + Assert.IsNotEmpty(read.Result); } [UnityTest] - public IEnumerator Process_BeforeSendScreenshotCallbackModifiesTexture_UsesModifiedTexture() + public IEnumerator Process_BeforeSendScreenshot_ReceivesFirstCapturedEvent() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); - - var callbackInvoked = false; - byte[]? modifiedTextureBytes = 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); - + 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(); - var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); + processor.Process(dropped, droppedHint); + processor.Process(captured, capturedHint); - var attachmentCaptured = false; - byte[]? capturedBytes = null; + var read = ReadOnWorker(capturedHint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - 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); + Assert.AreSame(captured, receivedEvent); } [UnityTest] - public IEnumerator Process_BeforeCaptureScreenshotCallback_ReturnsFalse_SkipsCapture() + public IEnumerator Process_BeforeSendScreenshotReturnsNull_AttachmentIsEmpty() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); + options.SetBeforeSendScreenshot((_, _) => null); + var hint = new SentryHint(); - 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 }); + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); - yield return null; - yield return null; + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - // BeforeCaptureScreenshot should prevent capture entirely - Assert.AreEqual(0, screenshotCaptureCallCount); + Assert.IsEmpty(read.Result); } [UnityTest] - public IEnumerator Process_BeforeCaptureScreenshotCallbackReturnsTrue_CapturesScreenshot() + public IEnumerator Process_BeforeSendScreenshotReturnsNewTexture_AttachesNewTexture() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); var options = new SentryUnityOptions(); + var newTexture = new Texture2D(10, 10); + var newTextureBytes = newTexture.EncodeToJPG(options.ScreenshotCompression); + options.SetBeforeSendScreenshot((_, _) => newTexture); + var hint = new SentryHint(); - 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 }); + GetSut(options).Process(new SentryEvent { IsCaptured = true }, hint); - yield return null; - yield return null; + var read = ReadOnWorker(hint.Attachments.Single()); + yield return new WaitUntil(() => read.IsCompleted); - Assert.IsTrue(callbackInvoked); - Assert.AreEqual(1, screenshotCaptureCallCount); + Assert.AreEqual(newTextureBytes.Length, read.Result.Length); } [UnityTest] - public IEnumerator Process_EventNotCaptured_SkipsAttachment() + public IEnumerator Process_PendingCaptureTimedOut_StartsNewCapture() { - var sentryMonoBehaviour = GetTestMonoBehaviour(); - var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - - var screenshotCaptureCallCount = 0; - screenshotProcessor.CreateScreenshotFunc = _ => + var processor = GetSut(); + var captureCount = 0; + processor.CreateScreenshotFunc = _ => { - screenshotCaptureCallCount++; + captureCount++; 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); + // 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); + + yield return new WaitForSecondsRealtime((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.1f); + + processor.EndOfFrame = null; + var hint = new SentryHint(); + processor.Process(new SentryEvent { IsCaptured = true }, hint); + + 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] - public IEnumerator Process_EventCapturedSuccessfully_ScreenshotAttachmentIsSent() + public IEnumerator GetStream_ReadAfterTheCaptureTimeout_DoesNotWaitAgain() { - // 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 - }; + var content = new SentryScreenshotAttachmentContent(new TaskCompletionSource().Task, new SentryUnityOptions()); + var attachment = new SentryAttachment(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg"); - // 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)); + yield return new WaitForSecondsRealtime((float)SentryScreenshotAttachmentContent.CaptureTimeout.TotalSeconds + 0.1f); - 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"); + var stopwatch = Stopwatch.StartNew(); + var read = ReadOnWorker(attachment); + yield return new WaitUntil(() => read.IsCompleted); - // Wait for the screenshot coroutine to complete - yield return null; - yield return null; - - // 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.Less(stopwatch.ElapsedMilliseconds, 500); + Assert.IsEmpty(read.Result); } [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(); - + // 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 }; - - // 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(GetSut(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 + // Lets the screenshot get taken before blocking the main thread on the request 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; - } }