Skip to content

Commit

Permalink
Feat: screenshots (#670)
Browse files Browse the repository at this point in the history
* feat: add AttachScreenshot option

* feat: capture screenshots

* refactor: throw in ScreenshotAttachmentContent.GetStream()

* test: screenshot integration test

* wip: screenshot capture options

* feat: Screenshot resizing

* fix: windows integration test must enable AttachScreenshot

* chore: update changelog

* fix: handle non-main thread screenshot attachment explicitly

* chore: editor UI changes

* chore: ScreenshotAttachment: minor refactoring

* chore: fix webgl test script after screenshot attachment

* test: SmokeTest - check for 0-byte attachments

* chore: disable screenshot capture on WebGL

Co-authored-by: Bruno Garcia <bruno@brunogarcia.com>
Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
  • Loading branch information
3 people committed Apr 21, 2022
1 parent c346531 commit d49cf66
Show file tree
Hide file tree
Showing 17 changed files with 263 additions and 24 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

- Capture `Debug.LogError()` and `Debug.LogException()` also on background threads ([#673](https://github.com/getsentry/sentry-unity/pull/673))
- Adding override for Sentry CLI URL ([#666](https://github.com/getsentry/sentry-unity/pull/666))
- Option to automatically attach screenshots to all events ([#670](https://github.com/getsentry/sentry-unity/pull/670))

### Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ MonoBehaviour:
<ReleaseOverride>k__BackingField:
<EnvironmentOverride>k__BackingField:
<AttachStacktrace>k__BackingField: 0
<AttachScreenshot>k__BackingField: 1
<ScreenshotMaxWidth>k__BackingField: 0
<ScreenshotMaxHeight>k__BackingField: 0
<ScreenshotQuality>k__BackingField: 75
<MaxBreadcrumbs>k__BackingField: 100
<ReportAssembliesMode>k__BackingField: 1
<SendDefaultPii>k__BackingField: 0
Expand Down
16 changes: 13 additions & 3 deletions samples/unity-of-bugs/Assets/Scripts/SmokeTester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,14 @@ public static void SmokeTest()

t.ExpectMessage(currentMessage, "'type':'event'");
t.ExpectMessage(currentMessage, $"LogError(GUID)={guid}");
t.ExpectMessage(currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'");
t.ExpectMessageNot(currentMessage, "'length':0");

SentrySdk.CaptureMessage($"CaptureMessage(GUID)={guid}");
t.ExpectMessage(++currentMessage, "'type':'event'");
t.ExpectMessage(currentMessage, $"CaptureMessage(GUID)={guid}");
t.ExpectMessage(currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'");
t.ExpectMessageNot(currentMessage, "'length':0");

var ex = new Exception("Exception & context test");
AddContext();
Expand All @@ -139,6 +143,8 @@ public static void SmokeTest()
t.ExpectMessage(currentMessage, "'extra':{'extra-key':42}");
t.ExpectMessage(currentMessage, "'tags':{'tag-key':'tag-value'");
t.ExpectMessage(currentMessage, "'user':{'email':'email@example.com','id':'user-id','ip_address':'::1','username':'username','other':{'role':'admin'}}");
t.ExpectMessage(currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'");
t.ExpectMessageNot(currentMessage, "'length':0");

t.Pass();
}
Expand Down Expand Up @@ -303,19 +309,23 @@ public string GetMessage(int index)
}
}

public bool CheckMessage(int index, String substring)
public bool CheckMessage(int index, String substring, bool negate = false)
{
#if UNITY_WEBGL
// Note: we cannot use the standard checks on WebGL - it would get stuck here because of the lack of multi-threading.
// The verification is done in the python script used for WebGL smoke test
// The verification is done in the python script used for WebGL smoke test - smoke-test-webgl.py
return true;
#else
var message = GetMessage(index);
return message.Contains(substring) || message.Contains(substring.Replace("'", "\""));
var contains = message.Contains(substring) || message.Contains(substring.Replace("'", "\""));
return negate ? !contains : contains;
#endif
}

public void ExpectMessage(int index, String substring) =>
Expect($"HTTP Request #{index} contains \"{substring}\".", CheckMessage(index, substring));

public void ExpectMessageNot(int index, String substring) =>
Expect($"HTTP Request #{index} doesn't contain \"{substring}\".", CheckMessage(index, substring, negate: true));
}
}
35 changes: 27 additions & 8 deletions scripts/smoke-test-webgl.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,19 @@ def Expect(self, message, result):
else:
raise Exception(info)

def ExpectMessage(self, index, substring):
def CheckMessage(self, index, substring, negate):
message = self.__requests[index]["body"]
self.Expect("HTTP Request #{} contains \"{}\".".format(index, substring),
substring in message or substring.replace("'", "\"") in message)
contains = substring in message or substring.replace(
"'", "\"") in message
return contains if not negate else not contains

def ExpectMessage(self, index, substring):
self.Expect("HTTP Request #{} contains \"{}\".".format(
index, substring), self.CheckMessage(index, substring, False))

def ExpectMessageNot(self, index, substring):
self.Expect("HTTP Request #{} doesn't contain \"{}\".".format(
index, substring), self.CheckMessage(index, substring, True))


t = RequestVerifier()
Expand All @@ -68,11 +77,12 @@ def __init__(self, *args, **kwargs):
def do_POST(self):
body = ""
content = self.rfile.read(int(self.headers['Content-Length']))
try:
body = content.decode("utf-8")
except:
logging.exception("Exception while parsing an API request")
body = binascii.hexlify(bytearray(content))
parts = content.split(b'\n')
for part in parts:
try:
body += '\n' + part.decode("utf-8")
except:
body += '\n(binary chunk: {} bytes)'.format(len(part))
t.Capture(self.requestline, body)
self.send_response(HTTPStatus.OK, '{'+'}')
self.end_headers()
Expand Down Expand Up @@ -135,9 +145,15 @@ def waitUntil(condition, interval=0.1, timeout=1):
currentMessage += 1
t.ExpectMessage(currentMessage, "'type':'event'")
t.ExpectMessage(currentMessage, "LogError(GUID)")
# t.ExpectMessage(
# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
# t.ExpectMessageNot(currentMessage, "'length':0")
currentMessage += 1
t.ExpectMessage(currentMessage, "'type':'event'")
t.ExpectMessage(currentMessage, "CaptureMessage(GUID)")
# t.ExpectMessage(
# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
# t.ExpectMessageNot(currentMessage, "'length':0")
currentMessage += 1
t.ExpectMessage(currentMessage, "'type':'event'")
t.ExpectMessage(
Expand All @@ -147,4 +163,7 @@ def waitUntil(condition, interval=0.1, timeout=1):
t.ExpectMessage(currentMessage, "'tags':{'tag-key':'tag-value'")
t.ExpectMessage(
currentMessage, "'user':{'email':'email@example.com','id':'user-id','ip_address':'::1','username':'username','other':{'role':'admin'}}")
# t.ExpectMessage(
# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
# t.ExpectMessageNot(currentMessage, "'length':0")
print('TEST: PASS', flush=True)
48 changes: 37 additions & 11 deletions src/Sentry.Unity.Editor/ConfigurationWindow/EnrichmentTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,19 @@ internal static void Display(ScriptableSentryUnityOptions options)
EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray);
EditorGUILayout.Space();

options.SendDefaultPii = EditorGUILayout.BeginToggleGroup(
new GUIContent("Send default Pii", "Whether to include default Personal Identifiable " +
"Information."),
options.SendDefaultPii);
{
options.SendDefaultPii = EditorGUILayout.BeginToggleGroup(
new GUIContent("Send default PII", "Whether to include default Personal Identifiable " +
"Information."),
options.SendDefaultPii);

options.IsEnvironmentUser = EditorGUILayout.Toggle(
new GUIContent("Auto Set UserName", "Whether to report the 'Environment.UserName' as " +
"the User affected in the event. Should be disabled for " +
"Android and iOS."),
options.IsEnvironmentUser);

EditorGUILayout.EndToggleGroup();
options.IsEnvironmentUser = EditorGUILayout.Toggle(
new GUIContent("Auto Set UserName", "Whether to report the 'Environment.UserName' as " +
"the User affected in the event. Should be disabled for " +
"Android and iOS."),
options.IsEnvironmentUser);
EditorGUILayout.EndToggleGroup();
}

EditorGUILayout.Space();
EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray);
Expand All @@ -72,6 +73,31 @@ internal static void Display(ScriptableSentryUnityOptions options)
new GUIContent("Report Assemblies Mode", "Whether or not to include referenced assemblies " +
"Version or InformationalVersion in each event sent to sentry."),
options.ReportAssembliesMode);

EditorGUILayout.Space();
EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray);
EditorGUILayout.Space();

{
options.AttachScreenshot = EditorGUILayout.BeginToggleGroup(
new GUIContent("Attach Screenshot", "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" +
"A screenshot might not be able to be attached, for example when the error happens on a background thread."),
options.AttachScreenshot);
options.ScreenshotMaxWidth = EditorGUILayout.IntField(
new GUIContent("Max Width", "Maximum width of the screenshot or 0 to keep the original size. " +
"If the application window is larger, the screenshot will be resized proportionally."),
options.ScreenshotMaxWidth);
options.ScreenshotMaxHeight = EditorGUILayout.IntField(
new GUIContent("Max Height", "Maximum height of the screenshot or 0 to keep the original size. " +
"If the application window is larger, the screenshot will be resized proportionally."),
options.ScreenshotMaxHeight);
options.ScreenshotQuality = EditorGUILayout.IntSlider(
new GUIContent("JPG quality", "Quality of the JPG screenshot: 0 - 100, where 100 is the best quality and highest size."),
options.ScreenshotQuality, 0, 100);
EditorGUILayout.EndToggleGroup();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ private static void ConfigureOptions(Dictionary<string, string> args, [CallerMem
OptionsConfigurationDotNet.SetScript(value);
}

if (args.TryGetValue("attachScreenshot", out value))
{
bool boolValue;
if (!Boolean.TryParse(value, out boolValue))
{
throw new ArgumentException("Unknown boolean argument value: " + value, "attachScreenshot");
}
Debug.LogFormat("{0}: Configuring AttachScreenshot to {1}", functionName, boolValue);
options.AttachScreenshot = boolValue;
}

optionsWindow.Close();
Debug.LogFormat("{0}: Sentry options Configured", functionName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class SentryWindow : EditorWindow
public static SentryWindow OpenSentryWindow()
{
var window = (SentryWindow)GetWindow(typeof(SentryWindow));
window.minSize = new Vector2(600, 350);
window.minSize = new Vector2(600, 420);
return window;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Sentry.Unity.Editor/ScriptableSentryUnityOptionsEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public override void OnInspectorGUI()
EditorGUILayout.TextField("Release Override", options.ReleaseOverride);
EditorGUILayout.TextField("Environment Override", options.EnvironmentOverride);
EditorGUILayout.Toggle("Attach Stacktrace", options.AttachStacktrace);
EditorGUILayout.Toggle("Attach Screenshot", options.AttachScreenshot);
EditorGUILayout.IntField("Screenshot Max Height", options.ScreenshotMaxHeight);
EditorGUILayout.IntField("Screenshot Max Width", options.ScreenshotMaxWidth);
EditorGUILayout.IntField("Screenshot Quality", options.ScreenshotQuality);
EditorGUILayout.IntField("Max Breadcrumbs", options.MaxBreadcrumbs);
EditorGUILayout.EnumPopup("Report Assemblies Mode", options.ReportAssembliesMode);
EditorGUILayout.Toggle("Send Default Pii", options.SendDefaultPii);
Expand Down
97 changes: 97 additions & 0 deletions src/Sentry.Unity/ScreenshotAttachment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.IO;
using Sentry;
using Sentry.Extensibility;
using Sentry.Unity.Integrations;
using UnityEngine;
using UnityEngine.Rendering;

namespace Sentry.Unity
{
internal class ScreenshotAttachment : Attachment
{
public ScreenshotAttachment(IAttachmentContent content)
: base(AttachmentType.Default, content, "screenshot.jpg", "image/jpeg") { }
}

internal class ScreenshotAttachmentContent : IAttachmentContent
{
private readonly SentryMonoBehaviour _behaviour;
private readonly SentryUnityOptions _options;

public ScreenshotAttachmentContent(SentryUnityOptions options, SentryMonoBehaviour behaviour)
{
_behaviour = behaviour;
_options = options;
}

public Stream GetStream()
{
// Note: we need to check explicitly that we're on the same thread. While Unity would throw otherwise
// when capturing the screenshot, it would only do so on development builds. On release, it just crashes...
if (!_behaviour.MainThreadData.IsMainThread())
{
_options.DiagnosticLogger?.LogDebug("Won't capture screenshot because we're not on the main thread");
// Throwing here to avoid empty attachment being sent to Sentry.
// return new MemoryStream();
throw new Exception("Sentry: cannot capture screenshot attachment on other than the main (UI) thread.");
}

return new MemoryStream(CaptureScreenshot());
}

private byte[] CaptureScreenshot()
{
// Calculate the desired size by calculating the ratio between the desired height/width and the actual one,
// and than resizing based on the smaller of the two ratios.
var width = Screen.width;
var height = Screen.height;
var ratioW = _options.ScreenshotMaxWidth <= 0 ? 1.0f : (float)_options.ScreenshotMaxWidth / (float)width;
var ratioH = _options.ScreenshotMaxHeight <= 0 ? 1.0f : (float)_options.ScreenshotMaxHeight / (float)height;
var ratio = Mathf.Min(ratioH, ratioW);
if (ratio > 0.0f && ratio < 1.0f)
{
width = Mathf.FloorToInt((float)width * ratio);
height = Mathf.FloorToInt((float)height * ratio);
}

// Captures the current screenshot synchronously.
var screenshot = new Texture2D(width, height, TextureFormat.RGB24, false);
var rtFull = RenderTexture.GetTemporary(Screen.width, Screen.height);
ScreenCapture.CaptureScreenshotIntoRenderTexture(rtFull);
var rtResized = RenderTexture.GetTemporary(width, height);
// On all (currently supported) platforms except Android, the image is mirrored horizontally & vertically.
// So we must mirror it back.
if (ApplicationAdapter.Instance.Platform == RuntimePlatform.Android)
{
Graphics.Blit(rtFull, rtResized);
}
else
{
Graphics.Blit(rtFull, rtResized, new Vector2(1, -1), new Vector2(0, 1));
}
RenderTexture.ReleaseTemporary(rtFull);

// Remember the previous render target and change it to our target texture.
var previousRT = RenderTexture.active;
RenderTexture.active = rtResized;

try
{
// actually copy from the current render target a texture & read data from the active RenderTexture
screenshot.ReadPixels(new Rect(0, 0, width, height), 0, 0);
screenshot.Apply();
}
finally
{
// Restore the render target.
RenderTexture.active = previousRT;
}

var bytes = screenshot.EncodeToJPG(_options.ScreenshotQuality);
_options.DiagnosticLogger?.Log(SentryLevel.Debug,
"Screenshot captured at {0}x{1}: {0} bytes", null, width, height, bytes.Length);
return bytes;
}
}
}
8 changes: 8 additions & 0 deletions src/Sentry.Unity/ScriptableSentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public static string GetConfigPath(string? notDefaultConfigName = null)
[field: SerializeField] public string ReleaseOverride { get; set; } = string.Empty;
[field: SerializeField] public string EnvironmentOverride { get; set; } = string.Empty;
[field: SerializeField] public bool AttachStacktrace { get; set; }
[field: SerializeField] public bool AttachScreenshot { get; set; }
[field: SerializeField] public int ScreenshotMaxWidth { get; set; }
[field: SerializeField] public int ScreenshotMaxHeight { get; set; }
[field: SerializeField] public int ScreenshotQuality { get; set; }
[field: SerializeField] public int MaxBreadcrumbs { get; set; }
[field: SerializeField] public ReportAssembliesMode ReportAssembliesMode { get; set; }
[field: SerializeField] public bool SendDefaultPii { get; set; }
Expand Down Expand Up @@ -92,6 +96,10 @@ internal static SentryUnityOptions ToSentryUnityOptions(ScriptableSentryUnityOpt
AutoSessionTracking = scriptableOptions.AutoSessionTracking,
AutoSessionTrackingInterval = TimeSpan.FromMilliseconds(scriptableOptions.AutoSessionTrackingInterval),
AttachStacktrace = scriptableOptions.AttachStacktrace,
AttachScreenshot = scriptableOptions.AttachScreenshot,
ScreenshotMaxWidth = scriptableOptions.ScreenshotMaxWidth,
ScreenshotMaxHeight = scriptableOptions.ScreenshotMaxHeight,
ScreenshotQuality = scriptableOptions.ScreenshotQuality,
MaxBreadcrumbs = scriptableOptions.MaxBreadcrumbs,
ReportAssembliesMode = scriptableOptions.ReportAssembliesMode,
SendDefaultPii = scriptableOptions.SendDefaultPii,
Expand Down
4 changes: 4 additions & 0 deletions src/Sentry.Unity/SentryOptionsUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public static void SetDefaults(ScriptableSentryUnityOptions scriptableOptions)
scriptableOptions.AutoSessionTracking = options.AutoSessionTracking;

scriptableOptions.AttachStacktrace = options.AttachStacktrace;
scriptableOptions.AttachScreenshot = options.AttachScreenshot;
scriptableOptions.ScreenshotMaxWidth = options.ScreenshotMaxWidth;
scriptableOptions.ScreenshotMaxHeight = options.ScreenshotMaxHeight;
scriptableOptions.ScreenshotQuality = options.ScreenshotQuality;
scriptableOptions.MaxBreadcrumbs = options.MaxBreadcrumbs;
scriptableOptions.ReportAssembliesMode = options.ReportAssembliesMode;
scriptableOptions.SendDefaultPii = options.SendDefaultPii;
Expand Down
Loading

0 comments on commit d49cf66

Please sign in to comment.