Readarr/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs
ta264 d5c69d0375
Set sentry environment to be develop/nightly based on config file (#703)
* Set sentry environment to be develop/nightly based on config file

Also add details on sqlite version and database migration.  The
separate ReconfigureSentry class is required because
ReconfigureLogging happens before the database has been resolved, so
you can't access IMainDatabase there

* Set environment to develop/nightly in frontend too
2019-03-28 09:26:39 +00:00

300 lines
12 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Data.SQLite;
using NLog;
using NLog.Common;
using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo;
using Sentry;
using Sentry.Protocol;
namespace NzbDrone.Common.Instrumentation.Sentry
{
[Target("Sentry")]
public class SentryTarget : TargetWithLayout
{
// don't report uninformative SQLite exceptions
// busy/locked are benign https://forums.sonarr.tv/t/owin-sqlite-error-5-database-is-locked/5423/11
// The others will be user configuration problems and silt up Sentry
private static readonly HashSet<SQLiteErrorCode> FilteredSQLiteErrors = new HashSet<SQLiteErrorCode> {
SQLiteErrorCode.Busy,
SQLiteErrorCode.Locked,
SQLiteErrorCode.Perm,
SQLiteErrorCode.ReadOnly,
SQLiteErrorCode.IoErr,
SQLiteErrorCode.Corrupt,
SQLiteErrorCode.Full,
SQLiteErrorCode.CantOpen,
SQLiteErrorCode.Auth
};
// use string and not Type so we don't need a reference to the project
// where these are defined
private static readonly HashSet<string> FilteredExceptionTypeNames = new HashSet<string> {
// UnauthorizedAccessExceptions will just be user configuration issues
"UnauthorizedAccessException",
// Filter out people stuck in boot loops
"CorruptDatabaseException",
// This also filters some people in boot loops
"TinyIoCResolutionException"
};
public static readonly List<string> FilteredExceptionMessages = new List<string> {
// Swallow the many, many exceptions flowing through from Jackett
"Jackett.Common.IndexerException",
// Fix openflixr being stupid with permissions
"openflixr"
};
// exception types in this list will additionally have the exception message added to the
// sentry fingerprint. Make sure that this message doesn't vary by exception
// (e.g. containing a path or a url) so that the sentry grouping is sensible
private static readonly HashSet<string> IncludeExceptionMessageTypes = new HashSet<string> {
"SQLiteException"
};
private static readonly IDictionary<LogLevel, SentryLevel> LoggingLevelMap = new Dictionary<LogLevel, SentryLevel>
{
{LogLevel.Debug, SentryLevel.Debug},
{LogLevel.Error, SentryLevel.Error},
{LogLevel.Fatal, SentryLevel.Fatal},
{LogLevel.Info, SentryLevel.Info},
{LogLevel.Trace, SentryLevel.Debug},
{LogLevel.Warn, SentryLevel.Warning},
};
private static readonly IDictionary<LogLevel, BreadcrumbLevel> BreadcrumbLevelMap = new Dictionary<LogLevel, BreadcrumbLevel>
{
{LogLevel.Debug, BreadcrumbLevel.Debug},
{LogLevel.Error, BreadcrumbLevel.Error},
{LogLevel.Fatal, BreadcrumbLevel.Critical},
{LogLevel.Info, BreadcrumbLevel.Info},
{LogLevel.Trace, BreadcrumbLevel.Debug},
{LogLevel.Warn, BreadcrumbLevel.Warning},
};
private readonly IDisposable _sdk;
private bool _disposed;
private readonly SentryDebounce _debounce;
private bool _unauthorized;
public bool FilterEvents { get; set; }
public string UpdateBranch { get; set; }
public Version DatabaseVersion { get; set; }
public int DatabaseMigration { get; set; }
public SentryTarget(string dsn)
{
_sdk = SentrySdk.Init(o =>
{
o.Dsn = new Dsn(dsn);
o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200;
o.SendDefaultPii = true;
o.Debug = false;
o.DiagnosticsLevel = SentryLevel.Debug;
o.Release = BuildInfo.Release;
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
});
SentrySdk.ConfigureScope(scope =>
{
scope.User = new User {
Username = HashUtil.AnonymousToken()
};
scope.SetTag("osfamily", OsInfo.Os.ToString());
scope.SetTag("runtime", PlatformInfo.PlatformName);
scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name);
scope.SetTag("branch", BuildInfo.Branch);
scope.SetTag("version", BuildInfo.Version.ToString());
scope.SetTag("production", RuntimeInfo.IsProduction.ToString());
});
_debounce = new SentryDebounce();
// initialize to true and reconfigure later
// Otherwise it will default to false and any errors occuring
// before config file gets read will not be filtered
FilterEvents = true;
}
private void OnError(Exception ex)
{
var webException = ex as WebException;
if (webException != null)
{
var response = webException.Response as HttpWebResponse;
var statusCode = response?.StatusCode;
if (statusCode == HttpStatusCode.Unauthorized)
{
_unauthorized = true;
_debounce.Clear();
}
}
InternalLogger.Error(ex, "Unable to send error to Sentry");
}
private static List<string> GetFingerPrint(LogEventInfo logEvent)
{
if (logEvent.Properties.ContainsKey("Sentry"))
{
return ((string[])logEvent.Properties["Sentry"]).ToList();
}
var fingerPrint = new List<string>
{
logEvent.Level.ToString(),
logEvent.LoggerName,
logEvent.Message
};
var ex = logEvent.Exception;
if (ex != null)
{
fingerPrint.Add(ex.GetType().FullName);
fingerPrint.Add(ex.TargetSite.ToString());
if (ex.InnerException != null)
{
fingerPrint.Add(ex.InnerException.GetType().FullName);
}
else if (IncludeExceptionMessageTypes.Contains(ex.GetType().Name))
{
fingerPrint.Add(ex?.Message);
}
}
return fingerPrint;
}
public bool IsSentryMessage(LogEventInfo logEvent)
{
if (logEvent.Properties.ContainsKey("Sentry"))
{
return logEvent.Properties["Sentry"] != null;
}
if (logEvent.Level >= LogLevel.Error && logEvent.Exception != null)
{
if (FilterEvents)
{
var sqlEx = logEvent.Exception as SQLiteException;
if (sqlEx != null && FilteredSQLiteErrors.Contains(sqlEx.ResultCode))
{
return false;
}
if (FilteredExceptionTypeNames.Contains(logEvent.Exception.GetType().Name))
{
return false;
}
if (FilteredExceptionMessages.Any(x => logEvent.Exception.Message.Contains(x)))
{
return false;
}
}
return true;
}
return false;
}
protected override void Write(LogEventInfo logEvent)
{
if (_unauthorized)
{
return;
}
try
{
SentrySdk.AddBreadcrumb(logEvent.FormattedMessage, logEvent.LoggerName, level: BreadcrumbLevelMap[logEvent.Level]);
// don't report non-critical events without exceptions
if (!IsSentryMessage(logEvent))
{
return;
}
var fingerPrint = GetFingerPrint(logEvent);
if (!_debounce.Allowed(fingerPrint))
{
return;
}
var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => (object)x.Value.ToString());
extras.Remove("Sentry");
if (logEvent.Exception != null)
{
foreach (DictionaryEntry data in logEvent.Exception.Data)
{
extras.Add(data.Key.ToString(), data.Value.ToString());
}
}
var sentryEvent = new SentryEvent(logEvent.Exception)
{
Level = LoggingLevelMap[logEvent.Level],
Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage,
Environment = UpdateBranch
};
sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint);
// this can't be in the constructor as at that point OsInfo won't have
// populated these values yet
var osName = Environment.GetEnvironmentVariable("OS_NAME");
var osVersion = Environment.GetEnvironmentVariable("OS_VERSION");
var runTimeVersion = Environment.GetEnvironmentVariable("RUNTIME_VERSION");
sentryEvent.SetTag("os_name", osName);
sentryEvent.SetTag("os_version", $"{osName} {osVersion}");
sentryEvent.SetTag("runtime_version", $"{PlatformInfo.PlatformName} {runTimeVersion}");
sentryEvent.SetTag("sqlite_version", $"{DatabaseVersion}");
sentryEvent.SetTag("database_migration", $"{DatabaseMigration}");
SentrySdk.CaptureEvent(sentryEvent);
}
catch (Exception e)
{
OnError(e);
}
}
// https://stackoverflow.com/questions/2496311/implementing-idisposable-on-a-subclass-when-the-parent-also-implements-idisposab
protected override void Dispose(bool disposing)
{
// Only do something if we're not already disposed
if (_disposed)
{
// If disposing == true, we're being called from a call to base.Dispose(). In this case, we Dispose() our logger
// If we're being called from a finalizer, our logger will get finalized as well, so no need to do anything.
if (disposing)
{
_sdk?.Dispose();
}
// Flag us as disposed. This allows us to handle multiple calls to Dispose() as well as ObjectDisposedException
_disposed = true;
}
// This should always be safe to call multiple times!
// We could include it in the check for disposed above, but I left it out to demonstrate that it's safe
base.Dispose(disposing);
}
}
}