Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3d2ce29
reworked screenshots
bitsandfoxes Sep 4, 2026
e30f504
merged main
bitsandfoxes Oct 13, 2026
2f155cb
Merge branch 'main' into fix/screenshots
bitsandfoxes Oct 13, 2026
f78f4d4
perf(screenshots): Read back asynchronously and defer the encode
bitsandfoxes Oct 14, 2026
e8b974e
test(screenshots): Add an iOS device benchmark for screenshot capture
bitsandfoxes Oct 14, 2026
20d6a72
ref(screenshots): Drop benchmark tooling and profiling instrumentation
bitsandfoxes Oct 15, 2026
b53a586
fix(screenshots): Take screenshots at the end of the error's frame
bitsandfoxes Oct 16, 2026
a83f1c1
skip screenshots of dropped events and warm up capture at startup
bitsandfoxes Oct 20, 2026
cc51434
stripped down
bitsandfoxes Oct 21, 2026
ccbb34a
Encode screenshots right after the readback so raw frames don't pile …
bitsandfoxes Oct 23, 2026
be644f3
Make the screenshot timeout a deadline and replace stale captures so …
bitsandfoxes Oct 23, 2026
06e8f78
Skip the screenshot when none of the events sharing it got captured
bitsandfoxes Oct 23, 2026
b63efa8
Pass the first captured event to BeforeSendScreenshot and document it…
bitsandfoxes Oct 23, 2026
da0fd4e
Update screenshot docs and comments to match when captures happen
bitsandfoxes Oct 23, 2026
74f9a18
Cancel replaced stale captures so a recovering main thread doesn't ta…
bitsandfoxes Oct 23, 2026
f99970e
Time screenshot captures with a monotonic clock so wall clock jumps d…
bitsandfoxes Oct 23, 2026
974b177
Describe screenshots as shared while in flight rather than per frame
bitsandfoxes Oct 23, 2026
caac23e
Merge remote-tracking branch 'origin/main' into fix/screenshots
bitsandfoxes Oct 23, 2026
f40c3c8
Keep the readback texture out of the pool until the readback is done
bitsandfoxes Oct 27, 2026
acc529b
Take screenshots only once the SDK builds an event's envelope instead…
bitsandfoxes Oct 27, 2026
093a4a9
Fix the stale deadlock comment on reading the screenshot on the main …
bitsandfoxes Oct 27, 2026
1b5f60e
Restore the active render target after capturing the screenshot
bitsandfoxes Oct 27, 2026
73f71f2
Merge branch 'main' into fix/screenshots
bitsandfoxes Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## 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))

### Dependencies

- Bump CLI from v3.8.0 to v3.8.2 ([#2893](https://github.com/getsentry/sentry-unity/pull/2893))
Expand Down
1 change: 1 addition & 0 deletions docs/agent-guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
162 changes: 112 additions & 50 deletions src/Sentry.Unity/ScreenshotEventProcessor.cs
Original file line number Diff line number Diff line change
@@ -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<byte[]?>? _pending;
private readonly Stopwatch _pendingSince = new();
private List<SentryScreenshotAttachmentContent> _pendingContents = new();

public ScreenshotEventProcessor(SentryUnityOptions sentryOptions) : this(sentryOptions, SentryMonoBehaviour.Instance) { }

Expand All @@ -21,43 +27,120 @@ 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.LogError(e, "The BeforeCaptureScreenshot callback threw an exception. Skipping the screenshot.");
return @event;
}

SentryScreenshotAttachmentContent content;
lock (_lock)
{
_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));
// 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<byte[]?>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingSince.Restart();
_pendingContents = new List<SentryScreenshotAttachmentContent>();
_sentryMonoBehaviour.QueueCoroutine(CaptureScreenshotCoroutine(_pending, _pendingContents));
}

content = new SentryScreenshotAttachmentContent(_pending.Task, @event, _options);
_pendingContents.Add(content);
}

// The screenshot gets read when the SDK serializes the event's envelope on its worker
hint.Attachments.Add(new SentryAttachment(
AttachmentType.Default,
content,
"screenshot.jpg",
"image/jpeg"));

return @event;
}

internal IEnumerator CaptureScreenshotCoroutine(SentryEvent @event)
private IEnumerator CaptureScreenshotCoroutine(TaskCompletionSource<byte[]?> screenshot,
List<SentryScreenshotAttachmentContent> contents)
{
_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. The SDK only gets the attachment's stream for events that
// made it past them.
SentryEvent? @event;
lock (_lock)
{
@event = contents.Find(c => c.IsClaimed)?.Event;
}

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<byte[]?> 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)
Expand All @@ -67,7 +150,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
Expand All @@ -79,44 +162,23 @@ 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);
}
}
}

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();
}
2 changes: 1 addition & 1 deletion src/Sentry.Unity/SentryMonoBehaviour.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public void StartAwakeSpan(MonoBehaviour monoBehaviour)
/// </summary>
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<IEnumerator> _coroutineQueue = new();

public void QueueCoroutine(IEnumerator routine)
Expand Down
106 changes: 82 additions & 24 deletions src/Sentry.Unity/SentryScreenshot.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -21,6 +25,79 @@ 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;
Comment thread
bitsandfoxes marked this conversation as resolved.
Resolved

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

/// <summary>
/// Reads the current screenshot back from the GPU and encodes it to JPG on a background thread, without stalling
/// the main thread.
/// </summary>
internal static void CaptureAsync(SentryUnityOptions options, Action<byte[]?> onCaptured)
{
var renderTexture = CaptureIntoRenderTexture(options, Screen.width, Screen.height);
var (width, height) = ((uint)renderTexture.width, (uint)renderTexture.height);

try
{
// The texture has to stay out of the pool until the readback is done with it
AsyncGPUReadback.Request(renderTexture, 0, TextureFormat.RGBA32, readback =>
{
RenderTexture.ReleaseTemporary(renderTexture);

if (readback.hasError)
{
options.LogError("Failed to read back the screenshot.");
onCaptured(null);
return;
}

var pixels = readback.GetData<byte>().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);
});
});
}
catch
{
RenderTexture.ReleaseTemporary(renderTexture);
throw;
}
Comment thread
bitsandfoxes marked this conversation as resolved.
Resolved
}

// 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
Expand All @@ -37,17 +114,13 @@ internal static Texture2D CreateNewScreenshotTexture2D(SentryUnityOptions option
}
}

RenderTexture? renderTextureFull = null;
RenderTexture? renderTextureResized = null;
// Blit changes the active render target
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.
Expand All @@ -62,27 +135,12 @@ 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);
}
}
}
Loading
Loading