diff --git a/CHANGELOG.md b/CHANGELOG.md index 047904e22..a89e9fdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog -## Unreleased +## Unreleased ### Breaking Changes - `sentry-native` is now built on Ubuntu 22.04 instead of Ubuntu 20.04, which reached EOL in May 2025. If you are running you game on a server on Ubuntu 20.04, you should update the OS before upgrading to this SDK version. ([#2355](https://github.com/getsentry/sentry-unity/pull/2355)) +### Features + +- Added support for Structured Logging. The `SentrySdk.Logger` API is now exposed for Unity users, enabling structured log capture. The SDK can also automatically capture and send Debug logs based on the options configured. ([#2368](https://github.com/getsentry/sentry-unity/pull/2368)) + ### Dependencies - Bump CLI from v2.56.0 to v2.56.1 ([#2356](https://github.com/getsentry/sentry-unity/pull/2356)) diff --git a/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset b/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset index 542d04e04..069914f26 100644 --- a/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset +++ b/samples/unity-of-bugs/Assets/Resources/Sentry/SentryOptions.asset @@ -35,11 +35,17 @@ MonoBehaviour: k__BackingField: 100 k__BackingField: 20 k__BackingField: 10 + k__BackingField: 1 + k__BackingField: 0 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 1 + k__BackingField: 0 k__BackingField: 1 k__BackingField: 1 k__BackingField: 1 k__BackingField: 1 - k__BackingField: 1 k__BackingField: 1 k__BackingField: 100 k__BackingField: 1 @@ -51,7 +57,7 @@ MonoBehaviour: k__BackingField: 1 k__BackingField: 2000 k__BackingField: 30 - k__BackingField: 1 + k__BackingField: 0 k__BackingField: 5000 k__BackingField: 1 k__BackingField: f401000057020000 diff --git a/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs b/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs index 9cc7a814b..e0a3d072b 100644 --- a/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs +++ b/samples/unity-of-bugs/Assets/Scripts/BugFarmButtons.cs @@ -71,7 +71,7 @@ public void LogError() // Error logs get captured as messages and do not have a stacktrace attached by default. This is an opt-in feature. // Note: That stack traces generated for message events are provided without line numbers. See known limitations // https://docs.sentry.io/platforms/unity/troubleshooting/known-limitations/#line-numbers-missing-in-events-captured-through-debuglogerror-or-sentrysdkcapturemessage - Debug.LogError("Debug.LogError() called"); + Debug.LogError("This is a 'Debug.LogError()' message."); } } diff --git a/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs b/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs index 098446ffa..54575a7ea 100644 --- a/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs +++ b/samples/unity-of-bugs/Assets/Scripts/ThreadingSamples.cs @@ -81,7 +81,7 @@ public void LogError() // Error logs get captured as messages and do not have a stacktrace attached by default. This is an opt-in feature. // Note: That stack traces generated for message events are provided without line numbers. See known limitations // https://docs.sentry.io/platforms/unity/troubleshooting/known-limitations/#line-numbers-missing-in-events-captured-through-debuglogerror-or-sentrysdkcapturemessage - Debug.LogError("Debug.LogError() called"); + Debug.LogError("This is a 'Debug.LogError()' message."); } }); } diff --git a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs index da13dd041..4a33285f3 100644 --- a/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs +++ b/src/Sentry.Unity.Editor/ConfigurationWindow/LoggingTab.cs @@ -9,31 +9,35 @@ internal static class LoggingTab internal static void Display(ScriptableSentryUnityOptions options) { { - options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup( - new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " + - "same type if they are more frequent than once per second."), - options.EnableLogDebouncing); + GUILayout.Label("Structured Logging - Experimental", EditorStyles.boldLabel); + + options.EnableStructuredLogging = EditorGUILayout.BeginToggleGroup( + new GUIContent("Send Logs for:", "Enables the SDK to forward log messages to Sentry " + + "based on the log level."), + options.EnableStructuredLogging); EditorGUI.indentLevel++; - options.DebounceTimeLog = EditorGUILayout.IntField( - new GUIContent("Log Debounce [ms]", "The time that has to pass between events of " + - "LogType.Log before the SDK sends it again."), - options.DebounceTimeLog); - options.DebounceTimeLog = Math.Max(0, options.DebounceTimeLog); - - options.DebounceTimeWarning = EditorGUILayout.IntField( - new GUIContent("Warning Debounce [ms]", "The time that has to pass between events of " + - "LogType.Warning before the SDK sends it again."), - options.DebounceTimeWarning); - options.DebounceTimeWarning = Math.Max(0, options.DebounceTimeWarning); - - options.DebounceTimeError = EditorGUILayout.IntField( - new GUIContent("Error Debounce [ms]", "The time that has to pass between events of " + - "LogType.Assert, LogType.Exception and LogType.Error before " + - "the SDK sends it again."), - options.DebounceTimeError); - options.DebounceTimeError = Math.Max(0, options.DebounceTimeError); + options.StructuredLogOnDebugLog = EditorGUILayout.Toggle( + new GUIContent("Debug.Log", + "Whether the SDK should forward Debug.Log messages to Sentry structured logging"), + options.StructuredLogOnDebugLog); + options.StructuredLogOnDebugLogWarning = EditorGUILayout.Toggle( + new GUIContent("Debug.LogWarning", + "Whether the SDK should forward Debug.LogWarning messages to Sentry structured logging"), + options.StructuredLogOnDebugLogWarning); + options.StructuredLogOnDebugLogAssertion = EditorGUILayout.Toggle( + new GUIContent("Debug.LogAssertion", + "Whether the SDK should forward Debug.LogAssertion messages to Sentry structured logging"), + options.StructuredLogOnDebugLogAssertion); + options.StructuredLogOnDebugLogError = EditorGUILayout.Toggle( + new GUIContent("Debug.LogError", + "Whether the SDK should forward Debug.LogError messages to Sentry structured logging"), + options.StructuredLogOnDebugLogError); + options.StructuredLogOnDebugLogException = EditorGUILayout.Toggle( + new GUIContent("Debug.LogException", + "Whether the SDK should forward Debug.LogException messages to Sentry structured logging"), + options.StructuredLogOnDebugLogException); EditorGUI.indentLevel--; EditorGUILayout.EndToggleGroup(); @@ -44,42 +48,45 @@ internal static void Display(ScriptableSentryUnityOptions options) EditorGUILayout.Space(); { - GUILayout.Label("Automatically capture and send events for:", EditorStyles.boldLabel); - EditorGUI.indentLevel++; + GUILayout.Label("Breadcrumbs", EditorStyles.boldLabel); - options.CaptureLogErrorEvents = EditorGUILayout.Toggle( - new GUIContent("Debug.LogError", "Whether the SDK automatically captures events for 'Debug.LogError'."), - options.CaptureLogErrorEvents); + if (options.EnableStructuredLogging) + { + options.AttachBreadcrumbsToEvents = EditorGUILayout.BeginToggleGroup( + new GUIContent("Attach logs as breadcrumbs in addition to sending them as structured logs", "Whether the SDK should attach breadcrumbs to events in addition to structured logging."), + options.AttachBreadcrumbsToEvents); - EditorGUILayout.Space(); - GUILayout.Label("Automatically add breadcrumbs for:", EditorStyles.boldLabel); + GUILayout.Label("Note: With sending structured logs enabled you have to opt-into adding breadcrumbs to events.", EditorStyles.boldLabel); + } + + EditorGUI.indentLevel++; options.BreadcrumbsForLogs = EditorGUILayout.Toggle( new GUIContent("Debug.Log", "Whether the SDK automatically adds breadcrumbs 'Debug.Log'."), options.BreadcrumbsForLogs); options.BreadcrumbsForWarnings = EditorGUILayout.Toggle( - new GUIContent("Debug.Warning", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogWarning'."), + new GUIContent("Debug.LogWarning", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogWarning'."), options.BreadcrumbsForWarnings); options.BreadcrumbsForAsserts = EditorGUILayout.Toggle( - new GUIContent("Debug.Assert", "Whether the SDK automatically adds breadcrumbs for 'Debug.Assert'."), + new GUIContent("Debug.LogAssertion", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogAssertion'."), options.BreadcrumbsForAsserts); options.BreadcrumbsForErrors = EditorGUILayout.Toggle( - new GUIContent("Debug.Error", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogError'."), + new GUIContent("Debug.LogError", "Whether the SDK automatically adds breadcrumbs for 'Debug.LogError'."), options.BreadcrumbsForErrors); - EditorGUI.indentLevel--; - } - - EditorGUILayout.Space(); - EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray); - EditorGUILayout.Space(); + EditorGUILayout.Space(); - { options.MaxBreadcrumbs = EditorGUILayout.IntField( new GUIContent("Max Breadcrumbs", "Maximum number of breadcrumbs that get captured." + "\nDefault: 100"), options.MaxBreadcrumbs); options.MaxBreadcrumbs = Math.Max(0, options.MaxBreadcrumbs); + + EditorGUI.indentLevel--; + if (options.EnableStructuredLogging) + { + EditorGUILayout.EndToggleGroup(); + } } EditorGUILayout.Space(); @@ -87,22 +94,63 @@ internal static void Display(ScriptableSentryUnityOptions options) EditorGUILayout.Space(); { - GUILayout.Label("Attach the stack trace when capturing log messages. NOTE: These will not contain line numbers.", EditorStyles.boldLabel); + GUILayout.Label("CaptureMessage Settings", EditorStyles.boldLabel); EditorGUI.indentLevel++; + options.CaptureLogErrorEvents = EditorGUILayout.Toggle( + new GUIContent("Capture LogError", "Whether the SDK automatically captures events for 'Debug.LogError'."), + options.CaptureLogErrorEvents); + options.AttachStacktrace = EditorGUILayout.Toggle( - new GUIContent("Attach Stack Trace", "Whether to include a stack trace for non " + - "error events like logs. Even when Unity didn't include and no " + - "exception was thrown. Refer to AttachStacktrace on sentry docs."), + new GUIContent("Attach Stack Trace", "Whether the SDK should include a stack trace for CaptureMessage " + + "events. Refer to AttachStacktrace on sentry docs."), options.AttachStacktrace); - EditorGUI.indentLevel--; + GUILayout.Label("Note: The stack trace quality will depend on the IL2CPP line number setting and might not contain line numbers.", EditorStyles.boldLabel); + // Enhanced not supported on IL2CPP so not displaying this for the time being: // Options.StackTraceMode = (StackTraceMode) EditorGUILayout.EnumPopup( // new GUIContent("Stacktrace Mode", "Enhanced is the default." + // "\n - Enhanced: Include async, return type, args,..." + // "\n - Original - Default .NET stack trace format."), // Options.StackTraceMode); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(); + EditorGUI.DrawRect(EditorGUILayout.GetControlRect(false, 1), Color.gray); + EditorGUILayout.Space(); + + { + options.EnableLogDebouncing = EditorGUILayout.BeginToggleGroup( + new GUIContent("Enable Log Debouncing", "The SDK debounces log messages of the " + + "same type if they are more frequent than once per second."), + options.EnableLogDebouncing); + + EditorGUI.indentLevel++; + + options.DebounceTimeLog = EditorGUILayout.IntField( + new GUIContent("Log Debounce [ms]", "The time that has to pass between events of " + + "LogType.Log before the SDK sends it again."), + options.DebounceTimeLog); + options.DebounceTimeLog = Math.Max(0, options.DebounceTimeLog); + + options.DebounceTimeWarning = EditorGUILayout.IntField( + new GUIContent("Warning Debounce [ms]", "The time that has to pass between events of " + + "LogType.Warning before the SDK sends it again."), + options.DebounceTimeWarning); + options.DebounceTimeWarning = Math.Max(0, options.DebounceTimeWarning); + + options.DebounceTimeError = EditorGUILayout.IntField( + new GUIContent("Error Debounce [ms]", "The time that has to pass between events of " + + "LogType.Assert, LogType.Exception and LogType.Error before " + + "the SDK sends it again."), + options.DebounceTimeError); + options.DebounceTimeError = Math.Max(0, options.DebounceTimeError); + + EditorGUI.indentLevel--; + EditorGUILayout.EndToggleGroup(); } } } diff --git a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs index 52143fe65..7c222aaf9 100644 --- a/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityApplicationLoggingIntegration.cs @@ -1,3 +1,5 @@ +using System; +using Sentry.Extensibility; using Sentry.Integrations; using UnityEngine; @@ -12,12 +14,13 @@ internal class UnityApplicationLoggingIntegration : ISdkIntegration { private readonly IApplication _application; private readonly bool _captureExceptions; - private ErrorTimeDebounce? _errorTimeDebounce; - private LogTimeDebounce? _logTimeDebounce; - private WarningTimeDebounce? _warningTimeDebounce; + + private ErrorTimeDebounce _errorTimeDebounce = null!; // Set in Register + private LogTimeDebounce _logTimeDebounce = null!; // Set in Register + private WarningTimeDebounce _warningTimeDebounce = null!; // Set in Register private IHub? _hub; - private SentryUnityOptions? _options; + private SentryUnityOptions _options = null!; // Set in Register internal UnityApplicationLoggingIntegration(bool captureExceptions = false, IApplication? application = null) { @@ -28,11 +31,8 @@ internal UnityApplicationLoggingIntegration(bool captureExceptions = false, IApp public void Register(IHub hub, SentryOptions sentryOptions) { _hub = hub; - _options = sentryOptions as SentryUnityOptions; - if (_options is null) - { - return; - } + // This should never throw + _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); _logTimeDebounce = new LogTimeDebounce(_options.DebounceTimeLog); _warningTimeDebounce = new WarningTimeDebounce(_options.DebounceTimeWarning); @@ -49,72 +49,100 @@ internal void OnLogMessageReceived(string message, string stacktrace, LogType lo return; } - // We're not capturing or creating breadcrumbs from SDK logs + // We're not capturing the SDK's own logs if (message.StartsWith(UnityLogger.LogTag)) { return; } - // LogType.Exception are getting handled by the UnityLogHandlerIntegration - // Unless we're configured to handle them - i.e. WebGL - if (logType is LogType.Exception && !_captureExceptions) + if (IsGettingDebounced(logType)) { + _options.LogDebug("Log message of type '{0}' is getting debounced.", logType); return; } - if (_options?.EnableLogDebouncing is true) + ProcessException(message, stacktrace, logType); + ProcessError(message, stacktrace, logType); + ProcessBreadcrumbs(message, logType); + } + + private bool IsGettingDebounced(LogType logType) + { + if (_options.EnableLogDebouncing is false) { - var debounced = logType switch - { - LogType.Exception => _errorTimeDebounce?.Debounced(), - LogType.Error or LogType.Assert => _errorTimeDebounce?.Debounced(), - LogType.Log => _logTimeDebounce?.Debounced(), - LogType.Warning => _warningTimeDebounce?.Debounced(), - _ => true - }; - - if (debounced is not true) - { - return; - } + return false; } - if (logType is LogType.Exception) + return logType switch + { + LogType.Exception => !_errorTimeDebounce.Debounced(), + LogType.Error or LogType.Assert => !_errorTimeDebounce.Debounced(), + LogType.Log => !_logTimeDebounce.Debounced(), + LogType.Warning => !_warningTimeDebounce.Debounced(), + _ => true + }; + } + + private void ProcessException(string message, string stacktrace, LogType logType) + { + // LogType.Exception is getting handled by the `UnityLogHandlerIntegration` + // UNLESS we're configured to handle them - i.e. on WebGL + if (logType is LogType.Exception && _captureExceptions) { + _options.LogDebug("Exception capture has been enabled. Capturing exception through '{0}'.", nameof(UnityApplicationLoggingIntegration)); + var ule = new UnityErrorLogException(message, stacktrace, _options); - _hub.CaptureException(ule); + _hub?.CaptureException(ule); + } + } - // We don't capture breadcrumbs for exceptions - the .NET SDK handles this + private void ProcessError(string message, string stacktrace, LogType logType) + { + if (logType is not LogType.Error || !_options.CaptureLogErrorEvents) + { return; } - if (logType is LogType.Error && _options?.CaptureLogErrorEvents is true) + _options.LogDebug("Error capture for 'Debug.LogError' is enabled. Capturing message."); + + if (_options.AttachStacktrace && !string.IsNullOrEmpty(stacktrace)) { - if (_options?.AttachStacktrace is true && !string.IsNullOrEmpty(stacktrace)) - { - var ule = new UnityErrorLogException(message, stacktrace, _options); - var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; - - _hub.CaptureEvent(sentryEvent); - } - else - { - _hub.CaptureMessage(message, level: SentryLevel.Error); - } - } + _options.LogDebug("Attaching stacktrace to event."); - // Capture so the next event includes this error as breadcrumb - if (_options?.AddBreadcrumbsForLogType[logType] is true) + var ule = new UnityErrorLogException(message, stacktrace, _options); + var sentryEvent = new SentryEvent(ule) { Level = SentryLevel.Error }; + + _hub?.CaptureEvent(sentryEvent); + } + else { - _hub.AddBreadcrumb(message: message, category: "unity.logger", level: ToBreadcrumbLevel(logType)); + _hub?.CaptureMessage(message, level: SentryLevel.Error); } } - private void OnQuitting() + private void ProcessBreadcrumbs(string message, LogType logType) { - _application.LogMessageReceived -= OnLogMessageReceived; + if (logType is LogType.Exception) + { + // Capturing of breadcrumbs for exceptions happens inside the .NET SDK + return; + } + + // Breadcrumb collection on top of structure log capture must be opted in + if (_options.Experimental is { EnableLogs: true, AttachBreadcrumbsToEvents: false }) + { + return; + } + + if (_options.AddBreadcrumbsForLogType.TryGetValue(logType, out var value) && value) + { + _options.LogDebug("Adding breadcrumb for log message of type: {0}", logType); + _hub?.AddBreadcrumb(message: message, category: "unity.logger", level: ToBreadcrumbLevel(logType)); + } } + private void OnQuitting() => _application.LogMessageReceived -= OnLogMessageReceived; + private static BreadcrumbLevel ToBreadcrumbLevel(LogType logType) => logType switch { diff --git a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs index 8d7a6daeb..c1dcf6237 100644 --- a/src/Sentry.Unity/Integrations/UnityErrorLogException.cs +++ b/src/Sentry.Unity/Integrations/UnityErrorLogException.cs @@ -24,6 +24,7 @@ internal class UnityErrorLogException : Exception private readonly IDiagnosticLogger? _logger; public UnityErrorLogException(string logString, string logStackTrace, SentryOptions? options) + : base(logString) { _logString = logString; _logStackTrace = logStackTrace; diff --git a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs index b93b15a1a..d869a4ceb 100644 --- a/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs +++ b/src/Sentry.Unity/Integrations/UnityLogHandlerIntegration.cs @@ -13,31 +13,36 @@ namespace Sentry.Unity.Integrations; internal sealed class UnityLogHandlerIntegration : ISdkIntegration, ILogHandler { private readonly IApplication _application; - + private readonly Func? _loggerFactory; private IHub? _hub; - private SentryUnityOptions? _sentryOptions; - + private SentryUnityOptions _options = null!; // Set during register private ILogHandler _unityLogHandler = null!; // Set during register + private SentryStructuredLogger _structuredLogger = null!; // Set during register - public UnityLogHandlerIntegration(SentryUnityOptions options, IApplication? application = null) + public UnityLogHandlerIntegration(IApplication? application = null) { _application = application ?? ApplicationAdapter.Instance; } + // For testing: allows injecting a custom logger factory + internal UnityLogHandlerIntegration(IApplication? application, Func loggerFactory) + : this(application) + { + _loggerFactory = loggerFactory; + } + public void Register(IHub hub, SentryOptions sentryOptions) { _hub = hub; - _sentryOptions = sentryOptions as SentryUnityOptions; - if (_sentryOptions is null) - { - return; - } + // This should never happen, but if it does... + _options = sentryOptions as SentryUnityOptions ?? throw new ArgumentException("Options is not of type 'SentryUnityOptions'."); + _structuredLogger = _loggerFactory?.Invoke() ?? _hub.Logger; // If called twice (i.e. init with the same options object) the integration will reference itself as the // original handler loghandler and endlessly forward to itself if (Debug.unityLogger.logHandler == this) { - _sentryOptions.DiagnosticLogger?.LogWarning("UnityLogHandlerIntegration has already been registered."); + _options.DiagnosticLogger?.LogWarning("UnityLogHandlerIntegration has already been registered."); return; } @@ -51,7 +56,7 @@ public void LogException(Exception exception, UnityEngine.Object context) { try { - CaptureException(exception, context); + ProcessException(exception, context); } finally { @@ -60,7 +65,7 @@ public void LogException(Exception exception, UnityEngine.Object context) } } - internal void CaptureException(Exception exception, UnityEngine.Object? context) + internal void ProcessException(Exception exception, UnityEngine.Object? context) { if (_hub?.IsEnabled is not true) { @@ -75,19 +80,72 @@ internal void CaptureException(Exception exception, UnityEngine.Object? context) exception.Data[Mechanism.HandledKey] = false; exception.Data[Mechanism.MechanismKey] = "Unity.LogException"; _ = _hub.CaptureException(exception); + + if (_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(LogType.Exception, out var captureException) && captureException) + { + _options.LogDebug("Capturing structured log message of type '{0}'.", LogType.Exception); + _structuredLogger.LogError(exception.Message); + } } public void LogFormat(LogType logType, UnityEngine.Object? context, string format, params object[] args) { - // Always pass the log back to Unity - // Capturing of `Debug`, `Warning`, and `Error` happens in the Application Logging Integration. - // The LogHandler does not have access to the stacktrace information required - _unityLogHandler.LogFormat(logType, context, format, args); + try + { + ProcessLog(logType, context, format, args); + } + finally + { + // Always pass the log back to Unity + // Capturing of `Debug`, `Warning`, and `Error` happens in the Application Logging Integration. + // The LogHandler does not have access to the stacktrace information required + _unityLogHandler.LogFormat(logType, context, format, args); + } + } + + private void ProcessLog(LogType logType, UnityEngine.Object? context, string format, params object[] args) + { + if (_hub?.IsEnabled is not true || !_options.Experimental.EnableLogs) + { + return; + } + + // We're not capturing the SDK's own logs. + if (args.Length > 1 && Equals(args[0], UnityLogger.LogTag)) + { + return; + } + + ProcessStructuredLog(logType, format, args); + } + + private void ProcessStructuredLog(LogType logType, string format, params object[] args) + { + if (!_options.Experimental.CaptureStructuredLogsForLogType.TryGetValue(logType, out var captureLog) || !captureLog) + { + return; + } + + _options.LogDebug("Capturing structured log message of type '{0}'.", logType); + + switch (logType) + { + case LogType.Log: + _structuredLogger.LogInfo(format, args); + break; + case LogType.Warning: + _structuredLogger.LogWarning(format, args); + break; + case LogType.Assert: + case LogType.Error: + _structuredLogger.LogError(format, args); + break; + } } private void OnQuitting() { - _sentryOptions?.DiagnosticLogger?.LogInfo("OnQuitting was invoked. Unhooking log callback and pausing session."); + _options.DiagnosticLogger?.LogInfo("OnQuitting was invoked. Unhooking log callback and pausing session."); // 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. @@ -97,10 +155,10 @@ private void OnQuitting() // 'OnQuitting' is invoked even when an uncaught exception happens in the ART. To make sure the .NET // SDK checks with the native layer on restart if the previous run crashed (through the CrashedLastRun callback) // we'll just pause sessions on shutdown. On restart they can be closed with the right timestamp and as 'exited'. - if (_sentryOptions?.AutoSessionTracking is true) + if (_options.AutoSessionTracking) { _hub?.PauseSession(); } - _hub?.FlushAsync(_sentryOptions?.ShutdownTimeout ?? TimeSpan.FromSeconds(1)).GetAwaiter().GetResult(); + _hub?.FlushAsync(_options.ShutdownTimeout).GetAwaiter().GetResult(); } } diff --git a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs index 90e5808fb..7f1a60f26 100644 --- a/src/Sentry.Unity/ScriptableSentryUnityOptions.cs +++ b/src/Sentry.Unity/ScriptableSentryUnityOptions.cs @@ -58,6 +58,15 @@ public static string GetConfigPath(string? notDefaultConfigName = null) [field: SerializeField] public int MaxViewHierarchyObjectChildCount { get; set; } = 20; [field: SerializeField] public int MaxViewHierarchyDepth { get; set; } = 10; + [field: SerializeField] public bool EnableStructuredLogging { get; set; } = false; + [field: SerializeField] public bool StructuredLogOnDebugLog { get; set; } = false; + [field: SerializeField] public bool StructuredLogOnDebugLogWarning { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogAssertion { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogError { get; set; } = true; + [field: SerializeField] public bool StructuredLogOnDebugLogException { get; set; } = true; + + [field: SerializeField] public bool AttachBreadcrumbsToEvents { get; set; } = false; + [field: SerializeField] public bool BreadcrumbsForLogs { get; set; } = true; [field: SerializeField] public bool BreadcrumbsForWarnings { get; set; } = true; [field: SerializeField] public bool BreadcrumbsForAsserts { get; set; } = true; @@ -184,6 +193,19 @@ internal SentryUnityOptions ToSentryUnityOptions( XboxNativeSupportEnabled = XboxNativeSupportEnabled, Il2CppLineNumberSupportEnabled = Il2CppLineNumberSupportEnabled, PerformanceAutoInstrumentationEnabled = AutoAwakeTraces, + Experimental = new SentryUnityExperimentalOptions + { + EnableLogs = EnableStructuredLogging, + CaptureStructuredLogsForLogType = + { + [LogType.Log] = StructuredLogOnDebugLog, + [LogType.Warning] = StructuredLogOnDebugLogWarning, + [LogType.Assert] = StructuredLogOnDebugLogAssertion, + [LogType.Error] = StructuredLogOnDebugLogError, + [LogType.Exception] = StructuredLogOnDebugLogException + }, + AttachBreadcrumbsToEvents = AttachBreadcrumbsToEvents + } }; // By default, the cacheDirectoryPath gets set on known platforms. We're overwriting this behaviour here. diff --git a/src/Sentry.Unity/SentrySdk.Dotnet.cs b/src/Sentry.Unity/SentrySdk.Dotnet.cs index b4ea8fb07..1248f3a11 100644 --- a/src/Sentry.Unity/SentrySdk.Dotnet.cs +++ b/src/Sentry.Unity/SentrySdk.Dotnet.cs @@ -51,6 +51,17 @@ public static partial class SentrySdk /// public static bool IsEnabled { [DebuggerStepThrough] get => Sentry.SentrySdk.IsEnabled; } + /// + /// Gets the structured logger instance for creating and sending logs to Sentry. + /// + /// + /// Use this property to access structured logging functionality. Logs are only sent when + /// 's + /// is set to true. + /// + /// + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => Sentry.SentrySdk.Logger; } + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index e0008f79c..2555296a4 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -305,6 +305,13 @@ internal string? DefaultUserId internal ISentryUnityInfo UnityInfo { get; private set; } internal Action? PlatformConfiguration { get; private set; } + // Delegate to base property to ensure both Unity and base SDK reference the same instance + public new SentryUnityExperimentalOptions Experimental + { + get => (SentryUnityExperimentalOptions)base.Experimental; + set => base.Experimental = value; + } + public SentryUnityOptions() : this(isBuilding: false) { } // For testing @@ -319,6 +326,9 @@ internal SentryUnityOptions(IApplication? application = null, UnityInfo = unityInfo ?? SentryPlatformServices.UnityInfo; PlatformConfiguration = SentryPlatformServices.PlatformConfiguration; + // Initialize base.Experimental with Unity-specific experimental options + base.Experimental = new SentryUnityExperimentalOptions(); + application ??= ApplicationAdapter.Instance; behaviour ??= SentryMonoBehaviour.Instance; @@ -338,7 +348,7 @@ internal SentryUnityOptions(IApplication? application = null, // UnityLogHandlerIntegration is not compatible with WebGL, so it's added conditionally if (application.Platform != RuntimePlatform.WebGLPlayer) { - AddIntegration(new UnityLogHandlerIntegration(this)); + AddIntegration(new UnityLogHandlerIntegration()); AddIntegration(new UnityApplicationLoggingIntegration()); } @@ -490,3 +500,50 @@ public enum NativeInitializationType /// BuildTime, } + +/// +/// Unity-specific experimental options. +/// +/// +/// This extends the base with Unity-specific experimental features. +/// These options are subject to change in future versions. +/// +public sealed class SentryUnityExperimentalOptions : SentryOptions.SentryExperimentalOptions +{ + /// + /// Controls whether structured logs should be captured for each Unity log type. + /// + public Dictionary CaptureStructuredLogsForLogType { get; set; } + + /// + /// When set to true, breadcrumbs will be added on top of structured logging. + /// Defaults to false. + /// + public bool AttachBreadcrumbsToEvents { get; set; } = false; + + /// + /// Sets a callback function to be invoked before sending the log to Sentry. + /// When the delegate throws an during invocation, the log will not be captured. + /// + /// + /// It can be used to modify the log object before being sent to Sentry. + /// To prevent the log from being sent to Sentry, return . + /// + /// + public new void SetBeforeSendLog(Func beforeSendLog) + { + base.SetBeforeSendLog(beforeSendLog); + } + + internal SentryUnityExperimentalOptions() + { + CaptureStructuredLogsForLogType = new Dictionary + { + { LogType.Log, false }, + { LogType.Warning, true }, + { LogType.Assert, true }, + { LogType.Error, true }, + { LogType.Exception, true } + }; + } +} diff --git a/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs b/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs new file mode 100644 index 000000000..52b5c10ba --- /dev/null +++ b/test/Sentry.Unity.Tests/Stubs/TestStructuredLogger.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace Sentry.Unity.Tests.Stubs; + +internal sealed class TestStructuredLogger : SentryStructuredLogger +{ + public List<(string level, string message, object[] args)> LogCalls { get; } = new(); + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + => LogCalls.Add((level.ToString(), template, parameters ?? [])); + + protected internal override void CaptureLog(SentryLog log) + { + // Not needed for our tests + } + + protected internal override void Flush() + { + // Not needed for our tests + } + + public void Clear() => LogCalls.Clear(); +} diff --git a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs index 03f948a24..7654d1644 100644 --- a/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityApplicationLoggingIntegrationTests.cs @@ -175,5 +175,78 @@ public void OnLogMessageReceived_LogTypeException_CaptureExceptionsEnabled_Event Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsEnabledWithAttachBreadcrumbsFalse_BreadcrumbsNotAdded(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.AttachBreadcrumbsToEvents = false; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + Assert.AreEqual(0, _fixture.Hub.ConfigureScopeCalls.Count); + } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsEnabledWithAttachBreadcrumbsTrue_BreadcrumbsAdded(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.AttachBreadcrumbsToEvents = true; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + var scope = new Scope(_fixture.SentryOptions); + _fixture.Hub.ConfigureScopeCalls.Single().Invoke(scope); + var breadcrumb = scope.Breadcrumbs.Single(); + + Assert.AreEqual(message, breadcrumb.Message); + Assert.AreEqual("unity.logger", breadcrumb.Category); + } + + [Test] + [TestCase(LogType.Log)] + [TestCase(LogType.Warning)] + [TestCase(LogType.Error)] + public void OnLogMessageReceived_ExperimentalLogsDisabled_BreadcrumbsAddedAsNormal(LogType unityLogType) + { + _fixture.SentryOptions.Experimental.EnableLogs = false; + _fixture.SentryOptions.AddBreadcrumbsForLogType[unityLogType] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, string.Empty, unityLogType); + + var scope = new Scope(_fixture.SentryOptions); + _fixture.Hub.ConfigureScopeCalls.Single().Invoke(scope); + var breadcrumb = scope.Breadcrumbs.Single(); + + Assert.AreEqual(message, breadcrumb.Message); + Assert.AreEqual("unity.logger", breadcrumb.Category); + } + + [Test] + public void OnLogMessageReceived_ExceptionType_NoBreadcrumbAdded() + { + _fixture.SentryOptions.AddBreadcrumbsForLogType[LogType.Exception] = true; + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.OnLogMessageReceived(message, "stacktrace", LogType.Exception); + + // Exception breadcrumbs are handled by the .NET SDK, not by this integration + Assert.AreEqual(0, _fixture.Hub.ConfigureScopeCalls.Count); + } } } diff --git a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs index 9070daa95..7edff354c 100644 --- a/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs +++ b/test/Sentry.Unity.Tests/UnityLogHandlerIntegrationTests.cs @@ -1,11 +1,13 @@ using System; using System.Linq; using NUnit.Framework; +using Sentry.Internal; using Sentry.Protocol; using Sentry.Unity.Integrations; using Sentry.Unity.Tests.SharedClasses; using Sentry.Unity.Tests.Stubs; using UnityEngine; +using UnityEngine.TestTools; namespace Sentry.Unity.Tests; @@ -15,11 +17,14 @@ private class Fixture { public TestHub Hub { get; set; } = null!; public SentryUnityOptions SentryOptions { get; set; } = null!; + public TestStructuredLogger? StructuredLogger { get; set; } public UnityLogHandlerIntegration GetSut() { var application = new TestApplication(); - var integration = new UnityLogHandlerIntegration(SentryOptions, application); + var integration = StructuredLogger != null + ? new UnityLogHandlerIntegration(application, () => StructuredLogger) + : new UnityLogHandlerIntegration(application, () => DisabledSentryStructuredLogger.Instance); integration.Register(Hub, SentryOptions); return integration; } @@ -41,9 +46,9 @@ public void SetUp() public void CaptureException_ExceptionCapturedAndMechanismSet() { var sut = _fixture.GetSut(); - var message = NUnit.Framework.TestContext.CurrentContext.Test.Name; + var message = "test message" + Guid.NewGuid(); - sut.CaptureException(new Exception(message), null); + sut.ProcessException(new Exception(message), null); Assert.AreEqual(1, _fixture.Hub.CapturedEvents.Count); @@ -75,4 +80,101 @@ public void Register_RegisteredASecondTime_LogsWarningAndReturns() log.logLevel == SentryLevel.Warning && log.message.Contains("UnityLogHandlerIntegration has already been registered."))); } + + [Test] + public void ProcessException_ExperimentalCaptureEnabled_CapturesStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Exception] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.ProcessException(new Exception(message), null); + + Assert.AreEqual(1, _fixture.StructuredLogger.LogCalls.Count); + var logCall = _fixture.StructuredLogger.LogCalls.Single(); + Assert.AreEqual("Error", logCall.level); + Assert.AreEqual(message, logCall.message); + } + + [Test] + public void ProcessException_ExperimentalCaptureDisabled_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Exception] = false; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + sut.ProcessException(new Exception(message), null); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + public void LogFormat_WithSentryLogTag_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Error] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + + const string? format = "{0}: {1}"; + const string? message = "Test message"; + LogAssert.Expect(LogType.Error, string.Format(format, UnityLogger.LogTag, message)); + + sut.LogFormat(LogType.Error, null, format, UnityLogger.LogTag, message); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + public void LogFormat_WithEnableLogsFalse_DoesNotCaptureStructuredLog() + { + _fixture.SentryOptions.Experimental.EnableLogs = false; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[LogType.Error] = true; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + LogAssert.Expect(LogType.Error, message); + + sut.LogFormat(LogType.Error, null, message); + + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + + [Test] + [TestCase(LogType.Log, "Info", true)] + [TestCase(LogType.Log, "Info", false)] + [TestCase(LogType.Warning, "Warning", true)] + [TestCase(LogType.Warning, "Warning", false)] + [TestCase(LogType.Error, "Error", true)] + [TestCase(LogType.Error, "Error", false)] + [TestCase(LogType.Assert, "Error", true)] + [TestCase(LogType.Assert, "Error", false)] + public void LogFormat_WithExperimentalFlag_CapturesStructuredLogWhenEnabled(LogType logType, string expectedLevel, bool captureEnabled) + { + _fixture.SentryOptions.Experimental.EnableLogs = true; + _fixture.SentryOptions.Experimental.CaptureStructuredLogsForLogType[logType] = captureEnabled; + _fixture.StructuredLogger = new TestStructuredLogger(); + var sut = _fixture.GetSut(); + var message = TestContext.CurrentContext.Test.Name; + + LogAssert.Expect(logType, message); + + sut.LogFormat(logType, null, message); + + if (captureEnabled) + { + Assert.AreEqual(1, _fixture.StructuredLogger.LogCalls.Count); + var logCall = _fixture.StructuredLogger.LogCalls.Single(); + Assert.AreEqual(expectedLevel, logCall.level); + Assert.AreEqual(message, logCall.message); + } + else + { + Assert.AreEqual(0, _fixture.StructuredLogger.LogCalls.Count); + } + } }