|
| 1 | +// Copyright (c) Tomat. Licensed under the GPL v3 License. |
| 2 | +// See the LICENSE-GPL file in the repository root for full license text. |
| 3 | + |
| 4 | +using System; |
| 5 | +using System.IO; |
| 6 | +using HoloCure.Launcher.Base; |
| 7 | +using osu.Framework; |
| 8 | +using osu.Framework.Logging; |
| 9 | +using Sentry; |
| 10 | +using Sentry.Protocol; |
| 11 | + |
| 12 | +namespace HoloCure.Launcher.Desktop.Utils; |
| 13 | + |
| 14 | +public class SentryLogger : IDisposable |
| 15 | +{ |
| 16 | + private LauncherBase game; |
| 17 | + private readonly IDisposable? sentrySession; |
| 18 | + |
| 19 | + public SentryLogger(LauncherBase game) |
| 20 | + { |
| 21 | + this.game = game; |
| 22 | + sentrySession = SentrySdk.Init(options => |
| 23 | + { |
| 24 | + if (game.BuildInfo.IsDeployedBuild) options.Dsn = "https://265de30d478a413db48e350e3d36a515@sentry.tomat.dev/3"; |
| 25 | + options.AutoSessionTracking = true; |
| 26 | + options.IsEnvironmentUser = false; // ensure user isn't tracked; try to scrub away more information if any exists? |
| 27 | + options.Release = $"{LauncherBase.GAME_NAME}@{game.BuildInfo.AssemblyVersion.ToString()}-{game.BuildInfo.ReleaseChannel}"; |
| 28 | + }); |
| 29 | + |
| 30 | + Logger.NewEntry += processLogEntry; |
| 31 | + } |
| 32 | + |
| 33 | + private void processLogEntry(LogEntry entry) |
| 34 | + { |
| 35 | + if (entry.Level < LogLevel.Verbose) return; |
| 36 | + |
| 37 | + if (entry.Exception is { } ex) |
| 38 | + { |
| 39 | + if (!shouldSubmitException(ex)) return; |
| 40 | + |
| 41 | + // framework does some weird exception redirection which means sentry does not see unhandled exceptions using its automatic methods. |
| 42 | + // but all unhandled exceptions still arrive via this pathway. we just need to mark them as unhandled for tagging purposes. |
| 43 | + // easiest solution is to check the message matches what the framework logs this as. |
| 44 | + // see https://github.com/ppy/osu-framework/blob/f932f8df053f0011d755c95ad9a2ed61b94d136b/osu.Framework/Platform/GameHost.cs#L336 |
| 45 | + bool wasUnhandled = entry.Message == @"An unhandled error has occurred."; |
| 46 | + bool wasUnobserved = entry.Message == @"An unobserved error has occurred."; |
| 47 | + |
| 48 | + // see https://github.com/getsentry/sentry-dotnet/blob/c6a660b1affc894441c63df2695a995701671744/src/Sentry/Integrations/TaskUnobservedTaskExceptionIntegration.cs#L39 |
| 49 | + if (wasUnobserved) ex.Data[Mechanism.MechanismKey] = @"UnobservedTaskException"; |
| 50 | + |
| 51 | + // see https://github.com/getsentry/sentry-dotnet/blob/main/src/Sentry/Integrations/AppDomainUnhandledExceptionIntegration.cs#L38-L39 |
| 52 | + if (wasUnhandled) ex.Data[Mechanism.MechanismKey] = @"AppDomain.UnhandledException"; |
| 53 | + |
| 54 | + ex.Data[Mechanism.HandledKey] = !wasUnhandled; |
| 55 | + |
| 56 | + SentrySdk.CaptureEvent( |
| 57 | + new SentryEvent(ex) |
| 58 | + { |
| 59 | + Message = entry.Message, |
| 60 | + Level = getSentryLevel(entry.Level) |
| 61 | + }, |
| 62 | + scope => |
| 63 | + { |
| 64 | + // add scope contexts eventually too (running game (if any), etc.) |
| 65 | + scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})"); |
| 66 | + scope.SetTag(@"processor count", Environment.ProcessorCount.ToString()); |
| 67 | + } |
| 68 | + ); |
| 69 | + } |
| 70 | + else |
| 71 | + SentrySdk.AddBreadcrumb(entry.Message, entry.Target.ToString(), "navigation", level: getBreadcrumbLevel(entry.Level)); |
| 72 | + } |
| 73 | + |
| 74 | + private BreadcrumbLevel getBreadcrumbLevel(LogLevel entryLevel) => |
| 75 | + entryLevel switch |
| 76 | + { |
| 77 | + LogLevel.Debug => BreadcrumbLevel.Debug, |
| 78 | + LogLevel.Verbose => BreadcrumbLevel.Info, |
| 79 | + LogLevel.Important => BreadcrumbLevel.Warning, |
| 80 | + LogLevel.Error => BreadcrumbLevel.Error, |
| 81 | + _ => throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null) |
| 82 | + }; |
| 83 | + |
| 84 | + private SentryLevel getSentryLevel(LogLevel entryLevel) => |
| 85 | + entryLevel switch |
| 86 | + { |
| 87 | + LogLevel.Debug => SentryLevel.Debug, |
| 88 | + LogLevel.Verbose => SentryLevel.Info, |
| 89 | + LogLevel.Important => SentryLevel.Warning, |
| 90 | + LogLevel.Error => SentryLevel.Error, |
| 91 | + _ => throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null) |
| 92 | + }; |
| 93 | + |
| 94 | + private bool shouldSubmitException(Exception exception) |
| 95 | + { |
| 96 | + switch (exception) |
| 97 | + { |
| 98 | + case IOException ioe: |
| 99 | + // disk full exceptions, see https://stackoverflow.com/a/9294382 |
| 100 | + const int hr_error_handle_disk_full = unchecked((int)0x80070027); |
| 101 | + const int hr_error_disk_full = unchecked((int)0x80070070); |
| 102 | + |
| 103 | + if (ioe.HResult == hr_error_handle_disk_full || ioe.HResult == hr_error_disk_full) return false; |
| 104 | + |
| 105 | + break; |
| 106 | + } |
| 107 | + |
| 108 | + return true; |
| 109 | + } |
| 110 | + |
| 111 | + #region IDisposable Impl |
| 112 | + |
| 113 | + ~SentryLogger() => Dispose(false); |
| 114 | + |
| 115 | + public void Dispose() |
| 116 | + { |
| 117 | + Dispose(true); |
| 118 | + GC.SuppressFinalize(this); |
| 119 | + } |
| 120 | + |
| 121 | + protected virtual void Dispose(bool isDisposing) |
| 122 | + { |
| 123 | + Logger.NewEntry -= processLogEntry; |
| 124 | + sentrySession?.Dispose(); |
| 125 | + } |
| 126 | + |
| 127 | + #endregion |
| 128 | +} |
0 commit comments