diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e728b22..2cde92b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - https://github.com/getsentry/sentry-dotnet/blob/main/CHANGELOG.md#334 - Bug fixes for performance monitoring - Ability to keep failed envelopes for troubleshooting when they are too large +- Unity Sentry SDK programmatic setup (#130) + - SentryWindow updated ## 0.0.8 diff --git a/samples/unity-of-bugs/Packages/manifest.json b/samples/unity-of-bugs/Packages/manifest.json index 3009f9b3d..7b73c12dd 100644 --- a/samples/unity-of-bugs/Packages/manifest.json +++ b/samples/unity-of-bugs/Packages/manifest.json @@ -2,7 +2,7 @@ "dependencies": { "com.unity.ide.rider": "1.1.4", "com.unity.ide.vscode": "1.2.3", - "com.unity.test-framework": "1.1.22", + "com.unity.test-framework": "1.1.24", "com.unity.textmeshpro": "2.1.1", "com.unity.toolchain.macos-x86_64-linux-x86_64": "0.1.21-preview", "com.unity.ugui": "1.0.0", diff --git a/samples/unity-of-bugs/Packages/packages-lock.json b/samples/unity-of-bugs/Packages/packages-lock.json index 6eb8759a1..fae7df46d 100644 --- a/samples/unity-of-bugs/Packages/packages-lock.json +++ b/samples/unity-of-bugs/Packages/packages-lock.json @@ -40,7 +40,7 @@ "url": "https://packages.unity.com" }, "com.unity.test-framework": { - "version": "1.1.22", + "version": "1.1.24", "depth": 0, "source": "registry", "dependencies": { diff --git a/src/Sentry.Unity.Editor/SentryWindow.cs b/src/Sentry.Unity.Editor/SentryWindow.cs index 2b01e89fe..be13a7754 100644 --- a/src/Sentry.Unity.Editor/SentryWindow.cs +++ b/src/Sentry.Unity.Editor/SentryWindow.cs @@ -4,6 +4,8 @@ using UnityEditor; using UnityEngine; +using CompressionLevel = System.IO.Compression.CompressionLevel; + namespace Sentry.Unity.Editor { public class SentryWindow : EditorWindow @@ -26,7 +28,7 @@ private void OnEnable() Options = LoadUnitySentryOptions(); - TryCopyLinkXml(Options.Logger); + TryCopyLinkXml(Options.DiagnosticLogger); } private UnitySentryOptions LoadUnitySentryOptions() @@ -105,6 +107,7 @@ private void OnGUI() Options.Enabled = EditorGUILayout.BeginToggleGroup( new GUIContent("Enable", "Controls enabling Sentry by initializing the SDK or not."), Options.Enabled); + Options.CaptureInEditor = EditorGUILayout.Toggle( new GUIContent("Capture In Editor", "Capture errors while running in the Editor."), Options.CaptureInEditor); @@ -116,14 +119,16 @@ private void OnGUI() Options.Dsn); Options.SampleRate = EditorGUILayout.Slider( new GUIContent("Event Sample Rate", "What random sample rate to apply. 1.0 captures everything, 0.7 captures 70%."), - Options.SampleRate, 0.01f, 1); - Options.RequestBodyCompressionLevel = (SentryUnityCompression)EditorGUILayout.EnumPopup( - new GUIContent("Compress Payload", "The compression level to use on the data sent to Sentry. " + - "Some platforms don't support GZip, 'auto' attempts to disable compression in those cases."), + Options.SampleRate ?? 1.0f, 0.01f, 1); + Options.EnableAutoPayloadCompression = EditorGUILayout.Toggle( + new GUIContent("Compress Payload (Auto)", "The level of which to compress the Sentry event before sending to Sentry (Auto)."), + Options.EnableAutoPayloadCompression); + Options.RequestBodyCompressionLevel = (CompressionLevel)EditorGUILayout.EnumPopup( + new GUIContent("Compress Payload", "The level of which to compress the Sentry event before sending to Sentry."), Options.RequestBodyCompressionLevel); Options.AttachStacktrace = EditorGUILayout.Toggle( new GUIContent("Stacktrace For Logs", "Whether to include a stack trace for non error events like logs. " + - "Even when Unity didn't include and no Exception was thrown.."), + "Even when Unity didn't include and no Exception was thrown.."), Options.AttachStacktrace); Options.Release = EditorGUILayout.TextField( new GUIContent("Override Release", "By default release is taken from 'Application.version'. " + @@ -134,6 +139,8 @@ private void OnGUI() "If not set, auto detects such as 'development', 'production' or 'editor'."), Options.Environment); + GUILayout.Label(new GUIContent(GUIContent.none), EditorStyles.boldLabel); + GUILayout.Label(new GUIContent(GUIContent.none), EditorStyles.boldLabel); Options.Debug = EditorGUILayout.BeginToggleGroup( new GUIContent("Debug Mode", "Whether the Sentry SDK should print its diagnostic logs to the console."), diff --git a/src/Sentry.Unity/EventCapture.cs b/src/Sentry.Unity/EventCapture.cs new file mode 100644 index 000000000..f7e2e1323 --- /dev/null +++ b/src/Sentry.Unity/EventCapture.cs @@ -0,0 +1,7 @@ +namespace Sentry.Unity +{ + internal interface IEventCapture + { + SentryId Capture(SentryEvent sentryEvent); + } +} diff --git a/src/Sentry.Unity/Extensions/JsonExtensions.cs b/src/Sentry.Unity/Extensions/JsonExtensions.cs new file mode 100644 index 000000000..fdbbfabed --- /dev/null +++ b/src/Sentry.Unity/Extensions/JsonExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; + +namespace Sentry.Unity.Extensions +{ + internal static class JsonExtensions + { + // From Sentry.Internal.Extensions.JsonExtensions + public static JsonElement? GetPropertyOrNull(this JsonElement json, string name) + { + if (json.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (json.TryGetProperty(name, out var result)) + { + if (json.ValueKind == JsonValueKind.Undefined || + json.ValueKind == JsonValueKind.Null) + { + return null; + } + + return result; + } + + return null; + } + + public static TEnum? GetEnumOrNull(this JsonElement json, string name) + where TEnum : struct + { + var enumString = json.GetPropertyOrNull(name)?.ToString(); + if (string.IsNullOrWhiteSpace(enumString)) + { + return null; + } + + if (!Enum.TryParse(enumString, true, out TEnum value)) + { + return null; + } + + return value; + } + } +} diff --git a/src/Sentry.Unity/Integrations/IApplication.cs b/src/Sentry.Unity/Integrations/IApplication.cs new file mode 100644 index 000000000..8c393ca9e --- /dev/null +++ b/src/Sentry.Unity/Integrations/IApplication.cs @@ -0,0 +1,36 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Sentry.Unity.Integrations +{ + internal interface IApplication + { + event Application.LogCallback LogMessageReceived; + event Action Quitting; + string ActiveSceneName { get; } + } + + internal sealed class ApplicationAdapter : IApplication + { + public static readonly ApplicationAdapter Instance = new(); + + private ApplicationAdapter() + { + Application.logMessageReceived += OnLogMessageReceived; + Application.quitting += OnQuitting; + } + + public event Application.LogCallback? LogMessageReceived; + + public event Action? Quitting; + + public string ActiveSceneName => SceneManager.GetActiveScene().name; + + private void OnLogMessageReceived(string condition, string stackTrace, LogType type) + => LogMessageReceived?.Invoke(condition, stackTrace, type); + + private void OnQuitting() + => Quitting?.Invoke(); + } +} diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs new file mode 100644 index 000000000..3cf219e8f --- /dev/null +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -0,0 +1,103 @@ +using System; +using Sentry.Integrations; +using UnityEngine; + +namespace Sentry.Unity.Integrations +{ + internal sealed class UnityApplicationLoggingIntegration : ISdkIntegration + { + internal readonly ErrorTimeDebounce ErrorTimeDebounce = new(TimeSpan.FromSeconds(1)); + internal readonly LogTimeDebounce LogTimeDebounce = new(TimeSpan.FromSeconds(1)); + internal readonly WarningTimeDebounce WarningTimeDebounce = new(TimeSpan.FromSeconds(1)); + + // TODO: remove 'IEventCapture' in further iteration + private readonly IEventCapture? _eventCapture; + private readonly IApplication _application; + + private IHub? _hub; + private SentryOptions? _sentryOptions; + + public UnityApplicationLoggingIntegration(IApplication? appDomain = null, IEventCapture? eventCapture = null) + { + _application = appDomain ?? ApplicationAdapter.Instance; + _eventCapture = eventCapture; + } + + public void Register(IHub hub, SentryOptions sentryOptions) + { + _hub = hub; + _sentryOptions = sentryOptions; + + _application.LogMessageReceived += OnLogMessageReceived; + _application.Quitting += OnQuitting; + } + + // Internal for testability + internal void OnLogMessageReceived(string condition, string stackTrace, LogType type) + { + var debounced = type switch + { + LogType.Error or LogType.Exception or LogType.Assert => ErrorTimeDebounce.Debounced(), + LogType.Log => LogTimeDebounce.Debounced(), + LogType.Warning => WarningTimeDebounce.Debounced(), + _ => true + }; + if (!debounced || _hub is null) + { + return; + } + + // TODO: to check against 'MinBreadcrumbLevel' + if (type != LogType.Error && type != LogType.Exception && type != LogType.Assert) + { + // TODO: MinBreadcrumbLevel + // options.MinBreadcrumbLevel + _hub.AddBreadcrumb(condition, level: ToBreadcrumbLevel(type)); + return; + } + + var sentryEvent = new SentryEvent(new UnityLogException(condition, stackTrace)) + { + Level = ToEventTagType(type) + }; + + _eventCapture?.Capture(sentryEvent); // TODO: remove, for current integration tests compatibility + _hub.CaptureEvent(sentryEvent); + + // So the next event includes this error as a breadcrumb: + _hub.AddBreadcrumb(condition, level: ToBreadcrumbLevel(type)); + } + + private void OnQuitting() + { + // Note: iOS applications are usually suspended and do not quit. You should tick "Exit on Suspend" in Player settings for iOS builds to cause the game to quit and not suspend, otherwise you may not see this call. + // If "Exit on Suspend" is not ticked then you will see calls to OnApplicationPause instead. + // Note: On Windows Store Apps and Windows Phone 8.1 there is no application quit event. Consider using OnApplicationFocus event when focusStatus equals false. + // Note: On WebGL it is not possible to implement OnApplicationQuit due to nature of the browser tabs closing. + _application.LogMessageReceived -= OnLogMessageReceived; + _hub?.FlushAsync(_sentryOptions?.ShutdownTimeout ?? TimeSpan.FromSeconds(1)).GetAwaiter().GetResult(); + } + + private static SentryLevel ToEventTagType(LogType logType) + => logType switch + { + LogType.Assert => SentryLevel.Error, + LogType.Error => SentryLevel.Error, + LogType.Exception => SentryLevel.Error, + LogType.Log => SentryLevel.Info, + LogType.Warning => SentryLevel.Warning, + _ => SentryLevel.Fatal + }; + + private static BreadcrumbLevel ToBreadcrumbLevel(LogType logType) + => logType switch + { + LogType.Assert => BreadcrumbLevel.Error, + LogType.Error => BreadcrumbLevel.Error, + LogType.Exception => BreadcrumbLevel.Error, + LogType.Log => BreadcrumbLevel.Info, + LogType.Warning => BreadcrumbLevel.Warning, + _ => BreadcrumbLevel.Info + }; + } +} diff --git a/src/Sentry.Unity/Integrations/UnityBeforeSceneLoadIntegration.cs b/src/Sentry.Unity/Integrations/UnityBeforeSceneLoadIntegration.cs new file mode 100644 index 000000000..8ac79ff48 --- /dev/null +++ b/src/Sentry.Unity/Integrations/UnityBeforeSceneLoadIntegration.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Sentry.Integrations; + +namespace Sentry.Unity.Integrations +{ + internal sealed class UnityBeforeSceneLoadIntegration : ISdkIntegration + { + private readonly IApplication _application; + + public UnityBeforeSceneLoadIntegration(IApplication? appDomain = null) + => _application = appDomain ?? ApplicationAdapter.Instance; + + public void Register(IHub hub, SentryOptions options) + { + var data = _application.ActiveSceneName is { } name + ? new Dictionary {{"scene", name}} + : null; + + hub.AddBreadcrumb("BeforeSceneLoad", data: data); + + options.DiagnosticLogger?.Log(SentryLevel.Debug, "Registered BeforeSceneLoad integration."); + } + } +} diff --git a/src/Sentry.Unity/SentryInitialization.cs b/src/Sentry.Unity/SentryInitialization.cs index 669c9f79f..699e62213 100644 --- a/src/Sentry.Unity/SentryInitialization.cs +++ b/src/Sentry.Unity/SentryInitialization.cs @@ -2,203 +2,44 @@ using System.IO; using System.Collections.Generic; using UnityEngine; -using UnityEngine.SceneManagement; -using CompressionLevel = System.IO.Compression.CompressionLevel; namespace Sentry.Unity { - public interface IEventCapture - { - SentryId Capture(SentryEvent sentryEvent); - } - - internal class EventCapture : IEventCapture - { - public SentryId Capture(SentryEvent sentryEvent) - => SentrySdk.CaptureEvent(sentryEvent); - } - // https://94677106febe46b88b9b9ae5efd18a00@o447951.ingest.sentry.io/5439417 public static class SentryInitialization { - // TODO: Stuff that should be passed with https://github.com/getsentry/sentry-unity/issues/66 implementation - internal static IEventCapture EventCapture = new EventCapture(); - internal static ErrorTimeDebounce ErrorTimeDebounce = new(TimeSpan.FromSeconds(1)); - internal static LogTimeDebounce LogTimeDebounce = new(TimeSpan.FromSeconds(1)); - internal static WarningTimeDebounce WarningTimeDebounce = new(TimeSpan.FromSeconds(1)); - - internal static bool IsInit { get; private set; } - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] public static void Init() { - if (!File.Exists(UnitySentryOptions.GetConfigPath())) - { - Debug.LogWarning("Couldn't find the configuration file SentryOptions.json. Did you already configure Sentry?\nYou can do that through the editor: Tools -> Sentry"); - return; - } - - var options = UnitySentryOptions.LoadFromUnity(); - - if (!options.Enabled) + if (!File.Exists(UnitySentryOptions.GetConfigPath())) { - options.Logger?.Log(SentryLevel.Debug, "Disabled In Options."); + Debug.LogWarning("Couldn't find the configuration file SentryOptions.json. Did you already configure Sentry?\nYou can do that through the editor: Tools -> Sentry"); return; } - if (!options.CaptureInEditor && Application.isEditor) - { - options.Logger?.Log(SentryLevel.Info, "Disabled while in the Editor."); - return; - } + var options = UnitySentryOptions.LoadFromUnity(); - if (string.IsNullOrWhiteSpace(options.Dsn)) + if (!options.Enabled) { - options.Logger?.Log(SentryLevel.Warning, "No Sentry DSN configured. Sentry will be disabled."); + // We want to display the message in spite of how SentryOptions.json is configured + new UnityLogger(SentryLevel.Info).Log(SentryLevel.Info, "Programmatic access enabled. Configure Sentry manually."); return; } - Init(options); - } - - internal static void Init(UnitySentryOptions options) - { - _ = SentrySdk.Init(o => - { - o.Dsn = options.Dsn; - - // IL2CPP doesn't support Process.GetCurrentProcess().StartupUpTime - o.DetectStartupTime = StartupTimeDetectionMode.Fast; - - if (options.Logger != null) - { - o.Debug = true; - o.DiagnosticLogger = options.Logger; - o.DiagnosticLevel = options.DiagnosticsLevel; - } - - o.SampleRate = options.SampleRate; - - // Uses the game `version` as Release - o.Release = options.Release is { } release - ? release - : Application.version; - - o.Environment = options.Environment is { } environment - ? environment - : Application.isEditor - ? "editor" -#if DEVELOPMENT_BUILD - : "development"; -#else - : "production"; -#endif - - // If PDBs are available, CaptureMessage also includes a stack trace - o.AttachStacktrace = options.AttachStacktrace; - - // Required configurations to integrate with Unity - o.AddInAppExclude("UnityEngine"); - o.AddInAppExclude("UnityEditor"); - - o.RequestBodyCompressionLevel = options.RequestBodyCompressionLevel switch - { - SentryUnityCompression.Fastest => CompressionLevel.Fastest, - SentryUnityCompression.Optimal => CompressionLevel.Optimal, - // The target platform is known when building the player, so 'auto' should resolve there. - // Since some platforms don't support GZipping fallback no no compression. - SentryUnityCompression.Auto => CompressionLevel.NoCompression, - SentryUnityCompression.NoCompression => CompressionLevel.NoCompression, - _ => CompressionLevel.NoCompression - }; - o.AddEventProcessor(new UnityEventProcessor()); - o.AddExceptionProcessor(new UnityEventExceptionProcessor()); - }); - - // TODO: Consider ensuring this code path doesn't require UI thread - // Then use logMessageReceivedThreaded instead - void OnApplicationOnLogMessageReceived(string condition, string stackTrace, LogType type) => OnLogMessageReceived(condition, stackTrace, type, options); - - Application.logMessageReceived += OnApplicationOnLogMessageReceived; - Application.quitting += () => - { - // Note: iOS applications are usually suspended and do not quit. You should tick "Exit on Suspend" in Player settings for iOS builds to cause the game to quit and not suspend, otherwise you may not see this call. - // If "Exit on Suspend" is not ticked then you will see calls to OnApplicationPause instead. - // Note: On Windows Store Apps and Windows Phone 8.1 there is no application quit event. Consider using OnApplicationFocus event when focusStatus equals false. - // Note: On WebGL it is not possible to implement OnApplicationQuit due to nature of the browser tabs closing. - Application.logMessageReceived -= OnApplicationOnLogMessageReceived; - SentrySdk.Close(); - }; - - IDictionary? data = null; - if (SceneManager.GetActiveScene().name is { } name) - { - data = new Dictionary { { "scene", name } }; - } - SentrySdk.AddBreadcrumb("BeforeSceneLoad", data: data); - - options.Logger?.Log(SentryLevel.Debug, "Complete Sentry SDK initialization."); - - IsInit = true; - } - - // Happens with Domain Reloading - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void SubsystemRegistration() => SentrySdk.AddBreadcrumb("SubsystemRegistration"); - - private static void OnLogMessageReceived(string condition, string stackTrace, LogType type, UnitySentryOptions options) - { - // TODO: 'options' not used yet - _ = options; - - var debounced = type switch - { - LogType.Error or LogType.Exception or LogType.Assert => ErrorTimeDebounce.Debounced(), - LogType.Log => LogTimeDebounce.Debounced(), - LogType.Warning => WarningTimeDebounce.Debounced(), - _ => true - }; - if (!debounced) + if (!options.CaptureInEditor && Application.isEditor) { + options.DiagnosticLogger?.Log(SentryLevel.Info, "Disabled while in the Editor."); return; } - // TODO: to check against 'MinBreadcrumbLevel' - if (type != LogType.Error && type != LogType.Exception && type != LogType.Assert) + if (string.IsNullOrWhiteSpace(options.Dsn)) { - // TODO: MinBreadcrumbLevel - // options.MinBreadcrumbLevel - SentrySdk.AddBreadcrumb(condition, level: ToBreadcrumbLevel(type)); + options.DiagnosticLogger?.Log(SentryLevel.Warning, "No Sentry DSN configured. Sentry will be disabled."); return; } - var sentryEvent = new SentryEvent(new UnityLogException(condition, stackTrace)); - sentryEvent.SetTag("log.type", ToEventTagType(type)); - _ = EventCapture?.Capture(sentryEvent); - SentrySdk.AddBreadcrumb(condition, level: ToBreadcrumbLevel(type)); + SentryUnity.Init(options); } - - private static string ToEventTagType(LogType type) => - type switch - { - LogType.Assert => "assert", - LogType.Error => "error", - LogType.Exception => "exception", - LogType.Log => "log", - LogType.Warning => "warning", - _ => "unknown" - }; - - private static BreadcrumbLevel ToBreadcrumbLevel(LogType type) => - type switch - { - LogType.Assert => BreadcrumbLevel.Error, - LogType.Error => BreadcrumbLevel.Error, - LogType.Exception => BreadcrumbLevel.Error, - LogType.Log => BreadcrumbLevel.Info, - LogType.Warning => BreadcrumbLevel.Warning, - _ => BreadcrumbLevel.Info - }; } } diff --git a/src/Sentry.Unity/SentryUnity.cs b/src/Sentry.Unity/SentryUnity.cs new file mode 100644 index 000000000..6d7726a2f --- /dev/null +++ b/src/Sentry.Unity/SentryUnity.cs @@ -0,0 +1,19 @@ +using System; +using System.ComponentModel; + +namespace Sentry.Unity +{ + public static class SentryUnity + { + [EditorBrowsable(EditorBrowsableState.Never)] + public static void Init(UnitySentryOptions unitySentryOptions) + => SentrySdk.Init(unitySentryOptions); + + public static void Init(Action unitySentryOptionsConfigure) + { + var unitySentryOptions = new UnitySentryOptions(); + unitySentryOptionsConfigure.Invoke(unitySentryOptions); + Init(unitySentryOptions); + } + } +} diff --git a/src/Sentry.Unity/UnitySentryOptions.cs b/src/Sentry.Unity/UnitySentryOptions.cs index d58fd239e..7ca655f4e 100644 --- a/src/Sentry.Unity/UnitySentryOptions.cs +++ b/src/Sentry.Unity/UnitySentryOptions.cs @@ -1,20 +1,15 @@ -using System; using System.IO; using System.Text.Json; -using Sentry.Extensibility; +using Sentry.Unity.Extensions; +using Sentry.Unity.Integrations; using UnityEngine; +using CompressionLevel = System.IO.Compression.CompressionLevel; + namespace Sentry.Unity { - public enum SentryUnityCompression - { - Auto = 0, - Optimal = 1, - Fastest = 2, - NoCompression = 3 - } - - public sealed class UnitySentryOptions + // TODO: rename to `SentryUnityOptions` for consistency across dotnet Sentry SDK + public sealed class UnitySentryOptions : SentryOptions { /// /// Relative to Assets/Resources @@ -36,25 +31,43 @@ internal static string GetConfigPath(string? notDefaultConfigName = null) public bool Enabled { get; set; } = true; public bool CaptureInEditor { get; set; } = true; // Lower entry barrier, likely set to false after initial setup. - public string? Dsn { get; set; } - public bool Debug { get; set; } = true; // By default on only public bool DebugOnlyInEditor { get; set; } = true; public SentryLevel DiagnosticsLevel { get; set; } = SentryLevel.Error; // By default logs out Error or higher. - // Ideally this would be per platform - // Auto allows us to try figure out things in the SDK depending on the platform. Any other value means an explicit user choice. - public SentryUnityCompression RequestBodyCompressionLevel { get; set; } = SentryUnityCompression.Auto; - public bool AttachStacktrace { get; set; } - public float SampleRate { get; set; } = 1.0f; + public bool EnableAutoPayloadCompression { get; set; } - public IDiagnosticLogger? Logger { get; private set; } - public string? Release { get; set; } - public string? Environment { get; set; } + public UnitySentryOptions() + { + // IL2CPP doesn't support Process.GetCurrentProcess().StartupTime + DetectStartupTime = StartupTimeDetectionMode.Fast; + + // Uses the game `version` as Release unless the user defined one via the Options + Release ??= Application.version; // TODO: Should we move it out and use via IApplication something? + + // The target platform is known when building the player, so 'auto' should resolve there. + // Since some platforms don't support GZipping fallback no no compression. + RequestBodyCompressionLevel = EnableAutoPayloadCompression + ? CompressionLevel.NoCompression + : RequestBodyCompressionLevel; + + Environment = Environment is { } environment + ? environment + : Application.isEditor // TODO: Should we move it out and use via IApplication something? + ? "editor" + : "production"; + + this.AddInAppExclude("UnityEngine"); + this.AddInAppExclude("UnityEditor"); + this.AddEventProcessor(new UnityEventProcessor()); + this.AddExceptionProcessor(new UnityEventExceptionProcessor()); + this.AddIntegration(new UnityApplicationLoggingIntegration()); + this.AddIntegration(new UnityBeforeSceneLoadIntegration()); + } // Can't rely on Unity's OnEnable() hook. public UnitySentryOptions TryAttachLogger() { - Logger = Debug - && (!DebugOnlyInEditor || Application.isEditor) + DiagnosticLogger = Debug + && (!DebugOnlyInEditor || Application.isEditor) // TODO: Should we move it out and use via IApplication something? ? new UnityLogger(DiagnosticsLevel) : null; @@ -76,9 +89,15 @@ public void WriteTo(Utf8JsonWriter writer) writer.WriteBoolean("debug", Debug); writer.WriteBoolean("debugOnlyInEditor", DebugOnlyInEditor); writer.WriteNumber("diagnosticsLevel", (int)DiagnosticsLevel); - writer.WriteNumber("requestBodyCompressionLevel", (int)RequestBodyCompressionLevel); writer.WriteBoolean("attachStacktrace", AttachStacktrace); - writer.WriteNumber("sampleRate", SampleRate); + + writer.WriteBoolean("enableAutoPayloadCompression", EnableAutoPayloadCompression); + writer.WriteNumber("requestBodyCompressionLevel", EnableAutoPayloadCompression ? (int)CompressionLevel.NoCompression : (int)RequestBodyCompressionLevel); + + if (SampleRate != null) + { + writer.WriteNumber("sampleRate", SampleRate.Value); + } if (!string.IsNullOrWhiteSpace(Release)) { @@ -103,7 +122,8 @@ public static UnitySentryOptions FromJson(JsonElement json) Debug = json.GetPropertyOrNull("debug")?.GetBoolean() ?? true, DebugOnlyInEditor = json.GetPropertyOrNull("debugOnlyInEditor")?.GetBoolean() ?? true, DiagnosticsLevel = json.GetEnumOrNull("diagnosticsLevel") ?? SentryLevel.Error, - RequestBodyCompressionLevel = json.GetEnumOrNull("requestBodyCompressionLevel") ?? SentryUnityCompression.Auto, + RequestBodyCompressionLevel = json.GetEnumOrNull("requestBodyCompressionLevel") ?? CompressionLevel.NoCompression, + EnableAutoPayloadCompression = json.GetPropertyOrNull("enableAutoPayloadCompression")?.GetBoolean() ?? false, AttachStacktrace = json.GetPropertyOrNull("attachStacktrace")?.GetBoolean() ?? false, SampleRate = json.GetPropertyOrNull("sampleRate")?.GetSingle() ?? 1.0f, Release = json.GetPropertyOrNull("release")?.GetString(), @@ -125,46 +145,4 @@ public void SaveToUnity(string path) WriteTo(writer); } } - - internal static class JsonExtensions - { - // From Sentry.Internal.Extensions.JsonExtensions - public static JsonElement? GetPropertyOrNull(this JsonElement json, string name) - { - if (json.ValueKind != JsonValueKind.Object) - { - return null; - } - - if (json.TryGetProperty(name, out var result)) - { - if (json.ValueKind == JsonValueKind.Undefined || - json.ValueKind == JsonValueKind.Null) - { - return null; - } - - return result; - } - - return null; - } - - public static TEnum? GetEnumOrNull(this JsonElement json, string name) - where TEnum : struct - { - var enumString = json.GetPropertyOrNull(name)?.ToString(); - if (string.IsNullOrWhiteSpace(enumString)) - { - return null; - } - - if (!Enum.TryParse(enumString, true, out TEnum value)) - { - return null; - } - - return value; - } - } } diff --git a/src/test/Sentry.Unity.Editor.Tests/EditorModeTests.cs b/src/test/Sentry.Unity.Editor.Tests/EditorModeTests.cs index a59f7fdd6..0a7898066 100644 --- a/src/test/Sentry.Unity.Editor.Tests/EditorModeTests.cs +++ b/src/test/Sentry.Unity.Editor.Tests/EditorModeTests.cs @@ -1,5 +1,4 @@ -using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using NUnit.Framework; @@ -10,32 +9,6 @@ namespace Sentry.Unity.Editor.Tests { public sealed class EditorModeTests { - [UnitySetUp] - public IEnumerator InitializeOptions() - { - // Due to an issue, Sentry doesn't always load UnitySentryOptions, which - // results in tests not running on clean clone or on CI. - // https://github.com/getsentry/sentry-unity/issues/77 - // - // This hack sets the options manually if that happens. - // Since this skips a layer of testing, this is not desirable long term - // and we should find a proper way to solve this. - if (!SentryInitialization.IsInit) - { - var options = new UnitySentryOptions - { - Dsn = "https://94677106febe46b88b9b9ae5efd18a00@o447951.ingest.sentry.io/5439417", - Enabled = true - }; - - SentryInitialization.Init(options); - - Debug.LogWarning("Sentry has not been initialized prior to running tests. Using manual configuration."); - } - - yield break; - } - [Test] public void OptionsDsnField_WrongFormat_CreatesError() { diff --git a/src/test/Sentry.Unity.Tests/IntegrationTests.cs b/src/test/Sentry.Unity.Tests/IntegrationTests.cs new file mode 100644 index 000000000..dd18c998d --- /dev/null +++ b/src/test/Sentry.Unity.Tests/IntegrationTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Sentry.Unity.Integrations; +using Sentry.Unity.Tests.TestBehaviours; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace Sentry.Unity.Tests +{ + public sealed class IntegrationTests + { + [UnityTest] + public IEnumerator BugFarmScene_ObjectCreatedWithExceptionLogicAndCalled_OneEventIsCreated() + { + yield return SetupSceneCoroutine("BugFarmScene"); + + // arrange + var testEventCapture = new TestEventCapture(); + using var _ = InitSentrySdk(testEventCapture); + var testBehaviour = new GameObject("TestHolder").AddComponent(); + + // act + /* + * We don't want to call testBehaviour.TestException(); because it won't go via Sentry infra. + * We don't have it in tests, but in scenes. + */ + testBehaviour.gameObject.SendMessage(nameof(testBehaviour.TestException)); + + // assert + Assert.AreEqual(1, testEventCapture.Events.Count); + } + + private static IEnumerator SetupSceneCoroutine(string sceneName) + { + // load scene with initialized Sentry, SceneManager.LoadSceneAsync(sceneName); + SceneManager.LoadScene(sceneName); + + // skip a frame for a Unity to properly load a scene + yield return null; + + // don't fail test if exception is thrown via 'SendMessage', we want to continue + LogAssert.ignoreFailingMessages = true; + } + + private static IDisposable InitSentrySdk(IEventCapture eventCapture) + { + SentryUnity.Init(options => + { + options.Enabled = true; + options.Dsn = "https://94677106febe46b88b9b9ae5efd18a00@o447951.ingest.sentry.io/5439417"; + options.DiagnosticLogger = new UnityLogger(SentryLevel.Warning); + options.AddIntegration(new UnityApplicationLoggingIntegration(null, eventCapture)); + }); + return new SentryDisposable(); + } + + private sealed class SentryDisposable : IDisposable + { + public void Dispose() => SentrySdk.Close(); + } + } + + /* + * Example of event capture which is used in Sentry.Unity infra + */ + internal sealed class TestEventCapture : IEventCapture + { + private readonly List _events = new(); + + public IReadOnlyCollection Events => _events.AsReadOnly(); + + public SentryId Capture(SentryEvent sentryEvent) + { + _events.Add(sentryEvent); + return sentryEvent.EventId; + } + } +} diff --git a/src/test/Sentry.Unity.Tests/PlayModeTests.cs b/src/test/Sentry.Unity.Tests/PlayModeTests.cs deleted file mode 100644 index 51392dba3..000000000 --- a/src/test/Sentry.Unity.Tests/PlayModeTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using Sentry.Unity.Tests.TestBehaviours; -using UnityEngine; -using UnityEngine.SceneManagement; -using UnityEngine.TestTools; - -namespace Sentry.Unity.Tests -{ - public sealed class PlayModeTests - { - [UnitySetUp] - public IEnumerator InitializeOptions() - { - // Due to an issue, Sentry doesn't always load UnitySentryOptions, which - // results in tests not running on clean clone or on CI. - // https://github.com/getsentry/sentry-unity/issues/77 - // - // This hack sets the options manually if that happens. - // Since this skips a layer of testing, this is not desirable long term - // and we should find a proper way to solve this. - if (!SentryInitialization.IsInit) - { - var options = new UnitySentryOptions - { - Dsn = "https://94677106febe46b88b9b9ae5efd18a00@o447951.ingest.sentry.io/5439417", - Enabled = true - }; - - SentryInitialization.Init(options); - - Debug.LogWarning("Sentry has not been initialized prior to running tests. Using manual configuration."); - } - - yield break; - } - - [UnityTest] - public IEnumerator BugFarmScene_ObjectCreatedWithExceptionLogicAndCalled_OneEventIsCreated() - { - yield return SetupSceneCoroutine("BugFarmScene"); - - // arrange - var testEventCapture = CreateAndSetupSentryTestService(); - var testBehaviour = new GameObject("TestHolder").AddComponent(); - - // act - /* - * We don't want to call testBehaviour.TestException(); because it won't go via Sentry infra. - * We don't have it in tests, but in scenes. - */ - testBehaviour.gameObject.SendMessage(nameof(testBehaviour.TestException)); - - // assert - Assert.AreEqual(1, testEventCapture.Events.Count); - } - - [UnityTest] - public IEnumerator BugFarmScene_ThrowExceptionTwice_Outputs1Event() - { - yield return SetupSceneCoroutine("BugFarmScene"); - - // arrange - var testEventCapture = CreateAndSetupSentryTestService(); - /* - * We should NOT use 'GameObject.Find', it's quite expensive. - * 'GameObject.FindWithTag' is better but it needs additional setup. - */ - var scriptsGameObject = GameObject.Find("Scripts"); - - // act - const string throwNullName = "ThrowNull"; - scriptsGameObject.SendMessage(throwNullName); // first exception - scriptsGameObject.SendMessage(throwNullName); // second exception - - // assert - Assert.AreEqual(1, testEventCapture.Events.Count); - } - - [UnityTest] - public IEnumerator EmptyScene_LogErrorAndException_Outputs2Events() - { - yield return SetupSceneCoroutine("EmptyScene"); - - // arrange - var testEventCapture = CreateAndSetupSentryTestService(); - - // act - var testBehaviour = new GameObject("TestHolder").AddComponent(); - testBehaviour.SendMessage(nameof(testBehaviour.DebugLogError)); // Debug messages are in Breadcrumbs and not sent separately - testBehaviour.SendMessage(nameof(testBehaviour.TestException)); - - // assert - Assert.AreEqual(1, testEventCapture.Events.Count); - } - - [UnityTest] - public IEnumerator UnityEventExceptionProcessor_ILL2CPPStackTraceFilenameWithZeroes_ShouldReturnEmptyString() - { - yield return SetupSceneCoroutine("BugFarmScene"); - - // arrange - var unityEventProcessor = new UnityEventExceptionProcessor(); - var ill2CppUnityLogException = new UnityLogException( - "one: two", - "BugFarm.ThrowNull () (at <00000000000000000000000000000000>:0)"); - var sentryEvent = new SentryEvent(); - - // act - unityEventProcessor.Process(ill2CppUnityLogException, sentryEvent); - - // assert - Assert.NotNull(sentryEvent.SentryExceptions); - - var sentryException = sentryEvent.SentryExceptions!.First(); - Assert.NotNull(sentryException.Stacktrace); - Assert.Greater(sentryException.Stacktrace!.Frames.Count, 0); - - var sentryExceptionFirstFrame = sentryException.Stacktrace!.Frames[0]; - Assert.AreEqual(string.Empty, sentryExceptionFirstFrame.FileName); - } - - [UnityTest] - public IEnumerator UnityEventProcessor_SdkInfo_Correct() - { - yield return SetupSceneCoroutine("BugFarmScene"); - - // arrange - var unityEventProcessor = new UnityEventProcessor(); - var sentryEvent = new SentryEvent(); - - // act - unityEventProcessor.Process(sentryEvent); - - // assert - Assert.AreEqual(UnitySdkInfo.Name, sentryEvent.Sdk.Name); - Assert.AreEqual(UnitySdkInfo.Version, sentryEvent.Sdk.Version); - - var package = sentryEvent.Sdk.Packages.FirstOrDefault(); - Assert.IsNotNull(package); - Assert.AreEqual(UnitySdkInfo.PackageName, package!.Name); - Assert.AreEqual(UnitySdkInfo.Version, package!.Version); - } - - private static IEnumerator SetupSceneCoroutine(string sceneName) - { - // load scene with initialized Sentry, SceneManager.LoadSceneAsync(sceneName); - SceneManager.LoadScene(sceneName); - - // skip a frame for a Unity to properly load a scene - yield return null; - - // don't fail test if exception is thrown via 'SendMessage', we want to continue - LogAssert.ignoreFailingMessages = true; - } - - /* - * TODO: - * - * The current Sentry initialization is static. It means that the initialization is done once for all the tests. - * That's why we need to alter some state on 'per test' level before running them in bulk. - * - * This problem will be mitigated as we implement this https://github.com/getsentry/sentry-unity/issues/66 - */ - private static TestEventCapture CreateAndSetupSentryTestService() - { - var testEventCapture = new TestEventCapture(); - SentryInitialization.EventCapture = testEventCapture; - SentryInitialization.ErrorTimeDebounce = new(TimeSpan.FromSeconds(1)); - SentryInitialization.LogTimeDebounce = new(TimeSpan.FromSeconds(1)); - SentryInitialization.WarningTimeDebounce = new(TimeSpan.FromSeconds(1)); - return testEventCapture; - } - } - - /* - * Example of event capture which is used in Sentry.Unity infra - */ - internal sealed class TestEventCapture : IEventCapture - { - private readonly List _events = new(); - - public IReadOnlyCollection Events => _events.AsReadOnly(); - - public SentryId Capture(SentryEvent sentryEvent) - { - _events.Add(sentryEvent); - return sentryEvent.EventId; - } - } -} diff --git a/src/test/Sentry.Unity.Tests/Stubs/TestApplication.cs b/src/test/Sentry.Unity.Tests/Stubs/TestApplication.cs new file mode 100644 index 000000000..19bb73d07 --- /dev/null +++ b/src/test/Sentry.Unity.Tests/Stubs/TestApplication.cs @@ -0,0 +1,13 @@ +using System; +using Sentry.Unity.Integrations; +using UnityEngine; + +namespace Sentry.Unity.Tests.Stubs +{ + internal sealed class TestApplication : IApplication + { + public event Application.LogCallback? LogMessageReceived; + public event Action? Quitting; + public string ActiveSceneName => "TestSceneName"; + } +} diff --git a/src/test/Sentry.Unity.Tests/Stubs/TestHub.cs b/src/test/Sentry.Unity.Tests/Stubs/TestHub.cs new file mode 100644 index 000000000..b0591f5e0 --- /dev/null +++ b/src/test/Sentry.Unity.Tests/Stubs/TestHub.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Sentry.Unity.Tests.Stubs +{ + internal sealed class TestHub : IHub + { + private readonly List _capturedEvents = new(); + + public IReadOnlyList CapturedEvents => _capturedEvents; + + public bool IsEnabled { get; } = true; + + public SentryId CaptureEvent(SentryEvent evt, Scope? scope = null) + { + _capturedEvents.Add(evt); + return evt.EventId; + } + + public void CaptureUserFeedback(UserFeedback userFeedback) + { + throw new NotImplementedException(); + } + + public void CaptureTransaction(Transaction transaction) + { + } + + public Task FlushAsync(TimeSpan timeout) + { + throw new NotImplementedException(); + } + + public void ConfigureScope(Action configureScope) + { + _ = configureScope; + } + + public Task ConfigureScopeAsync(Func configureScope) + { + throw new NotImplementedException(); + } + + public void BindClient(ISentryClient client) + { + throw new NotImplementedException(); + } + + public IDisposable PushScope() + { + throw new NotImplementedException(); + } + + public IDisposable PushScope(TState state) + { + throw new NotImplementedException(); + } + + public void WithScope(Action scopeCallback) + { + throw new NotImplementedException(); + } + + public SentryId LastEventId { get; } + public ITransaction StartTransaction(ITransactionContext context, IReadOnlyDictionary customSamplingContext) + { + throw new NotImplementedException(); + } + + public void BindException(Exception exception, ISpan span) + { + throw new NotImplementedException(); + } + + public ISpan? GetSpan() + { + throw new NotImplementedException(); + } + + public SentryTraceHeader? GetTraceHeader() + { + throw new NotImplementedException(); + } + } +} diff --git a/src/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/src/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs new file mode 100644 index 000000000..a58af5fa4 --- /dev/null +++ b/src/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -0,0 +1,47 @@ +using NUnit.Framework; +using Sentry.Unity.Integrations; +using Sentry.Unity.Tests.Stubs; +using UnityEngine; + +namespace Sentry.Unity.Tests +{ + public sealed class UnityApplicationLoggingIntegrationTests + { + private class Fixture + { + public TestHub Hub => new(); + public IApplication Application => new TestApplication(); + + public UnityApplicationLoggingIntegration GetSut() => new(Application); + } + + private readonly Fixture _fixture = new(); + + private SentryOptions SentryOptions { get; set; } = new(); + + [Test] + public void OnLogMessageReceived_WithError_CaptureEvent() + { + var sut = _fixture.GetSut(); + var hub = _fixture.Hub; + sut.Register(hub, SentryOptions); + + sut.OnLogMessageReceived("condition", "stacktrace", LogType.Error); + + Assert.AreEqual(1, hub.CapturedEvents.Count); + } + + [Test] + public void OnLogMessageReceived_WithSeveralErrorsDebounced_CaptureEvent() + { + var sut = _fixture.GetSut(); + var hub = _fixture.Hub; + sut.Register(hub, SentryOptions); + + sut.OnLogMessageReceived("condition", "stacktrace", LogType.Error); + sut.OnLogMessageReceived("condition", "stacktrace", LogType.Error); + + Assert.AreEqual(1, hub.CapturedEvents.Count); + } + } +} diff --git a/src/test/Sentry.Unity.Tests/UnityBeforeSceneLoadIntegrationTests.cs b/src/test/Sentry.Unity.Tests/UnityBeforeSceneLoadIntegrationTests.cs new file mode 100644 index 000000000..22a2cd9b4 --- /dev/null +++ b/src/test/Sentry.Unity.Tests/UnityBeforeSceneLoadIntegrationTests.cs @@ -0,0 +1,29 @@ +using NUnit.Framework; +using Sentry.Unity.Integrations; +using Sentry.Unity.Tests.Stubs; + +namespace Sentry.Unity.Tests +{ + public sealed class UnityBeforeSceneLoadIntegrationTests + { + private class Fixture + { + public TestHub Hub => new(); + public IApplication Application => new TestApplication(); + + public UnityBeforeSceneLoadIntegration GetSut() => new(Application); + } + + private readonly Fixture _fixture = new(); + + private SentryOptions SentryOptions { get; set; } = new(); + + // TODO: How to stub Scope with Breadcrumbs? + public void BreadcrumbSceneName() + { + var sut = _fixture.GetSut(); + var hub = _fixture.Hub; + sut.Register(hub, SentryOptions); + } + } +} diff --git a/src/test/Sentry.Unity.Tests/UnityEventExceptionProcessorTests.cs b/src/test/Sentry.Unity.Tests/UnityEventExceptionProcessorTests.cs new file mode 100644 index 000000000..8a09ec61b --- /dev/null +++ b/src/test/Sentry.Unity.Tests/UnityEventExceptionProcessorTests.cs @@ -0,0 +1,52 @@ +using System.Linq; +using NUnit.Framework; + +namespace Sentry.Unity.Tests +{ + public class UnityEventExceptionProcessorTests + { + [Test] + public void Process_IL2CPPStackTraceFilenameWithZeroes_ShouldReturnEmptyString() + { + // arrange + var unityEventProcessor = new UnityEventExceptionProcessor(); + var ill2CppUnityLogException = new UnityLogException( + "one: two", + "BugFarm.ThrowNull () (at <00000000000000000000000000000000>:0)"); + var sentryEvent = new SentryEvent(); + + // act + unityEventProcessor.Process(ill2CppUnityLogException, sentryEvent); + + // assert + Assert.NotNull(sentryEvent.SentryExceptions); + + var sentryException = sentryEvent.SentryExceptions!.First(); + Assert.NotNull(sentryException.Stacktrace); + Assert.Greater(sentryException.Stacktrace!.Frames.Count, 0); + + var sentryExceptionFirstFrame = sentryException.Stacktrace!.Frames[0]; + Assert.AreEqual(string.Empty, sentryExceptionFirstFrame.FileName); + } + + [Test] + public void Process_SdkInfo_Correct() + { + // arrange + var unityEventProcessor = new UnityEventProcessor(); + var sentryEvent = new SentryEvent(); + + // act + unityEventProcessor.Process(sentryEvent); + + // assert + Assert.AreEqual(UnitySdkInfo.Name, sentryEvent.Sdk.Name); + Assert.AreEqual(UnitySdkInfo.Version, sentryEvent.Sdk.Version); + + var package = sentryEvent.Sdk.Packages.FirstOrDefault(); + Assert.IsNotNull(package); + Assert.AreEqual(UnitySdkInfo.PackageName, package!.Name); + Assert.AreEqual(UnitySdkInfo.Version, package!.Version); + } + } +} diff --git a/src/test/Sentry.Unity.Tests/UnitySentryOptionsTest.cs b/src/test/Sentry.Unity.Tests/UnitySentryOptionsTest.cs index 4a11ab8ee..07623422d 100644 --- a/src/test/Sentry.Unity.Tests/UnitySentryOptionsTest.cs +++ b/src/test/Sentry.Unity.Tests/UnitySentryOptionsTest.cs @@ -1,5 +1,5 @@ -using System; -using System.IO; +using System.IO; +using System.IO.Compression; using System.Reflection; using System.Text; using System.Text.Json; @@ -35,9 +35,10 @@ public void Options_WriteRead_Equals() Debug = true, DebugOnlyInEditor = false, DiagnosticsLevel = SentryLevel.Info, - RequestBodyCompressionLevel = SentryUnityCompression.Optimal, + EnableAutoPayloadCompression = false, + RequestBodyCompressionLevel = CompressionLevel.NoCompression, AttachStacktrace = true, - SampleRate = 1.15f, + SampleRate = 1f, Release = "release", Environment = "test" }; @@ -63,11 +64,12 @@ private static void AssertOptions(UnitySentryOptions actual, UnitySentryOptions Assert.AreEqual(expected.Debug, actual.Debug); Assert.AreEqual(expected.DebugOnlyInEditor, actual.DebugOnlyInEditor); Assert.AreEqual(expected.DiagnosticsLevel, actual.DiagnosticsLevel); - Assert.AreEqual(expected.RequestBodyCompressionLevel, actual.RequestBodyCompressionLevel); Assert.AreEqual(expected.AttachStacktrace, actual.AttachStacktrace); Assert.AreEqual(expected.SampleRate, actual.SampleRate); Assert.AreEqual(expected.Release, actual.Release); Assert.AreEqual(expected.Environment, actual.Environment); + Assert.AreEqual(expected.EnableAutoPayloadCompression, actual.EnableAutoPayloadCompression); + Assert.AreEqual(expected.RequestBodyCompressionLevel, actual.RequestBodyCompressionLevel); } private static string GetTestOptionsFilePath()