Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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();
}
Expand Down
130 changes: 36 additions & 94 deletions src/Sentry.Unity/ScreenshotEventProcessor.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions src/Sentry.Unity/ScriptableSentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -216,6 +217,7 @@ internal SentryUnityOptions ToSentryUnityOptions(
AttachScreenshot = AttachScreenshot,
ScreenshotQuality = ScreenshotQuality,
ScreenshotCompression = ScreenshotCompression,
ScreenshotCaptureInterval = TimeSpan.FromMilliseconds(ScreenshotCaptureIntervalMilliseconds),
AttachViewHierarchy = AttachViewHierarchy,
MaxViewHierarchyRootObjects = MaxViewHierarchyRootObjects,
MaxViewHierarchyObjectChildCount = MaxViewHierarchyObjectChildCount,
Expand Down
49 changes: 49 additions & 0 deletions src/Sentry.Unity/SentryMonoBehaviour.Screenshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Collections;
using UnityEngine;

namespace Sentry.Unity;

/// <summary>
/// Drives the refresh of the screenshot cache.
/// </summary>
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;
}
}
}
39 changes: 25 additions & 14 deletions src/Sentry.Unity/SentryScreenshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,37 @@ internal static int GetTargetResolution(ScreenshotQuality quality)
};
}

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
Loading
Loading