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;
- }
}