diff --git a/CHANGELOG.md b/CHANGELOG.md index 7073780df..c82236063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +### Breaking Changes + +- `SetBeforeCaptureScreenshot` signature changed from `Func` to `Func`, now receiving the event that + triggered the screenshot capture. This allows context-aware decisions before capture begins. ([#2428](https://github.com/getsentry/sentry-unity/pull/2428)) + +### Features + +- Added `SetBeforeSendScreenshot(Func)` callback that provides the captured screenshot as a + `Texture2D` before JPEG compression. ([#2428](https://github.com/getsentry/sentry-unity/pull/2428)) + This enables: + - **Modifying** the screenshot in-place (e.g., blurring sensitive UI areas, redacting PII) + - **Replacing** the screenshot with a different `Texture2D` + - **Discarding** the screenshot by returning `null` + - Access to the event context for conditional processing + ### Dependencies - Bump Cocoa SDK from v8.57.2 to v8.57.3 ([#2424](https://github.com/getsentry/sentry-unity/pull/2424)) diff --git a/src/Sentry.Unity/ScreenshotEventProcessor.cs b/src/Sentry.Unity/ScreenshotEventProcessor.cs index 0e1150c55..d9416205c 100644 --- a/src/Sentry.Unity/ScreenshotEventProcessor.cs +++ b/src/Sentry.Unity/ScreenshotEventProcessor.cs @@ -27,13 +27,13 @@ public SentryEvent Process(SentryEvent @event) if (Interlocked.CompareExchange(ref _isCapturingScreenshot, 1, 0) == 0) { _options.LogDebug("Starting coroutine to capture a screenshot."); - _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(@event.EventId)); + _sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(@event)); } return @event; } - internal IEnumerator CaptureScreenshotCoroutine(SentryId eventId) + internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event) { _options.LogDebug("Screenshot capture triggered. Waiting for End of Frame."); @@ -41,17 +41,39 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryId eventId) // See https://docs.unity3d.com/6000.1/Documentation/ScriptReference/WaitForEndOfFrame.html yield return WaitForEndOfFrame(); + Texture2D? screenshot = null; try { - if (_options.BeforeCaptureScreenshotInternal?.Invoke() is false) + if (_options.BeforeCaptureScreenshotInternal?.Invoke(@event) is false) { yield break; } - var screenshotBytes = CaptureScreenshot(_options); - if (screenshotBytes.Length == 0) + 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}", eventId); + _options.LogWarning("Screenshot capture returned empty data for event {0}", @event.EventId); yield break; } @@ -61,9 +83,9 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryId eventId) "screenshot.jpg", "image/jpeg"); - _options.LogDebug("Screenshot captured for event {0}", eventId); + _options.LogDebug("Screenshot captured for event {0}", @event.EventId); - CaptureAttachment(eventId, attachment); + CaptureAttachment(@event.EventId, attachment); } catch (Exception e) { @@ -72,11 +94,16 @@ internal IEnumerator CaptureScreenshotCoroutine(SentryId eventId) finally { Interlocked.Exchange(ref _isCapturingScreenshot, 0); + + if (screenshot != null) + { + UnityEngine.Object.Destroy(screenshot); + } } } - internal virtual byte[] CaptureScreenshot(SentryUnityOptions options) - => SentryScreenshot.Capture(options); + 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); diff --git a/src/Sentry.Unity/SentryScreenshot.cs b/src/Sentry.Unity/SentryScreenshot.cs index 718fb33bc..2e829d8ce 100644 --- a/src/Sentry.Unity/SentryScreenshot.cs +++ b/src/Sentry.Unity/SentryScreenshot.cs @@ -1,3 +1,4 @@ +using Sentry.Extensibility; using UnityEngine; namespace Sentry.Unity; @@ -15,11 +16,11 @@ internal static int GetTargetResolution(ScreenshotQuality quality) }; } - public static byte[] Capture(SentryUnityOptions options) => - Capture(options, Screen.width, Screen.height); + public static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) => + CreateNewScreenshotTexture2D(options, Screen.width, Screen.height); // For testing - internal static byte[] Capture(SentryUnityOptions options, int width, int height) + 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 @@ -36,7 +37,6 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height } } - Texture2D? screenshot = null; RenderTexture? renderTextureFull = null; RenderTexture? renderTextureResized = null; var previousRenderTexture = RenderTexture.active; @@ -44,7 +44,7 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height try { // Captures the current screenshot synchronously. - screenshot = new Texture2D(width, height, TextureFormat.RGB24, false); + var screenshot = new Texture2D(width, height, TextureFormat.RGB24, false); renderTextureFull = RenderTexture.GetTemporary(Screen.width, Screen.height); ScreenCapture.CaptureScreenshotIntoRenderTexture(renderTextureFull); renderTextureResized = RenderTexture.GetTemporary(width, height); @@ -66,12 +66,9 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height screenshot.ReadPixels(new Rect(0, 0, width, height), 0, 0); screenshot.Apply(); - var bytes = screenshot.EncodeToJPG(options.ScreenshotCompression); + options.LogDebug("Screenshot captured at {0}x{1}.", width, height); - options.DiagnosticLogger?.Log(SentryLevel.Debug, - "Screenshot captured at {0}x{1}: {2} bytes", null, width, height, bytes.Length); - - return bytes; + return screenshot; } finally { @@ -86,11 +83,6 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height { RenderTexture.ReleaseTemporary(renderTextureResized); } - - if (screenshot) - { - Object.Destroy(screenshot); - } } } } diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index 899398f11..a152d5458 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -252,9 +252,7 @@ public sealed class SentryUnityOptions : SentryOptions /// public new StackTraceMode StackTraceMode { get; private set; } - private Func? _beforeCaptureScreenshot; - - internal Func? BeforeCaptureScreenshotInternal => _beforeCaptureScreenshot; + internal Func? BeforeCaptureScreenshotInternal { get; private set; } /// /// Configures a callback function to be invoked before capturing and attaching a screenshot to an event. @@ -263,9 +261,24 @@ public sealed class SentryUnityOptions : SentryOptions /// This callback will get invoked right before a screenshot gets taken. If the screenshot should not /// be taken return `false`. /// - public void SetBeforeCaptureScreenshot(Func beforeAttachScreenshot) + public void SetBeforeCaptureScreenshot(Func beforeAttachScreenshot) + { + BeforeCaptureScreenshotInternal = beforeAttachScreenshot; + } + + internal Func? BeforeSendScreenshotInternal { get; private set; } + + /// + /// Configures a callback to modify or discard screenshots before they are sent. + /// + /// + /// 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. + /// + /// The callback function to invoke before sending screenshots. + public void SetBeforeSendScreenshot(Func beforeSendScreenshot) { - _beforeCaptureScreenshot = beforeAttachScreenshot; + BeforeSendScreenshotInternal = beforeSendScreenshot; } private Func? _beforeCaptureViewHierarchy; diff --git a/src/Sentry.Unity/SentryUnitySdk.cs b/src/Sentry.Unity/SentryUnitySdk.cs index 098d9b862..5d741c9ad 100644 --- a/src/Sentry.Unity/SentryUnitySdk.cs +++ b/src/Sentry.Unity/SentryUnitySdk.cs @@ -121,14 +121,34 @@ public void CaptureFeedback(string message, string? email, string? name, bool ad return; } - var hint = addScreenshot - ? SentryHint.WithAttachments( - new SentryAttachment( - AttachmentType.Default, - new ByteAttachmentContent(SentryScreenshot.Capture(_options)), - "screenshot.jpg", - "image/jpeg")) - : null; + 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 + { + if (screenshot) + { + UnityEngine.Object.Destroy(screenshot); + } + } + } Sentry.SentrySdk.CurrentHub.CaptureFeedback(message, email, name, hint: hint); } diff --git a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs index 83e7e5e72..81891bc47 100644 --- a/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ScreenshotEventProcessorTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.IO; using NUnit.Framework; using Sentry.Unity.Tests.Stubs; using UnityEngine; @@ -11,26 +12,26 @@ public class ScreenshotEventProcessorTests { private class TestScreenshotEventProcessor : ScreenshotEventProcessor { - public Func CaptureScreenshotFunc { get; set; } + public Func CreateScreenshotFunc { get; set; } public Action CaptureAttachmentAction { get; set; } public Func WaitForEndOfFrameFunc { get; set; } public TestScreenshotEventProcessor(SentryUnityOptions options, ISentryMonoBehaviour sentryMonoBehaviour) : base(options, sentryMonoBehaviour) { - CaptureScreenshotFunc = _ => [0xFF, 0xD8, 0xFF]; + CreateScreenshotFunc = _ => new Texture2D(1, 1); CaptureAttachmentAction = (_, _) => { }; WaitForEndOfFrameFunc = () => new YieldInstruction(); } - internal override byte[] CaptureScreenshot(SentryUnityOptions options) - => CaptureScreenshotFunc.Invoke(options); + internal override Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions options) + => CreateScreenshotFunc.Invoke(options); internal override void CaptureAttachment(SentryId eventId, SentryAttachment attachment) => CaptureAttachmentAction(eventId, attachment); internal override YieldInstruction WaitForEndOfFrame() - => WaitForEndOfFrameFunc!.Invoke(); + => WaitForEndOfFrameFunc.Invoke(); } [Test] public void Process_FirstCallInAFrame_StartsCoroutine() @@ -81,10 +82,10 @@ public IEnumerator Process_CalledMultipleTimesQuickly_OnlyExecutesScreenshotCapt var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); var screenshotCaptureCallCount = 0; - screenshotProcessor.CaptureScreenshotFunc = _ => + screenshotProcessor.CreateScreenshotFunc = _ => { screenshotCaptureCallCount++; - return [0]; + return new Texture2D(1, 1); }; var attachmentCaptureCallCount = 0; @@ -107,12 +108,12 @@ public IEnumerator Process_CalledMultipleTimesQuickly_OnlyExecutesScreenshotCapt } [UnityTest] - public IEnumerator Process_EmptyScreenshotData_SkipsAttachmentCapture() + public IEnumerator Process_ScreenshotCaptureThrowsException_HandlesGracefully() { var sentryMonoBehaviour = GetTestMonoBehaviour(); var screenshotProcessor = new TestScreenshotEventProcessor(new SentryUnityOptions(), sentryMonoBehaviour); - screenshotProcessor.CaptureScreenshotFunc = _ => []; + screenshotProcessor.CreateScreenshotFunc = _ => throw new Exception("Screenshot capture failed"); var attachmentCaptureCallCount = 0; screenshotProcessor.CaptureAttachmentAction = (_, _) => @@ -120,19 +121,233 @@ public IEnumerator Process_EmptyScreenshotData_SkipsAttachmentCapture() attachmentCaptureCallCount++; }; + var sentryEvent = new SentryEvent(); + screenshotProcessor.Process(sentryEvent); + + // Wait for the coroutine to complete - need to wait for processing + yield return null; + yield return null; + + Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); + Assert.AreEqual(0, attachmentCaptureCallCount); + } + + [UnityTest] + public IEnumerator Process_BeforeSendScreenshotCallback_ReceivesScreenshotAndEvent() + { + var sentryMonoBehaviour = GetTestMonoBehaviour(); + var options = new SentryUnityOptions(); + + Texture2D? receivedScreenshot = null; + SentryEvent? receivedEvent = null; + + options.SetBeforeSendScreenshot((screenshot, @event) => + { + receivedScreenshot = screenshot; + receivedEvent = @event; + return screenshot; + }); + + var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); + var eventId = SentryId.Create(); var sentryEvent = new SentryEvent(eventId: eventId); screenshotProcessor.Process(sentryEvent); - // Wait for the coroutine to complete - need to wait for processing yield return null; yield return null; - Assert.IsTrue(sentryMonoBehaviour.StartCoroutineCalled); + Assert.NotNull(receivedScreenshot); + Assert.NotNull(receivedEvent); + Assert.AreEqual(eventId, receivedEvent!.EventId); + } + + [UnityTest] + public IEnumerator Process_BeforeSendScreenshotCallback_ReturnsNull_SkipsAttachment() + { + var sentryMonoBehaviour = GetTestMonoBehaviour(); + var options = new SentryUnityOptions(); + + options.SetBeforeSendScreenshot((_, _) => null); + + var screenshotProcessor = new TestScreenshotEventProcessor(options, sentryMonoBehaviour); + + var attachmentCaptureCallCount = 0; + screenshotProcessor.CaptureAttachmentAction = (_, _) => + { + attachmentCaptureCallCount++; + }; + + var sentryEvent = new SentryEvent(); + screenshotProcessor.Process(sentryEvent); + + yield return null; + yield return null; + Assert.AreEqual(0, attachmentCaptureCallCount); } + [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()); + + 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() + { + var sentryMonoBehaviour = GetTestMonoBehaviour(); + var options = new SentryUnityOptions(); + + var callbackInvoked = false; + byte[]? modifiedTextureBytes = 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); + + return screenshot; + }); + + 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()); + + 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()); + + 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()); + + yield return null; + yield return null; + + Assert.IsTrue(callbackInvoked); + Assert.AreEqual(1, screenshotCaptureCallCount); + } + private static TestSentryMonoBehaviour GetTestMonoBehaviour() { var gameObject = new GameObject("ScreenshotProcessorTest"); diff --git a/test/Sentry.Unity.Tests/SentryScreenshotTests.cs b/test/Sentry.Unity.Tests/SentryScreenshotTests.cs index e7ca887e1..5bde51c5c 100644 --- a/test/Sentry.Unity.Tests/SentryScreenshotTests.cs +++ b/test/Sentry.Unity.Tests/SentryScreenshotTests.cs @@ -24,11 +24,10 @@ public void CaptureScreenshot_QualitySet_ScreenshotDoesNotExceedDimensionLimit(S { var options = new SentryUnityOptions { ScreenshotQuality = quality }; - var bytes = SentryScreenshot.Capture(options, 2000, 2000); - var texture = new Texture2D(1, 1); // Size does not matter. Will be overwritten by loading - texture.LoadImage(bytes); + var texture = SentryScreenshot.CreateNewScreenshotTexture2D(options, 2000, 2000); Assert.IsTrue(texture.width <= maximumAllowedDimension && texture.height <= maximumAllowedDimension); + Object.Destroy(texture); } [Test] @@ -37,10 +36,9 @@ public void CaptureScreenshot_QualitySetToFull_ScreenshotInFullSize() var testScreenSize = 2000; var options = new SentryUnityOptions { ScreenshotQuality = ScreenshotQuality.Full }; - var bytes = SentryScreenshot.Capture(options, testScreenSize, testScreenSize); - var texture = new Texture2D(1, 1); // Size does not matter. Will be overwritten by loading - texture.LoadImage(bytes); + var texture = SentryScreenshot.CreateNewScreenshotTexture2D(options, testScreenSize, testScreenSize); Assert.IsTrue(texture.width == testScreenSize && texture.height == testScreenSize); + Object.Destroy(texture); } }