1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-19 05:49:52 +08:00
Files
osu-lazer/osu.Game/Utils/SentryLogger.cs
T
Bartłomiej Dach 8bb885a0dc Filter out more exceptions from being sent to sentry
More or less covers the first page of client sentry issues sorted by
volume, all of which is pretty much useless for anything because it's
client-specific-failure noise.
2025-12-09 09:54:46 +01:00

298 lines
11 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Skinning;
using Sentry;
using Sentry.Protocol;
namespace osu.Game.Utils
{
/// <summary>
/// Report errors to sentry.
/// </summary>
public class SentryLogger : IDisposable
{
private IBindable<APIUser>? localUser;
private readonly IDisposable? sentrySession;
private readonly OsuGame game;
public SentryLogger(OsuGame game, Storage? storage = null)
{
this.game = game;
if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal))
return;
sentrySession = SentrySdk.Init(options =>
{
options.Dsn = "https://ad9f78529cef40ac874afb95a9aca04e@sentry.ppy.sh/2";
options.AutoSessionTracking = true;
options.IsEnvironmentUser = false;
options.IsGlobalModeEnabled = true;
options.CacheDirectoryPath = storage?.GetFullPath(string.Empty);
// The reported release needs to match version as reported to Sentry in .github/workflows/sentry-release.yml
options.Release = $"osu@{game.Version.Split('-').First()}";
});
Logger.NewEntry += processLogEntry;
}
~SentryLogger()
{
Dispose(false);
}
public void AttachUser(IBindable<APIUser> user)
{
if (sentrySession == null)
return;
Debug.Assert(localUser == null);
localUser = user.GetBoundCopy();
localUser.BindValueChanged(u =>
{
SentrySdk.ConfigureScope(scope => scope.User = new SentryUser
{
Username = u.NewValue.Username,
Id = u.NewValue.Id.ToString(),
});
}, true);
}
private void processLogEntry(LogEntry entry)
{
if (entry.Level < LogLevel.Verbose) return;
var exception = entry.Exception;
if (exception != null)
{
if (!shouldSubmitException(exception)) return;
// framework does some weird exception redirection which means sentry does not see unhandled exceptions using its automatic methods.
// but all unhandled exceptions still arrive via this pathway. we just need to mark them as unhandled for tagging purposes.
// easiest solution is to check the message matches what the framework logs this as.
// see https://github.com/ppy/osu-framework/blob/f932f8df053f0011d755c95ad9a2ed61b94d136b/osu.Framework/Platform/GameHost.cs#L336
bool wasUnhandled = entry.Message == @"An unhandled error has occurred.";
bool wasUnobserved = entry.Message == @"An unobserved error has occurred.";
if (wasUnobserved)
{
// see https://github.com/getsentry/sentry-dotnet/blob/c6a660b1affc894441c63df2695a995701671744/src/Sentry/Integrations/TaskUnobservedTaskExceptionIntegration.cs#L39
exception.Data[Mechanism.MechanismKey] = @"UnobservedTaskException";
}
if (wasUnhandled)
{
// see https://github.com/getsentry/sentry-dotnet/blob/main/src/Sentry/Integrations/AppDomainUnhandledExceptionIntegration.cs#L38-L39
exception.Data[Mechanism.MechanismKey] = @"AppDomain.UnhandledException";
}
exception.Data[Mechanism.HandledKey] = !wasUnhandled;
SentrySdk.CaptureEvent(new SentryEvent(exception)
{
Message = entry.Message,
Level = getSentryLevel(entry.Level),
}, scope =>
{
var beatmap = game.Dependencies.Get<IBindable<WorkingBeatmap>>().Value.BeatmapInfo;
var ruleset = game.Dependencies.Get<IBindable<RulesetInfo>>().Value;
scope.Contexts[@"config"] = new
{
Game = game.Dependencies.Get<OsuConfigManager>().GetCurrentConfigurationForLogging(),
Framework = game.Dependencies.Get<FrameworkConfigManager>().GetCurrentConfigurationForLogging(),
};
game.Dependencies.Get<RealmAccess>().Run(realm =>
{
scope.Contexts[@"realm"] = new
{
Counts = new
{
BeatmapSets = realm.All<BeatmapSetInfo>().Count(),
Beatmaps = realm.All<BeatmapInfo>().Count(),
Files = realm.All<RealmFile>().Count(),
Rulesets = realm.All<RulesetInfo>().Count(),
RulesetsAvailable = realm.All<RulesetInfo>().Count(r => r.Available),
Skins = realm.All<SkinInfo>().Count(),
}
};
});
scope.Contexts[@"global statistics"] = GlobalStatistics.GetStatistics()
.GroupBy(s => s.Group)
.ToDictionary(g => g.Key, items => items.ToDictionary(i => i.Name, g => g.DisplayValue));
scope.Contexts[@"beatmap"] = new
{
Name = beatmap.ToString(),
Ruleset = beatmap.Ruleset.InstantiationInfo,
beatmap.OnlineID,
};
scope.Contexts[@"ruleset"] = new
{
ruleset.ShortName,
ruleset.Name,
ruleset.InstantiationInfo,
ruleset.OnlineID
};
scope.Contexts[@"clocks"] = new
{
Audio = game.Dependencies.Get<MusicController>().CurrentTrack.CurrentTime,
Game = game.Clock.CurrentTime,
};
scope.SetTag(@"beatmap", $"{beatmap.OnlineID}");
scope.SetTag(@"ruleset", ruleset.ShortName);
scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})");
scope.SetTag(@"processor count", Environment.ProcessorCount.ToString());
});
}
else
SentrySdk.AddBreadcrumb(entry.Message, entry.Target.ToString(), "navigation", level: getBreadcrumbLevel(entry.Level));
}
private BreadcrumbLevel getBreadcrumbLevel(LogLevel entryLevel)
{
switch (entryLevel)
{
case LogLevel.Debug:
return BreadcrumbLevel.Debug;
case LogLevel.Verbose:
return BreadcrumbLevel.Info;
case LogLevel.Important:
return BreadcrumbLevel.Warning;
case LogLevel.Error:
return BreadcrumbLevel.Error;
default:
throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null);
}
}
private SentryLevel getSentryLevel(LogLevel entryLevel)
{
switch (entryLevel)
{
case LogLevel.Debug:
return SentryLevel.Debug;
case LogLevel.Verbose:
return SentryLevel.Info;
case LogLevel.Important:
return SentryLevel.Warning;
case LogLevel.Error:
return SentryLevel.Error;
default:
throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null);
}
}
private static readonly HashSet<int> ignored_io_exception_hresults =
[
// see https://stackoverflow.com/a/9294382 for how these are synthesised
unchecked((int)0x80070020), // ERROR_SHARING_VIOLATION
unchecked((int)0x80070027), // ERROR_HANDLE_DISK_FULL
unchecked((int)0x80070070), // ERROR_DISK_FULL
];
private bool shouldSubmitException(Exception exception)
{
switch (exception)
{
// disk I/O failures, invalid formats, etc.
case IOException ioe:
if (ignored_io_exception_hresults.Contains(ioe.HResult))
return false;
break;
case UnauthorizedAccessException:
case SharpCompress.Common.InvalidFormatException:
return false;
// connectivity failures
case TimeoutException te:
return !te.Message.Contains(@"elapsed without receiving a message from the server");
case WebException we:
switch (we.Status)
{
// more statuses may need to be blocked as we come across them.
case WebExceptionStatus.Timeout:
return false;
}
break;
case WebSocketException:
case SocketException:
return false;
// stuff that should really never make it to sentry
case APIAccess.WebRequestFlushedException:
case TaskCanceledException:
return false;
}
return true;
}
#region Disposal
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
Logger.NewEntry -= processLogEntry;
sentrySession?.Dispose();
}
#endregion
}
}