Skip to content
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

## Unreleased

### Breaking Changes

- `SetBeforeCaptureScreenshot` signature changed from `Func<bool>` to `Func<SentryEvent, bool>`, 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))
- `SetBeforeCaptureViewHierarchy` signature changed from `Func<bool>` to `Func<SentryEvent, bool>`, now receiving the event that
triggered the view hierarchy capture. This allows context-aware decisions before capture begins. ([#2429](https://github.com/getsentry/sentry-unity/pull/2429))

### Features

- Added `SetBeforeSendScreenshot(Func<Texture2D, SentryEvent, Texture2D?>)` 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
- Added `SetBeforeSendViewHierarchy(Func<ViewHierarchy, SentryEvent, ViewHierarchy?>)` callback that provides the captured
`ViewHierarchy` to be modified before compression. ([#2429](https://github.com/getsentry/sentry-unity/pull/2429))

### Dependencies

- Bump Cocoa SDK from v8.57.2 to v8.57.3 ([#2424](https://github.com/getsentry/sentry-unity/pull/2424))
Expand Down
47 changes: 37 additions & 10 deletions src/Sentry.Unity/ScreenshotEventProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,53 @@ 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.");

// 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();

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

Expand All @@ -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)
{
Expand All @@ -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);
Expand Down
22 changes: 7 additions & 15 deletions src/Sentry.Unity/SentryScreenshot.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Sentry.Extensibility;
using UnityEngine;

namespace Sentry.Unity;
Expand All @@ -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
Expand All @@ -36,15 +37,14 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height
}
}

Texture2D? screenshot = null;
RenderTexture? renderTextureFull = null;
RenderTexture? renderTextureResized = null;
var previousRenderTexture = RenderTexture.active;

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);
Expand All @@ -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
{
Expand All @@ -86,11 +83,6 @@ internal static byte[] Capture(SentryUnityOptions options, int width, int height
{
RenderTexture.ReleaseTemporary(renderTextureResized);
}

if (screenshot)
{
Object.Destroy(screenshot);
}
}
}
}
45 changes: 36 additions & 9 deletions src/Sentry.Unity/SentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,7 @@ public sealed class SentryUnityOptions : SentryOptions
/// </summary>
public new StackTraceMode StackTraceMode { get; private set; }

private Func<bool>? _beforeCaptureScreenshot;

internal Func<bool>? BeforeCaptureScreenshotInternal => _beforeCaptureScreenshot;
internal Func<SentryEvent, bool>? BeforeCaptureScreenshotInternal { get; private set; }

/// <summary>
/// Configures a callback function to be invoked before capturing and attaching a screenshot to an event.
Expand All @@ -263,14 +261,43 @@ 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`.
/// </remarks>
public void SetBeforeCaptureScreenshot(Func<bool> beforeAttachScreenshot)
public void SetBeforeCaptureScreenshot(Func<SentryEvent, bool> beforeAttachScreenshot)
{
BeforeCaptureScreenshotInternal = beforeAttachScreenshot;
}

internal Func<Texture2D, SentryEvent, Texture2D?>? BeforeSendScreenshotInternal { get; private set; }

/// <summary>
/// Configures a callback to modify or discard screenshots before they are sent.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="beforeSendScreenshot">The callback function to invoke before sending screenshots.</param>
public void SetBeforeSendScreenshot(Func<Texture2D, SentryEvent, Texture2D?> beforeSendScreenshot)
{
_beforeCaptureScreenshot = beforeAttachScreenshot;
BeforeSendScreenshotInternal = beforeSendScreenshot;
}

private Func<bool>? _beforeCaptureViewHierarchy;
internal Func<SentryEvent, bool>? BeforeCaptureViewHierarchyInternal { get; private set; }

internal Func<ViewHierarchy, SentryEvent, ViewHierarchy?>? BeforeSendViewHierarchyInternal { get; private set; }

internal Func<bool>? BeforeCaptureViewHierarchyInternal => _beforeCaptureViewHierarchy;
/// <summary>
/// Configures a callback to modify or discard view hierarchy before it is sent.
/// </summary>
/// <remarks>
/// This callback receives the captured view hierarchy before JSON serialization.
/// You can modify the hierarchy structure (remove nodes, filter sensitive info, etc.)
/// and return it, or return null to discard.
/// </remarks>
/// <param name="beforeSendViewHierarchy">The callback function to invoke before sending view hierarchy.</param>
public void SetBeforeSendViewHierarchy(Func<ViewHierarchy, SentryEvent, ViewHierarchy?> beforeSendViewHierarchy)
{
BeforeSendViewHierarchyInternal = beforeSendViewHierarchy;
}

/// <summary>
/// Configures a callback function to be invoked before capturing and attaching the view hierarchy to an event.
Expand All @@ -279,9 +306,9 @@ public void SetBeforeCaptureScreenshot(Func<bool> beforeAttachScreenshot)
/// This callback will get invoked right before the view hierarchy gets taken. If the view hierarchy should not
/// be taken return `false`.
/// </remarks>
public void SetBeforeCaptureViewHierarchy(Func<bool> beforeAttachViewHierarchy)
public void SetBeforeCaptureViewHierarchy(Func<SentryEvent, bool> beforeAttachViewHierarchy)
{
_beforeCaptureViewHierarchy = beforeAttachViewHierarchy;
BeforeCaptureViewHierarchyInternal = beforeAttachViewHierarchy;
}

// Initialized by native SDK binding code to set the User.ID in .NET (UnityEventProcessor).
Expand Down
36 changes: 28 additions & 8 deletions src/Sentry.Unity/SentryUnitySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Sentry.Unity/UnityViewHierarchyNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Sentry.Unity;

internal class UnityViewHierarchyNode : ViewHierarchyNode
public class UnityViewHierarchyNode : ViewHierarchyNode
{
public string? Tag { get; set; }
public string? Position { get; set; }
Expand Down
30 changes: 21 additions & 9 deletions src/Sentry.Unity/ViewHierarchyEventProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,39 @@ public ViewHierarchyEventProcessor(SentryUnityOptions sentryOptions)
return @event;
}

if (_options.BeforeCaptureViewHierarchyInternal?.Invoke() is not false)
if (_options.BeforeCaptureViewHierarchyInternal?.Invoke(@event) is false)
{
hint.AddAttachment(CaptureViewHierarchy(), "view-hierarchy.json", AttachmentType.ViewHierarchy, "application/json");
_options.DiagnosticLogger?.LogInfo("Hierarchy capture skipped by BeforeCaptureViewHierarchy callback.");
return @event;
}
else

var viewHierarchy = CreateViewHierarchy(
_options.MaxViewHierarchyRootObjects,
_options.MaxViewHierarchyObjectChildCount,
_options.MaxViewHierarchyDepth);

if (_options.BeforeSendViewHierarchyInternal != null)
{
_options.DiagnosticLogger?.LogInfo("Hierarchy capture skipped by BeforeAttachViewHierarchy callback.");
viewHierarchy = _options.BeforeSendViewHierarchyInternal(viewHierarchy, @event);

if (viewHierarchy == null)
{
_options.DiagnosticLogger?.LogInfo("View hierarchy discarded by BeforeSendViewHierarchy callback.");
return @event;
}
}

var bytes = SerializeViewHierarchy(viewHierarchy);
hint.AddAttachment(bytes, "view-hierarchy.json", AttachmentType.ViewHierarchy, "application/json");

return @event;
}

internal byte[] CaptureViewHierarchy()
internal byte[] SerializeViewHierarchy(ViewHierarchy viewHierarchy)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);

var viewHierarchy = CreateViewHierarchy(
_options.MaxViewHierarchyRootObjects,
_options.MaxViewHierarchyObjectChildCount,
_options.MaxViewHierarchyDepth);
viewHierarchy.WriteTo(writer, _options.DiagnosticLogger);

writer.Flush();
Expand Down
Loading
Loading