From 0add668b6821a7c5fdc2116c31ee2f2f2db3cba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:09:47 +0200 Subject: [PATCH 001/101] feat(logs): initial experiment --- .../Sentry.Samples.Console.Basic/Program.cs | 12 +++ .../Experimental/SentryExperimentalSdk.cs | 20 ++++ .../Experimental/SentryHubExtensions.cs | 15 +++ src/Sentry/Experimental/SentryLog.cs | 102 ++++++++++++++++++ src/Sentry/Experimental/SentrySeverity.cs | 34 ++++++ ...tics.CodeAnalysis.ExperimentalAttribute.cs | 29 +++++ src/Sentry/Infrastructure/DiagnosticId.cs | 2 + src/Sentry/Protocol/Envelopes/Envelope.cs | 14 +++ src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 16 +++ 9 files changed, 244 insertions(+) create mode 100644 src/Sentry/Experimental/SentryExperimentalSdk.cs create mode 100644 src/Sentry/Experimental/SentryHubExtensions.cs create mode 100644 src/Sentry/Experimental/SentryLog.cs create mode 100644 src/Sentry/Experimental/SentrySeverity.cs create mode 100644 src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index be18640323..4dd36556a2 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -9,6 +9,7 @@ */ using System.Net.Http; +using Sentry.Experimental; using static System.Console; // Initialize the Sentry SDK. (It is not necessary to dispose it.) @@ -35,6 +36,16 @@ options.TracesSampleRate = 1.0; }); +#pragma warning disable SENTRY0002 +SentryExperimentalSdk.CaptureLog(SentrySeverity.Trace, "Hello, World!"); +SentryExperimentalSdk.CaptureLog(SentrySeverity.Debug, "Hello, .NET!"); +SentryExperimentalSdk.CaptureLog(SentrySeverity.Info, "Information"); +SentryExperimentalSdk.CaptureLog(SentrySeverity.Warn, "Warning with one {0}", "parameter"); +SentryExperimentalSdk.CaptureLog(SentrySeverity.Error, "Error with {0} {1}", 2, "parameters"); +SentryExperimentalSdk.CaptureLog(SentrySeverity.Fatal, "Fatal {0} and {1}", true, false); +#pragma warning restore SENTRY0002 + +/* // This starts a new transaction and attaches it to the scope. var transaction = SentrySdk.StartTransaction("Program Main", "function"); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); @@ -96,3 +107,4 @@ async Task ThirdFunction() span.Finish(); } } +*/ diff --git a/src/Sentry/Experimental/SentryExperimentalSdk.cs b/src/Sentry/Experimental/SentryExperimentalSdk.cs new file mode 100644 index 0000000000..a08bb073d8 --- /dev/null +++ b/src/Sentry/Experimental/SentryExperimentalSdk.cs @@ -0,0 +1,20 @@ +using Sentry.Infrastructure; + +namespace Sentry.Experimental; + +/// +/// Experimental Sentry SDK entrypoint. +/// +public static class SentryExperimentalSdk +{ + /// + /// See: https://github.com/getsentry/sentry-dotnet/issues/4132 + /// + [Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = "https://github.com/getsentry/sentry-dotnet/issues/4132")] + public static void CaptureLog(SentrySeverity level, string template, params object[]? parameters) + { + string message = String.Format(template, parameters ?? []); + SentryLog log = new(level, message); + _ = SentrySdk.CurrentHub.CaptureLog(log); + } +} diff --git a/src/Sentry/Experimental/SentryHubExtensions.cs b/src/Sentry/Experimental/SentryHubExtensions.cs new file mode 100644 index 0000000000..fd89211250 --- /dev/null +++ b/src/Sentry/Experimental/SentryHubExtensions.cs @@ -0,0 +1,15 @@ +using Sentry.Infrastructure; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Experimental; + +internal static class SentryHubExtensions +{ + [Experimental(DiagnosticId.ExperimentalSentryLogs)] + internal static int CaptureLog(this IHub hub, SentryLog log) + { + _ = hub.CaptureEnvelope(Envelope.FromLog(log)); + + return default; + } +} diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs new file mode 100644 index 0000000000..f46dacf397 --- /dev/null +++ b/src/Sentry/Experimental/SentryLog.cs @@ -0,0 +1,102 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Internal.Extensions; + +namespace Sentry.Experimental; + +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +internal sealed class SentryLog : ISentryJsonSerializable +{ + [SetsRequiredMembers] + public SentryLog(SentrySeverity level, string message, object[]? parameters = null) + { + Timestamp = DateTimeOffset.UtcNow; + TraceId = SentryId.Empty; + Level = level; + Message = message; + Parameters = parameters; + } + + public required DateTimeOffset Timestamp { get; init; } + + public required SentryId TraceId { get; init; } + + public required SentrySeverity Level { get; init; } + + public required string Message { get; init; } + + public Dictionary? Attributes { get; private set; } + + public string? Template { get; init; } + + public object[]? Parameters { get; init; } + + public int SeverityNumber { get; init; } = -1; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + Attributes = new Dictionary + { + //{ "sentry.environment", new ValueTypePair("production", "string")}, + //{ "sentry.release", new ValueTypePair("1.0.0", "string")}, + //{ "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")}, + }; + if (Template is not null) + { + Attributes["sentry.message.template"] = new ValueTypePair("User %s has logged in!", "string"); + } + + if (Parameters is not null) + { + for (var index = 0; index < Parameters.Length; index++) + { + Attributes[$"sentry.message.parameters.{index}"] = new ValueTypePair(Parameters[index], "string"); + } + } + + writer.WriteStartObject(); + + writer.WriteStartArray("items"); + + writer.WriteStartObject(); + + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); + writer.WriteString("trace_id", TraceId); + writer.WriteString("level", Level.ToLogString()); + writer.WriteString("body", Message); + writer.WriteDictionaryIfNotEmpty("attributes", Attributes, logger); + + if (SeverityNumber != -1) + { + writer.WriteNumber("severity_number", SeverityNumber); + } + + writer.WriteEndObject(); + + writer.WriteEndArray(); + + writer.WriteEndObject(); + } +} + +internal readonly struct ValueTypePair : ISentryJsonSerializable +{ + public ValueTypePair(object value, string type) + { + Value = value.ToString()!; + Type = type; + } + + public string Value { get; } + public string Type { get; } + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteString("value", Value); + writer.WriteString("type", Type); + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Experimental/SentrySeverity.cs b/src/Sentry/Experimental/SentrySeverity.cs new file mode 100644 index 0000000000..92bcdd5c46 --- /dev/null +++ b/src/Sentry/Experimental/SentrySeverity.cs @@ -0,0 +1,34 @@ +using Sentry.Infrastructure; + +namespace Sentry.Experimental; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +public enum SentrySeverity : short +{ + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + +[Experimental(DiagnosticId.ExperimentalSentryLogs)] +internal static class SentrySeverityExtensions +{ + public static string ToLogString(this SentrySeverity severity) + { + return severity switch + { + SentrySeverity.Trace => "trace", + SentrySeverity.Debug => "debug", + SentrySeverity.Info => "info", + SentrySeverity.Warn => "warn", + SentrySeverity.Error => "error", + SentrySeverity.Fatal => "fatal", + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), + }; + } +} diff --git a/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs b/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs new file mode 100644 index 0000000000..b173d83323 --- /dev/null +++ b/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs @@ -0,0 +1,29 @@ +#if !NET8_0_OR_GREATER +// ReSharper disable CheckNamespace +// ReSharper disable ConvertToPrimaryConstructor +namespace System.Diagnostics.CodeAnalysis; + +[AttributeUsage(AttributeTargets.Assembly | + AttributeTargets.Module | + AttributeTargets.Class | + AttributeTargets.Struct | + AttributeTargets.Enum | + AttributeTargets.Constructor | + AttributeTargets.Method | + AttributeTargets.Property | + AttributeTargets.Field | + AttributeTargets.Event | + AttributeTargets.Interface | + AttributeTargets.Delegate, Inherited = false)] +internal sealed class ExperimentalAttribute : Attribute +{ + public ExperimentalAttribute(string diagnosticId) + { + DiagnosticId = diagnosticId; + } + + public string DiagnosticId { get; } + + public string? UrlFormat { get; set; } +} +#endif diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index 92703ddc87..ebd17b51d2 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -8,4 +8,6 @@ internal static class DiagnosticId /// internal const string ExperimentalFeature = "SENTRY0001"; #endif + + internal const string ExperimentalSentryLogs = "SENTRY0002"; } diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b62dc82c98..0fb07b47d2 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -1,3 +1,4 @@ +using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; @@ -445,6 +446,19 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + [Experimental(DiagnosticId.ExperimentalSentryLogs)] + internal static Envelope FromLog(SentryLog log) + { + var header = DefaultHeader; + + var items = new[] + { + EnvelopeItem.FromLog(log) + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7c721db581..a73f9e4ab8 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -1,4 +1,6 @@ +using Sentry.Experimental; using Sentry.Extensibility; +using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Internal.Extensions; using Sentry.Protocol.Metrics; @@ -24,6 +26,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable internal const string TypeValueProfile = "profile"; internal const string TypeValueMetric = "statsd"; internal const string TypeValueCodeLocations = "metric_meta"; + internal const string TypeValueLog = "log"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -370,6 +373,19 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } + [Experimental(DiagnosticId.ExperimentalSentryLogs)] + internal static EnvelopeItem FromLog(SentryLog log) + { + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueLog, + ["item_count"] = 1, + ["content_type"] = "application/vnd.sentry.items.log+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(log)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) From db7d558081582ebf2802978b60de6eba6be29a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Apr 2025 18:06:38 +0200 Subject: [PATCH 002/101] feat(logs): basic Logger Module API shape --- .../Sentry.Samples.Console.Basic/Program.cs | 14 +-- .../Experimental/SentryExperimentalSdk.cs | 20 ---- .../Experimental/SentryHubExtensions.cs | 15 --- src/Sentry/Experimental/SentryLog.cs | 99 ++++++++++++++----- src/Sentry/Experimental/SentrySeverity.cs | 46 ++++++++- ...System.Diagnostics.UnreachableException.cs | 22 +++++ src/Sentry/Infrastructure/DiagnosticId.cs | 7 ++ .../Internal/Extensions/JsonExtensions.cs | 11 +++ src/Sentry/SentryLogger.cs | 68 +++++++++++++ src/Sentry/SentrySdk.cs | 9 ++ .../SqlListenerTests.verify.cs | 2 +- 11 files changed, 245 insertions(+), 68 deletions(-) delete mode 100644 src/Sentry/Experimental/SentryExperimentalSdk.cs delete mode 100644 src/Sentry/Experimental/SentryHubExtensions.cs create mode 100644 src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs create mode 100644 src/Sentry/SentryLogger.cs diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 4dd36556a2..7fab354823 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -37,14 +37,16 @@ }); #pragma warning disable SENTRY0002 -SentryExperimentalSdk.CaptureLog(SentrySeverity.Trace, "Hello, World!"); -SentryExperimentalSdk.CaptureLog(SentrySeverity.Debug, "Hello, .NET!"); -SentryExperimentalSdk.CaptureLog(SentrySeverity.Info, "Information"); -SentryExperimentalSdk.CaptureLog(SentrySeverity.Warn, "Warning with one {0}", "parameter"); -SentryExperimentalSdk.CaptureLog(SentrySeverity.Error, "Error with {0} {1}", 2, "parameters"); -SentryExperimentalSdk.CaptureLog(SentrySeverity.Fatal, "Fatal {0} and {1}", true, false); +SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("trace", "trace")); #pragma warning restore SENTRY0002 +await Task.Delay(5_000); + /* // This starts a new transaction and attaches it to the scope. var transaction = SentrySdk.StartTransaction("Program Main", "function"); diff --git a/src/Sentry/Experimental/SentryExperimentalSdk.cs b/src/Sentry/Experimental/SentryExperimentalSdk.cs deleted file mode 100644 index a08bb073d8..0000000000 --- a/src/Sentry/Experimental/SentryExperimentalSdk.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Sentry.Infrastructure; - -namespace Sentry.Experimental; - -/// -/// Experimental Sentry SDK entrypoint. -/// -public static class SentryExperimentalSdk -{ - /// - /// See: https://github.com/getsentry/sentry-dotnet/issues/4132 - /// - [Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = "https://github.com/getsentry/sentry-dotnet/issues/4132")] - public static void CaptureLog(SentrySeverity level, string template, params object[]? parameters) - { - string message = String.Format(template, parameters ?? []); - SentryLog log = new(level, message); - _ = SentrySdk.CurrentHub.CaptureLog(log); - } -} diff --git a/src/Sentry/Experimental/SentryHubExtensions.cs b/src/Sentry/Experimental/SentryHubExtensions.cs deleted file mode 100644 index fd89211250..0000000000 --- a/src/Sentry/Experimental/SentryHubExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Sentry.Infrastructure; -using Sentry.Protocol.Envelopes; - -namespace Sentry.Experimental; - -internal static class SentryHubExtensions -{ - [Experimental(DiagnosticId.ExperimentalSentryLogs)] - internal static int CaptureLog(this IHub hub, SentryLog log) - { - _ = hub.CaptureEnvelope(Envelope.FromLog(log)); - - return default; - } -} diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs index f46dacf397..fde5a3106f 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Experimental/SentryLog.cs @@ -2,13 +2,18 @@ using Sentry.Infrastructure; using Sentry.Internal.Extensions; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + namespace Sentry.Experimental; [Experimental(DiagnosticId.ExperimentalSentryLogs)] -internal sealed class SentryLog : ISentryJsonSerializable +public sealed class SentryLog : ISentryJsonSerializable { + private Dictionary? _attributes; + private int _severityNumber = -1; + [SetsRequiredMembers] - public SentryLog(SentrySeverity level, string message, object[]? parameters = null) + internal SentryLog(SentrySeverity level, string message, object[]? parameters = null) { Timestamp = DateTimeOffset.UtcNow; TraceId = SentryId.Empty; @@ -21,50 +26,99 @@ public SentryLog(SentrySeverity level, string message, object[]? parameters = nu public required SentryId TraceId { get; init; } - public required SentrySeverity Level { get; init; } + public SentrySeverity Level + { + get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber); + set => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); + } public required string Message { get; init; } - public Dictionary? Attributes { get; private set; } + //public Dictionary? Attributes { get { return _attributes; } } public string? Template { get; init; } public object[]? Parameters { get; init; } - public int SeverityNumber { get; init; } = -1; + public required int SeverityNumber + { + get => _severityNumber; + set + { + // + SentrySeverityExtensions.ThrowIfOutOfRange(value); + _severityNumber = value; + } + } + + public void SetAttribute(string key, string value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "string"); + } + + public void SetAttribute(string key, bool value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "boolean"); + } + + public void SetAttribute(string key, int value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "integer"); + } + + public void SetAttribute(string key, double value) + { + _attributes ??= new Dictionary(); + _attributes[key] = new ValueTypePair(value, "double"); + } public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { - Attributes = new Dictionary + _attributes = new Dictionary { - //{ "sentry.environment", new ValueTypePair("production", "string")}, - //{ "sentry.release", new ValueTypePair("1.0.0", "string")}, - //{ "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")}, + { "sentry.environment", new ValueTypePair("production", "string")}, + { "sentry.release", new ValueTypePair("1.0.0", "string")}, + { "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")}, }; + + writer.WriteStartObject(); + writer.WriteStartArray("items"); + writer.WriteStartObject(); + + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); + writer.WriteString("trace_id", TraceId); + writer.WriteString("level", Level.ToLogString()); + writer.WriteString("body", Message); + + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + if (Template is not null) { - Attributes["sentry.message.template"] = new ValueTypePair("User %s has logged in!", "string"); + writer.WriteSerializable("sentry.message.template", new ValueTypePair(Template, "string"), null); } if (Parameters is not null) { for (var index = 0; index < Parameters.Length; index++) { - Attributes[$"sentry.message.parameters.{index}"] = new ValueTypePair(Parameters[index], "string"); + var type = "string"; + writer.WriteSerializable($"sentry.message.parameters.{index}", new ValueTypePair(Parameters[index], type), null); } } - writer.WriteStartObject(); - - writer.WriteStartArray("items"); - - writer.WriteStartObject(); + if (_attributes is not null) + { + foreach (var attribute in _attributes) + { + writer.WriteSerializable(attribute.Key, attribute.Value, null); + } + } - writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); - writer.WriteString("trace_id", TraceId); - writer.WriteString("level", Level.ToLogString()); - writer.WriteString("body", Message); - writer.WriteDictionaryIfNotEmpty("attributes", Attributes, logger); + writer.WriteEndObject(); if (SeverityNumber != -1) { @@ -72,13 +126,12 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) } writer.WriteEndObject(); - writer.WriteEndArray(); - writer.WriteEndObject(); } } +//TODO: remove? perhaps a simple System.ValueTuple`2 suffices internal readonly struct ValueTypePair : ISentryJsonSerializable { public ValueTypePair(object value, string type) diff --git a/src/Sentry/Experimental/SentrySeverity.cs b/src/Sentry/Experimental/SentrySeverity.cs index 92bcdd5c46..189d8eac6c 100644 --- a/src/Sentry/Experimental/SentrySeverity.cs +++ b/src/Sentry/Experimental/SentrySeverity.cs @@ -1,8 +1,11 @@ using Sentry.Infrastructure; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + namespace Sentry.Experimental; -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +//TODO: QUESTION: not sure about the name +// this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel [Experimental(DiagnosticId.ExperimentalSentryLogs)] public enum SentrySeverity : short { @@ -13,12 +16,11 @@ public enum SentrySeverity : short Error, Fatal, } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member [Experimental(DiagnosticId.ExperimentalSentryLogs)] internal static class SentrySeverityExtensions { - public static string ToLogString(this SentrySeverity severity) + internal static string ToLogString(this SentrySeverity severity) { return severity switch { @@ -31,4 +33,42 @@ public static string ToLogString(this SentrySeverity severity) _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), }; } + + internal static SentrySeverity FromSeverityNumber(int severityNumber) + { + ThrowIfOutOfRange(severityNumber); + + return severityNumber switch + { + >= 1 and <= 4 => SentrySeverity.Trace, + >= 5 and <= 8 => SentrySeverity.Debug, + >= 9 and <= 12 => SentrySeverity.Info, + >= 13 and <= 16 => SentrySeverity.Warn, + >= 17 and <= 20 => SentrySeverity.Error, + >= 21 and <= 24 => SentrySeverity.Fatal, + _ => throw new UnreachableException(), + }; + } + + internal static int ToSeverityNumber(SentrySeverity severity) + { + return severity switch + { + SentrySeverity.Trace => 1, + SentrySeverity.Debug => 5, + SentrySeverity.Info => 9, + SentrySeverity.Warn => 13, + SentrySeverity.Error => 17, + SentrySeverity.Fatal => 21, + _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) + }; + } + + internal static void ThrowIfOutOfRange(int severityNumber) + { + if (severityNumber is < 1 or > 24) + { + throw new ArgumentOutOfRangeException(nameof(severityNumber), severityNumber, "SeverityNumber must be between 1 (inclusive) and 24 (inclusive)."); + } + } } diff --git a/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs b/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs new file mode 100644 index 0000000000..48b51df92e --- /dev/null +++ b/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs @@ -0,0 +1,22 @@ +#if !NET7_0_OR_GREATER +// ReSharper disable CheckNamespace +namespace System.Diagnostics; + +internal sealed class UnreachableException : Exception +{ + public UnreachableException() + : base("The program executed an instruction that was thought to be unreachable.") + { + } + + public UnreachableException(string? message) + : base(message ?? "The program executed an instruction that was thought to be unreachable.") + { + } + + public UnreachableException(string? message, Exception? innerException) + : base(message ?? "The program executed an instruction that was thought to be unreachable.", innerException) + { + } +} +#endif diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index ebd17b51d2..b85f489414 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -9,5 +9,12 @@ internal static class DiagnosticId internal const string ExperimentalFeature = "SENTRY0001"; #endif + //TODO: QUESTION: Should we re-use the above for all [Experimental] features or have one ID per experimental feature? internal const string ExperimentalSentryLogs = "SENTRY0002"; } + +//TODO: not sure about this type name +internal static class UrlFormats +{ + internal const string ExperimentalSentryLogs = "https://github.com/getsentry/sentry-dotnet/issues/4132"; +} diff --git a/src/Sentry/Internal/Extensions/JsonExtensions.cs b/src/Sentry/Internal/Extensions/JsonExtensions.cs index 96b28bf81b..435e9e441f 100644 --- a/src/Sentry/Internal/Extensions/JsonExtensions.cs +++ b/src/Sentry/Internal/Extensions/JsonExtensions.cs @@ -472,6 +472,17 @@ public static void WriteSerializable( writer.WriteSerializableValue(value, logger); } + public static void WriteSerializable( + this Utf8JsonWriter writer, + string propertyName, + TValue value, + IDiagnosticLogger? logger) + where TValue : struct, ISentryJsonSerializable + { + writer.WritePropertyName(propertyName); + value.WriteTo(writer, logger); + } + public static void WriteDynamicValue( this Utf8JsonWriter writer, object? value, diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs new file mode 100644 index 0000000000..af931f9761 --- /dev/null +++ b/src/Sentry/SentryLogger.cs @@ -0,0 +1,68 @@ +using Sentry.Experimental; +using Sentry.Infrastructure; +using Sentry.Protocol.Envelopes; + +//TODO: add XML docs +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Sentry; + +/// +/// Creates and sends logs to Sentry. +/// +[Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] +public sealed class SentryLogger +{ + //TODO: QUESTION: Trace vs LogTrace + // Trace() is from the Sentry Logs feature specs. LogTrace() would be more .NET idiomatic + public void Trace(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Trace, template, parameters, configureLog); + } + + //TODO: QUESTION: parameter name "template" vs "format" + // "template" from the "sentry.message.template" attributes of the envelope + // "format" as in System.String.Format to be more idiomatic + public void Debug(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Debug, template, parameters, configureLog); + } + + public void Info(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Info, template, parameters, configureLog); + } + + //TODO: QUESTION: Warn vs Warning + // Warn is from the Sentry Logs feature specs. Warning would be more .NET idiomatic + public void Warn(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Warn, template, parameters, configureLog); + } + + public void Error(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Error, template, parameters, configureLog); + } + + public void Fatal(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentrySeverity.Fatal, template, parameters, configureLog); + } + + //TODO: consider ReadOnlySpan for TFMs where Span is available + // or: utilize a custom [InterpolatedStringHandler] for modern TFMs + // with which we may not be able to enforce on compile-time to only support string, boolean, integer, double + // but we could have an Analyzer for that, indicating that Sentry does not support other types if used in the interpolated string + // or: utilize a SourceGen, similar to the Microsoft.Extensions.Logging [LoggerMessage] + // with which we could enforce on compile-time to only support string, boolean, integer, double + private static void CaptureLog(SentrySeverity level, string template, object[]? parameters, Action? configureLog) + { + string message = String.Format(template, parameters ?? []); + SentryLog log = new(level, message); + configureLog?.Invoke(log); + + IHub hub = SentrySdk.CurrentHub; + _ = hub.CaptureEnvelope(Envelope.FromLog(log)); + } +} diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 401a0fa6f0..cabb8334a0 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -279,6 +279,15 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } + /// + /// Creates and sends logs to Sentry. + /// + //TODO: add to IHub or ISentryClient + // adding to interfaces is breaking, perhaps via a DIM but what about netstandard2.0 runtimes + // or are these interfaces intended to be extended as user code is not meant to implement them + [Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] + public static SentryLogger Logger { get; } = new SentryLogger(); + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs index b9f4250999..afc7ed5a5d 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs @@ -155,7 +155,7 @@ public void ShouldIgnoreAllErrorAndExceptionIds() foreach (var field in eventIds) { var eventId = (EventId)field.GetValue(null)!; - var isEfExceptionMessage = SentryLogger.IsEfExceptionMessage(eventId); + var isEfExceptionMessage = Sentry.Extensions.Logging.SentryLogger.IsEfExceptionMessage(eventId); var name = field.Name; if (name.EndsWith("Exception") || name.EndsWith("Error") || From d96b092acede1f00892c6ca4d3e599d4ddcdf384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 5 May 2025 14:08:43 +0200 Subject: [PATCH 003/101] style(logs): consolidate --- src/Sentry/Experimental/SentryLog.cs | 24 ---------------------- src/Sentry/Experimental/ValueTypePair.cs | 26 ++++++++++++++++++++++++ src/Sentry/SentryLogger.cs | 4 ++-- 3 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 src/Sentry/Experimental/ValueTypePair.cs diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs index fde5a3106f..f7bdf687ed 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Experimental/SentryLog.cs @@ -45,7 +45,6 @@ public required int SeverityNumber get => _severityNumber; set { - // SentrySeverityExtensions.ThrowIfOutOfRange(value); _severityNumber = value; } @@ -130,26 +129,3 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndObject(); } } - -//TODO: remove? perhaps a simple System.ValueTuple`2 suffices -internal readonly struct ValueTypePair : ISentryJsonSerializable -{ - public ValueTypePair(object value, string type) - { - Value = value.ToString()!; - Type = type; - } - - public string Value { get; } - public string Type { get; } - - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) - { - writer.WriteStartObject(); - - writer.WriteString("value", Value); - writer.WriteString("type", Type); - - writer.WriteEndObject(); - } -} diff --git a/src/Sentry/Experimental/ValueTypePair.cs b/src/Sentry/Experimental/ValueTypePair.cs new file mode 100644 index 0000000000..f1d41a9a15 --- /dev/null +++ b/src/Sentry/Experimental/ValueTypePair.cs @@ -0,0 +1,26 @@ +using Sentry.Extensibility; + +namespace Sentry.Experimental; + +//TODO: remove? perhaps a simple System.ValueTuple`2 suffices +internal readonly struct ValueTypePair : ISentryJsonSerializable +{ + public ValueTypePair(object value, string type) + { + Value = value.ToString()!; + Type = type; + } + + public string Value { get; } + public string Type { get; } + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteString("value", Value); + writer.WriteString("type", Type); + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index af931f9761..9595f06733 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -58,11 +58,11 @@ public void Fatal(string template, object[]? parameters = null, Action? configureLog) { - string message = String.Format(template, parameters ?? []); + var message = string.Format(template, parameters ?? []); SentryLog log = new(level, message); configureLog?.Invoke(log); - IHub hub = SentrySdk.CurrentHub; + var hub = SentrySdk.CurrentHub; _ = hub.CaptureEnvelope(Envelope.FromLog(log)); } } From 2958a47de2606be8cc11fd8c63e6df559d3ea1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 5 May 2025 15:33:54 +0200 Subject: [PATCH 004/101] ref(logs): remove generic WriteSerializable overload --- src/Sentry/Internal/Extensions/JsonExtensions.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Sentry/Internal/Extensions/JsonExtensions.cs b/src/Sentry/Internal/Extensions/JsonExtensions.cs index 435e9e441f..96b28bf81b 100644 --- a/src/Sentry/Internal/Extensions/JsonExtensions.cs +++ b/src/Sentry/Internal/Extensions/JsonExtensions.cs @@ -472,17 +472,6 @@ public static void WriteSerializable( writer.WriteSerializableValue(value, logger); } - public static void WriteSerializable( - this Utf8JsonWriter writer, - string propertyName, - TValue value, - IDiagnosticLogger? logger) - where TValue : struct, ISentryJsonSerializable - { - writer.WritePropertyName(propertyName); - value.WriteTo(writer, logger); - } - public static void WriteDynamicValue( this Utf8JsonWriter writer, object? value, From a63371bd30115738dbc9a80977f3952b56a170e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 5 May 2025 19:56:28 +0200 Subject: [PATCH 005/101] ref(logs): consolidate experimental Diagnostic-ID --- samples/Sentry.Samples.Console.Basic/Program.cs | 2 -- src/Sentry/Experimental/SentryLog.cs | 2 +- src/Sentry/Experimental/SentrySeverity.cs | 4 ++-- src/Sentry/Infrastructure/DiagnosticId.cs | 11 ----------- src/Sentry/Protocol/Envelopes/Envelope.cs | 2 +- src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 2 +- src/Sentry/SentryLogger.cs | 2 +- src/Sentry/SentrySdk.cs | 2 +- 8 files changed, 7 insertions(+), 20 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 7fab354823..04e5699dc4 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -36,14 +36,12 @@ options.TracesSampleRate = 1.0; }); -#pragma warning disable SENTRY0002 SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace", "trace")); SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("trace", "trace")); SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("trace", "trace")); SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("trace", "trace")); SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("trace", "trace")); SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("trace", "trace")); -#pragma warning restore SENTRY0002 await Task.Delay(5_000); diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs index f7bdf687ed..fab804745d 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Experimental/SentryLog.cs @@ -6,7 +6,7 @@ namespace Sentry.Experimental; -[Experimental(DiagnosticId.ExperimentalSentryLogs)] +[Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLog : ISentryJsonSerializable { private Dictionary? _attributes; diff --git a/src/Sentry/Experimental/SentrySeverity.cs b/src/Sentry/Experimental/SentrySeverity.cs index 189d8eac6c..ad70d97c65 100644 --- a/src/Sentry/Experimental/SentrySeverity.cs +++ b/src/Sentry/Experimental/SentrySeverity.cs @@ -6,7 +6,7 @@ namespace Sentry.Experimental; //TODO: QUESTION: not sure about the name // this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel -[Experimental(DiagnosticId.ExperimentalSentryLogs)] +[Experimental(DiagnosticId.ExperimentalFeature)] public enum SentrySeverity : short { Trace, @@ -17,7 +17,7 @@ public enum SentrySeverity : short Fatal, } -[Experimental(DiagnosticId.ExperimentalSentryLogs)] +[Experimental(DiagnosticId.ExperimentalFeature)] internal static class SentrySeverityExtensions { internal static string ToLogString(this SentrySeverity severity) diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index b85f489414..c5bd026784 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -2,19 +2,8 @@ namespace Sentry.Infrastructure; internal static class DiagnosticId { -#if NET5_0_OR_GREATER /// /// Indicates that the feature is experimental and may be subject to change or removal in future versions. /// internal const string ExperimentalFeature = "SENTRY0001"; -#endif - - //TODO: QUESTION: Should we re-use the above for all [Experimental] features or have one ID per experimental feature? - internal const string ExperimentalSentryLogs = "SENTRY0002"; -} - -//TODO: not sure about this type name -internal static class UrlFormats -{ - internal const string ExperimentalSentryLogs = "https://github.com/getsentry/sentry-dotnet/issues/4132"; } diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 0fb07b47d2..e678aaac80 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -446,7 +446,7 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } - [Experimental(DiagnosticId.ExperimentalSentryLogs)] + [Experimental(DiagnosticId.ExperimentalFeature)] internal static Envelope FromLog(SentryLog log) { var header = DefaultHeader; diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index a73f9e4ab8..0b4c6d6ac4 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -373,7 +373,7 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } - [Experimental(DiagnosticId.ExperimentalSentryLogs)] + [Experimental(DiagnosticId.ExperimentalFeature)] internal static EnvelopeItem FromLog(SentryLog log) { var header = new Dictionary(3, StringComparer.Ordinal) diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index 9595f06733..41b92cf9b0 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -10,7 +10,7 @@ namespace Sentry; /// /// Creates and sends logs to Sentry. /// -[Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] +[Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLogger { //TODO: QUESTION: Trace vs LogTrace diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index cabb8334a0..670d22d052 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -285,7 +285,7 @@ public void Dispose() //TODO: add to IHub or ISentryClient // adding to interfaces is breaking, perhaps via a DIM but what about netstandard2.0 runtimes // or are these interfaces intended to be extended as user code is not meant to implement them - [Experimental(DiagnosticId.ExperimentalSentryLogs, UrlFormat = UrlFormats.ExperimentalSentryLogs)] + [Experimental(DiagnosticId.ExperimentalFeature)] public static SentryLogger Logger { get; } = new SentryLogger(); /// From d8d2567233cc63b793b799b0a3d72fc61b1ea6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 7 May 2025 11:42:30 +0200 Subject: [PATCH 006/101] feat(logs): add experimental options --- .../Sentry.Samples.Console.Basic/Program.cs | 27 ++- src/Sentry/Experimental/SentryLog.cs | 157 ++++++++++++++++-- src/Sentry/Experimental/ValueTypePair.cs | 26 --- src/Sentry/SentryLogger.cs | 114 +++++++++++-- src/Sentry/SentryOptions.cs | 50 ++++++ 5 files changed, 314 insertions(+), 60 deletions(-) delete mode 100644 src/Sentry/Experimental/ValueTypePair.cs diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 04e5699dc4..6c6f89f360 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -34,16 +34,29 @@ // This option tells Sentry to capture 100% of traces. You still need to start transactions and spans. options.TracesSampleRate = 1.0; + + options.EnableLogs = true; + options.SetBeforeSendLog(static (SentryLog log) => + { + //TODO: this feels a bit off ... perhaps a "TryGet{Type}Attribute" method group could help here instead of exposing the boxing object-TValue-based Dictionary`2 + if (log.Attributes.TryGetValue("plan.type", out var attribute) && attribute is "enterprise") + { + return null; + } + + return log.SeverityNumber is >= 17 and <= 20 ? log : null; + }); + options.LogsSampleRate = 1.0f; }); -SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace", "trace")); -SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("trace", "trace")); -SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("trace", "trace")); -SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("trace", "trace")); -SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("trace", "trace")); -SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("trace", "trace")); +SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace-key", "trace-value")); +SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("debug-key", "debug-value")); +SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("info-key", "info-value")); +SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("warn-key", "warn-value")); +SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("error-key", "error-value")); +SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("fatal-key", "fatal-value")); -await Task.Delay(5_000); +await Task.Delay(TimeSpan.FromSeconds(5)); /* // This starts a new transaction and attaches it to the scope. diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs index fab804745d..5c3e0e1785 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Experimental/SentryLog.cs @@ -1,6 +1,6 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Internal.Extensions; +using Sentry.Internal; #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member @@ -13,13 +13,12 @@ public sealed class SentryLog : ISentryJsonSerializable private int _severityNumber = -1; [SetsRequiredMembers] - internal SentryLog(SentrySeverity level, string message, object[]? parameters = null) + internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity level, string message) { - Timestamp = DateTimeOffset.UtcNow; - TraceId = SentryId.Empty; + Timestamp = timestamp; + TraceId = traceId; Level = level; Message = message; - Parameters = parameters; } public required DateTimeOffset Timestamp { get; init; } @@ -34,7 +33,15 @@ public SentrySeverity Level public required string Message { get; init; } - //public Dictionary? Attributes { get { return _attributes; } } + public IReadOnlyDictionary Attributes + { + get + { + return _attributes is null + ? [] + : _attributes.ToDictionary(static item => item.Key, item => item.Value.Value); + } + } public string? Template { get; init; } @@ -74,15 +81,40 @@ public void SetAttribute(string key, double value) _attributes[key] = new ValueTypePair(value, "double"); } - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, SentryOptions options) { - _attributes = new Dictionary + var environment = options.SettingLocator.GetEnvironment(); + SetAttribute("sentry.environment", environment); + + var release = options.SettingLocator.GetRelease(); + if (release is not null) + { + SetAttribute("sentry.release", release); + } + + if (hub.GetSpan() is {} span && span.ParentSpanId.HasValue) { - { "sentry.environment", new ValueTypePair("production", "string")}, - { "sentry.release", new ValueTypePair("1.0.0", "string")}, - { "sentry.trace.parent_span_id", new ValueTypePair("b0e6f15b45c36b12", "string")}, - }; + SetAttribute("sentry.trace.parent_span_id", span.ParentSpanId.Value.ToString()); + } + else if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + var parentSpanId = currentScope.PropagationContext.ParentSpanId; + if (parentSpanId.HasValue) + { + SetAttribute("sentry.trace.parent_span_id", parentSpanId.Value.ToString()); + } + } + + SetAttribute("sentry.sdk.name", Constants.SdkName); + if (SdkVersion.Instance.Version is {} version) + { + SetAttribute("sentry.sdk.version", version); + } + } + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { writer.WriteStartObject(); writer.WriteStartArray("items"); writer.WriteStartObject(); @@ -97,15 +129,14 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (Template is not null) { - writer.WriteSerializable("sentry.message.template", new ValueTypePair(Template, "string"), null); + WriteAttribute(writer, "sentry.message.template", Template, "string"); } if (Parameters is not null) { for (var index = 0; index < Parameters.Length; index++) { - var type = "string"; - writer.WriteSerializable($"sentry.message.parameters.{index}", new ValueTypePair(Parameters[index], type), null); + WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index], null); } } @@ -113,7 +144,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { foreach (var attribute in _attributes) { - writer.WriteSerializable(attribute.Key, attribute.Value, null); + WriteAttribute(writer, attribute.Key, attribute.Value); } } @@ -128,4 +159,98 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndArray(); writer.WriteEndObject(); } + + private static void WriteAttribute(Utf8JsonWriter writer, string propertyName, ValueTypePair attribute) + { + writer.WritePropertyName(propertyName); + if (attribute.Type is not null) + { + WriteAttributeValue(writer, attribute.Value, attribute.Type); + } + else + { + WriteAttributeValue(writer, attribute.Value); + } + } + + private static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string? type) + { + writer.WritePropertyName(propertyName); + if (type is not null) + { + WriteAttributeValue(writer, value, type); + } + else + { + WriteAttributeValue(writer, value); + } + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type) + { + writer.WriteStartObject(); + + if (type == "string") + { + writer.WriteString("value", (string)value); + writer.WriteString("type", type); + } + else if (type == "boolean") + { + writer.WriteBoolean("value", (bool)value); + writer.WriteString("type", type); + } + else if (type == "integer") + { + writer.WriteNumber("value", (int)value); + writer.WriteString("type", type); + } + else if (type == "double") + { + writer.WriteNumber("value", (double)value); + writer.WriteString("type", type); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + } + + writer.WriteEndObject(); + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value) + { + writer.WriteStartObject(); + + if (value is string str) + { + writer.WriteString("value", str); + writer.WriteString("type", "string"); + } + else if (value is bool boolean) + { + writer.WriteBoolean("value", boolean); + writer.WriteString("type", "boolean"); + } + else if (value is int int32) + { + writer.WriteNumber("value", int32); + writer.WriteString("type", "integer"); + } + else if (value is double float64) + { + writer.WriteNumber("value", float64); + writer.WriteString("type", "double"); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + } + + writer.WriteEndObject(); + } + + private record struct ValueTypePair(object Value, string? Type); } diff --git a/src/Sentry/Experimental/ValueTypePair.cs b/src/Sentry/Experimental/ValueTypePair.cs deleted file mode 100644 index f1d41a9a15..0000000000 --- a/src/Sentry/Experimental/ValueTypePair.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Sentry.Extensibility; - -namespace Sentry.Experimental; - -//TODO: remove? perhaps a simple System.ValueTuple`2 suffices -internal readonly struct ValueTypePair : ISentryJsonSerializable -{ - public ValueTypePair(object value, string type) - { - Value = value.ToString()!; - Type = type; - } - - public string Value { get; } - public string Type { get; } - - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) - { - writer.WriteStartObject(); - - writer.WriteString("value", Value); - writer.WriteString("type", Type); - - writer.WriteEndObject(); - } -} diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index 41b92cf9b0..bdc09bd2d3 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -1,5 +1,6 @@ using Sentry.Experimental; using Sentry.Infrastructure; +using Sentry.Internal; using Sentry.Protocol.Envelopes; //TODO: add XML docs @@ -13,11 +14,21 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLogger { + private readonly RandomValuesFactory _randomValuesFactory; + + internal SentryLogger() + { + _randomValuesFactory = new SynchronizedRandomValuesFactory(); + } + //TODO: QUESTION: Trace vs LogTrace // Trace() is from the Sentry Logs feature specs. LogTrace() would be more .NET idiomatic public void Trace(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Trace, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Trace, template, parameters, configureLog); + } } //TODO: QUESTION: parameter name "template" vs "format" @@ -25,29 +36,56 @@ public void Trace(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Debug, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Debug, template, parameters, configureLog); + } } public void Info(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Info, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Info, template, parameters, configureLog); + } } //TODO: QUESTION: Warn vs Warning // Warn is from the Sentry Logs feature specs. Warning would be more .NET idiomatic public void Warn(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Warn, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Warn, template, parameters, configureLog); + } } public void Error(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Error, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Error, template, parameters, configureLog); + } } public void Fatal(string template, object[]? parameters = null, Action? configureLog = null) { - CaptureLog(SentrySeverity.Fatal, template, parameters, configureLog); + if (IsEnabled()) + { + CaptureLog(SentrySeverity.Fatal, template, parameters, configureLog); + } + } + + private bool IsEnabled() + { + var hub = SentrySdk.CurrentHub; + + if (hub.GetSentryOptions() is {} options) + { + return options.EnableLogs; + } + + return false; } //TODO: consider ReadOnlySpan for TFMs where Span is available @@ -56,13 +94,67 @@ public void Fatal(string template, object[]? parameters = null, Action? configureLog) + private void CaptureLog(SentrySeverity level, string template, object[]? parameters, Action? configureLog) { - var message = string.Format(template, parameters ?? []); - SentryLog log = new(level, message); - configureLog?.Invoke(log); + var timestamp = DateTimeOffset.UtcNow; var hub = SentrySdk.CurrentHub; - _ = hub.CaptureEnvelope(Envelope.FromLog(log)); + + if (hub.GetSentryOptions() is not { EnableLogs: true } options) + { + //Logs disabled + return; + } + + if (!_randomValuesFactory.NextBool(options.LogsSampleRate)) + { + //Log sampled + return; + } + + //process log (attach attributes) + + var scopeManager = (hub as Hub)?.ScopeManager; + SentryId traceId; + if (hub.GetSpan() is {} span) + { + traceId = span.TraceId; + } + else if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + traceId = currentScope.PropagationContext.TraceId; + } + else + { + traceId = SentryId.Empty; + } + + var message = string.Format(template, parameters ?? []); + SentryLog log = new(timestamp, traceId, level, message) + { + Template = template, + Parameters = parameters, + }; + log.SetAttributes(hub, scopeManager, options); + + SentryLog? configuredLog; + try + { + configureLog?.Invoke(log); + configuredLog = options.BeforeSendLogInternal?.Invoke(log); + } + catch (Exception e) + { + //TODO: diagnostic log + Console.WriteLine(e); + return; + } + + if (configuredLog is not null) + { + //TODO: enqueue in Batch-Processor / Background-Worker + _ = hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + } } } diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index a3bba2f9c9..cc071ab387 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1,3 +1,4 @@ +using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Http; using Sentry.Infrastructure; @@ -518,6 +519,55 @@ public void SetBeforeBreadcrumb(Func beforeBreadcrumb) _beforeBreadcrumb = (breadcrumb, _) => beforeBreadcrumb(breadcrumb); } + /// + /// When set to , logs are sent to Sentry. + /// Defaults to . + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public bool EnableLogs { get; set; } = false; + + [Experimental(DiagnosticId.ExperimentalFeature)] + private Func? _beforeSendLog; + + [Experimental(DiagnosticId.ExperimentalFeature)] + internal Func? BeforeSendLogInternal => _beforeSendLog; + + /// + /// Sets a callback function to be invoked before sending the log to Sentry. + /// + /// + /// It can be used to modify the log object before being sent to Sentry. + /// To prevent the log from being sent to Sentry, return . + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public void SetBeforeSendLog(Func beforeSendLog) + { + _beforeSendLog = beforeSendLog; + } + + [Experimental(DiagnosticId.ExperimentalFeature)] + private float _logsSampleRate = 1.0f; + + /// + /// A between 0.0f and 1.0f that represents the probability that a log will be sent to Sentry. + /// Defaults to 1.0. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public float LogsSampleRate + { + get => _logsSampleRate; + set + { + if (value is < 0.0f or > 1.0f) + { + throw new ArgumentOutOfRangeException(nameof(value), value, + "The logs sample rate must be between 0.0 and 1.0, inclusive."); + } + + _logsSampleRate = value; + } + } + private int _maxQueueItems = 30; /// From 165996a7a8f654609d064d0b0507279ce91856e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 7 May 2025 12:02:40 +0200 Subject: [PATCH 007/101] ref(logs): remove custom polyfills now provided through Polyfill --- ...tics.CodeAnalysis.ExperimentalAttribute.cs | 29 ------------------- ...System.Diagnostics.UnreachableException.cs | 22 -------------- 2 files changed, 51 deletions(-) delete mode 100644 src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs delete mode 100644 src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs diff --git a/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs b/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs deleted file mode 100644 index b173d83323..0000000000 --- a/src/Sentry/Experimental/System.Diagnostics.CodeAnalysis.ExperimentalAttribute.cs +++ /dev/null @@ -1,29 +0,0 @@ -#if !NET8_0_OR_GREATER -// ReSharper disable CheckNamespace -// ReSharper disable ConvertToPrimaryConstructor -namespace System.Diagnostics.CodeAnalysis; - -[AttributeUsage(AttributeTargets.Assembly | - AttributeTargets.Module | - AttributeTargets.Class | - AttributeTargets.Struct | - AttributeTargets.Enum | - AttributeTargets.Constructor | - AttributeTargets.Method | - AttributeTargets.Property | - AttributeTargets.Field | - AttributeTargets.Event | - AttributeTargets.Interface | - AttributeTargets.Delegate, Inherited = false)] -internal sealed class ExperimentalAttribute : Attribute -{ - public ExperimentalAttribute(string diagnosticId) - { - DiagnosticId = diagnosticId; - } - - public string DiagnosticId { get; } - - public string? UrlFormat { get; set; } -} -#endif diff --git a/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs b/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs deleted file mode 100644 index 48b51df92e..0000000000 --- a/src/Sentry/Experimental/System.Diagnostics.UnreachableException.cs +++ /dev/null @@ -1,22 +0,0 @@ -#if !NET7_0_OR_GREATER -// ReSharper disable CheckNamespace -namespace System.Diagnostics; - -internal sealed class UnreachableException : Exception -{ - public UnreachableException() - : base("The program executed an instruction that was thought to be unreachable.") - { - } - - public UnreachableException(string? message) - : base(message ?? "The program executed an instruction that was thought to be unreachable.") - { - } - - public UnreachableException(string? message, Exception? innerException) - : base(message ?? "The program executed an instruction that was thought to be unreachable.", innerException) - { - } -} -#endif From 32e7e25bb6264364f3e95849ef1c9cce52ef5f53 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 7 May 2025 10:13:12 +0000 Subject: [PATCH 008/101] Format code --- src/Sentry/Experimental/SentryLog.cs | 4 ++-- src/Sentry/SentryLogger.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Experimental/SentryLog.cs index 5c3e0e1785..232756e0ae 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Experimental/SentryLog.cs @@ -92,7 +92,7 @@ internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, Sentr SetAttribute("sentry.release", release); } - if (hub.GetSpan() is {} span && span.ParentSpanId.HasValue) + if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) { SetAttribute("sentry.trace.parent_span_id", span.ParentSpanId.Value.ToString()); } @@ -107,7 +107,7 @@ internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, Sentr } SetAttribute("sentry.sdk.name", Constants.SdkName); - if (SdkVersion.Instance.Version is {} version) + if (SdkVersion.Instance.Version is { } version) { SetAttribute("sentry.sdk.version", version); } diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index bdc09bd2d3..fa4b2b0c0a 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -80,7 +80,7 @@ private bool IsEnabled() { var hub = SentrySdk.CurrentHub; - if (hub.GetSentryOptions() is {} options) + if (hub.GetSentryOptions() is { } options) { return options.EnableLogs; } @@ -116,7 +116,7 @@ private void CaptureLog(SentrySeverity level, string template, object[]? paramet var scopeManager = (hub as Hub)?.ScopeManager; SentryId traceId; - if (hub.GetSpan() is {} span) + if (hub.GetSpan() is { } span) { traceId = span.TraceId; } From 2ba87e4aa85aaca0a56f0a94018a1035d8db7d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 7 May 2025 14:59:51 +0200 Subject: [PATCH 009/101] ref(logs): move types out of Experimental namespace --- samples/Sentry.Samples.Console.Basic/Program.cs | 2 +- src/Sentry/Protocol/Envelopes/Envelope.cs | 1 - src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 1 - src/Sentry/{Experimental => Protocol}/SentryLog.cs | 4 ++-- src/Sentry/{Experimental => Protocol}/SentrySeverity.cs | 4 ++-- src/Sentry/SentryLogger.cs | 2 +- src/Sentry/SentryOptions.cs | 2 +- 7 files changed, 7 insertions(+), 9 deletions(-) rename src/Sentry/{Experimental => Protocol}/SentryLog.cs (99%) rename src/Sentry/{Experimental => Protocol}/SentrySeverity.cs (97%) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 6c6f89f360..dd1972ddeb 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -9,7 +9,7 @@ */ using System.Net.Http; -using Sentry.Experimental; +using Sentry.Protocol; using static System.Console; // Initialize the Sentry SDK. (It is not necessary to dispose it.) diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index e678aaac80..5f30d3539f 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -1,4 +1,3 @@ -using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 0b4c6d6ac4..3e05aa8017 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -1,4 +1,3 @@ -using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; diff --git a/src/Sentry/Experimental/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs similarity index 99% rename from src/Sentry/Experimental/SentryLog.cs rename to src/Sentry/Protocol/SentryLog.cs index 232756e0ae..e6da56e6d6 100644 --- a/src/Sentry/Experimental/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -4,7 +4,7 @@ #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -namespace Sentry.Experimental; +namespace Sentry.Protocol; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLog : ISentryJsonSerializable @@ -25,7 +25,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity le public required SentryId TraceId { get; init; } - public SentrySeverity Level + private SentrySeverity Level { get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber); set => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); diff --git a/src/Sentry/Experimental/SentrySeverity.cs b/src/Sentry/Protocol/SentrySeverity.cs similarity index 97% rename from src/Sentry/Experimental/SentrySeverity.cs rename to src/Sentry/Protocol/SentrySeverity.cs index ad70d97c65..bf3b54daf4 100644 --- a/src/Sentry/Experimental/SentrySeverity.cs +++ b/src/Sentry/Protocol/SentrySeverity.cs @@ -2,12 +2,12 @@ #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -namespace Sentry.Experimental; +namespace Sentry.Protocol; //TODO: QUESTION: not sure about the name // this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel [Experimental(DiagnosticId.ExperimentalFeature)] -public enum SentrySeverity : short +internal enum SentrySeverity : short { Trace, Debug, diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index fa4b2b0c0a..637a886e52 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -1,6 +1,6 @@ -using Sentry.Experimental; using Sentry.Infrastructure; using Sentry.Internal; +using Sentry.Protocol; using Sentry.Protocol.Envelopes; //TODO: add XML docs diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index cc071ab387..73a6d25a6b 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1,4 +1,3 @@ -using Sentry.Experimental; using Sentry.Extensibility; using Sentry.Http; using Sentry.Infrastructure; @@ -8,6 +7,7 @@ using Sentry.Internal.Http; using Sentry.Internal.ScopeStack; using Sentry.PlatformAbstractions; +using Sentry.Protocol; using static Sentry.SentryConstants; #if HAS_DIAGNOSTIC_INTEGRATION From 0f1d4a420319263bd4d45010b3c8a1b88ba6cada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 7 May 2025 18:58:59 +0200 Subject: [PATCH 010/101] feat(logs): change 'integer' from Int32 to Int64 --- .../Sentry.Samples.Console.Basic/Program.cs | 20 +++++++++++++------ src/Sentry/Protocol/SentryLog.cs | 8 ++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index dd1972ddeb..00f695442e 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -49,12 +49,20 @@ options.LogsSampleRate = 1.0f; }); -SentrySdk.Logger.Trace("Hello, World!", null, log => log.SetAttribute("trace-key", "trace-value")); -SentrySdk.Logger.Debug("Hello, .NET!", null, log => log.SetAttribute("debug-key", "debug-value")); -SentrySdk.Logger.Info("Information", null, log => log.SetAttribute("info-key", "info-value")); -SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], log => log.SetAttribute("warn-key", "warn-value")); -SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], log => log.SetAttribute("error-key", "error-value")); -SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], log => log.SetAttribute("fatal-key", "fatal-value")); +var configureLog = static (SentryLog log) => +{ + log.SetAttribute("string-attribute", "value"); + log.SetAttribute("boolean-attribute", true); + log.SetAttribute("integer-attribute", long.MaxValue); + log.SetAttribute("double-attribute", double.MaxValue); +}; + +SentrySdk.Logger.Trace("Hello, World!", null, configureLog); +SentrySdk.Logger.Debug("Hello, .NET!", null, configureLog); +SentrySdk.Logger.Info("Information", null, configureLog); +SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], configureLog); +SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], configureLog); +SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], configureLog); await Task.Delay(TimeSpan.FromSeconds(5)); diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index e6da56e6d6..f1f829b754 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -69,7 +69,7 @@ public void SetAttribute(string key, bool value) _attributes[key] = new ValueTypePair(value, "boolean"); } - public void SetAttribute(string key, int value) + public void SetAttribute(string key, long value) { _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "integer"); @@ -202,7 +202,7 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, str } else if (type == "integer") { - writer.WriteNumber("value", (int)value); + writer.WriteNumber("value", (long)value); writer.WriteString("type", type); } else if (type == "double") @@ -233,9 +233,9 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value) writer.WriteBoolean("value", boolean); writer.WriteString("type", "boolean"); } - else if (value is int int32) + else if (value is long int64) { - writer.WriteNumber("value", int32); + writer.WriteNumber("value", int64); writer.WriteString("type", "integer"); } else if (value is double float64) From 8dec5d5223a3d5846ff21b79e84ef2c046755a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 8 May 2025 12:50:32 +0200 Subject: [PATCH 011/101] ref(logs): refine API surface area --- src/Sentry/Protocol/Envelopes/Envelope.cs | 2 + src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 2 + src/Sentry/Protocol/SentryLog.cs | 104 +++++++++++++++--- src/Sentry/Protocol/SentrySeverity.cs | 29 ++++- src/Sentry/SentryLogger.cs | 54 +++++---- src/Sentry/SentryOptions.cs | 5 +- src/Sentry/SentrySdk.cs | 12 +- 7 files changed, 163 insertions(+), 45 deletions(-) diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 5f30d3539f..67b6daf55f 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -448,6 +448,8 @@ internal static Envelope FromClientReport(ClientReport clientReport) [Experimental(DiagnosticId.ExperimentalFeature)] internal static Envelope FromLog(SentryLog log) { + //TODO: allow batching Sentry logs + //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = DefaultHeader; var items = new[] diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 3e05aa8017..0bf9ff94b9 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -375,6 +375,8 @@ internal static EnvelopeItem FromClientReport(ClientReport report) [Experimental(DiagnosticId.ExperimentalFeature)] internal static EnvelopeItem FromLog(SentryLog log) { + //TODO: allow batching Sentry logs + //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = new Dictionary(3, StringComparer.Ordinal) { [TypeKey] = TypeValueLog, diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index f1f829b754..df69a818f6 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -2,10 +2,12 @@ using Sentry.Infrastructure; using Sentry.Internal; -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - namespace Sentry.Protocol; +/// +/// Represents the Sentry Log protocol. +/// This API is experimental and it may change in the future. +/// [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLog : ISentryJsonSerializable { @@ -21,18 +23,71 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity le Message = message; } + /// + /// The timestamp of the log. + /// This API is experimental and it may change in the future. + /// + /// + /// Sent as seconds since the Unix epoch. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public required DateTimeOffset Timestamp { get; init; } + /// + /// The trace id of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public required SentryId TraceId { get; init; } - private SentrySeverity Level + /// + /// The severity level of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required SentrySeverity Level { get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber); - set => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); + init => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); + } + + /// + /// The severity number of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public int SeverityNumber + { + get => _severityNumber; + set + { + SentrySeverityExtensions.ThrowIfOutOfRange(value); + _severityNumber = value; + } } + /// + /// The formatted log message. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public required string Message { get; init; } + /// + /// A dictionary of key-value pairs of arbitrary data attached to the log. + /// This API is experimental and it may change in the future. + /// + /// + /// Attributes must also declare the type of the value. + /// The following types are supported: + /// + /// + /// + /// + /// + /// + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public IReadOnlyDictionary Attributes { get @@ -43,38 +98,58 @@ public IReadOnlyDictionary Attributes } } + /// + /// The parameterized template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public string? Template { get; init; } + /// + /// The parameters to the template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public object[]? Parameters { get; init; } - public required int SeverityNumber - { - get => _severityNumber; - set - { - SentrySeverityExtensions.ThrowIfOutOfRange(value); - _severityNumber = value; - } - } - + /// + /// Set a key-value pair of arbitrary data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, string value) { _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "string"); } + /// + /// Set a key-value pair of arbitrary data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, bool value) { _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "boolean"); } + /// + /// Set a key-value pair of arbitrary data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, long value) { _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "integer"); } + /// + /// Set a key-value pair of arbitrary data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, double value) { _attributes ??= new Dictionary(); @@ -113,6 +188,7 @@ internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, Sentr } } + /// public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); diff --git a/src/Sentry/Protocol/SentrySeverity.cs b/src/Sentry/Protocol/SentrySeverity.cs index bf3b54daf4..e66b20c034 100644 --- a/src/Sentry/Protocol/SentrySeverity.cs +++ b/src/Sentry/Protocol/SentrySeverity.cs @@ -1,19 +1,38 @@ using Sentry.Infrastructure; -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - namespace Sentry.Protocol; -//TODO: QUESTION: not sure about the name -// this is a bit different to Sentry.SentryLevel and Sentry.BreadcrumbLevel +/// +/// The severity of the structured log. +/// This API is experimental and it may change in the future. +/// +/// [Experimental(DiagnosticId.ExperimentalFeature)] -internal enum SentrySeverity : short +public enum SentrySeverity : short { + /// + /// A fine-grained debugging event. + /// Trace, + /// + /// A debugging event. + /// Debug, + /// + /// An informational event. + /// Info, + /// + /// A warning event. + /// Warn, + /// + /// An error event. + /// Error, + /// + /// A fatal error such as application or system crash. + /// Fatal, } diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index 637a886e52..ac89c06303 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -3,13 +3,11 @@ using Sentry.Protocol; using Sentry.Protocol.Envelopes; -//TODO: add XML docs -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - namespace Sentry; /// /// Creates and sends logs to Sentry. +/// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLogger @@ -21,8 +19,10 @@ internal SentryLogger() _randomValuesFactory = new SynchronizedRandomValuesFactory(); } - //TODO: QUESTION: Trace vs LogTrace - // Trace() is from the Sentry Logs feature specs. LogTrace() would be more .NET idiomatic + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// public void Trace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -31,9 +31,11 @@ public void Trace(string template, object[]? parameters = null, Action + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void Debug(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -42,6 +44,11 @@ public void Debug(string template, object[]? parameters = null, Action + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void Info(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -50,8 +57,11 @@ public void Info(string template, object[]? parameters = null, Action } } - //TODO: QUESTION: Warn vs Warning - // Warn is from the Sentry Logs feature specs. Warning would be more .NET idiomatic + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void Warn(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -60,6 +70,11 @@ public void Warn(string template, object[]? parameters = null, Action } } + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void Error(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -68,6 +83,11 @@ public void Error(string template, object[]? parameters = null, Action + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] public void Fatal(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) @@ -88,12 +108,6 @@ private bool IsEnabled() return false; } - //TODO: consider ReadOnlySpan for TFMs where Span is available - // or: utilize a custom [InterpolatedStringHandler] for modern TFMs - // with which we may not be able to enforce on compile-time to only support string, boolean, integer, double - // but we could have an Analyzer for that, indicating that Sentry does not support other types if used in the interpolated string - // or: utilize a SourceGen, similar to the Microsoft.Extensions.Logging [LoggerMessage] - // with which we could enforce on compile-time to only support string, boolean, integer, double private void CaptureLog(SentrySeverity level, string template, object[]? parameters, Action? configureLog) { var timestamp = DateTimeOffset.UtcNow; @@ -102,18 +116,14 @@ private void CaptureLog(SentrySeverity level, string template, object[]? paramet if (hub.GetSentryOptions() is not { EnableLogs: true } options) { - //Logs disabled return; } if (!_randomValuesFactory.NextBool(options.LogsSampleRate)) { - //Log sampled return; } - //process log (attach attributes) - var scopeManager = (hub as Hub)?.ScopeManager; SentryId traceId; if (hub.GetSpan() is { } span) @@ -146,7 +156,8 @@ private void CaptureLog(SentrySeverity level, string template, object[]? paramet } catch (Exception e) { - //TODO: diagnostic log + //TODO: change to Diagnostic Logger (if enabled) + // see https://github.com/getsentry/sentry-dotnet/issues/4132 Console.WriteLine(e); return; } @@ -154,6 +165,7 @@ private void CaptureLog(SentrySeverity level, string template, object[]? paramet if (configuredLog is not null) { //TODO: enqueue in Batch-Processor / Background-Worker + // see https://github.com/getsentry/sentry-dotnet/issues/4132 _ = hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); } } diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 73a6d25a6b..a86f8250ff 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -522,11 +522,11 @@ public void SetBeforeBreadcrumb(Func beforeBreadcrumb) /// /// When set to , logs are sent to Sentry. /// Defaults to . + /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public bool EnableLogs { get; set; } = false; - [Experimental(DiagnosticId.ExperimentalFeature)] private Func? _beforeSendLog; [Experimental(DiagnosticId.ExperimentalFeature)] @@ -534,6 +534,7 @@ public void SetBeforeBreadcrumb(Func beforeBreadcrumb) /// /// Sets a callback function to be invoked before sending the log to Sentry. + /// This API is experimental and it may change in the future. /// /// /// It can be used to modify the log object before being sent to Sentry. @@ -545,12 +546,12 @@ public void SetBeforeSendLog(Func beforeSendLog) _beforeSendLog = beforeSendLog; } - [Experimental(DiagnosticId.ExperimentalFeature)] private float _logsSampleRate = 1.0f; /// /// A between 0.0f and 1.0f that represents the probability that a log will be sent to Sentry. /// Defaults to 1.0. + /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public float LogsSampleRate diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 22f8d8b943..952e4d6e68 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -282,10 +282,16 @@ public void Dispose() /// /// Creates and sends logs to Sentry. + /// This API is experimental and it may change in the future. /// - //TODO: add to IHub or ISentryClient - // adding to interfaces is breaking, perhaps via a DIM but what about netstandard2.0 runtimes - // or are these interfaces intended to be extended as user code is not meant to implement them + /// + /// Available options: + /// + /// + /// + /// + /// + /// [Experimental(DiagnosticId.ExperimentalFeature)] public static SentryLogger Logger { get; } = new SentryLogger(); From a664f7efd5a433456ad68ab8ff1f5912559a9ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 8 May 2025 17:55:34 +0200 Subject: [PATCH 012/101] ref(logs): match SeverityLevel to OTel spec --- .../Sentry.Samples.Console.Basic/Program.cs | 2 +- src/Sentry/Protocol/SentryLog.cs | 33 +++----- src/Sentry/Protocol/SentrySeverity.cs | 76 ++++++++----------- 3 files changed, 45 insertions(+), 66 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 00f695442e..74182feb65 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -44,7 +44,7 @@ return null; } - return log.SeverityNumber is >= 17 and <= 20 ? log : null; + return log.Level is SentrySeverity.Error ? log : null; }); options.LogsSampleRate = 1.0f; }); diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index df69a818f6..f86deec36d 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -12,7 +12,7 @@ namespace Sentry.Protocol; public sealed class SentryLog : ISentryJsonSerializable { private Dictionary? _attributes; - private int _severityNumber = -1; + private readonly SentrySeverity _severity; [SetsRequiredMembers] internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity level, string message) @@ -47,22 +47,11 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity le [Experimental(DiagnosticId.ExperimentalFeature)] public required SentrySeverity Level { - get => SentrySeverityExtensions.FromSeverityNumber(_severityNumber); - init => _severityNumber = SentrySeverityExtensions.ToSeverityNumber(value); - } - - /// - /// The severity number of the log. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public int SeverityNumber - { - get => _severityNumber; - set + get => _severity; + init { SentrySeverityExtensions.ThrowIfOutOfRange(value); - _severityNumber = value; + _severity = value; } } @@ -197,7 +186,14 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); writer.WriteString("trace_id", TraceId); - writer.WriteString("level", Level.ToLogString()); + + var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalNumber(); + writer.WriteString("level", severityText); + if (severityNumber.HasValue) + { + writer.WriteNumber("severity_number", severityNumber.Value); + } + writer.WriteString("body", Message); writer.WritePropertyName("attributes"); @@ -226,11 +222,6 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndObject(); - if (SeverityNumber != -1) - { - writer.WriteNumber("severity_number", SeverityNumber); - } - writer.WriteEndObject(); writer.WriteEndArray(); writer.WriteEndObject(); diff --git a/src/Sentry/Protocol/SentrySeverity.cs b/src/Sentry/Protocol/SentrySeverity.cs index e66b20c034..56727b8ee1 100644 --- a/src/Sentry/Protocol/SentrySeverity.cs +++ b/src/Sentry/Protocol/SentrySeverity.cs @@ -8,86 +8,74 @@ namespace Sentry.Protocol; /// /// [Experimental(DiagnosticId.ExperimentalFeature)] -public enum SentrySeverity : short +public enum SentrySeverity { /// /// A fine-grained debugging event. /// - Trace, + Trace = 1, /// /// A debugging event. /// - Debug, + Debug = 5, /// /// An informational event. /// - Info, + Info = 9, /// /// A warning event. /// - Warn, + Warn = 13, /// /// An error event. /// - Error, + Error = 17, /// /// A fatal error such as application or system crash. /// - Fatal, + Fatal = 21, } [Experimental(DiagnosticId.ExperimentalFeature)] internal static class SentrySeverityExtensions { - internal static string ToLogString(this SentrySeverity severity) + internal static (string, int?) ToSeverityTextAndOptionalNumber(this SentrySeverity severity) { - return severity switch + return (int)severity switch { - SentrySeverity.Trace => "trace", - SentrySeverity.Debug => "debug", - SentrySeverity.Info => "info", - SentrySeverity.Warn => "warn", - SentrySeverity.Error => "error", - SentrySeverity.Fatal => "fatal", - _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null), + 1 => ("trace", null), + >= 2 and <= 4 => ("trace", (int)severity), + 5 => ("debug", null), + >= 6 and <= 8 => ("debug", (int)severity), + 9 => ("info", null), + >= 10 and <= 12 => ("info", (int)severity), + 13 => ("warn", null), + >= 14 and <= 16 => ("warn", (int)severity), + 17 => ("error", null), + >= 18 and <= 20 => ("error", (int)severity), + 21 => ("fatal", null), + >= 22 and <= 24 => ("fatal", (int)severity), + _ => ThrowOutOfRange<(string, int?)>(severity, nameof(severity)), }; } - internal static SentrySeverity FromSeverityNumber(int severityNumber) + internal static void ThrowIfOutOfRange(SentrySeverity severity, [CallerArgumentExpression(nameof(severity))] string? paramName = null) { - ThrowIfOutOfRange(severityNumber); - - return severityNumber switch + if ((int)severity is < 1 or > 24) { - >= 1 and <= 4 => SentrySeverity.Trace, - >= 5 and <= 8 => SentrySeverity.Debug, - >= 9 and <= 12 => SentrySeverity.Info, - >= 13 and <= 16 => SentrySeverity.Warn, - >= 17 and <= 20 => SentrySeverity.Error, - >= 21 and <= 24 => SentrySeverity.Fatal, - _ => throw new UnreachableException(), - }; + ThrowOutOfRange(severity, paramName); + } } - internal static int ToSeverityNumber(SentrySeverity severity) + [DoesNotReturn] + private static void ThrowOutOfRange(SentrySeverity severity, string? paramName) { - return severity switch - { - SentrySeverity.Trace => 1, - SentrySeverity.Debug => 5, - SentrySeverity.Info => 9, - SentrySeverity.Warn => 13, - SentrySeverity.Error => 17, - SentrySeverity.Fatal => 21, - _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) - }; + throw new ArgumentOutOfRangeException(paramName, severity, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } - internal static void ThrowIfOutOfRange(int severityNumber) + [DoesNotReturn] + private static T ThrowOutOfRange(SentrySeverity severity, string? paramName) { - if (severityNumber is < 1 or > 24) - { - throw new ArgumentOutOfRangeException(nameof(severityNumber), severityNumber, "SeverityNumber must be between 1 (inclusive) and 24 (inclusive)."); - } + throw new ArgumentOutOfRangeException(paramName, severity, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } } From 96693d09ee5def433fe77eba171dd412176ae4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 8 May 2025 18:26:31 +0200 Subject: [PATCH 013/101] ref(logs): rename SentrySeverity to LogSeverityLevel --- .../Sentry.Samples.Console.Basic/Program.cs | 2 +- ...{SentrySeverity.cs => LogSeverityLevel.cs} | 36 +++++++++---------- src/Sentry/Protocol/SentryLog.cs | 12 +++---- src/Sentry/SentryLogger.cs | 26 +++++++------- 4 files changed, 38 insertions(+), 38 deletions(-) rename src/Sentry/Protocol/{SentrySeverity.cs => LogSeverityLevel.cs} (51%) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 74182feb65..4f99dab93c 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -44,7 +44,7 @@ return null; } - return log.Level is SentrySeverity.Error ? log : null; + return log.Level is LogSeverityLevel.Error ? log : null; }); options.LogsSampleRate = 1.0f; }); diff --git a/src/Sentry/Protocol/SentrySeverity.cs b/src/Sentry/Protocol/LogSeverityLevel.cs similarity index 51% rename from src/Sentry/Protocol/SentrySeverity.cs rename to src/Sentry/Protocol/LogSeverityLevel.cs index 56727b8ee1..2642d7e850 100644 --- a/src/Sentry/Protocol/SentrySeverity.cs +++ b/src/Sentry/Protocol/LogSeverityLevel.cs @@ -8,7 +8,7 @@ namespace Sentry.Protocol; /// /// [Experimental(DiagnosticId.ExperimentalFeature)] -public enum SentrySeverity +public enum LogSeverityLevel { /// /// A fine-grained debugging event. @@ -37,45 +37,45 @@ public enum SentrySeverity } [Experimental(DiagnosticId.ExperimentalFeature)] -internal static class SentrySeverityExtensions +internal static class LogSeverityLevelExtensions { - internal static (string, int?) ToSeverityTextAndOptionalNumber(this SentrySeverity severity) + internal static (string, int?) ToSeverityTextAndOptionalNumber(this LogSeverityLevel level) { - return (int)severity switch + return (int)level switch { 1 => ("trace", null), - >= 2 and <= 4 => ("trace", (int)severity), + >= 2 and <= 4 => ("trace", (int)level), 5 => ("debug", null), - >= 6 and <= 8 => ("debug", (int)severity), + >= 6 and <= 8 => ("debug", (int)level), 9 => ("info", null), - >= 10 and <= 12 => ("info", (int)severity), + >= 10 and <= 12 => ("info", (int)level), 13 => ("warn", null), - >= 14 and <= 16 => ("warn", (int)severity), + >= 14 and <= 16 => ("warn", (int)level), 17 => ("error", null), - >= 18 and <= 20 => ("error", (int)severity), + >= 18 and <= 20 => ("error", (int)level), 21 => ("fatal", null), - >= 22 and <= 24 => ("fatal", (int)severity), - _ => ThrowOutOfRange<(string, int?)>(severity, nameof(severity)), + >= 22 and <= 24 => ("fatal", (int)level), + _ => ThrowOutOfRange<(string, int?)>(level, nameof(level)), }; } - internal static void ThrowIfOutOfRange(SentrySeverity severity, [CallerArgumentExpression(nameof(severity))] string? paramName = null) + internal static void ThrowIfOutOfRange(LogSeverityLevel level, [CallerArgumentExpression(nameof(level))] string? paramName = null) { - if ((int)severity is < 1 or > 24) + if ((int)level is < 1 or > 24) { - ThrowOutOfRange(severity, paramName); + ThrowOutOfRange(level, paramName); } } [DoesNotReturn] - private static void ThrowOutOfRange(SentrySeverity severity, string? paramName) + private static void ThrowOutOfRange(LogSeverityLevel level, string? paramName) { - throw new ArgumentOutOfRangeException(paramName, severity, "Severity must be between 1 (inclusive) and 24 (inclusive)."); + throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } [DoesNotReturn] - private static T ThrowOutOfRange(SentrySeverity severity, string? paramName) + private static T ThrowOutOfRange(LogSeverityLevel level, string? paramName) { - throw new ArgumentOutOfRangeException(paramName, severity, "Severity must be between 1 (inclusive) and 24 (inclusive)."); + throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } } diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index f86deec36d..66e1a63cb5 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -12,10 +12,10 @@ namespace Sentry.Protocol; public sealed class SentryLog : ISentryJsonSerializable { private Dictionary? _attributes; - private readonly SentrySeverity _severity; + private readonly LogSeverityLevel _level; [SetsRequiredMembers] - internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity level, string message) + internal SentryLog(DateTimeOffset timestamp, SentryId traceId, LogSeverityLevel level, string message) { Timestamp = timestamp; TraceId = traceId; @@ -45,13 +45,13 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentrySeverity le /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public required SentrySeverity Level + public required LogSeverityLevel Level { - get => _severity; + get => _level; init { - SentrySeverityExtensions.ThrowIfOutOfRange(value); - _severity = value; + LogSeverityLevelExtensions.ThrowIfOutOfRange(value); + _level = value; } } diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index ac89c06303..a1514927ca 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -20,19 +20,19 @@ internal SentryLogger() } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// public void Trace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { - CaptureLog(SentrySeverity.Trace, template, parameters, configureLog); + CaptureLog(LogSeverityLevel.Trace, template, parameters, configureLog); } } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -40,12 +40,12 @@ public void Debug(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -53,12 +53,12 @@ public void Info(string template, object[]? parameters = null, Action { if (IsEnabled()) { - CaptureLog(SentrySeverity.Info, template, parameters, configureLog); + CaptureLog(LogSeverityLevel.Info, template, parameters, configureLog); } } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -66,12 +66,12 @@ public void Warn(string template, object[]? parameters = null, Action { if (IsEnabled()) { - CaptureLog(SentrySeverity.Warn, template, parameters, configureLog); + CaptureLog(LogSeverityLevel.Warn, template, parameters, configureLog); } } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -79,12 +79,12 @@ public void Error(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -92,7 +92,7 @@ public void Fatal(string template, object[]? parameters = null, Action? configureLog) + private void CaptureLog(LogSeverityLevel level, string template, object[]? parameters, Action? configureLog) { var timestamp = DateTimeOffset.UtcNow; From 83964cfc78cfa73d026bebc99aa31620beb1ff2c Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Thu, 8 May 2025 16:39:46 +0000 Subject: [PATCH 014/101] Format code --- src/Sentry/Protocol/SentryLog.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index 66e1a63cb5..a6bb4aedf6 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -70,10 +70,10 @@ public required LogSeverityLevel Level /// Attributes must also declare the type of the value. /// The following types are supported: /// - /// - /// - /// - /// + /// + /// + /// + /// /// /// [Experimental(DiagnosticId.ExperimentalFeature)] From dadc69b3aecfb8f556e8e49d71461d7490e52421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 9 May 2025 12:11:58 +0200 Subject: [PATCH 015/101] ref(logs): hide underlying Dictionary`2 for Attributes --- .../Sentry.Samples.Console.Basic/Program.cs | 3 +- src/Sentry/Protocol/SentryLog.cs | 117 +++++++++++++----- 2 files changed, 86 insertions(+), 34 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 4f99dab93c..17cf00ab21 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -38,8 +38,7 @@ options.EnableLogs = true; options.SetBeforeSendLog(static (SentryLog log) => { - //TODO: this feels a bit off ... perhaps a "TryGet{Type}Attribute" method group could help here instead of exposing the boxing object-TValue-based Dictionary`2 - if (log.Attributes.TryGetValue("plan.type", out var attribute) && attribute is "enterprise") + if (log.TryGetAttribute("plan.type", out string? attribute) && attribute == "enterprise") { return null; } diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index a6bb4aedf6..e3563ddf64 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -11,7 +11,7 @@ namespace Sentry.Protocol; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLog : ISentryJsonSerializable { - private Dictionary? _attributes; + private readonly Dictionary _attributes; private readonly LogSeverityLevel _level; [SetsRequiredMembers] @@ -21,6 +21,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, LogSeverityLevel TraceId = traceId; Level = level; Message = message; + _attributes = new Dictionary(7); } /// @@ -63,85 +64,140 @@ public required LogSeverityLevel Level public required string Message { get; init; } /// - /// A dictionary of key-value pairs of arbitrary data attached to the log. + /// The parameterized template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public string? Template { get; init; } + + /// + /// The parameters to the template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public object[]? Parameters { get; init; } + + /// + /// Gets the attribute value associated with the specified key when of type . /// This API is experimental and it may change in the future. /// /// - /// Attributes must also declare the type of the value. - /// The following types are supported: - /// - /// - /// - /// - /// - /// + /// Returns if the contains an attribute with the specified key of type . + /// Otherwise . /// [Experimental(DiagnosticId.ExperimentalFeature)] - public IReadOnlyDictionary Attributes + public bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) { - get + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "string") { - return _attributes is null - ? [] - : _attributes.ToDictionary(static item => item.Key, item => item.Value.Value); + value = (string)attribute.Value; + return true; } + + value = null; + return false; } /// - /// The parameterized template string. + /// Gets the attribute value associated with the specified key when of type . /// This API is experimental and it may change in the future. /// + /// + /// Returns if the contains an attribute with the specified key of type . + /// Otherwise . + /// [Experimental(DiagnosticId.ExperimentalFeature)] - public string? Template { get; init; } + public bool TryGetAttribute(string key, out bool value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "boolean") + { + value = (bool)attribute.Value; + return true; + } + + value = false; + return false; + } /// - /// The parameters to the template string. + /// Gets the attribute value associated with the specified key when of type . /// This API is experimental and it may change in the future. /// + /// + /// Returns if the contains an attribute with the specified key of type . + /// Otherwise . + /// [Experimental(DiagnosticId.ExperimentalFeature)] - public object[]? Parameters { get; init; } + public bool TryGetAttribute(string key, out long value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "integer") + { + value = (long)attribute.Value; + return true; + } + + value = 0L; + return false; + } /// - /// Set a key-value pair of arbitrary data attached to the log. + /// Gets the attribute value associated with the specified key when of type . + /// This API is experimental and it may change in the future. + /// + /// + /// Returns if the contains an attribute with the specified key of type . + /// Otherwise . + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public bool TryGetAttribute(string key, out double value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "double") + { + value = (double)attribute.Value; + return true; + } + + value = 0.0; + return false; + } + + /// + /// Set a key-value pair of data attached to the log. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, string value) { - _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "string"); } /// - /// Set a key-value pair of arbitrary data attached to the log. + /// Set a key-value pair of data attached to the log. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, bool value) { - _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "boolean"); } /// - /// Set a key-value pair of arbitrary data attached to the log. + /// Set a key-value pair of data attached to the log. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, long value) { - _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "integer"); } /// - /// Set a key-value pair of arbitrary data attached to the log. + /// Set a key-value pair of data attached to the log. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, double value) { - _attributes ??= new Dictionary(); _attributes[key] = new ValueTypePair(value, "double"); } @@ -212,12 +268,9 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) } } - if (_attributes is not null) + foreach (var attribute in _attributes) { - foreach (var attribute in _attributes) - { - WriteAttribute(writer, attribute.Key, attribute.Value); - } + WriteAttribute(writer, attribute.Key, attribute.Value); } writer.WriteEndObject(); From c91cdde22d3ebb671f6a6dd87b21f84aac126995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 9 May 2025 13:28:53 +0200 Subject: [PATCH 016/101] ref(logs): restructure attributes --- src/Sentry/Protocol/SentryAttribute.cs | 101 +++++++++++++++++++++ src/Sentry/Protocol/SentryLog.cs | 119 +++---------------------- src/Sentry/SentryLogger.cs | 2 +- 3 files changed, 113 insertions(+), 109 deletions(-) create mode 100644 src/Sentry/Protocol/SentryAttribute.cs diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs new file mode 100644 index 0000000000..6e5845e6dc --- /dev/null +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -0,0 +1,101 @@ +namespace Sentry.Protocol; + +internal readonly struct SentryAttribute +{ + public SentryAttribute(object value, string type) + { + Value = value; + Type = type; + } + + public object Value { get; } + public string Type { get; } +} + +internal static class SentryAttributeSerializer +{ + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute) + { + Debug.Assert(attribute.Type is not null); + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, attribute.Value, attribute.Type); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string type) + { + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, value, type); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value) + { + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, value); + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type) + { + writer.WriteStartObject(); + + if (type == "string") + { + writer.WriteString("value", (string)value); + writer.WriteString("type", type); + } + else if (type == "boolean") + { + writer.WriteBoolean("value", (bool)value); + writer.WriteString("type", type); + } + else if (type == "integer") + { + writer.WriteNumber("value", (long)value); + writer.WriteString("type", type); + } + else if (type == "double") + { + writer.WriteNumber("value", (double)value); + writer.WriteString("type", type); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + } + + writer.WriteEndObject(); + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value) + { + writer.WriteStartObject(); + + if (value is string str) + { + writer.WriteString("value", str); + writer.WriteString("type", "string"); + } + else if (value is bool boolean) + { + writer.WriteBoolean("value", boolean); + writer.WriteString("type", "boolean"); + } + else if (value is long int64) + { + writer.WriteNumber("value", int64); + writer.WriteString("type", "integer"); + } + else if (value is double float64) + { + writer.WriteNumber("value", float64); + writer.WriteString("type", "double"); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index e3563ddf64..a01d465e47 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -11,7 +11,7 @@ namespace Sentry.Protocol; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLog : ISentryJsonSerializable { - private readonly Dictionary _attributes; + private readonly Dictionary _attributes; private readonly LogSeverityLevel _level; [SetsRequiredMembers] @@ -21,7 +21,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, LogSeverityLevel TraceId = traceId; Level = level; Message = message; - _attributes = new Dictionary(7); + _attributes = new Dictionary(7); } /// @@ -75,7 +75,7 @@ public required LogSeverityLevel Level /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public object[]? Parameters { get; init; } + public ImmutableArray Parameters { get; init; } /// /// Gets the attribute value associated with the specified key when of type . @@ -168,7 +168,7 @@ public bool TryGetAttribute(string key, out double value) [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, string value) { - _attributes[key] = new ValueTypePair(value, "string"); + _attributes[key] = new SentryAttribute(value, "string"); } /// @@ -178,7 +178,7 @@ public void SetAttribute(string key, string value) [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, bool value) { - _attributes[key] = new ValueTypePair(value, "boolean"); + _attributes[key] = new SentryAttribute(value, "boolean"); } /// @@ -188,7 +188,7 @@ public void SetAttribute(string key, bool value) [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, long value) { - _attributes[key] = new ValueTypePair(value, "integer"); + _attributes[key] = new SentryAttribute(value, "integer"); } /// @@ -198,7 +198,7 @@ public void SetAttribute(string key, long value) [Experimental(DiagnosticId.ExperimentalFeature)] public void SetAttribute(string key, double value) { - _attributes[key] = new ValueTypePair(value, "double"); + _attributes[key] = new SentryAttribute(value, "double"); } internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, SentryOptions options) @@ -257,20 +257,17 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (Template is not null) { - WriteAttribute(writer, "sentry.message.template", Template, "string"); + SentryAttributeSerializer.WriteAttribute(writer, "sentry.message.template", Template, "string"); } - if (Parameters is not null) + for (var index = 0; index < Parameters.Length; index++) { - for (var index = 0; index < Parameters.Length; index++) - { - WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index], null); - } + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index]); } foreach (var attribute in _attributes) { - WriteAttribute(writer, attribute.Key, attribute.Value); + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value); } writer.WriteEndObject(); @@ -279,98 +276,4 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndArray(); writer.WriteEndObject(); } - - private static void WriteAttribute(Utf8JsonWriter writer, string propertyName, ValueTypePair attribute) - { - writer.WritePropertyName(propertyName); - if (attribute.Type is not null) - { - WriteAttributeValue(writer, attribute.Value, attribute.Type); - } - else - { - WriteAttributeValue(writer, attribute.Value); - } - } - - private static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string? type) - { - writer.WritePropertyName(propertyName); - if (type is not null) - { - WriteAttributeValue(writer, value, type); - } - else - { - WriteAttributeValue(writer, value); - } - } - - private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type) - { - writer.WriteStartObject(); - - if (type == "string") - { - writer.WriteString("value", (string)value); - writer.WriteString("type", type); - } - else if (type == "boolean") - { - writer.WriteBoolean("value", (bool)value); - writer.WriteString("type", type); - } - else if (type == "integer") - { - writer.WriteNumber("value", (long)value); - writer.WriteString("type", type); - } - else if (type == "double") - { - writer.WriteNumber("value", (double)value); - writer.WriteString("type", type); - } - else - { - writer.WriteString("value", value.ToString()); - writer.WriteString("type", "string"); - } - - writer.WriteEndObject(); - } - - private static void WriteAttributeValue(Utf8JsonWriter writer, object value) - { - writer.WriteStartObject(); - - if (value is string str) - { - writer.WriteString("value", str); - writer.WriteString("type", "string"); - } - else if (value is bool boolean) - { - writer.WriteBoolean("value", boolean); - writer.WriteString("type", "boolean"); - } - else if (value is long int64) - { - writer.WriteNumber("value", int64); - writer.WriteString("type", "integer"); - } - else if (value is double float64) - { - writer.WriteNumber("value", float64); - writer.WriteString("type", "double"); - } - else - { - writer.WriteString("value", value.ToString()); - writer.WriteString("type", "string"); - } - - writer.WriteEndObject(); - } - - private record struct ValueTypePair(object Value, string? Type); } diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index a1514927ca..57a962ca55 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -144,7 +144,7 @@ private void CaptureLog(LogSeverityLevel level, string template, object[]? param SentryLog log = new(timestamp, traceId, level, message) { Template = template, - Parameters = parameters, + Parameters = ImmutableArray.Create(parameters), }; log.SetAttributes(hub, scopeManager, options); From 0740c3b19e3142f5b0c59c5fad36a8bc37e85b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 9 May 2025 14:41:34 +0200 Subject: [PATCH 017/101] ref(logs): extract TraceId and ParentSpanId methods --- src/Sentry/Protocol/SentryLog.cs | 15 ++------- src/Sentry/SentryLogger.cs | 58 +++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index a01d465e47..7122609130 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -201,7 +201,7 @@ public void SetAttribute(string key, double value) _attributes[key] = new SentryAttribute(value, "double"); } - internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, SentryOptions options) + internal void SetAttributes(SentryOptions options, SpanId? parentSpanId) { var environment = options.SettingLocator.GetEnvironment(); SetAttribute("sentry.environment", environment); @@ -212,18 +212,9 @@ internal void SetAttributes(IHub hub, IInternalScopeManager? scopeManager, Sentr SetAttribute("sentry.release", release); } - if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) + if (parentSpanId.HasValue) { - SetAttribute("sentry.trace.parent_span_id", span.ParentSpanId.Value.ToString()); - } - else if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - var parentSpanId = currentScope.PropagationContext.ParentSpanId; - if (parentSpanId.HasValue) - { - SetAttribute("sentry.trace.parent_span_id", parentSpanId.Value.ToString()); - } + SetAttribute("sentry.trace.parent_span_id", parentSpanId.Value.ToString()); } SetAttribute("sentry.sdk.name", Constants.SdkName); diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index 57a962ca55..6bc87c3376 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -1,3 +1,4 @@ +using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Protocol; @@ -125,28 +126,21 @@ private void CaptureLog(LogSeverityLevel level, string template, object[]? param } var scopeManager = (hub as Hub)?.ScopeManager; - SentryId traceId; - if (hub.GetSpan() is { } span) - { - traceId = span.TraceId; - } - else if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - traceId = currentScope.PropagationContext.TraceId; - } - else + + if (!TryGetTraceId(hub, scopeManager, out var traceId)) { - traceId = SentryId.Empty; + options.DiagnosticLogger?.LogWarning("TraceId not found"); } + _ = TryGetParentSpanId(hub, scopeManager, out var parentSpanId); + var message = string.Format(template, parameters ?? []); SentryLog log = new(timestamp, traceId, level, message) { Template = template, Parameters = ImmutableArray.Create(parameters), }; - log.SetAttributes(hub, scopeManager, options); + log.SetAttributes(options, parentSpanId); SentryLog? configuredLog; try @@ -169,4 +163,42 @@ private void CaptureLog(LogSeverityLevel level, string template, object[]? param _ = hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); } } + + private static bool TryGetTraceId(IHub hub, IInternalScopeManager? scopeManager, out SentryId traceId) + { + if (hub.GetSpan() is { } span) + { + traceId = span.TraceId; + return true; + } + + if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + traceId = currentScope.PropagationContext.TraceId; + return true; + } + + traceId = SentryId.Empty; + return false; + } + + private static bool TryGetParentSpanId(IHub hub, IInternalScopeManager? scopeManager, out SpanId? parentSpanId) + { + if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) + { + parentSpanId = span.ParentSpanId; + return true; + } + + if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + parentSpanId = currentScope.PropagationContext.ParentSpanId; + return true; + } + + parentSpanId = null; + return false; + } } From eee06bf5bb590186832285274137f1170dd8aa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 12 May 2025 15:46:16 +0200 Subject: [PATCH 018/101] ref(logs): remove `SentryOptions.LogsSampleRate` --- .../Sentry.Samples.Console.Basic/Program.cs | 1 - src/Sentry/Protocol/LogSeverityLevel.cs | 2 +- src/Sentry/Protocol/SentryLog.cs | 2 +- src/Sentry/SentryLogger.cs | 8 ------- src/Sentry/SentryOptions.cs | 23 ------------------- src/Sentry/SentrySdk.cs | 1 - 6 files changed, 2 insertions(+), 35 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 17cf00ab21..d55f9045b3 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -45,7 +45,6 @@ return log.Level is LogSeverityLevel.Error ? log : null; }); - options.LogsSampleRate = 1.0f; }); var configureLog = static (SentryLog log) => diff --git a/src/Sentry/Protocol/LogSeverityLevel.cs b/src/Sentry/Protocol/LogSeverityLevel.cs index 2642d7e850..efd445fcfe 100644 --- a/src/Sentry/Protocol/LogSeverityLevel.cs +++ b/src/Sentry/Protocol/LogSeverityLevel.cs @@ -39,7 +39,7 @@ public enum LogSeverityLevel [Experimental(DiagnosticId.ExperimentalFeature)] internal static class LogSeverityLevelExtensions { - internal static (string, int?) ToSeverityTextAndOptionalNumber(this LogSeverityLevel level) + internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this LogSeverityLevel level) { return (int)level switch { diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index 7122609130..ea689578db 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -234,7 +234,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); writer.WriteString("trace_id", TraceId); - var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalNumber(); + var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(); writer.WriteString("level", severityText); if (severityNumber.HasValue) { diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index 6bc87c3376..c9e19e59f6 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -13,11 +13,8 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLogger { - private readonly RandomValuesFactory _randomValuesFactory; - internal SentryLogger() { - _randomValuesFactory = new SynchronizedRandomValuesFactory(); } /// @@ -120,11 +117,6 @@ private void CaptureLog(LogSeverityLevel level, string template, object[]? param return; } - if (!_randomValuesFactory.NextBool(options.LogsSampleRate)) - { - return; - } - var scopeManager = (hub as Hub)?.ScopeManager; if (!TryGetTraceId(hub, scopeManager, out var traceId)) diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index a86f8250ff..8c8a7d1f57 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -546,29 +546,6 @@ public void SetBeforeSendLog(Func beforeSendLog) _beforeSendLog = beforeSendLog; } - private float _logsSampleRate = 1.0f; - - /// - /// A between 0.0f and 1.0f that represents the probability that a log will be sent to Sentry. - /// Defaults to 1.0. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public float LogsSampleRate - { - get => _logsSampleRate; - set - { - if (value is < 0.0f or > 1.0f) - { - throw new ArgumentOutOfRangeException(nameof(value), value, - "The logs sample rate must be between 0.0 and 1.0, inclusive."); - } - - _logsSampleRate = value; - } - } - private int _maxQueueItems = 30; /// diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 952e4d6e68..2b9ae9ffff 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -289,7 +289,6 @@ public void Dispose() /// /// /// - /// /// /// [Experimental(DiagnosticId.ExperimentalFeature)] From 8c61d8b0d0cd6a560da562eab81ebc34e818fd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 12 May 2025 15:57:09 +0200 Subject: [PATCH 019/101] feat(logs): support ISystemClock abstraction --- src/Sentry/SentryLogger.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentryLogger.cs index c9e19e59f6..329464a1b0 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentryLogger.cs @@ -13,8 +13,16 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryLogger { + private readonly ISystemClock _clock; + internal SentryLogger() + :this(SystemClock.Clock) + { + } + + internal SentryLogger(ISystemClock clock) { + _clock = clock; } /// @@ -108,7 +116,7 @@ private bool IsEnabled() private void CaptureLog(LogSeverityLevel level, string template, object[]? parameters, Action? configureLog) { - var timestamp = DateTimeOffset.UtcNow; + var timestamp = _clock.GetUtcNow(); var hub = SentrySdk.CurrentHub; From 80683ae6fd895635500d4dfdf95b1cc61b605b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 12 May 2025 16:21:48 +0200 Subject: [PATCH 020/101] ref(logs): disambiguate SentryLogger names --- src/Sentry/SentrySdk.cs | 2 +- src/Sentry/{SentryLogger.cs => SentrySdkLogger.cs} | 8 ++++---- .../SqlListenerTests.verify.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/Sentry/{SentryLogger.cs => SentrySdkLogger.cs} (97%) diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 2b9ae9ffff..7f88448ea6 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -292,7 +292,7 @@ public void Dispose() /// /// [Experimental(DiagnosticId.ExperimentalFeature)] - public static SentryLogger Logger { get; } = new SentryLogger(); + public static SentrySdkLogger Logger { get; } = new SentrySdkLogger(); /// /// Creates a new scope that will terminate when disposed. diff --git a/src/Sentry/SentryLogger.cs b/src/Sentry/SentrySdkLogger.cs similarity index 97% rename from src/Sentry/SentryLogger.cs rename to src/Sentry/SentrySdkLogger.cs index 329464a1b0..07a9072111 100644 --- a/src/Sentry/SentryLogger.cs +++ b/src/Sentry/SentrySdkLogger.cs @@ -11,16 +11,16 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public sealed class SentryLogger +public sealed class SentrySdkLogger { private readonly ISystemClock _clock; - internal SentryLogger() - :this(SystemClock.Clock) + internal SentrySdkLogger() + : this(SystemClock.Clock) { } - internal SentryLogger(ISystemClock clock) + internal SentrySdkLogger(ISystemClock clock) { _clock = clock; } diff --git a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs index afc7ed5a5d..b9f4250999 100644 --- a/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs +++ b/test/Sentry.DiagnosticSource.IntegrationTests/SqlListenerTests.verify.cs @@ -155,7 +155,7 @@ public void ShouldIgnoreAllErrorAndExceptionIds() foreach (var field in eventIds) { var eventId = (EventId)field.GetValue(null)!; - var isEfExceptionMessage = Sentry.Extensions.Logging.SentryLogger.IsEfExceptionMessage(eventId); + var isEfExceptionMessage = SentryLogger.IsEfExceptionMessage(eventId); var name = field.Name; if (name.EndsWith("Exception") || name.EndsWith("Error") || From cb20118c3f649df9867b0439ea1d626af82816f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 12 May 2025 17:10:58 +0200 Subject: [PATCH 021/101] ref(logs): consolidate names of Log-Methods --- samples/Sentry.Samples.Console.Basic/Program.cs | 12 ++++++------ src/Sentry/Protocol/LogSeverityLevel.cs | 2 +- src/Sentry/SentrySdkLogger.cs | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index d55f9045b3..e6d552ffb6 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -55,12 +55,12 @@ log.SetAttribute("double-attribute", double.MaxValue); }; -SentrySdk.Logger.Trace("Hello, World!", null, configureLog); -SentrySdk.Logger.Debug("Hello, .NET!", null, configureLog); -SentrySdk.Logger.Info("Information", null, configureLog); -SentrySdk.Logger.Warn("Warning with one {0}", ["parameter"], configureLog); -SentrySdk.Logger.Error("Error with {0} {1}", [2, "parameters"], configureLog); -SentrySdk.Logger.Fatal("Fatal {0} and {1}", [true, false], configureLog); +SentrySdk.Logger.LogTrace("Hello, World!", null, configureLog); +SentrySdk.Logger.LogDebug("Hello, .NET!", null, configureLog); +SentrySdk.Logger.LogInfo("Information", null, configureLog); +SentrySdk.Logger.LogWarning("Warning with one {0}", ["parameter"], configureLog); +SentrySdk.Logger.LogError("Error with {0} {1}", [2, "parameters"], configureLog); +SentrySdk.Logger.LogFatal("Fatal {0} and {1}", [true, false], configureLog); await Task.Delay(TimeSpan.FromSeconds(5)); diff --git a/src/Sentry/Protocol/LogSeverityLevel.cs b/src/Sentry/Protocol/LogSeverityLevel.cs index efd445fcfe..f6f0d9cae5 100644 --- a/src/Sentry/Protocol/LogSeverityLevel.cs +++ b/src/Sentry/Protocol/LogSeverityLevel.cs @@ -25,7 +25,7 @@ public enum LogSeverityLevel /// /// A warning event. /// - Warn = 13, + Warning = 13, /// /// An error event. /// diff --git a/src/Sentry/SentrySdkLogger.cs b/src/Sentry/SentrySdkLogger.cs index 07a9072111..a4185581c8 100644 --- a/src/Sentry/SentrySdkLogger.cs +++ b/src/Sentry/SentrySdkLogger.cs @@ -29,7 +29,7 @@ internal SentrySdkLogger(ISystemClock clock) /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// - public void Trace(string template, object[]? parameters = null, Action? configureLog = null) + public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { @@ -42,7 +42,7 @@ public void Trace(string template, object[]? parameters = null, ActionThis API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public void Debug(string template, object[]? parameters = null, Action? configureLog = null) + public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { @@ -55,7 +55,7 @@ public void Debug(string template, object[]? parameters = null, ActionThis API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public void Info(string template, object[]? parameters = null, Action? configureLog = null) + public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { @@ -64,15 +64,15 @@ public void Info(string template, object[]? parameters = null, Action } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public void Warn(string template, object[]? parameters = null, Action? configureLog = null) + public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { - CaptureLog(LogSeverityLevel.Warn, template, parameters, configureLog); + CaptureLog(LogSeverityLevel.Warning, template, parameters, configureLog); } } @@ -81,7 +81,7 @@ public void Warn(string template, object[]? parameters = null, Action /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public void Error(string template, object[]? parameters = null, Action? configureLog = null) + public void LogError(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { @@ -94,7 +94,7 @@ public void Error(string template, object[]? parameters = null, ActionThis API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public void Fatal(string template, object[]? parameters = null, Action? configureLog = null) + public void LogFatal(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { From 2cb306fc6165fbaefc35da23f81e83742c12f1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 12 May 2025 17:19:46 +0200 Subject: [PATCH 022/101] ref(logs): rename LogSeverityLevel to SentryLogLevel --- .../Sentry.Samples.Console.Basic/Program.cs | 2 +- src/Sentry/Protocol/SentryLog.cs | 8 +++--- ...{LogSeverityLevel.cs => SentryLogLevel.cs} | 12 ++++----- src/Sentry/SentrySdkLogger.cs | 26 +++++++++---------- 4 files changed, 24 insertions(+), 24 deletions(-) rename src/Sentry/Protocol/{LogSeverityLevel.cs => SentryLogLevel.cs} (83%) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index e6d552ffb6..60c8dd3b31 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -43,7 +43,7 @@ return null; } - return log.Level is LogSeverityLevel.Error ? log : null; + return log.Level is SentryLogLevel.Error ? log : null; }); }); diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index ea689578db..ce368868b3 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -12,10 +12,10 @@ namespace Sentry.Protocol; public sealed class SentryLog : ISentryJsonSerializable { private readonly Dictionary _attributes; - private readonly LogSeverityLevel _level; + private readonly SentryLogLevel _level; [SetsRequiredMembers] - internal SentryLog(DateTimeOffset timestamp, SentryId traceId, LogSeverityLevel level, string message) + internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) { Timestamp = timestamp; TraceId = traceId; @@ -46,12 +46,12 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, LogSeverityLevel /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public required LogSeverityLevel Level + public required SentryLogLevel Level { get => _level; init { - LogSeverityLevelExtensions.ThrowIfOutOfRange(value); + SentryLogLevelExtensions.ThrowIfOutOfRange(value); _level = value; } } diff --git a/src/Sentry/Protocol/LogSeverityLevel.cs b/src/Sentry/Protocol/SentryLogLevel.cs similarity index 83% rename from src/Sentry/Protocol/LogSeverityLevel.cs rename to src/Sentry/Protocol/SentryLogLevel.cs index f6f0d9cae5..b1e08ef8bd 100644 --- a/src/Sentry/Protocol/LogSeverityLevel.cs +++ b/src/Sentry/Protocol/SentryLogLevel.cs @@ -8,7 +8,7 @@ namespace Sentry.Protocol; /// /// [Experimental(DiagnosticId.ExperimentalFeature)] -public enum LogSeverityLevel +public enum SentryLogLevel { /// /// A fine-grained debugging event. @@ -37,9 +37,9 @@ public enum LogSeverityLevel } [Experimental(DiagnosticId.ExperimentalFeature)] -internal static class LogSeverityLevelExtensions +internal static class SentryLogLevelExtensions { - internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this LogSeverityLevel level) + internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level) { return (int)level switch { @@ -59,7 +59,7 @@ internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this LogS }; } - internal static void ThrowIfOutOfRange(LogSeverityLevel level, [CallerArgumentExpression(nameof(level))] string? paramName = null) + internal static void ThrowIfOutOfRange(SentryLogLevel level, [CallerArgumentExpression(nameof(level))] string? paramName = null) { if ((int)level is < 1 or > 24) { @@ -68,13 +68,13 @@ internal static void ThrowIfOutOfRange(LogSeverityLevel level, [CallerArgumentEx } [DoesNotReturn] - private static void ThrowOutOfRange(LogSeverityLevel level, string? paramName) + private static void ThrowOutOfRange(SentryLogLevel level, string? paramName) { throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } [DoesNotReturn] - private static T ThrowOutOfRange(LogSeverityLevel level, string? paramName) + private static T ThrowOutOfRange(SentryLogLevel level, string? paramName) { throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); } diff --git a/src/Sentry/SentrySdkLogger.cs b/src/Sentry/SentrySdkLogger.cs index a4185581c8..144e9d90c6 100644 --- a/src/Sentry/SentrySdkLogger.cs +++ b/src/Sentry/SentrySdkLogger.cs @@ -26,19 +26,19 @@ internal SentrySdkLogger(ISystemClock clock) } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled()) { - CaptureLog(LogSeverityLevel.Trace, template, parameters, configureLog); + CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); } } /// - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -46,12 +46,12 @@ public void LogDebug(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -59,12 +59,12 @@ public void LogInfo(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -72,12 +72,12 @@ public void LogWarning(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -85,12 +85,12 @@ public void LogError(string template, object[]? parameters = null, Action - /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] @@ -98,7 +98,7 @@ public void LogFatal(string template, object[]? parameters = null, Action? configureLog) + private void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { var timestamp = _clock.GetUtcNow(); From dcc0ec1711a9a6728ceac58423dad67259515146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 13 May 2025 10:43:23 +0200 Subject: [PATCH 023/101] ref(logs): re-rename new logger type --- src/Sentry/SentrySdk.cs | 2 +- .../{SentrySdkLogger.cs => SentryStructuredLogger.cs} | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/Sentry/{SentrySdkLogger.cs => SentryStructuredLogger.cs} (97%) diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 7f88448ea6..8181ee79df 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -292,7 +292,7 @@ public void Dispose() /// /// [Experimental(DiagnosticId.ExperimentalFeature)] - public static SentrySdkLogger Logger { get; } = new SentrySdkLogger(); + public static SentryStructuredLogger Logger { get; } = new SentryStructuredLogger(); /// /// Creates a new scope that will terminate when disposed. diff --git a/src/Sentry/SentrySdkLogger.cs b/src/Sentry/SentryStructuredLogger.cs similarity index 97% rename from src/Sentry/SentrySdkLogger.cs rename to src/Sentry/SentryStructuredLogger.cs index 144e9d90c6..55b7ec439d 100644 --- a/src/Sentry/SentrySdkLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -11,16 +11,16 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public sealed class SentrySdkLogger +public sealed class SentryStructuredLogger { private readonly ISystemClock _clock; - internal SentrySdkLogger() + internal SentryStructuredLogger() : this(SystemClock.Clock) { } - internal SentrySdkLogger(ISystemClock clock) + internal SentryStructuredLogger(ISystemClock clock) { _clock = clock; } From 58dce74cac400de6d1c1806315a4d067da174836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 09:27:20 +0200 Subject: [PATCH 024/101] ref(logs): move Logger instances to Hubs --- src/Sentry/Extensibility/DisabledHub.cs | 9 +++ src/Sentry/Extensibility/HubAdapter.cs | 7 ++ src/Sentry/IHub.cs | 16 +++++ src/Sentry/Internal/Hub.cs | 4 ++ src/Sentry/Protocol/SentryLog.cs | 13 +++- src/Sentry/SentrySdk.cs | 14 +--- src/Sentry/SentryStructuredLogger.cs | 89 ++++++++++++++----------- 7 files changed, 99 insertions(+), 53 deletions(-) diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index a5d7541dde..a29627308c 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -1,3 +1,4 @@ +using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Protocol.Envelopes; using Sentry.Protocol.Metrics; @@ -21,6 +22,7 @@ public class DisabledHub : IHub, IDisposable private DisabledHub() { + Logger = new SentryStructuredLogger(this); } /// @@ -228,4 +230,11 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// No-Op. /// public SentryId LastEventId => SentryId.Empty; + + /// + /// Disabled Logger. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index b21eb369ee..7159ac70a4 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -32,6 +32,13 @@ private HubAdapter() { } /// public SentryId LastEventId { [DebuggerStepThrough] get => SentrySdk.LastEventId; } + /// + /// Forwards the call to . + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Logger; } + /// /// Forwards the call to . /// diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index abf722c89d..40896cbe7a 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -1,3 +1,5 @@ +using Sentry.Infrastructure; + namespace Sentry; /// @@ -17,6 +19,20 @@ public interface IHub : ISentryClient, ISentryScopeManager /// public SentryId LastEventId { get; } + /// + /// Creates and sends logs to Sentry. + /// This API is experimental and it may change in the future. + /// + /// + /// Available options: + /// + /// + /// + /// + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } + /// /// Starts a transaction. /// diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index cc9cbd3ef3..0687e22fdd 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -57,6 +57,7 @@ internal Hub( client ??= new SentryClient(options, randomValuesFactory: _randomValuesFactory, sessionManager: _sessionManager); ScopeManager = scopeManager ?? new SentryScopeManager(options, client); + Logger = new SentryStructuredLogger(this); if (!options.IsGlobalModeEnabled) { @@ -755,4 +756,7 @@ public void Dispose() } public SentryId LastEventId => CurrentScope.LastEventId; + + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index ce368868b3..09f0108a78 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -77,6 +77,13 @@ public required SentryLogLevel Level [Experimental(DiagnosticId.ExperimentalFeature)] public ImmutableArray Parameters { get; init; } + /// + /// The span id of the span that was active when the log was collected. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SpanId? ParentSpanId { get; init; } + /// /// Gets the attribute value associated with the specified key when of type . /// This API is experimental and it may change in the future. @@ -201,7 +208,7 @@ public void SetAttribute(string key, double value) _attributes[key] = new SentryAttribute(value, "double"); } - internal void SetAttributes(SentryOptions options, SpanId? parentSpanId) + internal void SetAttributes(SentryOptions options) { var environment = options.SettingLocator.GetEnvironment(); SetAttribute("sentry.environment", environment); @@ -212,9 +219,9 @@ internal void SetAttributes(SentryOptions options, SpanId? parentSpanId) SetAttribute("sentry.release", release); } - if (parentSpanId.HasValue) + if (ParentSpanId.HasValue) { - SetAttribute("sentry.trace.parent_span_id", parentSpanId.Value.ToString()); + SetAttribute("sentry.trace.parent_span_id", ParentSpanId.Value.ToString()); } SetAttribute("sentry.sdk.name", Constants.SdkName); diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 8181ee79df..41e61751c9 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -280,19 +280,9 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } - /// - /// Creates and sends logs to Sentry. - /// This API is experimental and it may change in the future. - /// - /// - /// Available options: - /// - /// - /// - /// - /// + /// [Experimental(DiagnosticId.ExperimentalFeature)] - public static SentryStructuredLogger Logger { get; } = new SentryStructuredLogger(); + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => CurrentHub.Logger; } /// /// Creates a new scope that will terminate when disposed. diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 55b7ec439d..886efdd54a 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -13,25 +13,42 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public sealed class SentryStructuredLogger { + private readonly IHub _hub; private readonly ISystemClock _clock; - internal SentryStructuredLogger() - : this(SystemClock.Clock) + private readonly SentryOptions? _options; + private readonly IInternalScopeManager? _scopeManager; + + internal SentryStructuredLogger(IHub hub) + : this(hub, SystemClock.Clock) + { + } + + internal SentryStructuredLogger(IHub hub, ISystemClock clock) + : this(hub, (hub as Hub)?.ScopeManager, hub.GetSentryOptions(), clock) { } - internal SentryStructuredLogger(ISystemClock clock) + internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, SentryOptions? options, ISystemClock clock) { + _hub = hub; _clock = clock; + + _options = options; + _scopeManager = scopeManager; + IsEnabled = options is { EnableLogs: true }; } + [MemberNotNullWhen(true, nameof(_options))] + private bool IsEnabled { get; } + /// /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); } @@ -44,7 +61,7 @@ public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Debug, template, parameters, configureLog); } @@ -57,7 +74,7 @@ public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Info, template, parameters, configureLog); } @@ -70,7 +87,7 @@ public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Warning, template, parameters, configureLog); } @@ -83,7 +100,7 @@ public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Error, template, parameters, configureLog); } @@ -96,57 +113,37 @@ public void LogError(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled()) + if (IsEnabled) { CaptureLog(SentryLogLevel.Fatal, template, parameters, configureLog); } } - private bool IsEnabled() - { - var hub = SentrySdk.CurrentHub; - - if (hub.GetSentryOptions() is { } options) - { - return options.EnableLogs; - } - - return false; - } - private void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { - var timestamp = _clock.GetUtcNow(); - - var hub = SentrySdk.CurrentHub; - - if (hub.GetSentryOptions() is not { EnableLogs: true } options) - { - return; - } + Debug.Assert(_options is not null); - var scopeManager = (hub as Hub)?.ScopeManager; + var timestamp = _clock.GetUtcNow(); - if (!TryGetTraceId(hub, scopeManager, out var traceId)) + if (!TryGetTraceId(_hub, _scopeManager, out var traceId)) { - options.DiagnosticLogger?.LogWarning("TraceId not found"); + _options.DiagnosticLogger?.LogWarning("TraceId not found"); } - _ = TryGetParentSpanId(hub, scopeManager, out var parentSpanId); + _ = TryGetParentSpanId(_hub, _scopeManager, out var parentSpanId); - var message = string.Format(template, parameters ?? []); + var message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); SentryLog log = new(timestamp, traceId, level, message) { Template = template, Parameters = ImmutableArray.Create(parameters), + ParentSpanId = parentSpanId, }; - log.SetAttributes(options, parentSpanId); + log.SetAttributes(_options); - SentryLog? configuredLog; try { configureLog?.Invoke(log); - configuredLog = options.BeforeSendLogInternal?.Invoke(log); } catch (Exception e) { @@ -156,11 +153,27 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet return; } + var configuredLog = log; + if (_options.BeforeSendLogInternal is { } beforeSendLog) + { + try + { + configuredLog = beforeSendLog.Invoke(log); + } + catch (Exception e) + { + //TODO: change to Diagnostic Logger (if enabled) + // see https://github.com/getsentry/sentry-dotnet/issues/4132 + Console.WriteLine(e); + return; + } + } + if (configuredLog is not null) { //TODO: enqueue in Batch-Processor / Background-Worker // see https://github.com/getsentry/sentry-dotnet/issues/4132 - _ = hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); } } From f2e1ba2bf662ea32a5c357fc116d409a8291c1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 15:56:47 +0200 Subject: [PATCH 025/101] test(logs): add tests --- src/Sentry/Internal/Hub.cs | 3 +- src/Sentry/Protocol/SentryLog.cs | 7 +- src/Sentry/SentryStructuredLogger.cs | 7 +- .../Protocol/SentryLogLevelTests.cs | 91 ++++++++ .../SentryStructuredLoggerTests.cs | 219 ++++++++++++++++++ 5 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 test/Sentry.Tests/Protocol/SentryLogLevelTests.cs create mode 100644 test/Sentry.Tests/SentryStructuredLoggerTests.cs diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 0687e22fdd..9e1e13cc9f 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -57,7 +57,6 @@ internal Hub( client ??= new SentryClient(options, randomValuesFactory: _randomValuesFactory, sessionManager: _sessionManager); ScopeManager = scopeManager ?? new SentryScopeManager(options, client); - Logger = new SentryStructuredLogger(this); if (!options.IsGlobalModeEnabled) { @@ -65,6 +64,8 @@ internal Hub( PushScope(); } + Logger = new SentryStructuredLogger(this); + #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) { diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index 09f0108a78..e22f5ce2d4 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -258,9 +258,12 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) SentryAttributeSerializer.WriteAttribute(writer, "sentry.message.template", Template, "string"); } - for (var index = 0; index < Parameters.Length; index++) + if (!Parameters.IsDefault) { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index]); + for (var index = 0; index < Parameters.Length; index++) + { + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index]); + } } foreach (var attribute in _attributes) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 886efdd54a..0148c4f6fb 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -20,12 +20,7 @@ public sealed class SentryStructuredLogger private readonly IInternalScopeManager? _scopeManager; internal SentryStructuredLogger(IHub hub) - : this(hub, SystemClock.Clock) - { - } - - internal SentryStructuredLogger(IHub hub, ISystemClock clock) - : this(hub, (hub as Hub)?.ScopeManager, hub.GetSentryOptions(), clock) + : this(hub, (hub as Hub)?.ScopeManager, hub.GetSentryOptions(), SystemClock.Clock) { } diff --git a/test/Sentry.Tests/Protocol/SentryLogLevelTests.cs b/test/Sentry.Tests/Protocol/SentryLogLevelTests.cs new file mode 100644 index 0000000000..6a2c6abbf8 --- /dev/null +++ b/test/Sentry.Tests/Protocol/SentryLogLevelTests.cs @@ -0,0 +1,91 @@ +namespace Sentry.Tests.Protocol; + +/// +/// +/// +public class SentryLogLevelTests +{ + [Theory] + [MemberData(nameof(SeverityTextAndSeverityNumber))] + public void SeverityTextAndSeverityNumber_WithinRange_MatchesProtocol(int level, string text, int? number) + { + var @enum = (SentryLogLevel)level; + + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(); + + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + } + + [Theory] + [InlineData(0)] + [InlineData(25)] + public void SeverityTextAndSeverityNumber_OutOfRange_ThrowOutOfRange(int level) + { + var @enum = (SentryLogLevel)level; + + var exception = Assert.Throws("level", () => @enum.ToSeverityTextAndOptionalSeverityNumber()); + Assert.StartsWith("Severity must be between 1 (inclusive) and 24 (inclusive).", exception.Message); + Assert.Equal(level, (int)exception.ActualValue!); + } + + [Fact] + public void ThrowOutOfRange_WithinRange_DoesNotThrow() + { + var range = Enumerable.Range(1, 24); + + var count = 0; + foreach (var item in range) + { + var level = (SentryLogLevel)item; + SentryLogLevelExtensions.ThrowIfOutOfRange(level); + count++; + } + + Assert.Equal(24, count); + } + + [Theory] + [InlineData(0)] + [InlineData(25)] + public void ThrowOutOfRange_OutOfRange_Throws(int level) + { + var @enum = (SentryLogLevel)level; + + var exception = Assert.Throws("@enum", () => SentryLogLevelExtensions.ThrowIfOutOfRange(@enum)); + Assert.StartsWith("Severity must be between 1 (inclusive) and 24 (inclusive).", exception.Message); + Assert.Equal(level, (int)exception.ActualValue!); + } + + public static TheoryData SeverityTextAndSeverityNumber() + { + return new TheoryData + { + { 1, "trace", null }, + { 2, "trace", 2 }, + { 3, "trace", 3 }, + { 4, "trace", 4 }, + { 5, "debug", null }, + { 6, "debug", 6 }, + { 7, "debug", 7 }, + { 8, "debug", 8 }, + { 9, "info", null }, + { 10, "info", 10 }, + { 11, "info", 11 }, + { 12, "info", 12 }, + { 13, "warn", null }, + { 14, "warn", 14 }, + { 15, "warn", 15 }, + { 16, "warn", 16 }, + { 17, "error", null }, + { 18, "error", 18 }, + { 19, "error", 19 }, + { 20, "error", 20 }, + { 21, "fatal", null }, + { 22, "fatal", 22 }, + { 23, "fatal", 23 }, + { 24, "fatal", 24 }, + }; + } +} diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..55e39d0e36 --- /dev/null +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,219 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public class SentryStructuredLoggerTests +{ + internal sealed class Fixture + { + public Fixture() + { + Hub = Substitute.For(); + ScopeManager = Substitute.For(); + Options = new SentryOptions(); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + Span = Substitute.For(); + TraceId = SentryId.Create(); + ParentSpanId = SpanId.Create(); + + Hub.GetSpan().Returns(Span); + Span.TraceId.Returns(TraceId); + Span.ParentSpanId.Returns(ParentSpanId); + } + + public IHub Hub { get; } + public IInternalScopeManager ScopeManager { get; } + public SentryOptions Options { get; } + public ISystemClock Clock { get; } + public ISpan Span { get; } + public SentryId TraceId { get; } + public SpanId? ParentSpanId { get; } + + public void UseScopeManager() + { + Hub.GetSpan().Returns((ISpan?)null); + + var propagationContext = new SentryPropagationContext(TraceId, ParentSpanId!.Value); + var scope = new Scope(Options, propagationContext); + var scopeAndClient = new KeyValuePair(scope, null!); + ScopeManager.GetCurrent().Returns(scopeAndClient); + } + + public SentryStructuredLogger GetSut() => new(Hub, ScopeManager, Options, Clock); + } + + private readonly Fixture _fixture; + + public SentryStructuredLoggerTests() + { + _fixture = new Fixture(); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) + { + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, level); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) + { + _fixture.Options.EnableLogs.Should().BeFalse(); + var logger = _fixture.GetSut(); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } + + [Fact] + public void Log_UseScopeManager_CapturesEnvelope() + { + _fixture.UseScopeManager(); + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WithBeforeSendLog_InvokesCallback() + { + var invocations = 0; + SentryLog configuredLog = null!; + + _fixture.Options.EnableLogs = true; + _fixture.Options.SetBeforeSendLog((SentryLog log) => + { + invocations++; + configuredLog = log; + return log; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + configuredLog.AssertLog(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() + { + var invocations = 0; + + _fixture.Options.EnableLogs = true; + _fixture.Options.SetBeforeSendLog((SentryLog log) => + { + invocations++; + return null; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + } + + private static void ConfigureLog(SentryLog log) + { + log.SetAttribute("attribute-key", "attribute-value"); + } +} + +file static class AssertionExtensions +{ + public static void AssertEnvelope(this Envelope envelope, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + envelope.Header.Should().ContainSingle().Which.Key.Should().Be("sdk"); + var item = envelope.Items.Should().ContainSingle().Which; + + var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; + AssertLog(log, fixture, level); + + Assert.Collection(item.Header, + element => Assert.Equal(CreateHeader("type", "log"), element), + element => Assert.Equal(CreateHeader("item_count", 1), element), + element => Assert.Equal(CreateHeader("content_type", "application/vnd.sentry.items.log+json"), element)); + } + + public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + log.Timestamp.Should().Be(fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(fixture.TraceId); + log.Level.Should().Be(level); + log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); + log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); + log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 } ); + log.ParentSpanId.Should().Be(fixture.ParentSpanId); + log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); + value.Should().Be("attribute-value"); + } + + private static KeyValuePair CreateHeader(string name, object? value) + { + return new KeyValuePair(name, value); + } +} + +file static class SentryStructuredLoggerExtensions +{ + public static void Log(this SentryStructuredLogger logger, SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + switch (level) + { + case SentryLogLevel.Trace: + logger.LogTrace(template, parameters, configureLog); + break; + case SentryLogLevel.Debug: + logger.LogDebug(template, parameters, configureLog); + break; + case SentryLogLevel.Info: + logger.LogInfo(template, parameters, configureLog); + break; + case SentryLogLevel.Warning: + logger.LogWarning(template, parameters, configureLog); + break; + case SentryLogLevel.Error: + logger.LogError(template, parameters, configureLog); + break; + case SentryLogLevel.Fatal: + logger.LogFatal(template, parameters, configureLog); + break; + default: + throw new ArgumentOutOfRangeException(nameof(level), level, null); + } + } +} From 6822b23218de0c436abbb3990b758bdc57fa6187 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 14 May 2025 14:09:00 +0000 Subject: [PATCH 026/101] Format code --- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 55e39d0e36..952c546427 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -176,7 +176,7 @@ public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fix log.Level.Should().Be(level); log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); - log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 } ); + log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 }); log.ParentSpanId.Should().Be(fixture.ParentSpanId); log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); value.Should().Be("attribute-value"); From dd39fae9350eb1c1e92d8541ac7fb2a5be308dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 21:01:56 +0200 Subject: [PATCH 027/101] fix(logs): incorrectly serializing attributes --- src/Sentry/Protocol/SentryAttribute.cs | 5 + src/Sentry/Protocol/SentryLog.cs | 26 ++- src/Sentry/SentryStructuredLogger.cs | 3 +- test/Sentry.Tests/Protocol/SentryLogTests.cs | 224 +++++++++++++++++++ 4 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 test/Sentry.Tests/Protocol/SentryLogTests.cs diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 6e5845e6dc..69b95594a7 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -80,6 +80,11 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value) writer.WriteBoolean("value", boolean); writer.WriteString("type", "boolean"); } + else if (value is int int32) + { + writer.WriteNumber("value", int32); + writer.WriteString("type", "integer"); + } else if (value is long int64) { writer.WriteNumber("value", int64); diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/Protocol/SentryLog.cs index e22f5ce2d4..7fd6d760c5 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/Protocol/SentryLog.cs @@ -219,11 +219,6 @@ internal void SetAttributes(SentryOptions options) SetAttribute("sentry.release", release); } - if (ParentSpanId.HasValue) - { - SetAttribute("sentry.trace.parent_span_id", ParentSpanId.Value.ToString()); - } - SetAttribute("sentry.sdk.name", Constants.SdkName); if (SdkVersion.Instance.Version is { } version) { @@ -239,17 +234,20 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteStartObject(); writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); - writer.WriteString("trace_id", TraceId); var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(); writer.WriteString("level", severityText); + + writer.WriteString("body", Message); + + writer.WritePropertyName("trace_id"); + TraceId.WriteTo(writer, logger); + if (severityNumber.HasValue) { writer.WriteNumber("severity_number", severityNumber.Value); } - writer.WriteString("body", Message); - writer.WritePropertyName("attributes"); writer.WriteStartObject(); @@ -262,7 +260,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { for (var index = 0; index < Parameters.Length; index++) { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameters.{index}", Parameters[index]); + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index]); } } @@ -271,6 +269,16 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value); } + if (ParentSpanId.HasValue) + { + writer.WritePropertyName("sentry.trace.parent_span_id"); + writer.WriteStartObject(); + writer.WritePropertyName("value"); + ParentSpanId.Value.WriteTo(writer, logger); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + writer.WriteEndObject(); writer.WriteEndObject(); diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 0148c4f6fb..07bd10c1a9 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -134,7 +134,6 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet Parameters = ImmutableArray.Create(parameters), ParentSpanId = parentSpanId, }; - log.SetAttributes(_options); try { @@ -148,6 +147,8 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet return; } + log.SetAttributes(_options); + var configuredLog = log; if (_options.BeforeSendLogInternal is { } beforeSendLog) { diff --git a/test/Sentry.Tests/Protocol/SentryLogTests.cs b/test/Sentry.Tests/Protocol/SentryLogTests.cs new file mode 100644 index 0000000000..5b0f4835d9 --- /dev/null +++ b/test/Sentry.Tests/Protocol/SentryLogTests.cs @@ -0,0 +1,224 @@ +using System.Text.Encodings.Web; + +namespace Sentry.Tests.Protocol; + +/// +/// +/// +public class SentryLogTests +{ + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, TimeSpan.FromHours(2)); + private static readonly SentryId TraceId = SentryId.Create(); + private static readonly SpanId? ParentSpanId = SpanId.Create(); + + private static readonly ISystemClock Clock = new MockClock(Timestamp); + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + private readonly IDiagnosticLogger _output; + + public SentryLogTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void WriteTo_Envelope_MinimalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetAttributes(options); + + var envelope = Envelope.FromLog(log); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output, Clock); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var header = JsonDocument.Parse(reader.ReadLine()!); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + JsonSerializer.Serialize(header, JsonSerializerOptions).Should().Be($$""" + { + "sdk": { + "name": "{{SdkVersion.Instance.Name}}", + "version": "{{SdkVersion.Instance.Version}}" + }, + "sent_at": "{{Timestamp.Format()}}" + } + """); + + JsonSerializer.Serialize(item, JsonSerializerOptions).Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + JsonSerializer.Serialize(payload, JsonSerializerOptions).Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "level": "trace", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "attributes": { + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + }, + "sentry.sdk.name": { + "value": "{{SdkVersion.Instance.Name}}", + "type": "string" + }, + "sentry.sdk.version": { + "value": "{{SdkVersion.Instance.Version}}", + "type": "string" + } + } + } + ] + } + """); + } + + [Fact] + public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create("string", false, 1, 2.2), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("string-attribute", "string-value"); + log.SetAttribute("boolean-attribute", true); + log.SetAttribute("integer-attribute", 3); + log.SetAttribute("double-attribute", 4.4); + log.SetAttributes(options); + + var envelope = EnvelopeItem.FromLog(log); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + JsonSerializer.Serialize(item, JsonSerializerOptions).Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + JsonSerializer.Serialize(payload, JsonSerializerOptions).Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "level": "fatal", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "severity_number": 24, + "attributes": { + "sentry.message.template": { + "value": "template", + "type": "string" + }, + "sentry.message.parameter.0": { + "value": "string", + "type": "string" + }, + "sentry.message.parameter.1": { + "value": false, + "type": "boolean" + }, + "sentry.message.parameter.2": { + "value": 1, + "type": "integer" + }, + "sentry.message.parameter.3": { + "value": 2.2, + "type": "double" + }, + "string-attribute": { + "value": "string-value", + "type": "string" + }, + "boolean-attribute": { + "value": true, + "type": "boolean" + }, + "integer-attribute": { + "value": 3, + "type": "integer" + }, + "double-attribute": { + "value": 4.4, + "type": "double" + }, + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + }, + "sentry.sdk.name": { + "value": "{{SdkVersion.Instance.Name}}", + "type": "string" + }, + "sentry.sdk.version": { + "value": "{{SdkVersion.Instance.Version}}", + "type": "string" + }, + "sentry.trace.parent_span_id": { + "value": "{{ParentSpanId.ToString()}}", + "type": "string" + } + } + } + ] + } + """); + } +} + +file static class JsonFormatterExtensions +{ + public static string Format(this DateTimeOffset value) + { + return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); + } +} From 6eb5b9ba6b4c27808040b6fd68282b97a0b8078d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 21:31:58 +0200 Subject: [PATCH 028/101] fix(logs): do not capture log when template/parameters are invalid --- src/Sentry/SentryStructuredLogger.cs | 12 +++++++++- .../SentryStructuredLoggerTests.cs | 24 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 07bd10c1a9..13798656a1 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -127,7 +127,17 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet _ = TryGetParentSpanId(_hub, _scopeManager, out var parentSpanId); - var message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); + string message; + try + { + message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument."); + return; + } + SentryLog log = new(timestamp, traceId, level, message) { Template = template, diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 952c546427..be1294232f 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -11,9 +11,14 @@ internal sealed class Fixture { public Fixture() { + DiagnosticLogger = new InMemoryDiagnosticLogger(); Hub = Substitute.For(); ScopeManager = Substitute.For(); - Options = new SentryOptions(); + Options = new SentryOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + }; Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); Span = Substitute.For(); TraceId = SentryId.Create(); @@ -24,6 +29,7 @@ public Fixture() Span.ParentSpanId.Returns(ParentSpanId); } + public InMemoryDiagnosticLogger DiagnosticLogger { get; } public IHub Hub { get; } public IInternalScopeManager ScopeManager { get; } public SentryOptions Options { get; } @@ -147,6 +153,22 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() invocations.Should().Be(1); } + [Fact] + public void Log_InvalidFormat_DoesNotCaptureEnvelope() + { + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + private static void ConfigureLog(SentryLog log) { log.SetAttribute("attribute-key", "attribute-value"); From 69c05b82b89361aa92847c8da5a68409635a8a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 21:46:53 +0200 Subject: [PATCH 029/101] fix(logs): do not capture log on user callback exceptions --- src/Sentry/SentryStructuredLogger.cs | 10 ++--- .../SentryStructuredLoggerTests.cs | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 13798656a1..39df41b2f2 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -134,7 +134,7 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet } catch (FormatException e) { - _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument."); + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); return; } @@ -151,9 +151,7 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet } catch (Exception e) { - //TODO: change to Diagnostic Logger (if enabled) - // see https://github.com/getsentry/sentry-dotnet/issues/4132 - Console.WriteLine(e); + _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); return; } @@ -168,9 +166,7 @@ private void CaptureLog(SentryLogLevel level, string template, object[]? paramet } catch (Exception e) { - //TODO: change to Diagnostic Logger (if enabled) - // see https://github.com/getsentry/sentry-dotnet/issues/4132 - Console.WriteLine(e); + _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); return; } } diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index be1294232f..1f30e53e6e 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -164,15 +164,53 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; entry.Level.Should().Be(SentryLevel.Error); - entry.Message.Should().Be("Template string does not match the provided argument."); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); } + [Fact] + public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() + { + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], Throw); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() + { + _fixture.Options.EnableLogs = true; + _fixture.Options.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + private static void ConfigureLog(SentryLog log) { log.SetAttribute("attribute-key", "attribute-value"); } + + private static void Throw(SentryLog log) + { + throw new InvalidOperationException(); + } } file static class AssertionExtensions From 430cf824759667efeff8a026b61cc3d9e372c530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 21:58:54 +0200 Subject: [PATCH 030/101] ref(logs): move new public types to root namespace --- samples/Sentry.Samples.Console.Basic/Program.cs | 1 - src/Sentry/IHub.cs | 2 +- src/Sentry/{Protocol => }/SentryLog.cs | 3 ++- src/Sentry/{Protocol => }/SentryLogLevel.cs | 2 +- src/Sentry/SentryStructuredLogger.cs | 1 - test/Sentry.Tests/{Protocol => }/SentryLogLevelTests.cs | 2 +- test/Sentry.Tests/{Protocol => }/SentryLogTests.cs | 2 +- 7 files changed, 6 insertions(+), 7 deletions(-) rename src/Sentry/{Protocol => }/SentryLog.cs (99%) rename src/Sentry/{Protocol => }/SentryLogLevel.cs (98%) rename test/Sentry.Tests/{Protocol => }/SentryLogLevelTests.cs (98%) rename test/Sentry.Tests/{Protocol => }/SentryLogTests.cs (99%) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 60c8dd3b31..59120735f6 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -9,7 +9,6 @@ */ using System.Net.Http; -using Sentry.Protocol; using static System.Console; // Initialize the Sentry SDK. (It is not necessary to dispose it.) diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 40896cbe7a..68fafcd86c 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -27,7 +27,7 @@ public interface IHub : ISentryClient, ISentryScopeManager /// Available options: /// /// - /// + /// /// /// [Experimental(DiagnosticId.ExperimentalFeature)] diff --git a/src/Sentry/Protocol/SentryLog.cs b/src/Sentry/SentryLog.cs similarity index 99% rename from src/Sentry/Protocol/SentryLog.cs rename to src/Sentry/SentryLog.cs index 7fd6d760c5..2a605ccc33 100644 --- a/src/Sentry/Protocol/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -1,8 +1,9 @@ using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; +using Sentry.Protocol; -namespace Sentry.Protocol; +namespace Sentry; /// /// Represents the Sentry Log protocol. diff --git a/src/Sentry/Protocol/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs similarity index 98% rename from src/Sentry/Protocol/SentryLogLevel.cs rename to src/Sentry/SentryLogLevel.cs index b1e08ef8bd..37b30174c0 100644 --- a/src/Sentry/Protocol/SentryLogLevel.cs +++ b/src/Sentry/SentryLogLevel.cs @@ -1,6 +1,6 @@ using Sentry.Infrastructure; -namespace Sentry.Protocol; +namespace Sentry; /// /// The severity of the structured log. diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 39df41b2f2..fd7095ef35 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -1,7 +1,6 @@ using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Internal; -using Sentry.Protocol; using Sentry.Protocol.Envelopes; namespace Sentry; diff --git a/test/Sentry.Tests/Protocol/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs similarity index 98% rename from test/Sentry.Tests/Protocol/SentryLogLevelTests.cs rename to test/Sentry.Tests/SentryLogLevelTests.cs index 6a2c6abbf8..9312b25185 100644 --- a/test/Sentry.Tests/Protocol/SentryLogLevelTests.cs +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -1,4 +1,4 @@ -namespace Sentry.Tests.Protocol; +namespace Sentry.Tests; /// /// diff --git a/test/Sentry.Tests/Protocol/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs similarity index 99% rename from test/Sentry.Tests/Protocol/SentryLogTests.cs rename to test/Sentry.Tests/SentryLogTests.cs index 5b0f4835d9..86b4d0b261 100644 --- a/test/Sentry.Tests/Protocol/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -1,6 +1,6 @@ using System.Text.Encodings.Web; -namespace Sentry.Tests.Protocol; +namespace Sentry.Tests; /// /// From 31a8f1feafe02239606093976733ce17cf3979a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 22:30:10 +0200 Subject: [PATCH 031/101] ref(logs): rework sample --- .../Sentry.Samples.Console.Basic/Program.cs | 32 ++++++------------- src/Sentry/Protocol/SentryAttribute.cs | 1 + 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 59120735f6..b1e131615f 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -34,36 +34,20 @@ // This option tells Sentry to capture 100% of traces. You still need to start transactions and spans. options.TracesSampleRate = 1.0; + // This option enables the (experimental) Sentry Logs. options.EnableLogs = true; - options.SetBeforeSendLog(static (SentryLog log) => + options.SetBeforeSendLog(static log => { - if (log.TryGetAttribute("plan.type", out string? attribute) && attribute == "enterprise") + if (log.TryGetAttribute("suppress", out bool attribute) && attribute) { return null; } - return log.Level is SentryLogLevel.Error ? log : null; + // Drop logs with level Info + return log.Level is SentryLogLevel.Info ? null : log; }); }); -var configureLog = static (SentryLog log) => -{ - log.SetAttribute("string-attribute", "value"); - log.SetAttribute("boolean-attribute", true); - log.SetAttribute("integer-attribute", long.MaxValue); - log.SetAttribute("double-attribute", double.MaxValue); -}; - -SentrySdk.Logger.LogTrace("Hello, World!", null, configureLog); -SentrySdk.Logger.LogDebug("Hello, .NET!", null, configureLog); -SentrySdk.Logger.LogInfo("Information", null, configureLog); -SentrySdk.Logger.LogWarning("Warning with one {0}", ["parameter"], configureLog); -SentrySdk.Logger.LogError("Error with {0} {1}", [2, "parameters"], configureLog); -SentrySdk.Logger.LogFatal("Fatal {0} and {1}", [true, false], configureLog); - -await Task.Delay(TimeSpan.FromSeconds(5)); - -/* // This starts a new transaction and attaches it to the scope. var transaction = SentrySdk.StartTransaction("Program Main", "function"); SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); @@ -85,6 +69,7 @@ async Task FirstFunction() var httpClient = new HttpClient(messageHandler, true); var html = await httpClient.GetStringAsync("https://example.com/"); WriteLine(html); + SentrySdk.Logger.LogInfo("HTTP Request completed."); } async Task SecondFunction() @@ -104,6 +89,8 @@ async Task SecondFunction() // This is an example of capturing a handled exception. SentrySdk.CaptureException(exception); span.Finish(exception); + + SentrySdk.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction))); } span.Finish(); @@ -117,6 +104,8 @@ async Task ThirdFunction() // Simulate doing some work await Task.Delay(100); + SentrySdk.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true)); + // This is an example of an unhandled exception. It will be captured automatically. throw new InvalidOperationException("Something happened that crashed the app!"); } @@ -125,4 +114,3 @@ async Task ThirdFunction() span.Finish(); } } -*/ diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 69b95594a7..b4a2c82d99 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -1,5 +1,6 @@ namespace Sentry.Protocol; +[DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")] internal readonly struct SentryAttribute { public SentryAttribute(object value, string type) From 2ae4476c1303168070d498f1986eab325b7726c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 22:46:55 +0200 Subject: [PATCH 032/101] ref(logs): ensure that DisabledHub dues not capture logs --- src/Sentry/Extensibility/DisabledHub.cs | 2 +- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryStructuredLogger.cs | 4 ++-- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 12 ++++++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index a29627308c..b05c1f4d73 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -22,7 +22,7 @@ public class DisabledHub : IHub, IDisposable private DisabledHub() { - Logger = new SentryStructuredLogger(this); + Logger = SentryStructuredLogger.CreateDisabled(this); } /// diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 138cad5001..496073420b 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -66,7 +66,7 @@ internal Hub( PushScope(); } - Logger = new SentryStructuredLogger(this); + Logger = new SentryStructuredLogger(this, ScopeManager, options, _clock); #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index fd7095ef35..2c7ecbef39 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -18,9 +18,9 @@ public sealed class SentryStructuredLogger private readonly SentryOptions? _options; private readonly IInternalScopeManager? _scopeManager; - internal SentryStructuredLogger(IHub hub) - : this(hub, (hub as Hub)?.ScopeManager, hub.GetSentryOptions(), SystemClock.Clock) + internal static SentryStructuredLogger CreateDisabled(IHub hub) { + return new SentryStructuredLogger(hub, null, null, SystemClock.Clock); } internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, SentryOptions? options, ISystemClock clock) diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 1f30e53e6e..d00e007262 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -49,6 +49,7 @@ public void UseScopeManager() } public SentryStructuredLogger GetSut() => new(Hub, ScopeManager, Options, Clock); + public SentryStructuredLogger GetDisabledSut() => SentryStructuredLogger.CreateDisabled(Hub); } private readonly Fixture _fixture; @@ -211,6 +212,17 @@ private static void Throw(SentryLog log) { throw new InvalidOperationException(); } + + [Fact] + public void CreateDisabled_EvenWhenEnabled_DoesNotCaptureEnvelope() + { + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetDisabledSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } } file static class AssertionExtensions From 69678cedb845f9763747a4bb899bcbb0a596df95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 23:29:50 +0200 Subject: [PATCH 033/101] ref(logs): allow out-of-range Log-Level --- .../DiagnosticLoggerExtensions.cs | 22 ++++++++ src/Sentry/SentryLog.cs | 13 +---- src/Sentry/SentryLogLevel.cs | 26 ++++----- test/Sentry.Tests/SentryLogLevelTests.cs | 56 ++++++++----------- 4 files changed, 56 insertions(+), 61 deletions(-) diff --git a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs index 7c3a2e5b6b..3a51399539 100644 --- a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs +++ b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs @@ -58,6 +58,17 @@ internal static void LogDebug( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2); + /// + /// Log a debug message. + /// + public static void LogDebug( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2, arg3); + /// /// Log a debug message. /// @@ -233,6 +244,17 @@ internal static void LogWarning( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2); + /// + /// Log a warning message. + /// + public static void LogWarning( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2, arg3); + /// /// Log a warning message. /// diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 2a605ccc33..ea8eff9934 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -13,7 +13,6 @@ namespace Sentry; public sealed class SentryLog : ISentryJsonSerializable { private readonly Dictionary _attributes; - private readonly SentryLogLevel _level; [SetsRequiredMembers] internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) @@ -47,15 +46,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public required SentryLogLevel Level - { - get => _level; - init - { - SentryLogLevelExtensions.ThrowIfOutOfRange(value); - _level = value; - } - } + public required SentryLogLevel Level { get; init; } /// /// The formatted log message. @@ -236,7 +227,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); - var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(); + var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(logger); writer.WriteString("level", severityText); writer.WriteString("body", Message); diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs index 37b30174c0..13ed27be1e 100644 --- a/src/Sentry/SentryLogLevel.cs +++ b/src/Sentry/SentryLogLevel.cs @@ -1,3 +1,4 @@ +using Sentry.Extensibility; using Sentry.Infrastructure; namespace Sentry; @@ -39,10 +40,11 @@ public enum SentryLogLevel [Experimental(DiagnosticId.ExperimentalFeature)] internal static class SentryLogLevelExtensions { - internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level) + internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level, IDiagnosticLogger? logger) { return (int)level switch { + <= 0 => Underflow(level, logger), 1 => ("trace", null), >= 2 and <= 4 => ("trace", (int)level), 5 => ("debug", null), @@ -55,27 +57,19 @@ internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this Sent >= 18 and <= 20 => ("error", (int)level), 21 => ("fatal", null), >= 22 and <= 24 => ("fatal", (int)level), - _ => ThrowOutOfRange<(string, int?)>(level, nameof(level)), + >= 25 => Overflow(level, logger), }; } - internal static void ThrowIfOutOfRange(SentryLogLevel level, [CallerArgumentExpression(nameof(level))] string? paramName = null) + private static (string, int?) Underflow(SentryLogLevel level, IDiagnosticLogger? logger) { - if ((int)level is < 1 or > 24) - { - ThrowOutOfRange(level, paramName); - } - } - - [DoesNotReturn] - private static void ThrowOutOfRange(SentryLogLevel level, string? paramName) - { - throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); + logger?.LogDebug("Log level {0} out of range ... clamping to minimum value {1} ({2})", level, 1, "trace"); + return ("trace", 1); } - [DoesNotReturn] - private static T ThrowOutOfRange(SentryLogLevel level, string? paramName) + private static (string, int?) Overflow(SentryLogLevel level, IDiagnosticLogger? logger) { - throw new ArgumentOutOfRangeException(paramName, level, "Severity must be between 1 (inclusive) and 24 (inclusive)."); + logger?.LogDebug("Log level {0} out of range ... clamping to maximum value {1} ({2})", level, 24, "fatal"); + return ("fatal", 24); } } diff --git a/test/Sentry.Tests/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs index 9312b25185..93a553ae55 100644 --- a/test/Sentry.Tests/SentryLogLevelTests.cs +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -5,57 +5,45 @@ namespace Sentry.Tests; /// public class SentryLogLevelTests { + private readonly InMemoryDiagnosticLogger _logger; + + public SentryLogLevelTests() + { + _logger = new InMemoryDiagnosticLogger(); + } + [Theory] [MemberData(nameof(SeverityTextAndSeverityNumber))] public void SeverityTextAndSeverityNumber_WithinRange_MatchesProtocol(int level, string text, int? number) { var @enum = (SentryLogLevel)level; - var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(); + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); Assert.Multiple( () => Assert.Equal(text, severityText), () => Assert.Equal(number, severityNumber)); + Assert.Empty(_logger.Entries); } [Theory] - [InlineData(0)] - [InlineData(25)] - public void SeverityTextAndSeverityNumber_OutOfRange_ThrowOutOfRange(int level) + [InlineData(0, "trace", 1, "minimum")] + [InlineData(25, "fatal", 24, "maximum")] + public void SeverityTextAndSeverityNumber_OutOfRange_ClampValue(int level, string text, int? number, string clamp) { var @enum = (SentryLogLevel)level; - var exception = Assert.Throws("level", () => @enum.ToSeverityTextAndOptionalSeverityNumber()); - Assert.StartsWith("Severity must be between 1 (inclusive) and 24 (inclusive).", exception.Message); - Assert.Equal(level, (int)exception.ActualValue!); - } - - [Fact] - public void ThrowOutOfRange_WithinRange_DoesNotThrow() - { - var range = Enumerable.Range(1, 24); + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); - var count = 0; - foreach (var item in range) - { - var level = (SentryLogLevel)item; - SentryLogLevelExtensions.ThrowIfOutOfRange(level); - count++; - } - - Assert.Equal(24, count); - } - - [Theory] - [InlineData(0)] - [InlineData(25)] - public void ThrowOutOfRange_OutOfRange_Throws(int level) - { - var @enum = (SentryLogLevel)level; - - var exception = Assert.Throws("@enum", () => SentryLogLevelExtensions.ThrowIfOutOfRange(@enum)); - Assert.StartsWith("Severity must be between 1 (inclusive) and 24 (inclusive).", exception.Message); - Assert.Equal(level, (int)exception.ActualValue!); + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log level {0} out of range ... clamping to {{clamp}} value {1} ({2})""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([@enum, number, text], entry.Args)); } public static TheoryData SeverityTextAndSeverityNumber() From 97995a8b1b749dbe27fc32e2d8dc11e6f00b29c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 14 May 2025 23:55:50 +0200 Subject: [PATCH 034/101] docs(logs): add XML comments indicating that logs will be ignored on delegate exceptions --- src/Sentry/SentryOptions.cs | 1 + src/Sentry/SentryStructuredLogger.cs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 8c8a7d1f57..f99c83cc1b 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -534,6 +534,7 @@ public void SetBeforeBreadcrumb(Func beforeBreadcrumb) /// /// 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. /// This API is experimental and it may change in the future. /// /// diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 2c7ecbef39..445c73223f 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -40,6 +40,9 @@ internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, S /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled) @@ -52,6 +55,9 @@ public void LogTrace(string template, object[]? parameters = null, Action, when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. [Experimental(DiagnosticId.ExperimentalFeature)] public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) { @@ -65,6 +71,9 @@ public void LogDebug(string template, object[]? parameters = null, Action, when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. [Experimental(DiagnosticId.ExperimentalFeature)] public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) { @@ -78,6 +87,9 @@ public void LogInfo(string template, object[]? parameters = null, Action, when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. [Experimental(DiagnosticId.ExperimentalFeature)] public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) { @@ -91,6 +103,9 @@ public void LogWarning(string template, object[]? parameters = null, Action, when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. [Experimental(DiagnosticId.ExperimentalFeature)] public void LogError(string template, object[]? parameters = null, Action? configureLog = null) { @@ -104,6 +119,9 @@ public void LogError(string template, object[]? parameters = null, Action, when enabled and sampled. /// This API is experimental and it may change in the future. /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. [Experimental(DiagnosticId.ExperimentalFeature)] public void LogFatal(string template, object[]? parameters = null, Action? configureLog = null) { @@ -115,7 +133,7 @@ public void LogFatal(string template, object[]? parameters = null, Action? configureLog) { - Debug.Assert(_options is not null); + Debug.Assert(IsEnabled); var timestamp = _clock.GetUtcNow(); From fbe747d0a700d6c23aea74f6d6c7d53e331db718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 12:24:17 +0200 Subject: [PATCH 035/101] docs(logs): add to changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b1515a0f..cc9e552a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) + ## 5.7.0 ### Features From 64adf334a7112fd17861a93fb6474fc85722dcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 12:24:40 +0200 Subject: [PATCH 036/101] fix(logs): add to Bindable-Options --- src/Sentry/BindableSentryOptions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index cd9e5cc8d8..0b2c7ea0e8 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -20,6 +20,8 @@ internal partial class BindableSentryOptions public string? Distribution { get; set; } public string? Environment { get; set; } public string? Dsn { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public bool? EnableLogs { get; set; } public int? MaxQueueItems { get; set; } public int? MaxCacheItems { get; set; } public TimeSpan? ShutdownTimeout { get; set; } @@ -68,6 +70,7 @@ public void ApplyTo(SentryOptions options) options.Distribution = Distribution ?? options.Distribution; options.Environment = Environment ?? options.Environment; options.Dsn = Dsn ?? options.Dsn; + options.EnableLogs = EnableLogs ?? options.EnableLogs; options.MaxQueueItems = MaxQueueItems ?? options.MaxQueueItems; options.MaxCacheItems = MaxCacheItems ?? options.MaxCacheItems; options.ShutdownTimeout = ShutdownTimeout ?? options.ShutdownTimeout; From cdfa90125d3ca453c581ea99691e5fb57743ad17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 12:41:59 +0200 Subject: [PATCH 037/101] fix(logs): add to ApiApprovalTests --- src/Sentry/SentryStructuredLogger.cs | 1 + ...piApprovalTests.Run.DotNet8_0.verified.txt | 80 +++++++++++++++++++ ...piApprovalTests.Run.DotNet9_0.verified.txt | 80 +++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 445c73223f..569deae952 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -43,6 +43,7 @@ internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, S /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. /// The arguments to the . See System.String.Format. /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { if (IsEnabled) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 2b8f31a57a..ca0f7cefd7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -209,6 +209,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -628,6 +630,56 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, bool value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, double value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, long value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, string value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out bool value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out double value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out long value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -695,6 +747,8 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } @@ -780,6 +834,8 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } @@ -814,6 +870,8 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -947,6 +1005,22 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryStructuredLogger + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1315,6 +1389,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1332,12 +1407,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1380,6 +1458,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 2b8f31a57a..ca0f7cefd7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -209,6 +209,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -628,6 +630,56 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, bool value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, double value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, long value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, string value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out bool value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out double value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, out long value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -695,6 +747,8 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } @@ -780,6 +834,8 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } @@ -814,6 +870,8 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -947,6 +1005,22 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryStructuredLogger + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1315,6 +1389,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1332,12 +1407,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1380,6 +1458,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } From d2ac53bd058f1354fe4252d6562d61efbb01ffa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 13:11:08 +0200 Subject: [PATCH 038/101] test(logs): add missing net48 ApiApproval --- .../ApiApprovalTests.Run.Net4_8.verified.txt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 5f9c1d34fe..ac93299bc0 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -197,6 +197,7 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -616,6 +617,34 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + public Sentry.SentryLogLevel Level { get; init; } + public string Message { get; init; } + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public Sentry.SpanId? ParentSpanId { get; init; } + public string? Template { get; init; } + public System.DateTimeOffset Timestamp { get; init; } + public Sentry.SentryId TraceId { get; init; } + public void SetAttribute(string key, bool value) { } + public void SetAttribute(string key, double value) { } + public void SetAttribute(string key, long value) { } + public void SetAttribute(string key, string value) { } + public bool TryGetAttribute(string key, out bool value) { } + public bool TryGetAttribute(string key, out double value) { } + public bool TryGetAttribute(string key, out long value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -682,6 +711,7 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } + public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } @@ -761,6 +791,7 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } + public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } @@ -795,6 +826,7 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -928,6 +960,15 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + public sealed class SentryStructuredLogger + { + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1296,6 +1337,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1313,12 +1355,14 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1361,6 +1405,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } From 4011ba61a42a42f563bca39c9605b4e91b6955d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 13:43:55 +0200 Subject: [PATCH 039/101] test(logs): fix line endings on Windows --- test/Sentry.Tests/SentryLogTests.cs | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 86b4d0b261..4b3e19a6c5 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -12,11 +12,6 @@ public class SentryLogTests private static readonly SpanId? ParentSpanId = SpanId.Create(); private static readonly ISystemClock Clock = new MockClock(Timestamp); - private static readonly JsonSerializerOptions JsonSerializerOptions = new() - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = true, - }; private readonly IDiagnosticLogger _output; @@ -49,7 +44,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() reader.EndOfStream.Should().BeTrue(); - JsonSerializer.Serialize(header, JsonSerializerOptions).Should().Be($$""" + header.ToIndentedJsonString().Should().Be($$""" { "sdk": { "name": "{{SdkVersion.Instance.Name}}", @@ -59,7 +54,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() } """); - JsonSerializer.Serialize(item, JsonSerializerOptions).Should().Match(""" + item.ToIndentedJsonString().Should().Match(""" { "type": "log", "item_count": 1, @@ -68,7 +63,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() } """); - JsonSerializer.Serialize(payload, JsonSerializerOptions).Should().Be($$""" + payload.ToIndentedJsonString().Should().Be($$""" { "items": [ { @@ -132,7 +127,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() reader.EndOfStream.Should().BeTrue(); - JsonSerializer.Serialize(item, JsonSerializerOptions).Should().Match(""" + item.ToIndentedJsonString().Should().Match(""" { "type": "log", "item_count": 1, @@ -141,7 +136,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() } """); - JsonSerializer.Serialize(payload, JsonSerializerOptions).Should().Be($$""" + payload.ToIndentedJsonString().Should().Be($$""" { "items": [ { @@ -222,3 +217,22 @@ public static string Format(this DateTimeOffset value) return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); } } + +file static class JsonDocumentExtensions +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + public static string ToIndentedJsonString(this JsonDocument document) + { + var json = JsonSerializer.Serialize(document, Options); + + // Standardize on \n on all platforms, for consistency in tests. + return IsWindows ? json.Replace("\r\n", "\n") : json; + } +} From 4ae82d0a0c307c8855263f3eb447c9c3a8ee2c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 14:05:22 +0200 Subject: [PATCH 040/101] Update src/Sentry/Protocol/Envelopes/Envelope.cs Co-authored-by: Bruno Garcia --- src/Sentry/Protocol/Envelopes/Envelope.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 67b6daf55f..d9ac774a60 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -445,6 +445,7 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + // TODO: This is temporary. We don't expect single log messages to become an envelope by themselves since batching is needed [Experimental(DiagnosticId.ExperimentalFeature)] internal static Envelope FromLog(SentryLog log) { From bc1c4652afa5630059c50f61276323940d70e7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 14:09:22 +0200 Subject: [PATCH 041/101] Update SentryLog.cs Co-Authored-By: Bruno Garcia --- src/Sentry/SentryLog.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index ea8eff9934..9f467c94c6 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -21,6 +21,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le TraceId = traceId; Level = level; Message = message; + // 7 is the number of built-in attributes, so we start with that. _attributes = new Dictionary(7); } From 79fb190f4f5164fed33ea527e3a82711d48c8334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 14:48:24 +0200 Subject: [PATCH 042/101] test(logs): fix floating-point ToString expectation for .NET Framework --- test/Sentry.Tests/SentryLogTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 4b3e19a6c5..bdb5d22f5b 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -163,7 +163,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() "type": "integer" }, "sentry.message.parameter.3": { - "value": 2.2, + "value": {{2.2.ToString(NumberFormatInfo.InvariantInfo)/*.NET Core 3.0+ returns the shortest roundtrippable string https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/*/}}, "type": "double" }, "string-attribute": { @@ -179,7 +179,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() "type": "integer" }, "double-attribute": { - "value": 4.4, + "value": {{4.4.ToString(NumberFormatInfo.InvariantInfo)/*.NET Core 3.0+ returns the shortest roundtrippable string https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/*/}}, "type": "double" }, "sentry.environment": { From b4e80f46b249815e1c6a38f910658e7391d4fd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 15:49:45 +0200 Subject: [PATCH 043/101] ref(logs): remove some using declarations --- src/Sentry/Extensibility/DisabledHub.cs | 3 +-- src/Sentry/IHub.cs | 4 +--- src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 3 +-- src/Sentry/SentryOptions.cs | 1 - 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index b05c1f4d73..11c64c6d8c 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -1,4 +1,3 @@ -using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Protocol.Envelopes; using Sentry.Protocol.Metrics; @@ -235,6 +234,6 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// Disabled Logger. /// This API is experimental and it may change in the future. /// - [Experimental(DiagnosticId.ExperimentalFeature)] + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 68fafcd86c..89e2ddc7e4 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -1,5 +1,3 @@ -using Sentry.Infrastructure; - namespace Sentry; /// @@ -30,7 +28,7 @@ public interface IHub : ISentryClient, ISentryScopeManager /// /// /// - [Experimental(DiagnosticId.ExperimentalFeature)] + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] public SentryStructuredLogger Logger { get; } /// diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 0bf9ff94b9..7da1c7b53a 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -1,5 +1,4 @@ using Sentry.Extensibility; -using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Internal.Extensions; using Sentry.Protocol.Metrics; @@ -372,7 +371,7 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } - [Experimental(DiagnosticId.ExperimentalFeature)] + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal static EnvelopeItem FromLog(SentryLog log) { //TODO: allow batching Sentry logs diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index f99c83cc1b..eb1ae58611 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -7,7 +7,6 @@ using Sentry.Internal.Http; using Sentry.Internal.ScopeStack; using Sentry.PlatformAbstractions; -using Sentry.Protocol; using static Sentry.SentryConstants; #if HAS_DIAGNOSTIC_INTEGRATION From b21adefa2cbd890d9cbc4a5b9c7226654fead15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 17:22:16 +0200 Subject: [PATCH 044/101] test(ci): trying to work around floating-point formatter on .NET Framework --- test/Sentry.Tests/SentryLogTests.cs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index bdb5d22f5b..b55f2d8715 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -163,7 +163,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() "type": "integer" }, "sentry.message.parameter.3": { - "value": {{2.2.ToString(NumberFormatInfo.InvariantInfo)/*.NET Core 3.0+ returns the shortest roundtrippable string https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/*/}}, + "value": {{2.2.Format()}}, "type": "double" }, "string-attribute": { @@ -179,7 +179,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() "type": "integer" }, "double-attribute": { - "value": {{4.4.ToString(NumberFormatInfo.InvariantInfo)/*.NET Core 3.0+ returns the shortest roundtrippable string https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/*/}}, + "value": {{4.4.Format()}}, "type": "double" }, "sentry.environment": { @@ -212,10 +212,30 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() file static class JsonFormatterExtensions { + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public static string Format(this DateTimeOffset value) { return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); } + + public static string Format(this double value) + { + // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string + // e.g. on .NET Framework (Windows) + // * 2.2.ToString() -> 2.2000000000000002 + // * 4.4.ToString() -> 4.4000000000000004 + // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ + + if (!IsWindows) + { + return value.ToString(NumberFormatInfo.InvariantInfo); + } + + var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); + var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); + return Encoding.UTF8.GetString(utf8Bytes); + } } file static class JsonDocumentExtensions From 003285827ea80aedb14c75f2fcbf929bdef97e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 17:29:28 +0200 Subject: [PATCH 045/101] test(logs): skip failing tests on Mono (non-Windows) --- test/Sentry.Tests/SentryLogTests.cs | 3 +++ test/Sentry.Tests/SentryStructuredLoggerTests.cs | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index b55f2d8715..e692b90f17 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -1,4 +1,5 @@ using System.Text.Encodings.Web; +using Sentry.PlatformAbstractions; namespace Sentry.Tests; @@ -98,6 +99,8 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() [Fact] public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() { + Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); + var options = new SentryOptions { Environment = "my-environment", diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index d00e007262..7968e72473 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -1,5 +1,7 @@ #nullable enable +using Sentry.PlatformAbstractions; + namespace Sentry.Tests; /// @@ -68,6 +70,8 @@ public SentryStructuredLoggerTests() [InlineData(SentryLogLevel.Fatal)] public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { + Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); + _fixture.Options.EnableLogs = true; var logger = _fixture.GetSut(); @@ -100,6 +104,8 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) [Fact] public void Log_UseScopeManager_CapturesEnvelope() { + Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); + _fixture.UseScopeManager(); _fixture.Options.EnableLogs = true; var logger = _fixture.GetSut(); @@ -116,6 +122,8 @@ public void Log_UseScopeManager_CapturesEnvelope() [Fact] public void Log_WithBeforeSendLog_InvokesCallback() { + Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); + var invocations = 0; SentryLog configuredLog = null!; From a9769f8129ae4287cec13f00b24aeba69029c21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 19:24:04 +0200 Subject: [PATCH 046/101] test(log): fix Skip.If missing SkippableFact --- test/Sentry.Tests/SentryLogTests.cs | 5 +---- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 14 +++----------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index e692b90f17..33b8c04299 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -1,5 +1,4 @@ using System.Text.Encodings.Web; -using Sentry.PlatformAbstractions; namespace Sentry.Tests; @@ -96,11 +95,9 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() """); } - [Fact] + [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() { - Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); - var options = new SentryOptions { Environment = "my-environment", diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 7968e72473..d20e45bdb8 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -1,7 +1,5 @@ #nullable enable -using Sentry.PlatformAbstractions; - namespace Sentry.Tests; /// @@ -61,7 +59,7 @@ public SentryStructuredLoggerTests() _fixture = new Fixture(); } - [Theory] + [SkippableTheory(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 [InlineData(SentryLogLevel.Trace)] [InlineData(SentryLogLevel.Debug)] [InlineData(SentryLogLevel.Info)] @@ -70,8 +68,6 @@ public SentryStructuredLoggerTests() [InlineData(SentryLogLevel.Fatal)] public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { - Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); - _fixture.Options.EnableLogs = true; var logger = _fixture.GetSut(); @@ -101,11 +97,9 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); } - [Fact] + [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 public void Log_UseScopeManager_CapturesEnvelope() { - Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); - _fixture.UseScopeManager(); _fixture.Options.EnableLogs = true; var logger = _fixture.GetSut(); @@ -119,11 +113,9 @@ public void Log_UseScopeManager_CapturesEnvelope() envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); } - [Fact] + [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 public void Log_WithBeforeSendLog_InvokesCallback() { - Skip.If(RuntimeInfo.GetRuntime().IsMono(), "System.MissingMethodException on System.Collections.Immutable.ImmutableArray`1"); - var invocations = 0; SentryLog configuredLog = null!; From 9a510332b3aadec7294c5dd7ad87e2ddbe073ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 15 May 2025 19:28:35 +0200 Subject: [PATCH 047/101] try: fix floating-point formatting on Windows --- test/Sentry.Tests/SentryLogTests.cs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 33b8c04299..e9f094722a 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -221,20 +221,22 @@ public static string Format(this DateTimeOffset value) public static string Format(this double value) { - // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string - // e.g. on .NET Framework (Windows) - // * 2.2.ToString() -> 2.2000000000000002 - // * 4.4.ToString() -> 4.4000000000000004 - // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ - - if (!IsWindows) +#if NETFRAMEWORK + if (IsWindows) { - return value.ToString(NumberFormatInfo.InvariantInfo); + // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string + // e.g. on .NET Framework (Windows) + // * 2.2.ToString() -> 2.2000000000000002 + // * 4.4.ToString() -> 4.4000000000000004 + // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ + + var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); + var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); + return Encoding.UTF8.GetString(utf8Bytes); } +#endif - var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); - var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); - return Encoding.UTF8.GetString(utf8Bytes); + return value.ToString(NumberFormatInfo.InvariantInfo); } } From 8d034494d0815374a58e1831865636c4564568ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 16 May 2025 21:37:09 +0200 Subject: [PATCH 048/101] feat(logs): Sentry.Extensions.Logging --- samples/Sentry.Samples.ME.Logging/Program.cs | 10 ++ .../BindableSentryLoggingOptions.cs | 3 + .../LoggingBuilderExtensions.cs | 8 ++ .../SentryLoggingOptions.cs | 19 +++- .../SentryStructuredLogger.cs | 96 +++++++++++++++++++ .../SentryStructuredLoggerProvider.cs | 32 +++++++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 2 + ...piApprovalTests.Run.DotNet9_0.verified.txt | 2 + .../ApiApprovalTests.Run.Net4_8.verified.txt | 1 + .../SentryLoggingOptionsSetupTests.cs | 6 ++ .../SentryStructuredLoggerProviderTests.cs | 25 +++++ .../SentryStructuredLoggerTests.cs | 38 ++++++++ 12 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/Sentry.Extensions.Logging/SentryStructuredLogger.cs create mode 100644 src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs create mode 100644 test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs create mode 100644 test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs diff --git a/samples/Sentry.Samples.ME.Logging/Program.cs b/samples/Sentry.Samples.ME.Logging/Program.cs index e782f184bc..c2d362b054 100644 --- a/samples/Sentry.Samples.ME.Logging/Program.cs +++ b/samples/Sentry.Samples.ME.Logging/Program.cs @@ -19,7 +19,17 @@ // Optionally configure options: The default values are: options.MinimumBreadcrumbLevel = LogLevel.Information; // It requires at least this level to store breadcrumb options.MinimumEventLevel = LogLevel.Error; // This level or above will result in event sent to Sentry + options.MinimumLogLevel = LogLevel.Trace; // This level or above will result in log sent to Sentry + // This option enables the (experimental) Sentry Logs. + options.EnableLogs = true; + options.SetBeforeSendLog(static log => + { + log.SetAttribute("attribute-key", "attribute-value"); + return log; + }); + + // TODO: AddLogEntryFilter // Don't keep as a breadcrumb or send events for messages of level less than Critical with exception of type DivideByZeroException options.AddLogEntryFilter((_, level, _, exception) => level < LogLevel.Critical && exception is DivideByZeroException); diff --git a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs index 299e61a21c..648e6c6a2a 100644 --- a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs @@ -7,6 +7,8 @@ internal class BindableSentryLoggingOptions : BindableSentryOptions { public LogLevel? MinimumBreadcrumbLevel { get; set; } public LogLevel? MinimumEventLevel { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public LogLevel? MinimumLogLevel { get; set; } public bool? InitializeSdk { get; set; } public void ApplyTo(SentryLoggingOptions options) @@ -14,6 +16,7 @@ public void ApplyTo(SentryLoggingOptions options) base.ApplyTo(options); options.MinimumBreadcrumbLevel = MinimumBreadcrumbLevel ?? options.MinimumBreadcrumbLevel; options.MinimumEventLevel = MinimumEventLevel ?? options.MinimumEventLevel; + options.MinimumLogLevel = MinimumLogLevel ?? options.MinimumLogLevel; options.InitializeSdk = InitializeSdk ?? options.InitializeSdk; } } diff --git a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs index 9b79803de0..97ffb6d205 100644 --- a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs +++ b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs @@ -51,6 +51,7 @@ internal static ILoggingBuilder AddSentry( builder.Services.AddSingleton, SentryLoggingOptionsSetup>(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSentry(); // All logs should flow to the SentryLogger, regardless of level. @@ -58,6 +59,13 @@ internal static ILoggingBuilder AddSentry( // Filtering of breadcrumbs is handled in SentryLogger, using SentryOptions.MinimumBreadcrumbLevel builder.AddFilter(_ => true); + // Logs from the SentryLogger should not flow to the SentryStructuredLogger as this may cause recursive invocations. + // Filtering of logs is handled in SentryStructuredLogger, using SentryOptions.MinimumLogLevel + builder.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null || !categoryName.StartsWith("Sentry"); + }); + return builder; } } diff --git a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs index 52bd4a0260..f2528af338 100644 --- a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs @@ -11,7 +11,9 @@ public class SentryLoggingOptions : SentryOptions /// /// Gets or sets the minimum breadcrumb level. /// - /// Events with this level or higher will be stored as + /// + /// Events with this level or higher will be stored as . + /// /// /// The minimum breadcrumb level. /// @@ -21,13 +23,26 @@ public class SentryLoggingOptions : SentryOptions /// Gets or sets the minimum event level. /// /// - /// Events with this level or higher will be sent to Sentry + /// Events with this level or higher will be sent to Sentry. /// /// /// The minimum event level. /// public LogLevel MinimumEventLevel { get; set; } = LogLevel.Error; + /// + /// Gets or sets the minimum log level. + /// This API is experimental and it may change in the future. + /// + /// + /// Logs with this level or higher will be stored as . + /// + /// + /// The minimum log level. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; + /// /// Whether to initialize this SDK through this integration /// diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs new file mode 100644 index 0000000000..0d44d346bd --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Logging; + +namespace Sentry.Extensions.Logging; + +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryStructuredLogger : ILogger +{ + private readonly string _categoryName; + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + + internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub) + { + _categoryName = categoryName; + _options = options; + _hub = hub; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return NullDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return _hub.IsEnabled + && _options.EnableLogs + && logLevel != LogLevel.None + && logLevel >= _options.MinimumLogLevel; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + // not quite ideal as this is a boxing allocation from Microsoft.Extensions.Logging.FormattedLogValues + /* + string? template = null; + object[]? parameters = null; + if (state is IReadOnlyList> formattedLogValues) + { + foreach (var formattedLogValue in formattedLogValues) + { + if (formattedLogValue.Key == "{OriginalFormat}" && formattedLogValue.Value is string formattedString) + { + template = formattedString; + break; + } + } + } + */ + + string message = formatter.Invoke(state, exception); + + switch (logLevel) + { + case LogLevel.Trace: + _hub.Logger.LogTrace(message); + break; + case LogLevel.Debug: + _hub.Logger.LogDebug(message); + break; + case LogLevel.Information: + _hub.Logger.LogInfo(message); + break; + case LogLevel.Warning: + _hub.Logger.LogWarning(message); + break; + case LogLevel.Error: + _hub.Logger.LogError(message); + break; + case LogLevel.Critical: + _hub.Logger.LogFatal(message); + break; + case LogLevel.None: + default: + break; + } + } +} + +file sealed class NullDisposable : IDisposable +{ + public static NullDisposable Instance { get; } = new NullDisposable(); + + private NullDisposable() + { + } + + public void Dispose() + { + } +} diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs new file mode 100644 index 0000000000..47917d16fb --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging; + +/// +/// Sentry Structured Logger Provider. +/// +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryStructuredLoggerProvider : ILoggerProvider +{ + private readonly IOptions _options; + private readonly IHub _hub; + + // TODO: convert this comment into an automated test + // Constructor must be public for Microsoft.Extensions.DependencyInjection + public SentryStructuredLoggerProvider(IOptions options, IHub hub) + { + _options = options; + _hub = hub; + } + + public ILogger CreateLogger(string categoryName) + { + return new SentryStructuredLogger(categoryName, _options.Value, _hub); + } + + public void Dispose() + { + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index b438b0af45..847ff1fb6f 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -41,6 +41,8 @@ namespace Sentry.Extensions.Logging public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } } public static class SentryLoggingOptionsExtensions diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index b438b0af45..847ff1fb6f 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -41,6 +41,8 @@ namespace Sentry.Extensions.Logging public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } } public static class SentryLoggingOptionsExtensions diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index b438b0af45..3439430f0b 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -41,6 +41,7 @@ namespace Sentry.Extensions.Logging public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } } public static class SentryLoggingOptionsExtensions diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index 778215de16..d4a3e42c1e 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -25,6 +25,7 @@ public void Configure_BindsConfigurationToOptions() Distribution = "FakeDistribution", Environment = "Test", Dsn = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647", + EnableLogs = true, MaxQueueItems = 8, MaxCacheItems = 9, ShutdownTimeout = TimeSpan.FromSeconds(13), @@ -55,6 +56,7 @@ public void Configure_BindsConfigurationToOptions() MinimumBreadcrumbLevel = LogLevel.Debug, MinimumEventLevel = LogLevel.Error, + MinimumLogLevel = LogLevel.None, InitializeSdk = true }; var config = new ConfigurationBuilder() @@ -74,6 +76,7 @@ public void Configure_BindsConfigurationToOptions() ["Distribution"] = expected.Distribution, ["Environment"] = expected.Environment, ["Dsn"] = expected.Dsn, + ["EnableLogs"] = expected.EnableLogs.ToString(), ["MaxQueueItems"] = expected.MaxQueueItems.ToString(), ["MaxCacheItems"] = expected.MaxCacheItems.ToString(), ["ShutdownTimeout"] = expected.ShutdownTimeout.ToString(), @@ -105,6 +108,7 @@ public void Configure_BindsConfigurationToOptions() ["JsonPreserveReferences"] = expected.JsonPreserveReferences.ToString(), ["MinimumBreadcrumbLevel"] = expected.MinimumBreadcrumbLevel.ToString(), ["MinimumEventLevel"] = expected.MinimumEventLevel.ToString(), + ["MinimumLogLevel"] = expected.MinimumLogLevel.ToString(), ["InitializeSdk"] = expected.InitializeSdk.ToString(), }) .Build(); @@ -134,6 +138,7 @@ public void Configure_BindsConfigurationToOptions() actual.Distribution.Should().Be(expected.Distribution); actual.Environment.Should().Be(expected.Environment); actual.Dsn.Should().Be(expected.Dsn); + actual.EnableLogs.Should().Be(expected.EnableLogs); actual.MaxQueueItems.Should().Be(expected.MaxQueueItems); actual.MaxCacheItems.Should().Be(expected.MaxCacheItems); actual.ShutdownTimeout.Should().Be(expected.ShutdownTimeout); @@ -162,6 +167,7 @@ public void Configure_BindsConfigurationToOptions() actual.MinimumBreadcrumbLevel.Should().Be(expected.MinimumBreadcrumbLevel); actual.MinimumEventLevel.Should().Be(expected.MinimumEventLevel); + actual.MinimumLogLevel.Should().Be(expected.MinimumLogLevel); actual.InitializeSdk.Should().Be(expected.InitializeSdk); } } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..46f78755c8 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerProviderTests +{ + [Fact] + public void SmokeTest() + { + IOptions options = Options.Create(new SentryLoggingOptions + { + EnableLogs = true, + }); + IHub hub = Substitute.For(); + + var provider = new SentryStructuredLoggerProvider(options, hub); + + ILogger logger = provider.CreateLogger("categoryName"); + + logger.Should().BeOfType(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..bc659d66d2 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,38 @@ +#nullable enable + +using Microsoft.Extensions.Logging; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerTests +{ + [Fact] + public void SmokeTest() + { + string categoryName = "CategoryName"; + SentryLoggingOptions options = new() + { + EnableLogs = true, + }; + IHub hub = Substitute.For(); + hub.IsEnabled.Returns(true); + hub.Logger.Returns(Sentry.SentryStructuredLogger.CreateDisabled(hub)); + + var logger = new SentryStructuredLogger(categoryName, options, hub); + + IDisposable? disposable = logger.BeginScope("state"); + disposable.Should().NotBeNull(); + + logger.IsEnabled(LogLevel.Warning).Should().BeTrue(); + + EventId eventId = new(1, "eventId"); + Exception exception = new InvalidOperationException(); + Func formatter = (string state, Exception? exception) => + { + state.Should().Be("state"); + exception.Should().BeOfType(); + return "Message"; + }; + logger.Log(LogLevel.Warning, eventId, "state", exception, formatter); + } +} From 1bc3a6f17d93e9a26dbe0014e923b181949f6c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 16 May 2025 22:05:12 +0200 Subject: [PATCH 049/101] feat(logs): Sentry.AspNetCore --- .../Sentry.Samples.AspNetCore.Basic/Program.cs | 3 +++ ...SentryAspNetCoreStructuredLoggerProvider.cs | 18 ++++++++++++++++++ .../SentryWebHostBuilderExtensions.cs | 5 +++++ .../SentryStructuredLoggerProvider.cs | 2 +- 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs index a5757beb4f..a9d7c468e7 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs +++ b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs @@ -12,6 +12,9 @@ // Log debug information about the Sentry SDK options.Debug = true; #endif + + // This option enables the (experimental) Sentry Logs. + options.EnableLogs = true; }); var app = builder.Build(); diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs new file mode 100644 index 0000000000..cb77cc7c8b --- /dev/null +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; + +namespace Sentry.AspNetCore; + +/// +/// Structured Logger Provider for Sentry. +/// +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) + : base(options, hub) + { + } +} diff --git a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs index c00217368b..58c368bd60 100644 --- a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs @@ -93,10 +93,15 @@ public static IWebHostBuilder UseSentry( _ = logging.Services .AddSingleton, SentryAspNetCoreOptionsSetup>(); _ = logging.Services.AddSingleton(); + _ = logging.Services.AddSingleton(); _ = logging.AddFilter( "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware", LogLevel.None); + _ = logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null || !categoryName.StartsWith("Sentry"); + }); var sentryBuilder = logging.Services.AddSentry(); configureSentry?.Invoke(context, sentryBuilder); diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs index 47917d16fb..1f28db6320 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -8,7 +8,7 @@ namespace Sentry.Extensions.Logging; /// [ProviderAlias("SentryLogs")] [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] -internal sealed class SentryStructuredLoggerProvider : ILoggerProvider +internal class SentryStructuredLoggerProvider : ILoggerProvider { private readonly IOptions _options; private readonly IHub _hub; From 7624baaba21264a0f38015825757307b288ff04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 16 May 2025 22:28:23 +0200 Subject: [PATCH 050/101] feat(logs): Sentry.Maui --- samples/Sentry.Samples.Maui/MauiProgram.cs | 1 + .../SentryMauiStructuredLoggerProvider.cs | 15 +++++++++++++++ src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs | 1 + 3 files changed, 17 insertions(+) create mode 100644 src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs diff --git a/samples/Sentry.Samples.Maui/MauiProgram.cs b/samples/Sentry.Samples.Maui/MauiProgram.cs index 22c206bb8c..0d34ff6b14 100644 --- a/samples/Sentry.Samples.Maui/MauiProgram.cs +++ b/samples/Sentry.Samples.Maui/MauiProgram.cs @@ -23,6 +23,7 @@ public static MauiApp CreateMauiApp() options.AttachScreenshot = true; options.Debug = true; + options.EnableLogs = true; options.SampleRate = 1.0F; #if __ANDROID__ diff --git a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs new file mode 100644 index 0000000000..97efa2e9e5 --- /dev/null +++ b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; + +namespace Sentry.Maui.Internal; + +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryMauiStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryMauiStructuredLoggerProvider(IOptions options, IHub hub) + : base(options, hub) + { + } +} diff --git a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs index c893e38d2f..5aa73d4bf8 100644 --- a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs +++ b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs @@ -51,6 +51,7 @@ public static MauiAppBuilder UseSentry(this MauiAppBuilder builder, services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, SentryMauiOptionsSetup>(); services.AddSingleton(); From 11fe02bae18fe7017187affb7cf0e0f6b11580f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 16 May 2025 22:47:28 +0200 Subject: [PATCH 051/101] fix(logs): Logging Filter --- src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs | 3 ++- src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs | 3 ++- src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs index 58c368bd60..2b5f74bf4d 100644 --- a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs @@ -100,7 +100,8 @@ public static IWebHostBuilder UseSentry( LogLevel.None); _ = logging.AddFilter(static (string? categoryName, LogLevel logLevel) => { - return categoryName is null || !categoryName.StartsWith("Sentry"); + return categoryName is null + || (categoryName != "Sentry.ISentryClient" && categoryName != "Sentry.AspNetCore.SentryMiddleware"); }); var sentryBuilder = logging.Services.AddSentry(); diff --git a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs index 97ffb6d205..f2a4957c11 100644 --- a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs +++ b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs @@ -63,7 +63,8 @@ internal static ILoggingBuilder AddSentry( // Filtering of logs is handled in SentryStructuredLogger, using SentryOptions.MinimumLogLevel builder.AddFilter(static (string? categoryName, LogLevel logLevel) => { - return categoryName is null || !categoryName.StartsWith("Sentry"); + return categoryName is null + || categoryName != "Sentry.ISentryClient"; }); return builder; diff --git a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs index 5aa73d4bf8..7796f264e0 100644 --- a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs +++ b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs @@ -56,6 +56,12 @@ public static MauiAppBuilder UseSentry(this MauiAppBuilder builder, services.AddSingleton, SentryMauiOptionsSetup>(); services.AddSingleton(); + builder.Logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || categoryName != "Sentry.ISentryClient"; + }); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From fd532cad99e34ec2f4b24c905645081f356108fa Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 16 May 2025 21:04:43 +0000 Subject: [PATCH 052/101] Format code --- .../SentryLoggingOptionsSetupTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index d4a3e42c1e..658615cc77 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -25,7 +25,7 @@ public void Configure_BindsConfigurationToOptions() Distribution = "FakeDistribution", Environment = "Test", Dsn = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647", - EnableLogs = true, + EnableLogs = true, MaxQueueItems = 8, MaxCacheItems = 9, ShutdownTimeout = TimeSpan.FromSeconds(13), From 1fe9cc03eb3a89eb1ea68f6749d28d0f38e02aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 16 May 2025 23:12:09 +0200 Subject: [PATCH 053/101] feat(logs): add CHANGELOG entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9e552a0e..f400150b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Features - Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) +- Add experimental integrations of _Sentry Structured Logging_ ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.EnableLogs` + - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.EnableLogs` + - `Sentry.Maui`, enabled via `SentryMauiOptions.EnableLogs` ## 5.7.0 From 3e6dba5f8fa2c471b98228a930f492c9a2ad2d78 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 21 May 2025 15:11:54 +0000 Subject: [PATCH 054/101] release: 5.8.0-alpha.0 --- CHANGELOG.md | 3 ++- Directory.Build.props | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e8a81e25..1e47afd40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 5.8.0-alpha.0 ### Features @@ -9,6 +9,7 @@ - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.EnableLogs` - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.EnableLogs` - `Sentry.Maui`, enabled via `SentryMauiOptions.EnableLogs` + ## 5.8.0 ### Features diff --git a/Directory.Build.props b/Directory.Build.props index 051013938d..b35ace81c7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 5.8.0 + 5.8.0-alpha.0 13 true true From 72c9a9386d2d76bea0f394b10396dded071e3264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:42:50 +0200 Subject: [PATCH 055/101] ref: make SentryStructuredLogger abstract --- src/Sentry/Extensibility/DisabledHub.cs | 3 +- .../Internal/DefaultSentryStructuredLogger.cs | 131 ++++++++++++++ .../DisabledSentryStructuredLogger.cs | 15 ++ src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryStructuredLogger.cs | 160 +----------------- ...piApprovalTests.Run.DotNet8_0.verified.txt | 2 +- ...piApprovalTests.Run.DotNet9_0.verified.txt | 2 +- .../ApiApprovalTests.Run.Net4_8.verified.txt | 2 +- test/Sentry.Tests/SentryLogTests.cs | 7 +- .../SentryStructuredLoggerTests.cs | 20 +-- 10 files changed, 172 insertions(+), 172 deletions(-) create mode 100644 src/Sentry/Internal/DefaultSentryStructuredLogger.cs create mode 100644 src/Sentry/Internal/DisabledSentryStructuredLogger.cs diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index 271991f363..c8d2987a7a 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -21,7 +21,6 @@ public class DisabledHub : IHub, IDisposable private DisabledHub() { - Logger = SentryStructuredLogger.CreateDisabled(this); } /// @@ -249,5 +248,5 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// This API is experimental and it may change in the future. /// [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public SentryStructuredLogger Logger { get; } + public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs new file mode 100644 index 0000000000..eb1c0b8d40 --- /dev/null +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -0,0 +1,131 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal; + +internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger +{ + private readonly IHub _hub; + private readonly IInternalScopeManager _scopeManager; + private readonly SentryOptions _options; + private readonly ISystemClock _clock; + + private readonly bool _isEnabled; + + internal DefaultSentryStructuredLogger(IHub hub, IInternalScopeManager scopeManager, SentryOptions options, ISystemClock clock) + { + _hub = hub; + _scopeManager = scopeManager; + _options = options; + _clock = clock; + + _isEnabled = options is { EnableLogs: true }; + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + if (!_isEnabled) + { + return; + } + + var timestamp = _clock.GetUtcNow(); + + if (!TryGetTraceId(_hub, _scopeManager, out var traceId)) + { + _options.DiagnosticLogger?.LogWarning("TraceId not found"); + } + + _ = TryGetParentSpanId(_hub, _scopeManager, out var parentSpanId); + + string message; + try + { + message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } + + SentryLog log = new(timestamp, traceId, level, message) + { + Template = template, + Parameters = ImmutableArray.Create(parameters), + ParentSpanId = parentSpanId, + }; + + try + { + configureLog?.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); + return; + } + + log.SetAttributes(_options); + + var configuredLog = log; + if (_options.BeforeSendLogInternal is { } beforeSendLog) + { + try + { + configuredLog = beforeSendLog.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); + return; + } + } + + if (configuredLog is not null) + { + //TODO: enqueue in Batch-Processor / Background-Worker + // see https://github.com/getsentry/sentry-dotnet/issues/4132 + _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + } + } + + private static bool TryGetTraceId(IHub hub, IInternalScopeManager? scopeManager, out SentryId traceId) + { + if (hub.GetSpan() is { } span) + { + traceId = span.TraceId; + return true; + } + + if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + traceId = currentScope.PropagationContext.TraceId; + return true; + } + + traceId = SentryId.Empty; + return false; + } + + private static bool TryGetParentSpanId(IHub hub, IInternalScopeManager? scopeManager, out SpanId? parentSpanId) + { + if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) + { + parentSpanId = span.ParentSpanId; + return true; + } + + if (scopeManager is not null) + { + var currentScope = scopeManager.GetCurrent().Key; + parentSpanId = currentScope.PropagationContext.ParentSpanId; + return true; + } + + parentSpanId = null; + return false; + } +} diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs new file mode 100644 index 0000000000..086f67a1bd --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -0,0 +1,15 @@ +namespace Sentry.Internal; + +internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger +{ + internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger(); + + internal DisabledSentryStructuredLogger() + { + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + // disabled + } +} diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index ee4b12a93b..021169ed25 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -66,7 +66,7 @@ internal Hub( PushScope(); } - Logger = new SentryStructuredLogger(this, ScopeManager, options, _clock); + Logger = new DefaultSentryStructuredLogger(this, ScopeManager, options, _clock); #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 569deae952..4edee3ec68 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -1,7 +1,4 @@ -using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Internal; -using Sentry.Protocol.Envelopes; namespace Sentry; @@ -10,31 +7,13 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public sealed class SentryStructuredLogger +public abstract class SentryStructuredLogger { - private readonly IHub _hub; - private readonly ISystemClock _clock; - - private readonly SentryOptions? _options; - private readonly IInternalScopeManager? _scopeManager; - - internal static SentryStructuredLogger CreateDisabled(IHub hub) + private protected SentryStructuredLogger() { - return new SentryStructuredLogger(hub, null, null, SystemClock.Clock); } - internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, SentryOptions? options, ISystemClock clock) - { - _hub = hub; - _clock = clock; - - _options = options; - _scopeManager = scopeManager; - IsEnabled = options is { EnableLogs: true }; - } - - [MemberNotNullWhen(true, nameof(_options))] - private bool IsEnabled { get; } + private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); /// /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. @@ -46,10 +25,7 @@ internal SentryStructuredLogger(IHub hub, IInternalScopeManager? scopeManager, S [Experimental(DiagnosticId.ExperimentalFeature)] public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); - } + CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); } /// @@ -62,10 +38,7 @@ public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Debug, template, parameters, configureLog); - } + CaptureLog(SentryLogLevel.Debug, template, parameters, configureLog); } /// @@ -78,10 +51,7 @@ public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Info, template, parameters, configureLog); - } + CaptureLog(SentryLogLevel.Info, template, parameters, configureLog); } /// @@ -94,10 +64,7 @@ public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Warning, template, parameters, configureLog); - } + CaptureLog(SentryLogLevel.Warning, template, parameters, configureLog); } /// @@ -110,10 +77,7 @@ public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Error, template, parameters, configureLog); - } + CaptureLog(SentryLogLevel.Error, template, parameters, configureLog); } /// @@ -126,112 +90,6 @@ public void LogError(string template, object[]? parameters = null, Action? configureLog = null) { - if (IsEnabled) - { - CaptureLog(SentryLogLevel.Fatal, template, parameters, configureLog); - } - } - - private void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) - { - Debug.Assert(IsEnabled); - - var timestamp = _clock.GetUtcNow(); - - if (!TryGetTraceId(_hub, _scopeManager, out var traceId)) - { - _options.DiagnosticLogger?.LogWarning("TraceId not found"); - } - - _ = TryGetParentSpanId(_hub, _scopeManager, out var parentSpanId); - - string message; - try - { - message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); - } - catch (FormatException e) - { - _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); - return; - } - - SentryLog log = new(timestamp, traceId, level, message) - { - Template = template, - Parameters = ImmutableArray.Create(parameters), - ParentSpanId = parentSpanId, - }; - - try - { - configureLog?.Invoke(log); - } - catch (Exception e) - { - _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); - return; - } - - log.SetAttributes(_options); - - var configuredLog = log; - if (_options.BeforeSendLogInternal is { } beforeSendLog) - { - try - { - configuredLog = beforeSendLog.Invoke(log); - } - catch (Exception e) - { - _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); - return; - } - } - - if (configuredLog is not null) - { - //TODO: enqueue in Batch-Processor / Background-Worker - // see https://github.com/getsentry/sentry-dotnet/issues/4132 - _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); - } - } - - private static bool TryGetTraceId(IHub hub, IInternalScopeManager? scopeManager, out SentryId traceId) - { - if (hub.GetSpan() is { } span) - { - traceId = span.TraceId; - return true; - } - - if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - traceId = currentScope.PropagationContext.TraceId; - return true; - } - - traceId = SentryId.Empty; - return false; - } - - private static bool TryGetParentSpanId(IHub hub, IInternalScopeManager? scopeManager, out SpanId? parentSpanId) - { - if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) - { - parentSpanId = span.ParentSpanId; - return true; - } - - if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - parentSpanId = currentScope.PropagationContext.ParentSpanId; - return true; - } - - parentSpanId = null; - return false; + CaptureLog(SentryLogLevel.Fatal, template, parameters, configureLog); } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 1c1e14c443..214f27dc01 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1011,7 +1011,7 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public sealed class SentryStructuredLogger + public abstract class SentryStructuredLogger { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 1c1e14c443..214f27dc01 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1011,7 +1011,7 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public sealed class SentryStructuredLogger + public abstract class SentryStructuredLogger { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 83210384ff..de83123541 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -965,7 +965,7 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } - public sealed class SentryStructuredLogger + public abstract class SentryStructuredLogger { public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index e9f094722a..6258f5d06e 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -1,4 +1,5 @@ using System.Text.Encodings.Web; +using Sentry.PlatformAbstractions; namespace Sentry.Tests; @@ -212,8 +213,6 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() file static class JsonFormatterExtensions { - private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - public static string Format(this DateTimeOffset value) { return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); @@ -221,8 +220,7 @@ public static string Format(this DateTimeOffset value) public static string Format(this double value) { -#if NETFRAMEWORK - if (IsWindows) + if (SentryRuntime.Current.IsNetFx() || SentryRuntime.Current.IsMono()) { // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string // e.g. on .NET Framework (Windows) @@ -234,7 +232,6 @@ public static string Format(this double value) var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); return Encoding.UTF8.GetString(utf8Bytes); } -#endif return value.ToString(NumberFormatInfo.InvariantInfo); } diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index d20e45bdb8..3f50aeac8c 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -48,8 +48,8 @@ public void UseScopeManager() ScopeManager.GetCurrent().Returns(scopeAndClient); } - public SentryStructuredLogger GetSut() => new(Hub, ScopeManager, Options, Clock); - public SentryStructuredLogger GetDisabledSut() => SentryStructuredLogger.CreateDisabled(Hub); + public SentryStructuredLogger GetDefaultSut() => new DefaultSentryStructuredLogger(Hub, ScopeManager, Options, Clock); + public SentryStructuredLogger GetDisabledSut() => new DisabledSentryStructuredLogger(); } private readonly Fixture _fixture; @@ -69,7 +69,7 @@ public SentryStructuredLoggerTests() public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { _fixture.Options.EnableLogs = true; - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); Envelope envelope = null!; _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); @@ -90,7 +90,7 @@ public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) { _fixture.Options.EnableLogs.Should().BeFalse(); - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -102,7 +102,7 @@ public void Log_UseScopeManager_CapturesEnvelope() { _fixture.UseScopeManager(); _fixture.Options.EnableLogs = true; - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); Envelope envelope = null!; _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); @@ -126,7 +126,7 @@ public void Log_WithBeforeSendLog_InvokesCallback() configuredLog = log; return log; }); - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -146,7 +146,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() invocations++; return null; }); - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -158,7 +158,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() public void Log_InvalidFormat_DoesNotCaptureEnvelope() { _fixture.Options.EnableLogs = true; - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); @@ -174,7 +174,7 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() { _fixture.Options.EnableLogs = true; - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], Throw); @@ -191,7 +191,7 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() { _fixture.Options.EnableLogs = true; _fixture.Options.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); - var logger = _fixture.GetSut(); + var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); From c97f4ad9253c74d029929b6f5fa5b07b0e4c8d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:45:34 +0200 Subject: [PATCH 056/101] docs: add comment to sample usage of SetBeforeSendLog Co-authored-by: Bruno Garcia --- samples/Sentry.Samples.Console.Basic/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index b1e131615f..79627b1bd2 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -38,6 +38,7 @@ options.EnableLogs = true; options.SetBeforeSendLog(static log => { + // A demonstration of how you can drop logs based on some attribute they have if (log.TryGetAttribute("suppress", out bool attribute) && attribute) { return null; From a9eea90aff367706cc492628754e28eb80262297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:46:48 +0200 Subject: [PATCH 057/101] ref: clarify intent of usages of Debug.Assert --- src/Sentry/Protocol/SentryAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index b4a2c82d99..c73f0f1801 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -17,7 +17,7 @@ internal static class SentryAttributeSerializer { internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute) { - Debug.Assert(attribute.Type is not null); + Debug.Assert(attribute.Value is not null && attribute.Type is not null, $"The ValueType {nameof(attribute)} may have been assigned 'default', for which static flow analysis does not report nullable warnings."); writer.WritePropertyName(propertyName); WriteAttributeValue(writer, attribute.Value, attribute.Type); } From 8bd0ed26a66561b112a7707aee51422c39824f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:07:43 +0200 Subject: [PATCH 058/101] docs: improve XML comments --- .../Sentry.Samples.Console.Basic/Program.cs | 3 +- src/Sentry/SentryLogLevel.cs | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 79627b1bd2..1c251ea6cc 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -3,6 +3,7 @@ * - Error Monitoring (both handled and unhandled exceptions) * - Performance Tracing (Transactions / Spans) * - Release Health (Sessions) + * - Logs * - MSBuild integration for Source Context (see the csproj) * * For more advanced features of the SDK, see Sentry.Samples.Console.Customized. @@ -34,7 +35,7 @@ // This option tells Sentry to capture 100% of traces. You still need to start transactions and spans. options.TracesSampleRate = 1.0; - // This option enables the (experimental) Sentry Logs. + // This option enables Sentry Logs created via SentrySdk.Logger. options.EnableLogs = true; options.SetBeforeSendLog(static log => { diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs index 13ed27be1e..acc5cf3ad2 100644 --- a/src/Sentry/SentryLogLevel.cs +++ b/src/Sentry/SentryLogLevel.cs @@ -7,6 +7,39 @@ namespace Sentry; /// The severity of the structured log. /// This API is experimental and it may change in the future. /// +/// +/// The named constants use the value of the lowest severity number per severity level: +/// +/// +/// SeverityNumber +/// SeverityText +/// +/// +/// 1-4 +/// Trace +/// +/// +/// 5-8 +/// Debug +/// +/// +/// 9-12 +/// Info +/// +/// +/// 13-16 +/// Warn +/// +/// +/// 17-20 +/// Error +/// +/// +/// 21-24 +/// Fatal +/// +/// +/// /// [Experimental(DiagnosticId.ExperimentalFeature)] public enum SentryLogLevel From 51892de5d6dfb7006041e61bef03b85d1b4c46df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:08:31 +0200 Subject: [PATCH 059/101] test: range of Severity-Number specification --- src/Sentry/SentryLogLevel.cs | 43 ++++++++++++++---- test/Sentry.Tests/SentryLogLevelTests.cs | 57 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs index acc5cf3ad2..184fccc548 100644 --- a/src/Sentry/SentryLogLevel.cs +++ b/src/Sentry/SentryLogLevel.cs @@ -92,17 +92,44 @@ internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this Sent >= 22 and <= 24 => ("fatal", (int)level), >= 25 => Overflow(level, logger), }; - } - private static (string, int?) Underflow(SentryLogLevel level, IDiagnosticLogger? logger) - { - logger?.LogDebug("Log level {0} out of range ... clamping to minimum value {1} ({2})", level, 1, "trace"); - return ("trace", 1); + static (string, int?) Underflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to minimum value {1} ({2})", level, 1, "trace"); + return ("trace", 1); + } + + static (string, int?) Overflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to maximum value {1} ({2})", level, 24, "fatal"); + return ("fatal", 24); + } } - private static (string, int?) Overflow(SentryLogLevel level, IDiagnosticLogger? logger) + internal static SentryLogLevel FromValue(int value, IDiagnosticLogger? logger) { - logger?.LogDebug("Log level {0} out of range ... clamping to maximum value {1} ({2})", level, 24, "fatal"); - return ("fatal", 24); + return value switch + { + <= 0 => Underflow(value, logger), + >= 1 and <= 4 => SentryLogLevel.Trace, + >= 5 and <= 8 => SentryLogLevel.Debug, + >= 9 and <= 12 => SentryLogLevel.Info, + >= 13 and <= 16 => SentryLogLevel.Warning, + >= 17 and <= 20 => SentryLogLevel.Error, + >= 21 and <= 24 => SentryLogLevel.Fatal, + >= 25 => Overflow(value, logger), + }; + + static SentryLogLevel Underflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to minimum level {1}", value, SentryLogLevel.Trace); + return SentryLogLevel.Trace; + } + + static SentryLogLevel Overflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to maximum level {1}", value, SentryLogLevel.Fatal); + return SentryLogLevel.Fatal; + } } } diff --git a/test/Sentry.Tests/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs index 93a553ae55..8ebe9855be 100644 --- a/test/Sentry.Tests/SentryLogLevelTests.cs +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -76,4 +76,61 @@ public void SeverityTextAndSeverityNumber_OutOfRange_ClampValue(int level, strin { 24, "fatal", 24 }, }; } + + [Theory] + [MemberData(nameof(Create))] + public void Create_WithinRange_UsesLowestSeverityNumberOfRange(int value, SentryLogLevel level) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(0, SentryLogLevel.Trace, "minimum")] + [InlineData(25, SentryLogLevel.Fatal, "maximum")] + public void Create_OutOfRange_ClampValue(int value, SentryLogLevel level, string clamp) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log number {0} out of range ... clamping to {{clamp}} level {1}""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([value, level], entry.Args)); + } + + public static TheoryData Create() + { + return new TheoryData + { + { 1, SentryLogLevel.Trace }, + { 2, SentryLogLevel.Trace }, + { 3, SentryLogLevel.Trace }, + { 4, SentryLogLevel.Trace }, + { 5, SentryLogLevel.Debug }, + { 6, SentryLogLevel.Debug }, + { 7, SentryLogLevel.Debug }, + { 8, SentryLogLevel.Debug }, + { 9, SentryLogLevel.Info }, + { 10, SentryLogLevel.Info }, + { 11, SentryLogLevel.Info }, + { 12, SentryLogLevel.Info }, + { 13, SentryLogLevel.Warning }, + { 14, SentryLogLevel.Warning }, + { 15, SentryLogLevel.Warning }, + { 16, SentryLogLevel.Warning }, + { 17, SentryLogLevel.Error }, + { 18, SentryLogLevel.Error }, + { 19, SentryLogLevel.Error }, + { 20, SentryLogLevel.Error }, + { 21, SentryLogLevel.Fatal }, + { 22, SentryLogLevel.Fatal }, + { 23, SentryLogLevel.Fatal }, + { 24, SentryLogLevel.Fatal }, + }; + } } From acc899523d1a514e078f71fb1c1ba8b498f99234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:51:53 +0200 Subject: [PATCH 060/101] test: GetValuesAsUnderlyingType of the new enum --- test/Sentry.Tests/SentryLogLevelTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/Sentry.Tests/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs index 8ebe9855be..36b557ea08 100644 --- a/test/Sentry.Tests/SentryLogLevelTests.cs +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -12,6 +12,22 @@ public SentryLogLevelTests() _logger = new InMemoryDiagnosticLogger(); } +#if NET7_0_OR_GREATER + [Fact] + public void Enum_GetValuesAsUnderlyingType_LowestSeverityNumberPerSeverityRange() + { + var values = Enum.GetValuesAsUnderlyingType(); + + Assert.Collection(values.OfType(), + element => Assert.Equal(1, element), + element => Assert.Equal(5, element), + element => Assert.Equal(9, element), + element => Assert.Equal(13, element), + element => Assert.Equal(17, element), + element => Assert.Equal(21, element)); + } +#endif + [Theory] [MemberData(nameof(SeverityTextAndSeverityNumber))] public void SeverityTextAndSeverityNumber_WithinRange_MatchesProtocol(int level, string text, int? number) From 6bd4c96ad66c69360e7042c4307ce6f9d3de2d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 22:17:55 +0200 Subject: [PATCH 061/101] ref: move Log options to Experimental section --- .../Sentry.Samples.Console.Basic/Program.cs | 4 +- src/Sentry/BindableSentryOptions.cs | 14 +++- src/Sentry/IHub.cs | 4 +- .../Internal/DefaultSentryStructuredLogger.cs | 4 +- src/Sentry/SentryOptions.cs | 78 ++++++++++++------- test/Sentry.Testing/BindableTests.cs | 13 ++++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 12 ++- ...piApprovalTests.Run.DotNet9_0.verified.txt | 12 ++- .../ApiApprovalTests.Run.Net4_8.verified.txt | 8 +- .../SentryStructuredLoggerTests.cs | 24 +++--- 10 files changed, 114 insertions(+), 59 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 1c251ea6cc..17585888f7 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -36,8 +36,8 @@ options.TracesSampleRate = 1.0; // This option enables Sentry Logs created via SentrySdk.Logger. - options.EnableLogs = true; - options.SetBeforeSendLog(static log => + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => { // A demonstration of how you can drop logs based on some attribute they have if (log.TryGetAttribute("suppress", out bool attribute) && attribute) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 0b2c7ea0e8..acf6d1de0a 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -20,8 +20,6 @@ internal partial class BindableSentryOptions public string? Distribution { get; set; } public string? Environment { get; set; } public string? Dsn { get; set; } - [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public bool? EnableLogs { get; set; } public int? MaxQueueItems { get; set; } public int? MaxCacheItems { get; set; } public TimeSpan? ShutdownTimeout { get; set; } @@ -55,6 +53,15 @@ internal partial class BindableSentryOptions public bool? EnableSpotlight { get; set; } public string? SpotlightUrl { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public BindableSentryExperimentalOptions Experimental { get; set; } = new(); + + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + internal sealed class BindableSentryExperimentalOptions + { + public bool? EnableLogs { get; set; } + } + public void ApplyTo(SentryOptions options) { options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled; @@ -70,7 +77,6 @@ public void ApplyTo(SentryOptions options) options.Distribution = Distribution ?? options.Distribution; options.Environment = Environment ?? options.Environment; options.Dsn = Dsn ?? options.Dsn; - options.EnableLogs = EnableLogs ?? options.EnableLogs; options.MaxQueueItems = MaxQueueItems ?? options.MaxQueueItems; options.MaxCacheItems = MaxCacheItems ?? options.MaxCacheItems; options.ShutdownTimeout = ShutdownTimeout ?? options.ShutdownTimeout; @@ -103,6 +109,8 @@ public void ApplyTo(SentryOptions options) options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight; options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl; + options.Experimental.EnableLogs = Experimental.EnableLogs ?? options.Experimental.EnableLogs; + #if ANDROID Android.ApplyTo(options.Android); Native.ApplyTo(options.Native); diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 89e2ddc7e4..7232aea817 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -24,8 +24,8 @@ public interface IHub : ISentryClient, ISentryScopeManager /// /// Available options: /// - /// - /// + /// + /// /// /// [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index eb1c0b8d40..efead66317 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -20,7 +20,7 @@ internal DefaultSentryStructuredLogger(IHub hub, IInternalScopeManager scopeMana _options = options; _clock = clock; - _isEnabled = options is { EnableLogs: true }; + _isEnabled = options is { Experimental.EnableLogs: true }; } private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) @@ -70,7 +70,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template log.SetAttributes(_options); var configuredLog = log; - if (_options.BeforeSendLogInternal is { } beforeSendLog) + if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) { try { diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index a0d948315e..da381c2e2d 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -518,34 +518,6 @@ public void SetBeforeBreadcrumb(Func beforeBreadcrumb) _beforeBreadcrumb = (breadcrumb, _) => beforeBreadcrumb(breadcrumb); } - /// - /// When set to , logs are sent to Sentry. - /// Defaults to . - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public bool EnableLogs { get; set; } = false; - - private Func? _beforeSendLog; - - [Experimental(DiagnosticId.ExperimentalFeature)] - internal Func? BeforeSendLogInternal => _beforeSendLog; - - /// - /// 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. - /// This API is experimental and it may change in the future. - /// - /// - /// It can be used to modify the log object before being sent to Sentry. - /// To prevent the log from being sent to Sentry, return . - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public void SetBeforeSendLog(Func beforeSendLog) - { - _beforeSendLog = beforeSendLog; - } - private int _maxQueueItems = 30; /// @@ -1859,4 +1831,54 @@ internal static List GetDefaultInAppExclude() => "ServiceStack", "Java.Interop", ]; + + /// + /// Experimental Sentry features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryExperimentalOptions Experimental { get; set; } = new(); + + /// + /// Experimental Sentry SDK options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public sealed class SentryExperimentalOptions + { + internal SentryExperimentalOptions() + { + } + + /// + /// When set to , logs are sent to Sentry. + /// Defaults to . + /// This API is experimental and it may change in the future. + /// + /// + public bool EnableLogs { get; set; } = false; + + private Func? _beforeSendLog; + + internal Func? BeforeSendLogInternal => _beforeSendLog; + + /// + /// 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. + /// This API is experimental and it may change in the future. + /// + /// + /// 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 void SetBeforeSendLog(Func beforeSendLog) + { + _beforeSendLog = beforeSendLog; + } + } } diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 68dd553a36..13fee3df88 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -65,6 +65,10 @@ private static KeyValuePair GetDummyBindableValue(Property {$"key1", $"{propertyInfo.Name}value1"}, {$"key2", $"{propertyInfo.Name}value2"} }, + not null when propertyType == typeof(SentryOptions.SentryExperimentalOptions) => new SentryOptions.SentryExperimentalOptions + { + EnableLogs = true, + }, _ => throw new NotSupportedException($"Unsupported property type on property {propertyInfo.Name}") }; return new KeyValuePair(propertyInfo, value); @@ -81,6 +85,11 @@ private static IEnumerable> ToConfigValues(KeyValue yield return new KeyValuePair($"{prop.Name}:{kvp.Key}", kvp.Value); } } + else if (propertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + var experimental = (SentryOptions.SentryExperimentalOptions)value; + yield return new KeyValuePair($"{prop.Name}:{nameof(SentryOptions.SentryExperimentalOptions.EnableLogs)}", Convert.ToString(experimental.EnableLogs, CultureInfo.InvariantCulture)); + } else { yield return new KeyValuePair(prop.Name, Convert.ToString(value, CultureInfo.InvariantCulture)); @@ -115,6 +124,10 @@ protected void AssertContainsExpectedPropertyValues(TOptions actual) { actualValue.Should().BeEquivalentTo(expectedValue); } + else if (prop.PropertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + actualValue.Should().BeEquivalentTo(expectedValue); + } else { actualValue.Should().Be(expectedValue); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 214f27dc01..09861a040b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -750,11 +750,11 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -837,11 +837,15 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 214f27dc01..09861a040b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -750,11 +750,11 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -837,11 +837,15 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index de83123541..896522f9d6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -714,10 +714,10 @@ namespace Sentry public bool DisableSentryHttpMessageHandler { get; set; } public string? Distribution { get; set; } public string? Dsn { get; set; } - public bool EnableLogs { get; set; } public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -794,10 +794,14 @@ namespace Sentry public void SetBeforeBreadcrumb(System.Func beforeBreadcrumb) { } public void SetBeforeSend(System.Func beforeSend) { } public void SetBeforeSend(System.Func beforeSend) { } - public void SetBeforeSendLog(System.Func beforeSendLog) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 3f50aeac8c..d5b517c858 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -68,7 +68,7 @@ public SentryStructuredLoggerTests() [InlineData(SentryLogLevel.Fatal)] public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { - _fixture.Options.EnableLogs = true; + _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetDefaultSut(); Envelope envelope = null!; @@ -89,7 +89,7 @@ public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) [InlineData(SentryLogLevel.Fatal)] public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) { - _fixture.Options.EnableLogs.Should().BeFalse(); + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); var logger = _fixture.GetDefaultSut(); logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -101,7 +101,7 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) public void Log_UseScopeManager_CapturesEnvelope() { _fixture.UseScopeManager(); - _fixture.Options.EnableLogs = true; + _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetDefaultSut(); Envelope envelope = null!; @@ -119,8 +119,8 @@ public void Log_WithBeforeSendLog_InvokesCallback() var invocations = 0; SentryLog configuredLog = null!; - _fixture.Options.EnableLogs = true; - _fixture.Options.SetBeforeSendLog((SentryLog log) => + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => { invocations++; configuredLog = log; @@ -140,8 +140,8 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() { var invocations = 0; - _fixture.Options.EnableLogs = true; - _fixture.Options.SetBeforeSendLog((SentryLog log) => + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => { invocations++; return null; @@ -157,7 +157,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() [Fact] public void Log_InvalidFormat_DoesNotCaptureEnvelope() { - _fixture.Options.EnableLogs = true; + _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); @@ -173,7 +173,7 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() [Fact] public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() { - _fixture.Options.EnableLogs = true; + _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], Throw); @@ -189,8 +189,8 @@ public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() [Fact] public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() { - _fixture.Options.EnableLogs = true; - _fixture.Options.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); var logger = _fixture.GetDefaultSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); @@ -216,7 +216,7 @@ private static void Throw(SentryLog log) [Fact] public void CreateDisabled_EvenWhenEnabled_DoesNotCaptureEnvelope() { - _fixture.Options.EnableLogs = true; + _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetDisabledSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); From f673d1ef98d43be5c50178ebb6d97bff5d930b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:45:07 +0200 Subject: [PATCH 062/101] ref: move Logger to Experimental section of SDK --- samples/Sentry.Samples.Console.Basic/Program.cs | 6 +++--- src/Sentry/Extensibility/HubAdapter.cs | 2 +- src/Sentry/SentrySdk.cs | 13 +++++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 17585888f7..ac0320c42e 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -71,7 +71,7 @@ async Task FirstFunction() var httpClient = new HttpClient(messageHandler, true); var html = await httpClient.GetStringAsync("https://example.com/"); WriteLine(html); - SentrySdk.Logger.LogInfo("HTTP Request completed."); + SentrySdk.Experimental.Logger.LogInfo("HTTP Request completed."); } async Task SecondFunction() @@ -92,7 +92,7 @@ async Task SecondFunction() SentrySdk.CaptureException(exception); span.Finish(exception); - SentrySdk.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction))); + SentrySdk.Experimental.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction))); } span.Finish(); @@ -106,7 +106,7 @@ async Task ThirdFunction() // Simulate doing some work await Task.Delay(100); - SentrySdk.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true)); + SentrySdk.Experimental.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true)); // This is an example of an unhandled exception. It will be captured automatically. throw new InvalidOperationException("Something happened that crashed the app!"); diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index 333b4b623f..e32cbe2290 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -37,7 +37,7 @@ private HubAdapter() { } /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Logger; } + public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Experimental.Logger; } /// /// Forwards the call to . diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index b81da1fede..6e106e1f27 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -280,9 +280,18 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } - /// + /// + /// Experimental Sentry SDK features. + /// + /// + /// This and related experimental APIs may change in the future. + /// [Experimental(DiagnosticId.ExperimentalFeature)] - public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => CurrentHub.Logger; } + public static class Experimental + { + /// + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => CurrentHub.Logger; } + } /// /// Creates a new scope that will terminate when disposed. From daafd7fa9041c72d89e535d5c2447d21003094ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:45:18 +0200 Subject: [PATCH 063/101] test: add Hub tests --- .../InMemorySentryStructuredLogger.cs | 65 +++++++++++++++++++ .../Extensibility/DisabledHubTests.cs | 4 ++ .../Extensibility/HubAdapterTests.cs | 12 ++++ test/Sentry.Tests/HubTests.cs | 36 ++++++++++ 4 files changed, 117 insertions(+) create mode 100644 test/Sentry.Testing/InMemorySentryStructuredLogger.cs diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs new file mode 100644 index 0000000000..c173fa7f17 --- /dev/null +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -0,0 +1,65 @@ +#nullable enable + +namespace Sentry.Testing; + +public sealed class InMemorySentryStructuredLogger : SentryStructuredLogger +{ + public List Entries { get; } = new(); + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + Entries.Add(LogEntry.Create(level, template, parameters)); + } + + public sealed class LogEntry : IEquatable + { + public static LogEntry Create(SentryLogLevel level, string template, object[]? parameters) + { + return new LogEntry(level, template, parameters is null ? ImmutableArray.Empty : ImmutableCollectionsMarshal.AsImmutableArray(parameters)); + } + + private LogEntry(SentryLogLevel level, string template, ImmutableArray parameters) + { + Level = level; + Template = template; + Parameters = parameters; + } + + public SentryLogLevel Level { get; } + public string Template { get; } + public ImmutableArray Parameters { get; } + + public void AssertEqual(SentryLogLevel level, string template, params object[] parameters) + { + var expected = Create(level, template, parameters); + Assert.Equal(expected, this); + } + + public bool Equals(LogEntry? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Level == other.Level + && Template == other.Template + && Parameters.SequenceEqual(other.Parameters); + } + + public override bool Equals(object? obj) + { + return obj is LogEntry other && Equals(other); + } + + public override int GetHashCode() + { + throw new UnreachableException(); + } + } +} diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e56ff65370..e03f8a82a3 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -35,4 +35,8 @@ public void CaptureEvent_EmptyGuid() [Fact] public async Task FlushAsync_NoOp() => await DisabledHub.Instance.FlushAsync(); + + [Fact] + public void Logger_IsDisabled() + => Assert.IsType(DisabledHub.Instance.Logger); } diff --git a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs index 83de560c53..e698cd833e 100644 --- a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs +++ b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs @@ -70,6 +70,18 @@ public void LastEventId_MockInvoked() _ = Hub.Received(1).LastEventId; } + [Fact] + public void Logger_MockInvoked() + { + var logger = new InMemorySentryStructuredLogger(); + Hub.Logger.Returns(logger); + + HubAdapter.Instance.Logger.LogWarning("Message"); + + Assert.Collection(logger.Entries, + element => element.AssertEqual(SentryLogLevel.Warning, "Message")); + } + [Fact] public void EndSession_CrashedStatus_MockInvoked() { diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 89633c0752..be46df7e19 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1419,6 +1419,42 @@ public async Task CaptureTransaction_WithTransactionProfiler_SendsTransactionWit lines[5].Should().BeEmpty(); } + [Fact] + public void Logger_IsDisabled_DoesNotCaptureLog() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + + // Assert + _fixture.Client.Received(0).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + } + + [Fact] + public void Logger_IsEnabled_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { From 6a5420344b7cca647ab8e070c82a4c2d62c6c3e7 Mon Sep 17 00:00:00 2001 From: Flash0ver <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:17:19 +0200 Subject: [PATCH 064/101] test: update verified public API --- .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 7 +++++-- .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 7 +++++-- test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt | 5 ++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 09861a040b..6415689925 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -877,8 +877,6 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -935,6 +933,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 09861a040b..6415689925 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -877,8 +877,6 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -935,6 +933,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 896522f9d6..55ffa67e0a 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -833,7 +833,6 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } - public static Sentry.SentryStructuredLogger Logger { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.SentryHint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -890,6 +889,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { From 479cab87f5f4d66aec459a88a0fd7b6e93bbf247 Mon Sep 17 00:00:00 2001 From: Flash0ver <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:25:34 +0200 Subject: [PATCH 065/101] docs: update CHANGELOG.md --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efa1c4acd..5817f3fd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog +## Unreleased + +### Features + +- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) + ## 5.10.0 ### Features -- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Rename MemoryInfo.AllocatedBytes to MemoryInfo.TotalAllocatedBytes ([#4243](https://github.com/getsentry/sentry-dotnet/pull/4243)) - Replace libcurl with .NET HttpClient for sentry-native ([#4222](https://github.com/getsentry/sentry-dotnet/pull/4222)) From 18a82843cbe6e624f0705cf6698cffe95f60c384 Mon Sep 17 00:00:00 2001 From: Flash0ver <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 6 Jun 2025 14:34:15 +0200 Subject: [PATCH 066/101] ref: move Log-rerelated Options to Experimental sub-section --- CHANGELOG.md | 10 +++- .../Program.cs | 4 +- samples/Sentry.Samples.ME.Logging/Program.cs | 8 ++-- samples/Sentry.Samples.Maui/MauiProgram.cs | 2 +- .../BindableSentryLoggingOptions.cs | 14 ++++-- .../SentryLoggingOptions.cs | 48 ++++++++++++++----- .../SentryStructuredLogger.cs | 4 +- ...piApprovalTests.Run.DotNet8_0.verified.txt | 9 +++- ...piApprovalTests.Run.DotNet9_0.verified.txt | 9 +++- .../ApiApprovalTests.Run.Net4_8.verified.txt | 6 ++- .../SentryLoggingOptionsSetupTests.cs | 14 +++--- .../SentryStructuredLoggerProviderTests.cs | 7 ++- .../SentryStructuredLoggerTests.cs | 10 ++-- test/Sentry.Testing/BindableTests.cs | 19 ++++++++ 14 files changed, 117 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bde5db0a..23b327381b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Moved experimental _Sentry Structured Logger_ from `SentrySdk.Logger` to `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) +- Moved experimental options for _Sentry Structured Logging_ ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + - `Sentry.AspNetCore`: from `SentryAspNetCoreOptions.EnableLogs` to `SentryAspNetCoreOptions.Experimental.EnableLogs` + - `Sentry.Extensions.Logging`: from `SentryLoggingOptions.EnableLogs` to `SentryLoggingOptions.Experimental.EnableLogs` + - `Sentry.Maui`: from `SentryMauiOptions.EnableLogs` to `SentryMauiOptions.Experimental.EnableLogs` + ## 5.8.0-alpha.0 ### Features @@ -9,8 +17,6 @@ - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.EnableLogs` - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.EnableLogs` - `Sentry.Maui`, enabled via `SentryMauiOptions.EnableLogs` -## Unreleased -- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) ## 5.10.0 diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs index a9d7c468e7..b870722dc5 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs +++ b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs @@ -13,8 +13,8 @@ options.Debug = true; #endif - // This option enables the (experimental) Sentry Logs. - options.EnableLogs = true; + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; }); var app = builder.Build(); diff --git a/samples/Sentry.Samples.ME.Logging/Program.cs b/samples/Sentry.Samples.ME.Logging/Program.cs index c2d362b054..edca4e3854 100644 --- a/samples/Sentry.Samples.ME.Logging/Program.cs +++ b/samples/Sentry.Samples.ME.Logging/Program.cs @@ -19,11 +19,11 @@ // Optionally configure options: The default values are: options.MinimumBreadcrumbLevel = LogLevel.Information; // It requires at least this level to store breadcrumb options.MinimumEventLevel = LogLevel.Error; // This level or above will result in event sent to Sentry - options.MinimumLogLevel = LogLevel.Trace; // This level or above will result in log sent to Sentry + options.ExperimentalLogging.MinimumLogLevel = LogLevel.Trace; // This level or above will result in log sent to Sentry - // This option enables the (experimental) Sentry Logs. - options.EnableLogs = true; - options.SetBeforeSendLog(static log => + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => { log.SetAttribute("attribute-key", "attribute-value"); return log; diff --git a/samples/Sentry.Samples.Maui/MauiProgram.cs b/samples/Sentry.Samples.Maui/MauiProgram.cs index 86f3b9ebfb..193ccd19a4 100644 --- a/samples/Sentry.Samples.Maui/MauiProgram.cs +++ b/samples/Sentry.Samples.Maui/MauiProgram.cs @@ -24,7 +24,7 @@ public static MauiApp CreateMauiApp() options.AttachScreenshot = true; options.Debug = true; - options.EnableLogs = true; + options.Experimental.EnableLogs = true; options.SampleRate = 1.0F; // The Sentry MVVM Community Toolkit integration automatically creates traces for async relay commands, diff --git a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs index 648e6c6a2a..0c589552a7 100644 --- a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs @@ -7,16 +7,24 @@ internal class BindableSentryLoggingOptions : BindableSentryOptions { public LogLevel? MinimumBreadcrumbLevel { get; set; } public LogLevel? MinimumEventLevel { get; set; } - [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public LogLevel? MinimumLogLevel { get; set; } public bool? InitializeSdk { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public BindableSentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + internal sealed class BindableSentryLoggingExperimentalOptions + { + public LogLevel? MinimumLogLevel { get; set; } + } + public void ApplyTo(SentryLoggingOptions options) { base.ApplyTo(options); options.MinimumBreadcrumbLevel = MinimumBreadcrumbLevel ?? options.MinimumBreadcrumbLevel; options.MinimumEventLevel = MinimumEventLevel ?? options.MinimumEventLevel; - options.MinimumLogLevel = MinimumLogLevel ?? options.MinimumLogLevel; options.InitializeSdk = InitializeSdk ?? options.InitializeSdk; + + options.ExperimentalLogging.MinimumLogLevel = ExperimentalLogging.MinimumLogLevel ?? options.ExperimentalLogging.MinimumLogLevel; } } diff --git a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs index f2528af338..d181b645bf 100644 --- a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs @@ -30,19 +30,6 @@ public class SentryLoggingOptions : SentryOptions /// public LogLevel MinimumEventLevel { get; set; } = LogLevel.Error; - /// - /// Gets or sets the minimum log level. - /// This API is experimental and it may change in the future. - /// - /// - /// Logs with this level or higher will be stored as . - /// - /// - /// The minimum log level. - /// - [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; - /// /// Whether to initialize this SDK through this integration /// @@ -63,4 +50,39 @@ public class SentryLoggingOptions : SentryOptions /// List of callbacks to be invoked when initializing the SDK /// internal Action[] ConfigureScopeCallbacks { get; set; } = Array.Empty>(); + + /// + /// Experimental Sentry Logging features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + /// + /// Experimental Sentry Logging options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public sealed class SentryLoggingExperimentalOptions + { + internal SentryLoggingExperimentalOptions() + { + } + + /// + /// Gets or sets the minimum log level. + /// This API is experimental and it may change in the future. + /// + /// + /// Logs with this level or higher will be stored as . + /// + /// + /// The minimum log level. + /// + public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; + } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs index 0d44d346bd..d49e9a6763 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -24,9 +24,9 @@ internal SentryStructuredLogger(string categoryName, SentryLoggingOptions option public bool IsEnabled(LogLevel logLevel) { return _hub.IsEnabled - && _options.EnableLogs + && _options.Experimental.EnableLogs && logLevel != LogLevel.None - && logLevel >= _options.MinimumLogLevel; + && logLevel >= _options.ExperimentalLogging.MinimumLogLevel; } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 847ff1fb6f..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -38,12 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 847ff1fb6f..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -38,12 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 3439430f0b..e4dd758823 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -38,11 +38,15 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } - public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } public void ConfigureScope(System.Action action) { } + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index 658615cc77..8a570a8697 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -25,7 +25,6 @@ public void Configure_BindsConfigurationToOptions() Distribution = "FakeDistribution", Environment = "Test", Dsn = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647", - EnableLogs = true, MaxQueueItems = 8, MaxCacheItems = 9, ShutdownTimeout = TimeSpan.FromSeconds(13), @@ -56,9 +55,10 @@ public void Configure_BindsConfigurationToOptions() MinimumBreadcrumbLevel = LogLevel.Debug, MinimumEventLevel = LogLevel.Error, - MinimumLogLevel = LogLevel.None, InitializeSdk = true }; + expected.Experimental.EnableLogs = true; + expected.ExperimentalLogging.MinimumLogLevel = LogLevel.None; var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -76,7 +76,6 @@ public void Configure_BindsConfigurationToOptions() ["Distribution"] = expected.Distribution, ["Environment"] = expected.Environment, ["Dsn"] = expected.Dsn, - ["EnableLogs"] = expected.EnableLogs.ToString(), ["MaxQueueItems"] = expected.MaxQueueItems.ToString(), ["MaxCacheItems"] = expected.MaxCacheItems.ToString(), ["ShutdownTimeout"] = expected.ShutdownTimeout.ToString(), @@ -108,8 +107,10 @@ public void Configure_BindsConfigurationToOptions() ["JsonPreserveReferences"] = expected.JsonPreserveReferences.ToString(), ["MinimumBreadcrumbLevel"] = expected.MinimumBreadcrumbLevel.ToString(), ["MinimumEventLevel"] = expected.MinimumEventLevel.ToString(), - ["MinimumLogLevel"] = expected.MinimumLogLevel.ToString(), ["InitializeSdk"] = expected.InitializeSdk.ToString(), + + ["EnableLogs"] = expected.Experimental.EnableLogs.ToString(), + ["MinimumLogLevel"] = expected.ExperimentalLogging.MinimumLogLevel.ToString(), }) .Build(); @@ -138,7 +139,6 @@ public void Configure_BindsConfigurationToOptions() actual.Distribution.Should().Be(expected.Distribution); actual.Environment.Should().Be(expected.Environment); actual.Dsn.Should().Be(expected.Dsn); - actual.EnableLogs.Should().Be(expected.EnableLogs); actual.MaxQueueItems.Should().Be(expected.MaxQueueItems); actual.MaxCacheItems.Should().Be(expected.MaxCacheItems); actual.ShutdownTimeout.Should().Be(expected.ShutdownTimeout); @@ -167,8 +167,10 @@ public void Configure_BindsConfigurationToOptions() actual.MinimumBreadcrumbLevel.Should().Be(expected.MinimumBreadcrumbLevel); actual.MinimumEventLevel.Should().Be(expected.MinimumEventLevel); - actual.MinimumLogLevel.Should().Be(expected.MinimumLogLevel); actual.InitializeSdk.Should().Be(expected.InitializeSdk); + + actual.Experimental.EnableLogs.Should().Be(expected.Experimental.EnableLogs); + actual.ExperimentalLogging.MinimumLogLevel.Should().Be(expected.ExperimentalLogging.MinimumLogLevel); } } } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs index 46f78755c8..8b2cd79899 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -8,10 +8,9 @@ public class SentryStructuredLoggerProviderTests [Fact] public void SmokeTest() { - IOptions options = Options.Create(new SentryLoggingOptions - { - EnableLogs = true, - }); + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + IOptions options = Options.Create(loggingOptions); IHub hub = Substitute.For(); var provider = new SentryStructuredLoggerProvider(options, hub); diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs index bc659d66d2..cca67e6df2 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -9,14 +9,14 @@ public class SentryStructuredLoggerTests [Fact] public void SmokeTest() { + InMemorySentryStructuredLogger inMemory = new(); + string categoryName = "CategoryName"; - SentryLoggingOptions options = new() - { - EnableLogs = true, - }; + SentryLoggingOptions options = new(); + options.Experimental.EnableLogs = true; IHub hub = Substitute.For(); hub.IsEnabled.Returns(true); - hub.Logger.Returns(Sentry.SentryStructuredLogger.CreateDisabled(hub)); + hub.Logger.Returns(inMemory); var logger = new SentryStructuredLogger(categoryName, options, hub); diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 13fee3df88..b8baf13c8e 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -69,11 +69,21 @@ private static KeyValuePair GetDummyBindableValue(Property { EnableLogs = true, }, + not null when propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions" => CreateSentryLoggingExperimentalOptions(), _ => throw new NotSupportedException($"Unsupported property type on property {propertyInfo.Name}") }; return new KeyValuePair(propertyInfo, value); } + private static object CreateSentryLoggingExperimentalOptions() + { + var options = Activator.CreateInstance("Sentry.Extensions.Logging", "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions", false, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.CreateInstance, null, null, null, null); + var instance = options.Unwrap(); + var property = instance.GetType().GetProperty("MinimumLogLevel", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + property.SetValue(instance, int.MaxValue); + return instance; + } + private static IEnumerable> ToConfigValues(KeyValuePair item) { var (prop, value) = item; @@ -90,6 +100,11 @@ private static IEnumerable> ToConfigValues(KeyValue var experimental = (SentryOptions.SentryExperimentalOptions)value; yield return new KeyValuePair($"{prop.Name}:{nameof(SentryOptions.SentryExperimentalOptions.EnableLogs)}", Convert.ToString(experimental.EnableLogs, CultureInfo.InvariantCulture)); } + else if (propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + var property = value.GetType().GetProperty("MinimumLogLevel"); + yield return new KeyValuePair($"{prop.Name}:MinimumLogLevel", Convert.ToString(property.GetValue(value), CultureInfo.InvariantCulture)); + } else { yield return new KeyValuePair(prop.Name, Convert.ToString(value, CultureInfo.InvariantCulture)); @@ -128,6 +143,10 @@ protected void AssertContainsExpectedPropertyValues(TOptions actual) { actualValue.Should().BeEquivalentTo(expectedValue); } + else if (prop.PropertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + actualValue.Should().BeEquivalentTo(expectedValue); + } else { actualValue.Should().Be(expectedValue); From 54062d26a11376dd82b971df7cd321a4fe94a47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:12:15 +0200 Subject: [PATCH 067/101] ref: reuse Disabled-Instance when Structured-Logging is not enabled --- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryStructuredLogger.cs | 8 +++++++ test/Sentry.Tests/HubTests.cs | 2 ++ .../SentryStructuredLoggerTests.cs | 24 +++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index d9abf94418..4e7a315bbb 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -66,7 +66,7 @@ internal Hub( PushScope(); } - Logger = new DefaultSentryStructuredLogger(this, ScopeManager, options, _clock); + Logger = SentryStructuredLogger.Create(this, ScopeManager, options, _clock); #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 4edee3ec68..d089ee7d29 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -1,4 +1,5 @@ using Sentry.Infrastructure; +using Sentry.Internal; namespace Sentry; @@ -9,6 +10,13 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public abstract class SentryStructuredLogger { + internal static SentryStructuredLogger Create(IHub hub, IInternalScopeManager scopeManager, SentryOptions options, ISystemClock clock) + { + return options.Experimental.EnableLogs + ? new DefaultSentryStructuredLogger(hub, scopeManager, options, clock) + : DisabledSentryStructuredLogger.Instance; + } + private protected SentryStructuredLogger() { } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index be46df7e19..50a0f8c5d8 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1435,6 +1435,7 @@ public void Logger_IsDisabled_DoesNotCaptureLog() envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) ) ); + hub.Logger.Should().BeOfType(); } [Fact] @@ -1453,6 +1454,7 @@ public void Logger_IsEnabled_DoesCaptureLog() envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) ) ); + hub.Logger.Should().BeOfType(); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index d5b517c858..4311438325 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -59,6 +59,30 @@ public SentryStructuredLoggerTests() _fixture = new Fixture(); } + [Fact] + public void Create_Enabled_NewDefaultInstance() + { + _fixture.Options.Experimental.EnableLogs = true; + + var instance = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + var other = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + + instance.Should().BeOfType(); + instance.Should().NotBeSameAs(other); + } + + [Fact] + public void Create_Disabled_CachedDisabledInstance() + { + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); + + var instance = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + var other = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + + instance.Should().BeOfType(); + instance.Should().BeSameAs(other); + } + [SkippableTheory(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 [InlineData(SentryLogLevel.Trace)] [InlineData(SentryLogLevel.Debug)] From 7107bceb5e19a943008741ee960d16645ac9bb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:42:06 +0200 Subject: [PATCH 068/101] ref: remove Enabled-checks on Default-Logger --- .../Internal/DefaultSentryStructuredLogger.cs | 11 +---- test/Sentry.Tests/HubTests.cs | 28 ++++++++++++ .../SentryStructuredLoggerTests.cs | 45 ++++++------------- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index efead66317..1d235da967 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -11,25 +11,18 @@ internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger private readonly SentryOptions _options; private readonly ISystemClock _clock; - private readonly bool _isEnabled; - internal DefaultSentryStructuredLogger(IHub hub, IInternalScopeManager scopeManager, SentryOptions options, ISystemClock clock) { + Debug.Assert(options is { Experimental.EnableLogs: true }); + _hub = hub; _scopeManager = scopeManager; _options = options; _clock = clock; - - _isEnabled = options is { Experimental.EnableLogs: true }; } private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { - if (!_isEnabled) - { - return; - } - var timestamp = _clock.GetUtcNow(); if (!TryGetTraceId(_hub, _scopeManager, out var traceId)) diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 50a0f8c5d8..e464f8c526 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1457,6 +1457,34 @@ public void Logger_IsEnabled_DoesCaptureLog() hub.Logger.Should().BeOfType(); } + [Fact] + public void Logger_EnableAfterCreate_HasNoEffect() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = true; + + // Assert + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_DisableAfterCreate_HasNoEffect() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = false; + + // Assert + hub.Logger.Should().BeOfType(); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 4311438325..54adec3202 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -48,8 +48,7 @@ public void UseScopeManager() ScopeManager.GetCurrent().Returns(scopeAndClient); } - public SentryStructuredLogger GetDefaultSut() => new DefaultSentryStructuredLogger(Hub, ScopeManager, Options, Clock); - public SentryStructuredLogger GetDisabledSut() => new DisabledSentryStructuredLogger(); + public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, ScopeManager, Options, Clock); } private readonly Fixture _fixture; @@ -64,8 +63,8 @@ public void Create_Enabled_NewDefaultInstance() { _fixture.Options.Experimental.EnableLogs = true; - var instance = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); - var other = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); instance.Should().BeOfType(); instance.Should().NotBeSameAs(other); @@ -76,8 +75,8 @@ public void Create_Disabled_CachedDisabledInstance() { _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); - var instance = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); - var other = SentryStructuredLogger.Create(_fixture.Hub, _fixture.ScopeManager, _fixture.Options, _fixture.Clock); + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); instance.Should().BeOfType(); instance.Should().BeSameAs(other); @@ -93,7 +92,7 @@ public void Create_Disabled_CachedDisabledInstance() public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { _fixture.Options.Experimental.EnableLogs = true; - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); Envelope envelope = null!; _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); @@ -114,7 +113,7 @@ public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) { _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -126,7 +125,7 @@ public void Log_UseScopeManager_CapturesEnvelope() { _fixture.UseScopeManager(); _fixture.Options.Experimental.EnableLogs = true; - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); Envelope envelope = null!; _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); @@ -150,7 +149,7 @@ public void Log_WithBeforeSendLog_InvokesCallback() configuredLog = log; return log; }); - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -170,7 +169,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() invocations++; return null; }); - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); @@ -182,7 +181,7 @@ public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() public void Log_InvalidFormat_DoesNotCaptureEnvelope() { _fixture.Options.Experimental.EnableLogs = true; - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); @@ -198,9 +197,9 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() { _fixture.Options.Experimental.EnableLogs = true; - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); - logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], Throw); + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], static (SentryLog log) => throw new InvalidOperationException()); _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; @@ -215,7 +214,7 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() { _fixture.Options.Experimental.EnableLogs = true; _fixture.Options.Experimental.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); - var logger = _fixture.GetDefaultSut(); + var logger = _fixture.GetSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); @@ -231,22 +230,6 @@ private static void ConfigureLog(SentryLog log) { log.SetAttribute("attribute-key", "attribute-value"); } - - private static void Throw(SentryLog log) - { - throw new InvalidOperationException(); - } - - [Fact] - public void CreateDisabled_EvenWhenEnabled_DoesNotCaptureEnvelope() - { - _fixture.Options.Experimental.EnableLogs = true; - var logger = _fixture.GetDisabledSut(); - - logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); - - _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); - } } file static class AssertionExtensions From c0a1cd56b3e3b324d414772196cdf369bf876a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:47:37 +0200 Subject: [PATCH 069/101] ref: rename DisabledSentryStructuredLogger to NoOpSentryStructuredLogger --- src/Sentry/Extensibility/DisabledHub.cs | 2 +- .../Internal/DisabledSentryStructuredLogger.cs | 15 --------------- src/Sentry/Internal/NoOpSentryStructuredLogger.cs | 15 +++++++++++++++ src/Sentry/SentryStructuredLogger.cs | 2 +- .../Extensibility/DisabledHubTests.cs | 2 +- test/Sentry.Tests/HubTests.cs | 4 ++-- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) delete mode 100644 src/Sentry/Internal/DisabledSentryStructuredLogger.cs create mode 100644 src/Sentry/Internal/NoOpSentryStructuredLogger.cs diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index ad6165a50a..0b4e7f7ac5 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -260,5 +260,5 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// This API is experimental and it may change in the future. /// [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; + public SentryStructuredLogger Logger => NoOpSentryStructuredLogger.Instance; } diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs deleted file mode 100644 index 086f67a1bd..0000000000 --- a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Sentry.Internal; - -internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger -{ - internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger(); - - internal DisabledSentryStructuredLogger() - { - } - - private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) - { - // disabled - } -} diff --git a/src/Sentry/Internal/NoOpSentryStructuredLogger.cs b/src/Sentry/Internal/NoOpSentryStructuredLogger.cs new file mode 100644 index 0000000000..2cf68ee60f --- /dev/null +++ b/src/Sentry/Internal/NoOpSentryStructuredLogger.cs @@ -0,0 +1,15 @@ +namespace Sentry.Internal; + +internal sealed class NoOpSentryStructuredLogger : SentryStructuredLogger +{ + internal static NoOpSentryStructuredLogger Instance { get; } = new NoOpSentryStructuredLogger(); + + private NoOpSentryStructuredLogger() + { + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + // disabled + } +} diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index d089ee7d29..f932cca689 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -14,7 +14,7 @@ internal static SentryStructuredLogger Create(IHub hub, IInternalScopeManager sc { return options.Experimental.EnableLogs ? new DefaultSentryStructuredLogger(hub, scopeManager, options, clock) - : DisabledSentryStructuredLogger.Instance; + : NoOpSentryStructuredLogger.Instance; } private protected SentryStructuredLogger() diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e03f8a82a3..c76bd6bb96 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -38,5 +38,5 @@ public void CaptureEvent_EmptyGuid() [Fact] public void Logger_IsDisabled() - => Assert.IsType(DisabledHub.Instance.Logger); + => Assert.IsType(DisabledHub.Instance.Logger); } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index e464f8c526..b773a37004 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1435,7 +1435,7 @@ public void Logger_IsDisabled_DoesNotCaptureLog() envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) ) ); - hub.Logger.Should().BeOfType(); + hub.Logger.Should().BeOfType(); } [Fact] @@ -1468,7 +1468,7 @@ public void Logger_EnableAfterCreate_HasNoEffect() _fixture.Options.Experimental.EnableLogs = true; // Assert - hub.Logger.Should().BeOfType(); + hub.Logger.Should().BeOfType(); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 54adec3202..681558659f 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -78,7 +78,7 @@ public void Create_Disabled_CachedDisabledInstance() var instance = _fixture.GetSut(); var other = _fixture.GetSut(); - instance.Should().BeOfType(); + instance.Should().BeOfType(); instance.Should().BeSameAs(other); } From 45b868794dba3028e446b95e2b2a4d9f6f6c6428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:53:37 +0200 Subject: [PATCH 070/101] ref: make `BindableSentryOptions.Experimental` internal Co-authored-by: James Crosswell --- src/Sentry/BindableSentryOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index acf6d1de0a..95b8cd563c 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -54,7 +54,7 @@ internal partial class BindableSentryOptions public string? SpotlightUrl { get; set; } [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public BindableSentryExperimentalOptions Experimental { get; set; } = new(); + internal BindableSentryExperimentalOptions Experimental { get; set; } = new(); [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal sealed class BindableSentryExperimentalOptions From 3192534baa7da0f64d923d898d107b647013a940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:55:46 +0200 Subject: [PATCH 071/101] Revert "ref: make `BindableSentryOptions.Experimental` internal" This reverts commit 45b868794dba3028e446b95e2b2a4d9f6f6c6428. --- src/Sentry/BindableSentryOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 95b8cd563c..acf6d1de0a 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -54,7 +54,7 @@ internal partial class BindableSentryOptions public string? SpotlightUrl { get; set; } [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - internal BindableSentryExperimentalOptions Experimental { get; set; } = new(); + public BindableSentryExperimentalOptions Experimental { get; set; } = new(); [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal sealed class BindableSentryExperimentalOptions From b8bcea680826d1dd89545f3c1f6b86260c7971d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:56:50 +0200 Subject: [PATCH 072/101] docs: Update CHANGELOG.md Co-authored-by: Bruno Garcia --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e449a4ce..1c8822c3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) +- Add experimental support for [_Sentry Structured Logging_](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added non-allocating `ConfigureScope` and `ConfigureScopeAsync` overloads ([#4244](https://github.com/getsentry/sentry-dotnet/pull/4244)) - Add .NET MAUI `AutomationId` element information to breadcrumbs ([#4248](https://github.com/getsentry/sentry-dotnet/pull/4248)) From d4c82a2051931b961da6a2ee5148004697a234cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:29:17 +0200 Subject: [PATCH 073/101] Revert "ref: rename DisabledSentryStructuredLogger to NoOpSentryStructuredLogger" This reverts commit c0a1cd56b3e3b324d414772196cdf369bf876a24. --- src/Sentry/Extensibility/DisabledHub.cs | 2 +- .../Internal/DisabledSentryStructuredLogger.cs | 15 +++++++++++++++ src/Sentry/Internal/NoOpSentryStructuredLogger.cs | 15 --------------- src/Sentry/SentryStructuredLogger.cs | 2 +- .../Extensibility/DisabledHubTests.cs | 2 +- test/Sentry.Tests/HubTests.cs | 4 ++-- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 src/Sentry/Internal/DisabledSentryStructuredLogger.cs delete mode 100644 src/Sentry/Internal/NoOpSentryStructuredLogger.cs diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index 0b4e7f7ac5..ad6165a50a 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -260,5 +260,5 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// This API is experimental and it may change in the future. /// [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - public SentryStructuredLogger Logger => NoOpSentryStructuredLogger.Instance; + public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; } diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs new file mode 100644 index 0000000000..086f67a1bd --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -0,0 +1,15 @@ +namespace Sentry.Internal; + +internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger +{ + internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger(); + + internal DisabledSentryStructuredLogger() + { + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + // disabled + } +} diff --git a/src/Sentry/Internal/NoOpSentryStructuredLogger.cs b/src/Sentry/Internal/NoOpSentryStructuredLogger.cs deleted file mode 100644 index 2cf68ee60f..0000000000 --- a/src/Sentry/Internal/NoOpSentryStructuredLogger.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Sentry.Internal; - -internal sealed class NoOpSentryStructuredLogger : SentryStructuredLogger -{ - internal static NoOpSentryStructuredLogger Instance { get; } = new NoOpSentryStructuredLogger(); - - private NoOpSentryStructuredLogger() - { - } - - private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) - { - // disabled - } -} diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index f932cca689..d089ee7d29 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -14,7 +14,7 @@ internal static SentryStructuredLogger Create(IHub hub, IInternalScopeManager sc { return options.Experimental.EnableLogs ? new DefaultSentryStructuredLogger(hub, scopeManager, options, clock) - : NoOpSentryStructuredLogger.Instance; + : DisabledSentryStructuredLogger.Instance; } private protected SentryStructuredLogger() diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index c76bd6bb96..e03f8a82a3 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -38,5 +38,5 @@ public void CaptureEvent_EmptyGuid() [Fact] public void Logger_IsDisabled() - => Assert.IsType(DisabledHub.Instance.Logger); + => Assert.IsType(DisabledHub.Instance.Logger); } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index b773a37004..e464f8c526 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1435,7 +1435,7 @@ public void Logger_IsDisabled_DoesNotCaptureLog() envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) ) ); - hub.Logger.Should().BeOfType(); + hub.Logger.Should().BeOfType(); } [Fact] @@ -1468,7 +1468,7 @@ public void Logger_EnableAfterCreate_HasNoEffect() _fixture.Options.Experimental.EnableLogs = true; // Assert - hub.Logger.Should().BeOfType(); + hub.Logger.Should().BeOfType(); } [Fact] diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 681558659f..54adec3202 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -78,7 +78,7 @@ public void Create_Disabled_CachedDisabledInstance() var instance = _fixture.GetSut(); var other = _fixture.GetSut(); - instance.Should().BeOfType(); + instance.Should().BeOfType(); instance.Should().BeSameAs(other); } From 9193a969f19f17eaf2e7ba2206d4a2ca48dc6d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:19:52 +0200 Subject: [PATCH 074/101] ref: replace use of ScopeManager with TraceHeader --- .../Internal/DefaultSentryStructuredLogger.cs | 54 ++----------------- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryStructuredLogger.cs | 4 +- .../SentryStructuredLoggerTests.cs | 30 ++++------- 4 files changed, 18 insertions(+), 72 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 1d235da967..8f91ec6689 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -7,16 +7,14 @@ namespace Sentry.Internal; internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger { private readonly IHub _hub; - private readonly IInternalScopeManager _scopeManager; private readonly SentryOptions _options; private readonly ISystemClock _clock; - internal DefaultSentryStructuredLogger(IHub hub, IInternalScopeManager scopeManager, SentryOptions options, ISystemClock clock) + internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock) { Debug.Assert(options is { Experimental.EnableLogs: true }); _hub = hub; - _scopeManager = scopeManager; _options = options; _clock = clock; } @@ -24,13 +22,7 @@ internal DefaultSentryStructuredLogger(IHub hub, IInternalScopeManager scopeMana private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { var timestamp = _clock.GetUtcNow(); - - if (!TryGetTraceId(_hub, _scopeManager, out var traceId)) - { - _options.DiagnosticLogger?.LogWarning("TraceId not found"); - } - - _ = TryGetParentSpanId(_hub, _scopeManager, out var parentSpanId); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; string message; try @@ -43,11 +35,11 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } - SentryLog log = new(timestamp, traceId, level, message) + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) { Template = template, Parameters = ImmutableArray.Create(parameters), - ParentSpanId = parentSpanId, + ParentSpanId = traceHeader.SpanId, }; try @@ -83,42 +75,4 @@ private protected override void CaptureLog(SentryLogLevel level, string template _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); } } - - private static bool TryGetTraceId(IHub hub, IInternalScopeManager? scopeManager, out SentryId traceId) - { - if (hub.GetSpan() is { } span) - { - traceId = span.TraceId; - return true; - } - - if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - traceId = currentScope.PropagationContext.TraceId; - return true; - } - - traceId = SentryId.Empty; - return false; - } - - private static bool TryGetParentSpanId(IHub hub, IInternalScopeManager? scopeManager, out SpanId? parentSpanId) - { - if (hub.GetSpan() is { } span && span.ParentSpanId.HasValue) - { - parentSpanId = span.ParentSpanId; - return true; - } - - if (scopeManager is not null) - { - var currentScope = scopeManager.GetCurrent().Key; - parentSpanId = currentScope.PropagationContext.ParentSpanId; - return true; - } - - parentSpanId = null; - return false; - } } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 4e7a315bbb..dfb7a22bec 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -66,7 +66,7 @@ internal Hub( PushScope(); } - Logger = SentryStructuredLogger.Create(this, ScopeManager, options, _clock); + Logger = SentryStructuredLogger.Create(this, options, _clock); #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index d089ee7d29..f61f9e74da 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -10,10 +10,10 @@ namespace Sentry; [Experimental(DiagnosticId.ExperimentalFeature)] public abstract class SentryStructuredLogger { - internal static SentryStructuredLogger Create(IHub hub, IInternalScopeManager scopeManager, SentryOptions options, ISystemClock clock) + internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock) { return options.Experimental.EnableLogs - ? new DefaultSentryStructuredLogger(hub, scopeManager, options, clock) + ? new DefaultSentryStructuredLogger(hub, options, clock) : DisabledSentryStructuredLogger.Instance; } diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 54adec3202..2714b5ca64 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -13,42 +13,34 @@ public Fixture() { DiagnosticLogger = new InMemoryDiagnosticLogger(); Hub = Substitute.For(); - ScopeManager = Substitute.For(); Options = new SentryOptions { Debug = true, DiagnosticLogger = DiagnosticLogger, }; Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); - Span = Substitute.For(); TraceId = SentryId.Create(); ParentSpanId = SpanId.Create(); - Hub.GetSpan().Returns(Span); - Span.TraceId.Returns(TraceId); - Span.ParentSpanId.Returns(ParentSpanId); + var traceHeader = new SentryTraceHeader(TraceId, ParentSpanId.Value, null); + Hub.GetTraceHeader().Returns(traceHeader); } public InMemoryDiagnosticLogger DiagnosticLogger { get; } public IHub Hub { get; } - public IInternalScopeManager ScopeManager { get; } public SentryOptions Options { get; } public ISystemClock Clock { get; } - public ISpan Span { get; } - public SentryId TraceId { get; } - public SpanId? ParentSpanId { get; } + public SentryId TraceId { get; private set; } + public SpanId? ParentSpanId { get; private set; } - public void UseScopeManager() + public void WithoutTraceHeader() { - Hub.GetSpan().Returns((ISpan?)null); - - var propagationContext = new SentryPropagationContext(TraceId, ParentSpanId!.Value); - var scope = new Scope(Options, propagationContext); - var scopeAndClient = new KeyValuePair(scope, null!); - ScopeManager.GetCurrent().Returns(scopeAndClient); + Hub.GetTraceHeader().Returns((SentryTraceHeader?)null); + TraceId = SentryId.Empty; + ParentSpanId = SpanId.Empty; } - public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, ScopeManager, Options, Clock); + public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, Options, Clock); } private readonly Fixture _fixture; @@ -121,9 +113,9 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) } [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 - public void Log_UseScopeManager_CapturesEnvelope() + public void Log_WithoutTraceHeader_CapturesEnvelope() { - _fixture.UseScopeManager(); + _fixture.WithoutTraceHeader(); _fixture.Options.Experimental.EnableLogs = true; var logger = _fixture.GetSut(); From 7cb4043054f2ee1b1b7f8fd5a255e4949449f98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:26:44 +0200 Subject: [PATCH 075/101] test: remove Skip as we no longer test net48 against non-Windows --- test/Sentry.Tests/SentryLogTests.cs | 2 +- test/Sentry.Tests/SentryStructuredLoggerTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 6258f5d06e..78c249ed29 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -96,7 +96,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() """); } - [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 + [Fact] public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() { var options = new SentryOptions diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 2714b5ca64..429fa503b5 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -74,7 +74,7 @@ public void Create_Disabled_CachedDisabledInstance() instance.Should().BeSameAs(other); } - [SkippableTheory(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 + [Theory] [InlineData(SentryLogLevel.Trace)] [InlineData(SentryLogLevel.Debug)] [InlineData(SentryLogLevel.Info)] @@ -112,7 +112,7 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); } - [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 + [Fact] public void Log_WithoutTraceHeader_CapturesEnvelope() { _fixture.WithoutTraceHeader(); @@ -128,7 +128,7 @@ public void Log_WithoutTraceHeader_CapturesEnvelope() envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); } - [SkippableFact(typeof(MissingMethodException))] //throws in .NETFramework on non-Windows for System.Collections.Immutable.ImmutableArray`1 + [Fact] public void Log_WithBeforeSendLog_InvokesCallback() { var invocations = 0; From e3ca5b5fc209af8d51675a58083c8ad13149e410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:46:24 +0200 Subject: [PATCH 076/101] feat: support more numeric types --- src/Sentry/Protocol/SentryAttribute.cs | 88 ++++++++++++++++++--- src/Sentry/SentryLog.cs | 6 +- test/Sentry.Tests/SentryLogTests.cs | 101 ++++++++++++++++++++++++- 3 files changed, 181 insertions(+), 14 deletions(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index c73f0f1801..5dbe7e52e8 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -1,3 +1,5 @@ +using Sentry.Extensibility; + namespace Sentry.Protocol; [DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")] @@ -15,26 +17,26 @@ public SentryAttribute(object value, string type) internal static class SentryAttributeSerializer { - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute) + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute, IDiagnosticLogger? logger) { Debug.Assert(attribute.Value is not null && attribute.Type is not null, $"The ValueType {nameof(attribute)} may have been assigned 'default', for which static flow analysis does not report nullable warnings."); writer.WritePropertyName(propertyName); - WriteAttributeValue(writer, attribute.Value, attribute.Type); + WriteAttributeValue(writer, attribute.Value, attribute.Type, logger); } - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string type) + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string type, IDiagnosticLogger? logger) { writer.WritePropertyName(propertyName); - WriteAttributeValue(writer, value, type); + WriteAttributeValue(writer, value, type, logger); } - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value) + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, IDiagnosticLogger? logger) { writer.WritePropertyName(propertyName); - WriteAttributeValue(writer, value); + WriteAttributeValue(writer, value, logger); } - private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type) + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type, IDiagnosticLogger? logger) { writer.WriteStartObject(); @@ -67,10 +69,13 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, str writer.WriteEndObject(); } - private static void WriteAttributeValue(Utf8JsonWriter writer, object value) + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDiagnosticLogger? logger) { writer.WriteStartObject(); + // covering most built-in types of .NET with C# language support + // for `net7.0` or greater, we could utilize "Generic Math" in the future, if there is demand + // see documentation for supported types: https://develop.sentry.dev/sdk/telemetry/logs/ if (value is string str) { writer.WriteString("value", str); @@ -81,25 +86,88 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value) writer.WriteBoolean("value", boolean); writer.WriteString("type", "boolean"); } + else if (value is sbyte @sbyte) + { + writer.WriteNumber("value", @sbyte); + writer.WriteString("type", "integer"); + } + else if (value is byte @byte) + { + writer.WriteNumber("value", @byte); + writer.WriteString("type", "integer"); + } + else if (value is short int16) + { + writer.WriteNumber("value", int16); + writer.WriteString("type", "integer"); + } + else if (value is ushort uint16) + { + writer.WriteNumber("value", uint16); + writer.WriteString("type", "integer"); + } else if (value is int int32) { writer.WriteNumber("value", int32); writer.WriteString("type", "integer"); } + else if (value is uint uint32) + { + writer.WriteNumber("value", uint32); + writer.WriteString("type", "integer"); + } else if (value is long int64) { writer.WriteNumber("value", int64); writer.WriteString("type", "integer"); } - else if (value is double float64) + else if (value is ulong uint64) { - writer.WriteNumber("value", float64); + writer.WriteString("value", uint64.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'ulong' (unsigned 64-bit integer) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is nint intPtr) + { + writer.WriteNumber("value", intPtr); + writer.WriteString("type", "integer"); + } + else if (value is nuint uintPtr) + { +#if NET5_0_OR_GREATER + writer.WriteString("value", uintPtr.ToString(NumberFormatInfo.InvariantInfo)); +#else + writer.WriteString("value", uintPtr.ToString()); +#endif + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'nuint' (unsigned platform-dependent integer) is not supported by Sentry-Attributes due to possible overflows on 64-bit processes. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is float single) + { + writer.WriteNumber("value", single); writer.WriteString("type", "double"); } + else if (value is double @double) + { + writer.WriteNumber("value", @double); + writer.WriteString("type", "double"); + } + else if (value is decimal @decimal) + { + writer.WriteString("value", @decimal.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'decimal' (128-bit floating-point) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } else { + //TODO: test null writer.WriteString("value", value.ToString()); writer.WriteString("type", "string"); + + logger?.LogWarning("Type '{0}' is not supported by Sentry-Attributes. Using 'ToString' and type=string. Please use a supported type instead. To suppress this message, convert the value of this Attribute to type string explicitly.", value.GetType()); } writer.WriteEndObject(); diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 9f467c94c6..5f4b860939 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -246,20 +246,20 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (Template is not null) { - SentryAttributeSerializer.WriteAttribute(writer, "sentry.message.template", Template, "string"); + SentryAttributeSerializer.WriteAttribute(writer, "sentry.message.template", Template, "string", logger); } if (!Parameters.IsDefault) { for (var index = 0; index < Parameters.Length; index++) { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index]); + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index], logger); } } foreach (var attribute in _attributes) { - SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value); + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); } if (ParentSpanId.HasValue) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 78c249ed29..ff03507d63 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -14,7 +14,7 @@ public class SentryLogTests private static readonly ISystemClock Clock = new MockClock(Timestamp); - private readonly IDiagnosticLogger _output; + private readonly TestOutputDiagnosticLogger _output; public SentryLogTests(ITestOutputHelper output) { @@ -94,6 +94,8 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() ] } """); + + _output.Entries.Should().BeEmpty(); } [Fact] @@ -208,6 +210,103 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() ] } """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_Numerics_AsIntegerAndDouble() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message") + { + Parameters = + [ + sbyte.MinValue, + byte.MaxValue, + short.MinValue, + ushort.MaxValue, + int.MinValue, + uint.MaxValue, + long.MinValue, + ulong.MaxValue, + nint.MinValue, + nuint.MaxValue, + 1f, + 2d, + 3m, + KeyValuePair.Create("key", "value"), + ], + }; + + ArrayBufferWriter bufferWriter = new(); + Utf8JsonWriter writer = new(bufferWriter); + log.WriteTo(writer, _output); + writer.Flush(); + + var document = JsonDocument.Parse(bufferWriter.WrittenMemory); + var items = document.RootElement.GetProperty("items"); + items.GetArrayLength().Should().Be(1); + var attributes = items[0].GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sentry.message.parameter.0", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.1", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.2", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.3", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.4", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.5", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.6", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.7", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.8", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.9", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeString("sentry.message.parameter.13", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*") + ); + } +} + +file static class AssertExtensions +{ + public static void AssertAttributeString(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "string", getValue, value); + } + + public static void AssertAttributeBoolean(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "boolean", getValue, value); + } + + public static void AssertAttributeInteger(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "integer", getValue, value); + } + + public static void AssertAttributeDouble(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "double", getValue, value); + } + + private static void AssertAttribute(this JsonProperty attribute, string name, string type, Func getValue, T value) + { + Assert.Equal(name, attribute.Name); + Assert.Collection(attribute.Value.EnumerateObject().ToArray(), + property => + { + Assert.Equal("value", property.Name); + Assert.Equal(value, getValue(property.Value)); + }, property => + { + Assert.Equal("type", property.Name); + Assert.Equal(type, property.Value.GetString()); + }); } } From afb135e8fd309e385d3e97e991fa5b40dc1c1090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:05:49 +0200 Subject: [PATCH 077/101] feat: support char attributes --- src/Sentry/Protocol/SentryAttribute.cs | 13 +++++++++++-- test/Sentry.Tests/SentryLogTests.cs | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 5dbe7e52e8..16fff8ad60 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -76,9 +76,18 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDi // covering most built-in types of .NET with C# language support // for `net7.0` or greater, we could utilize "Generic Math" in the future, if there is demand // see documentation for supported types: https://develop.sentry.dev/sdk/telemetry/logs/ - if (value is string str) + if (value is string @string) { - writer.WriteString("value", str); + writer.WriteString("value", @string); + writer.WriteString("type", "string"); + } + else if (value is char @char) + { +#if NET7_0_OR_GREATER + writer.WriteString("value", new ReadOnlySpan(in @char)); +#else + writer.WriteString("value", MemoryMarshal.CreateReadOnlySpan(ref @char, 1)); +#endif writer.WriteString("type", "string"); } else if (value is bool boolean) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index ff03507d63..9c53b9dda0 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -215,7 +215,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() } [Fact] - public void WriteTo_Numerics_AsIntegerAndDouble() + public void WriteTo_Parameters_AsAttributes() { var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message") { @@ -234,6 +234,9 @@ public void WriteTo_Numerics_AsIntegerAndDouble() 1f, 2d, 3m, + true, + 'c', + "string", KeyValuePair.Create("key", "value"), ], }; @@ -261,7 +264,10 @@ public void WriteTo_Numerics_AsIntegerAndDouble() property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeString("sentry.message.parameter.13", json => json.GetString(), "[key, value]") + property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), + property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), + property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), + property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") ); Assert.Collection(_output.Entries, entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), From 7f675aabcb2d6a4f5a57a7ebe2b6e883fee1a103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:30:49 +0200 Subject: [PATCH 078/101] fix: build error targeting .NET Standard 2.0 and .NET Framework --- src/Sentry/Protocol/SentryAttribute.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 16fff8ad60..3a69643fd3 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -85,8 +85,10 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDi { #if NET7_0_OR_GREATER writer.WriteString("value", new ReadOnlySpan(in @char)); -#else +#elif (NET5_0_OR_GREATER || NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER) writer.WriteString("value", MemoryMarshal.CreateReadOnlySpan(ref @char, 1)); +#else + writer.WriteString("value", @char.ToString(CultureInfo.InvariantCulture)); #endif writer.WriteString("type", "string"); } From 750a388aab0e6b0493d4b1683c7555a274633f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:36:38 +0200 Subject: [PATCH 079/101] fix: exception when passing null as message parameter --- src/Sentry/Protocol/SentryAttribute.cs | 7 ++++++- test/Sentry.Tests/SentryLogTests.cs | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 3a69643fd3..9257959beb 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -32,6 +32,12 @@ internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, IDiagnosticLogger? logger) { + if (value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + writer.WritePropertyName(propertyName); WriteAttributeValue(writer, value, logger); } @@ -174,7 +180,6 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDi } else { - //TODO: test null writer.WriteString("value", value.ToString()); writer.WriteString("type", "string"); diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 9c53b9dda0..920fef7655 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -238,6 +238,7 @@ public void WriteTo_Parameters_AsAttributes() 'c', "string", KeyValuePair.Create("key", "value"), + null, ], }; @@ -273,7 +274,8 @@ public void WriteTo_Parameters_AsAttributes() entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), - entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*") + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } } From 9f62d3bd7f20f9cb9911724d834a2750c6d9ccfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:46:50 +0200 Subject: [PATCH 080/101] test: add Attributes-To-Json test --- test/Sentry.Tests/SentryLogTests.cs | 62 ++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 920fef7655..8906200f67 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -215,7 +215,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() } [Fact] - public void WriteTo_Parameters_AsAttributes() + public void WriteTo_MessageParameters_AsAttributes() { var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message") { @@ -278,6 +278,66 @@ public void WriteTo_Parameters_AsAttributes() entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } + + [Fact] + public void WriteTo_Attributes_AsJson() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetAttribute("sbyte.MinValue", sbyte.MinValue); + log.SetAttribute("byte.MaxValue", byte.MaxValue); + log.SetAttribute("short.MinValue", short.MinValue); + log.SetAttribute("ushort.MaxValue", ushort.MaxValue); + log.SetAttribute("int.MinValue", int.MinValue); + log.SetAttribute("uint.MaxValue", uint.MaxValue); + log.SetAttribute("long.MinValue", long.MinValue); + log.SetAttribute("ulong.MaxValue", ulong.MaxValue); + log.SetAttribute("nint.MinValue", nint.MinValue); + log.SetAttribute("nuint.MaxValue", nuint.MaxValue); + log.SetAttribute("float", 1f); + log.SetAttribute("double", 2d); + //log.SetAttribute("decimal", 3m); //not supported + log.SetAttribute("bool", true); + log.SetAttribute("char", 'c'); + log.SetAttribute("string", "string"); + //log.SetAttribute("object", KeyValuePair.Create("key", "value")); //not supported + log.SetAttribute("null", null!); + + ArrayBufferWriter bufferWriter = new(); + Utf8JsonWriter writer = new(bufferWriter); + log.WriteTo(writer, _output); + writer.Flush(); + + var document = JsonDocument.Parse(bufferWriter.WrittenMemory); + var items = document.RootElement.GetProperty("items"); + items.GetArrayLength().Should().Be(1); + var attributes = items[0].GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sbyte", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("byte", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("short", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("ushort", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("int", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("uint", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("long", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("ulong", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("nint", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("nuint", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeDouble("float", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("double", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("decimal", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("bool", json => json.GetBoolean(), true), + property => property.AssertAttributeString("char", json => json.GetString(), "c"), + property => property.AssertAttributeString("string", json => json.GetString(), "string"), + property => property.AssertAttributeString("object", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } } file static class AssertExtensions From 5b00c21e204004095606465d66f266953a1d54a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:29:28 +0200 Subject: [PATCH 081/101] fix: missing type on .NET Framework --- src/Sentry/Protocol/SentryAttribute.cs | 2 +- test/Sentry.Tests/SentryLogTests.cs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 9257959beb..11faf4f9a3 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -91,7 +91,7 @@ private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDi { #if NET7_0_OR_GREATER writer.WriteString("value", new ReadOnlySpan(in @char)); -#elif (NET5_0_OR_GREATER || NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER) +#elif (NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER) writer.WriteString("value", MemoryMarshal.CreateReadOnlySpan(ref @char, 1)); #else writer.WriteString("value", @char.ToString(CultureInfo.InvariantCulture)); diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 8906200f67..a4103821fb 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -214,6 +214,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() _output.Entries.Should().BeEmpty(); } +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_MessageParameters_AsAttributes() { @@ -243,7 +244,7 @@ public void WriteTo_MessageParameters_AsAttributes() }; ArrayBufferWriter bufferWriter = new(); - Utf8JsonWriter writer = new(bufferWriter); + using Utf8JsonWriter writer = new(bufferWriter); log.WriteTo(writer, _output); writer.Flush(); @@ -278,7 +279,9 @@ public void WriteTo_MessageParameters_AsAttributes() entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } +#endif +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_Attributes_AsJson() { @@ -303,7 +306,7 @@ public void WriteTo_Attributes_AsJson() log.SetAttribute("null", null!); ArrayBufferWriter bufferWriter = new(); - Utf8JsonWriter writer = new(bufferWriter); + using Utf8JsonWriter writer = new(bufferWriter); log.WriteTo(writer, _output); writer.Flush(); @@ -338,6 +341,7 @@ public void WriteTo_Attributes_AsJson() entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } +#endif } file static class AssertExtensions From 6d17918c90d86745298d78e65b24b0620a5621c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:01:21 +0200 Subject: [PATCH 082/101] feat: support Attribute-Types from spec --- src/Sentry/Protocol/SentryAttribute.cs | 61 +++++----- src/Sentry/SentryLog.cs | 147 ++++++++++--------------- test/Sentry.Tests/SentryLogTests.cs | 24 ++-- 3 files changed, 100 insertions(+), 132 deletions(-) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs index 11faf4f9a3..4a509b5f59 100644 --- a/src/Sentry/Protocol/SentryAttribute.cs +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -5,32 +5,51 @@ namespace Sentry.Protocol; [DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")] internal readonly struct SentryAttribute { + internal static SentryAttribute CreateString(object value) => new(value, "string"); + internal static SentryAttribute CreateBoolean(object value) => new(value, "boolean"); + internal static SentryAttribute CreateInteger(object value) => new(value, "integer"); + internal static SentryAttribute CreateDouble(object value) => new(value, "double"); + + public SentryAttribute(object value) + { + Value = value; + Type = null; + } + public SentryAttribute(object value, string type) { Value = value; Type = type; } - public object Value { get; } - public string Type { get; } + public object? Value { get; } + public string? Type { get; } } internal static class SentryAttributeSerializer { - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute, IDiagnosticLogger? logger) + internal static void WriteStringAttribute(Utf8JsonWriter writer, string propertyName, string value) { - Debug.Assert(attribute.Value is not null && attribute.Type is not null, $"The ValueType {nameof(attribute)} may have been assigned 'default', for which static flow analysis does not report nullable warnings."); writer.WritePropertyName(propertyName); - WriteAttributeValue(writer, attribute.Value, attribute.Type, logger); + writer.WriteStartObject(); + writer.WriteString("value", value); + writer.WriteString("type", "string"); + writer.WriteEndObject(); } - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, string type, IDiagnosticLogger? logger) + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute, IDiagnosticLogger? logger) { + if (attribute.Value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + writer.WritePropertyName(propertyName); - WriteAttributeValue(writer, value, type, logger); + WriteAttributeValue(writer, attribute.Value, attribute.Type, logger); } - internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object value, IDiagnosticLogger? logger) + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object? value, IDiagnosticLogger? logger) { if (value is null) { @@ -42,37 +61,19 @@ internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, WriteAttributeValue(writer, value, logger); } - private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string type, IDiagnosticLogger? logger) + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string? type, IDiagnosticLogger? logger) { - writer.WriteStartObject(); - if (type == "string") { + writer.WriteStartObject(); writer.WriteString("value", (string)value); writer.WriteString("type", type); - } - else if (type == "boolean") - { - writer.WriteBoolean("value", (bool)value); - writer.WriteString("type", type); - } - else if (type == "integer") - { - writer.WriteNumber("value", (long)value); - writer.WriteString("type", type); - } - else if (type == "double") - { - writer.WriteNumber("value", (double)value); - writer.WriteString("type", type); + writer.WriteEndObject(); } else { - writer.WriteString("value", value.ToString()); - writer.WriteString("type", "string"); + WriteAttributeValue(writer, value, logger); } - - writer.WriteEndObject(); } private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDiagnosticLogger? logger) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 5f4b860939..a1aa57ab46 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -78,19 +78,62 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le public SpanId? ParentSpanId { get; init; } /// - /// Gets the attribute value associated with the specified key when of type . + /// Gets the attribute value associated with the specified key. /// This API is experimental and it may change in the future. /// /// - /// Returns if the contains an attribute with the specified key of type . + /// Returns if the contains an attribute with the specified key and it's value is not . /// Otherwise . + /// Supported types: + /// + /// + /// Type + /// Range + /// + /// + /// string + /// and + /// + /// + /// boolean + /// and + /// + /// + /// integer + /// 64-bit signed integral numeric types + /// + /// + /// double + /// 64-bit floating-point numeric types + /// + /// + /// Unsupported types: + /// + /// + /// Type + /// Result + /// + /// + /// + /// ToString as "type": "string" + /// + /// + /// Collections + /// ToString as "type": "string" + /// + /// + /// + /// ignored + /// + /// /// + /// [Experimental(DiagnosticId.ExperimentalFeature)] - public bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) + public bool TryGetAttribute(string key, [NotNullWhen(true)] out object? value) { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "string") + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "object" && attribute.Value is not null) { - value = (string)attribute.Value; + value = attribute.Value; return true; } @@ -98,109 +141,33 @@ public bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) return false; } - /// - /// Gets the attribute value associated with the specified key when of type . - /// This API is experimental and it may change in the future. - /// - /// - /// Returns if the contains an attribute with the specified key of type . - /// Otherwise . - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public bool TryGetAttribute(string key, out bool value) + internal bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "boolean") + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "string" && attribute.Value is not null) { - value = (bool)attribute.Value; - return true; - } - - value = false; - return false; - } - - /// - /// Gets the attribute value associated with the specified key when of type . - /// This API is experimental and it may change in the future. - /// - /// - /// Returns if the contains an attribute with the specified key of type . - /// Otherwise . - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public bool TryGetAttribute(string key, out long value) - { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "integer") - { - value = (long)attribute.Value; + value = (string)attribute.Value; return true; } - value = 0L; + value = null; return false; } /// - /// Gets the attribute value associated with the specified key when of type . + /// Set a key-value pair of data attached to the log. /// This API is experimental and it may change in the future. /// - /// - /// Returns if the contains an attribute with the specified key of type . - /// Otherwise . - /// [Experimental(DiagnosticId.ExperimentalFeature)] - public bool TryGetAttribute(string key, out double value) + public void SetAttribute(string key, object value) { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "double") - { - value = (double)attribute.Value; - return true; - } - - value = 0.0; - return false; + _attributes[key] = new SentryAttribute(value); } - /// - /// Set a key-value pair of data attached to the log. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public void SetAttribute(string key, string value) + private void SetAttribute(string key, string value) { _attributes[key] = new SentryAttribute(value, "string"); } - /// - /// Set a key-value pair of data attached to the log. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public void SetAttribute(string key, bool value) - { - _attributes[key] = new SentryAttribute(value, "boolean"); - } - - /// - /// Set a key-value pair of data attached to the log. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public void SetAttribute(string key, long value) - { - _attributes[key] = new SentryAttribute(value, "integer"); - } - - /// - /// Set a key-value pair of data attached to the log. - /// This API is experimental and it may change in the future. - /// - [Experimental(DiagnosticId.ExperimentalFeature)] - public void SetAttribute(string key, double value) - { - _attributes[key] = new SentryAttribute(value, "double"); - } - internal void SetAttributes(SentryOptions options) { var environment = options.SettingLocator.GetEnvironment(); @@ -246,7 +213,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (Template is not null) { - SentryAttributeSerializer.WriteAttribute(writer, "sentry.message.template", Template, "string", logger); + SentryAttributeSerializer.WriteStringAttribute(writer, "sentry.message.template", Template); } if (!Parameters.IsDefault) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index a4103821fb..1adc0c88a1 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -286,23 +286,23 @@ public void WriteTo_MessageParameters_AsAttributes() public void WriteTo_Attributes_AsJson() { var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); - log.SetAttribute("sbyte.MinValue", sbyte.MinValue); - log.SetAttribute("byte.MaxValue", byte.MaxValue); - log.SetAttribute("short.MinValue", short.MinValue); - log.SetAttribute("ushort.MaxValue", ushort.MaxValue); - log.SetAttribute("int.MinValue", int.MinValue); - log.SetAttribute("uint.MaxValue", uint.MaxValue); - log.SetAttribute("long.MinValue", long.MinValue); - log.SetAttribute("ulong.MaxValue", ulong.MaxValue); - log.SetAttribute("nint.MinValue", nint.MinValue); - log.SetAttribute("nuint.MaxValue", nuint.MaxValue); + log.SetAttribute("sbyte", sbyte.MinValue); + log.SetAttribute("byte", byte.MaxValue); + log.SetAttribute("short", short.MinValue); + log.SetAttribute("ushort", ushort.MaxValue); + log.SetAttribute("int", int.MinValue); + log.SetAttribute("uint", uint.MaxValue); + log.SetAttribute("long", long.MinValue); + log.SetAttribute("ulong", ulong.MaxValue); + log.SetAttribute("nint", nint.MinValue); + log.SetAttribute("nuint", nuint.MaxValue); log.SetAttribute("float", 1f); log.SetAttribute("double", 2d); - //log.SetAttribute("decimal", 3m); //not supported + log.SetAttribute("decimal", 3m); log.SetAttribute("bool", true); log.SetAttribute("char", 'c'); log.SetAttribute("string", "string"); - //log.SetAttribute("object", KeyValuePair.Create("key", "value")); //not supported + log.SetAttribute("object", KeyValuePair.Create("key", "value")); log.SetAttribute("null", null!); ArrayBufferWriter bufferWriter = new(); From 7e2c57bae49b29ffa121a285e5e43c888d298c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:07:00 +0200 Subject: [PATCH 083/101] ref: clarify internal identifiers --- src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 2 +- src/Sentry/SentryLog.cs | 2 +- test/Sentry.Tests/SentryLogTests.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 8f91ec6689..0a60860ae8 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -52,7 +52,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } - log.SetAttributes(_options); + log.SetDefaultAttributes(_options); var configuredLog = log; if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index a1aa57ab46..3d897ab2d2 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -168,7 +168,7 @@ private void SetAttribute(string key, string value) _attributes[key] = new SentryAttribute(value, "string"); } - internal void SetAttributes(SentryOptions options) + internal void SetDefaultAttributes(SentryOptions options) { var environment = options.SettingLocator.GetEnvironment(); SetAttribute("sentry.environment", environment); diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 1adc0c88a1..1f35386f4c 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -31,7 +31,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() }; var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); - log.SetAttributes(options); + log.SetDefaultAttributes(options); var envelope = Envelope.FromLog(log); @@ -117,7 +117,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() log.SetAttribute("boolean-attribute", true); log.SetAttribute("integer-attribute", 3); log.SetAttribute("double-attribute", 4.4); - log.SetAttributes(options); + log.SetDefaultAttributes(options); var envelope = EnvelopeItem.FromLog(log); From 7115c427119708a62adaedf982d14f868825509c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:09:37 +0200 Subject: [PATCH 084/101] test: update approved API --- .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 16 ++-------------- .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 16 ++-------------- .../ApiApprovalTests.Run.Net4_8.verified.txt | 10 ++-------- 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index d9dcfbb08d..691f760b3a 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -658,21 +658,9 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public Sentry.SentryId TraceId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, bool value) { } + public void SetAttribute(string key, object value) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, double value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, long value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, string value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out bool value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out double value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out long value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index d9dcfbb08d..691f760b3a 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -658,21 +658,9 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public Sentry.SentryId TraceId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, bool value) { } + public void SetAttribute(string key, object value) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, double value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, long value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public void SetAttribute(string key, string value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out bool value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out double value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, out long value) { } - [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index ca63f2fade..5cb582e2d0 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -631,14 +631,8 @@ namespace Sentry public string? Template { get; init; } public System.DateTimeOffset Timestamp { get; init; } public Sentry.SentryId TraceId { get; init; } - public void SetAttribute(string key, bool value) { } - public void SetAttribute(string key, double value) { } - public void SetAttribute(string key, long value) { } - public void SetAttribute(string key, string value) { } - public bool TryGetAttribute(string key, out bool value) { } - public bool TryGetAttribute(string key, out double value) { } - public bool TryGetAttribute(string key, out long value) { } - public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { } + public void SetAttribute(string key, object value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } public enum SentryLogLevel From cd5246b89b909bc553995c4ae79709391a9d2055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:19:43 +0200 Subject: [PATCH 085/101] test: fix incorrect expectation --- src/Sentry/SentryLog.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 3d897ab2d2..c5d2f60eef 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -163,7 +163,7 @@ public void SetAttribute(string key, object value) _attributes[key] = new SentryAttribute(value); } - private void SetAttribute(string key, string value) + internal void SetAttribute(string key, string value) { _attributes[key] = new SentryAttribute(value, "string"); } From 6e13e956813de4bd171d737169f4895e6a68082d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:14:55 +0200 Subject: [PATCH 086/101] feat: use "wrapping" SDK's Name and Version --- src/Sentry/HubExtensions.cs | 12 ++++++++++++ .../Internal/DefaultSentryStructuredLogger.cs | 3 ++- src/Sentry/SentryLog.cs | 9 ++++++--- test/Sentry.Tests/SentryLogTests.cs | 16 ++++------------ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 736c06ed12..eb233b2644 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -259,4 +259,16 @@ internal static ITransactionTracer StartTransaction( var transaction = hub.GetTransaction(); return transaction?.IsSampled == true ? transaction : null; } + + internal static Scope? GetScope(this IHub hub) + { + if (hub is Hub fullHub) + { + return fullHub.ScopeManager.GetCurrent().Key; + } + + Scope? current = null; + hub.ConfigureScope(scope => current = scope); + return current; + } } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 0a60860ae8..5f6bd64064 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -52,7 +52,8 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } - log.SetDefaultAttributes(_options); + var scope = _hub.GetScope(); + log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance); var configuredLog = log; if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index c5d2f60eef..99e99e2ca8 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -168,7 +168,7 @@ internal void SetAttribute(string key, string value) _attributes[key] = new SentryAttribute(value, "string"); } - internal void SetDefaultAttributes(SentryOptions options) + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) { var environment = options.SettingLocator.GetEnvironment(); SetAttribute("sentry.environment", environment); @@ -179,8 +179,11 @@ internal void SetDefaultAttributes(SentryOptions options) SetAttribute("sentry.release", release); } - SetAttribute("sentry.sdk.name", Constants.SdkName); - if (SdkVersion.Instance.Version is { } version) + if (sdk.Name is { } name) + { + SetAttribute("sentry.sdk.name", name); + } + if (sdk.Version is { } version) { SetAttribute("sentry.sdk.version", version); } diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 1f35386f4c..2c9c46f0fd 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -31,7 +31,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() }; var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); - log.SetDefaultAttributes(options); + log.SetDefaultAttributes(options, new SdkVersion()); var envelope = Envelope.FromLog(log); @@ -80,14 +80,6 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() "sentry.release": { "value": "my-release", "type": "string" - }, - "sentry.sdk.name": { - "value": "{{SdkVersion.Instance.Name}}", - "type": "string" - }, - "sentry.sdk.version": { - "value": "{{SdkVersion.Instance.Version}}", - "type": "string" } } } @@ -117,7 +109,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() log.SetAttribute("boolean-attribute", true); log.SetAttribute("integer-attribute", 3); log.SetAttribute("double-attribute", 4.4); - log.SetDefaultAttributes(options); + log.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); var envelope = EnvelopeItem.FromLog(log); @@ -194,11 +186,11 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() "type": "string" }, "sentry.sdk.name": { - "value": "{{SdkVersion.Instance.Name}}", + "value": "Sentry.Test.SDK", "type": "string" }, "sentry.sdk.version": { - "value": "{{SdkVersion.Instance.Version}}", + "value": "1.2.3-test+Sentry", "type": "string" }, "sentry.trace.parent_span_id": { From 0a9a3b1866dc9cb73f50fc7f289c03657075b655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:06:00 +0200 Subject: [PATCH 087/101] fix: Get-Attribute-API --- .../Sentry.Samples.Console.Basic/Program.cs | 2 +- src/Sentry/SentryLog.cs | 3 +- test/Sentry.Tests/SentryLogTests.cs | 45 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 663471b48f..f1ae39b6ff 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -42,7 +42,7 @@ options.Experimental.SetBeforeSendLog(static log => { // A demonstration of how you can drop logs based on some attribute they have - if (log.TryGetAttribute("suppress", out bool attribute) && attribute) + if (log.TryGetAttribute("suppress", out var attribute) && attribute is true) { return null; } diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 99e99e2ca8..840bca9967 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -1,6 +1,5 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Internal; using Sentry.Protocol; namespace Sentry; @@ -131,7 +130,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le [Experimental(DiagnosticId.ExperimentalFeature)] public bool TryGetAttribute(string key, [NotNullWhen(true)] out object? value) { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "object" && attribute.Value is not null) + if (_attributes.TryGetValue(key, out var attribute) && attribute.Value is not null) { value = attribute.Value; return true; diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 2c9c46f0fd..4638d5896f 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -21,6 +21,51 @@ public SentryLogTests(ITestOutputHelper output) _output = new TestOutputDiagnosticLogger(output); } + [Fact] + public void Protocol_Default_VerifyAttributes() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + var sdk = new SdkVersion + { + Name = "Sentry.Test.SDK", + Version = "1.2.3-test+Sentry" + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create("params"), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("attribute", "value"); + log.SetDefaultAttributes(options, sdk); + + log.Timestamp.Should().Be(Timestamp); + log.TraceId.Should().Be(TraceId); + log.Level.Should().Be((SentryLogLevel)24); + log.Message.Should().Be("message"); + log.Template.Should().Be("template"); + log.Parameters.Should().BeEquivalentTo(["params"]); + log.ParentSpanId.Should().Be(ParentSpanId); + + log.TryGetAttribute("attribute", out object attribute).Should().BeTrue(); + attribute.Should().Be("value"); + log.TryGetAttribute("sentry.environment", out string environment).Should().BeTrue(); + environment.Should().Be(options.Environment); + log.TryGetAttribute("sentry.release", out string release).Should().BeTrue(); + release.Should().Be(options.Release); + log.TryGetAttribute("sentry.sdk.name", out string name).Should().BeTrue(); + name.Should().Be(sdk.Name); + log.TryGetAttribute("sentry.sdk.version", out string version).Should().BeTrue(); + version.Should().Be(sdk.Version); + log.TryGetAttribute("not-found", out object notFound).Should().BeFalse(); + notFound.Should().BeNull(); + } + [Fact] public void WriteTo_Envelope_MinimalSerializedSentryLog() { From 2c1608eb8c33f18d335a9f80ff71c2b5442bba36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:49:12 +0200 Subject: [PATCH 088/101] docs: update CHANGELOG --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662678c9df..3813517231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) + ## 5.11.2 ### Fixes @@ -16,7 +22,6 @@ ### Features -- Add experimental support for [_Sentry Structured Logging_](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added non-allocating `ConfigureScope` and `ConfigureScopeAsync` overloads ([#4244](https://github.com/getsentry/sentry-dotnet/pull/4244)) - Add .NET MAUI `AutomationId` element information to breadcrumbs ([#4248](https://github.com/getsentry/sentry-dotnet/pull/4248)) - The HTTP Response Status Code for spans instrumented using OpenTelemetry is now searchable ([#4283](https://github.com/getsentry/sentry-dotnet/pull/4283)) From 2950be9feae91a8a6241692eb6d622322cd9ddab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 25 Jun 2025 19:25:22 +0200 Subject: [PATCH 089/101] docs: update CHANGELOG --- CHANGELOG.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b23b41c8..ccbfc0c2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Features - Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) +- Add experimental integrations of [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.Experimental.EnableLogs` + - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.Experimental.EnableLogs` + - `Sentry.Maui`, enabled via `SentryMauiOptions.Experimental.EnableLogs` ## 5.11.2 @@ -20,22 +24,8 @@ ## 5.11.0 -- Moved experimental _Sentry Structured Logger_ from `SentrySdk.Logger` to `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) -- Moved experimental options for _Sentry Structured Logging_ ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) - - `Sentry.AspNetCore`: from `SentryAspNetCoreOptions.EnableLogs` to `SentryAspNetCoreOptions.Experimental.EnableLogs` - - `Sentry.Extensions.Logging`: from `SentryLoggingOptions.EnableLogs` to `SentryLoggingOptions.Experimental.EnableLogs` - - `Sentry.Maui`: from `SentryMauiOptions.EnableLogs` to `SentryMauiOptions.Experimental.EnableLogs` - -## 5.8.0-alpha.0 - ### Features -- Add experimental support for _Sentry Structured Logging_ via `SentrySdk.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) -- Add experimental integrations of _Sentry Structured Logging_ ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) - - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.EnableLogs` - - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.EnableLogs` - - `Sentry.Maui`, enabled via `SentryMauiOptions.EnableLogs` -- Add experimental support for [_Sentry Structured Logging_](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added non-allocating `ConfigureScope` and `ConfigureScopeAsync` overloads ([#4244](https://github.com/getsentry/sentry-dotnet/pull/4244)) - Add .NET MAUI `AutomationId` element information to breadcrumbs ([#4248](https://github.com/getsentry/sentry-dotnet/pull/4248)) - The HTTP Response Status Code for spans instrumented using OpenTelemetry is now searchable ([#4283](https://github.com/getsentry/sentry-dotnet/pull/4283)) From 8d3536206ff48dd9575b0c9e3c3eb024b1151f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 25 Jun 2025 19:52:01 +0200 Subject: [PATCH 090/101] feat(logs): initial API for Sentry Logs (#4158) --- CHANGELOG.md | 6 + .../Sentry.Samples.Console.Basic/Program.cs | 20 + src/Sentry/BindableSentryOptions.cs | 11 + .../DiagnosticLoggerExtensions.cs | 22 + src/Sentry/Extensibility/DisabledHub.cs | 7 + src/Sentry/Extensibility/HubAdapter.cs | 7 + src/Sentry/HubExtensions.cs | 12 + src/Sentry/IHub.cs | 14 + src/Sentry/Infrastructure/DiagnosticId.cs | 2 - .../Internal/DefaultSentryStructuredLogger.cs | 79 +++ .../DisabledSentryStructuredLogger.cs | 15 + src/Sentry/Internal/Hub.cs | 5 + src/Sentry/Protocol/Envelopes/Envelope.cs | 16 + src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 16 + src/Sentry/Protocol/SentryAttribute.cs | 192 ++++++++ src/Sentry/SentryLog.cs | 250 ++++++++++ src/Sentry/SentryLogLevel.cs | 135 +++++ src/Sentry/SentryOptions.cs | 50 ++ src/Sentry/SentrySdk.cs | 13 + src/Sentry/SentryStructuredLogger.cs | 103 ++++ test/Sentry.Testing/BindableTests.cs | 13 + .../InMemorySentryStructuredLogger.cs | 65 +++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 75 +++ ...piApprovalTests.Run.DotNet9_0.verified.txt | 75 +++ .../ApiApprovalTests.Run.Net4_8.verified.txt | 46 ++ .../Extensibility/DisabledHubTests.cs | 4 + .../Extensibility/HubAdapterTests.cs | 12 + test/Sentry.Tests/HubTests.cs | 66 +++ test/Sentry.Tests/SentryLogLevelTests.cs | 152 ++++++ test/Sentry.Tests/SentryLogTests.cs | 465 ++++++++++++++++++ .../SentryStructuredLoggerTests.cs | 290 +++++++++++ 31 files changed, 2236 insertions(+), 2 deletions(-) create mode 100644 src/Sentry/Internal/DefaultSentryStructuredLogger.cs create mode 100644 src/Sentry/Internal/DisabledSentryStructuredLogger.cs create mode 100644 src/Sentry/Protocol/SentryAttribute.cs create mode 100644 src/Sentry/SentryLog.cs create mode 100644 src/Sentry/SentryLogLevel.cs create mode 100644 src/Sentry/SentryStructuredLogger.cs create mode 100644 test/Sentry.Testing/InMemorySentryStructuredLogger.cs create mode 100644 test/Sentry.Tests/SentryLogLevelTests.cs create mode 100644 test/Sentry.Tests/SentryLogTests.cs create mode 100644 test/Sentry.Tests/SentryStructuredLoggerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ebefb3f8..3813517231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) + ## 5.11.2 ### Fixes diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 39027fa0ee..f1ae39b6ff 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -3,6 +3,7 @@ * - Error Monitoring (both handled and unhandled exceptions) * - Performance Tracing (Transactions / Spans) * - Release Health (Sessions) + * - Logs * - MSBuild integration for Source Context (see the csproj) * * For more advanced features of the SDK, see Sentry.Samples.Console.Customized. @@ -35,6 +36,20 @@ // This option tells Sentry to capture 100% of traces. You still need to start transactions and spans. options.TracesSampleRate = 1.0; + + // This option enables Sentry Logs created via SentrySdk.Logger. + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => + { + // A demonstration of how you can drop logs based on some attribute they have + if (log.TryGetAttribute("suppress", out var attribute) && attribute is true) + { + return null; + } + + // Drop logs with level Info + return log.Level is SentryLogLevel.Info ? null : log; + }); }); // This starts a new transaction and attaches it to the scope. @@ -58,6 +73,7 @@ async Task FirstFunction() var httpClient = new HttpClient(messageHandler, true); var html = await httpClient.GetStringAsync("https://example.com/"); WriteLine(html); + SentrySdk.Experimental.Logger.LogInfo("HTTP Request completed."); } async Task SecondFunction() @@ -77,6 +93,8 @@ async Task SecondFunction() // This is an example of capturing a handled exception. SentrySdk.CaptureException(exception); span.Finish(exception); + + SentrySdk.Experimental.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction))); } span.Finish(); @@ -90,6 +108,8 @@ async Task ThirdFunction() // Simulate doing some work await Task.Delay(100); + SentrySdk.Experimental.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true)); + // This is an example of an unhandled exception. It will be captured automatically. throw new InvalidOperationException("Something happened that crashed the app!"); } diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index cd9e5cc8d8..acf6d1de0a 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -53,6 +53,15 @@ internal partial class BindableSentryOptions public bool? EnableSpotlight { get; set; } public string? SpotlightUrl { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public BindableSentryExperimentalOptions Experimental { get; set; } = new(); + + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + internal sealed class BindableSentryExperimentalOptions + { + public bool? EnableLogs { get; set; } + } + public void ApplyTo(SentryOptions options) { options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled; @@ -100,6 +109,8 @@ public void ApplyTo(SentryOptions options) options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight; options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl; + options.Experimental.EnableLogs = Experimental.EnableLogs ?? options.Experimental.EnableLogs; + #if ANDROID Android.ApplyTo(options.Android); Native.ApplyTo(options.Native); diff --git a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs index 7c3a2e5b6b..3a51399539 100644 --- a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs +++ b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs @@ -58,6 +58,17 @@ internal static void LogDebug( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2); + /// + /// Log a debug message. + /// + public static void LogDebug( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2, arg3); + /// /// Log a debug message. /// @@ -233,6 +244,17 @@ internal static void LogWarning( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2); + /// + /// Log a warning message. + /// + public static void LogWarning( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2, arg3); + /// /// Log a warning message. /// diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index 339c295233..ad6165a50a 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -254,4 +254,11 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// No-Op. /// public SentryId LastEventId => SentryId.Empty; + + /// + /// Disabled Logger. + /// This API is experimental and it may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; } diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index c5953eeefa..132997cb5f 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -32,6 +32,13 @@ private HubAdapter() { } /// public SentryId LastEventId { [DebuggerStepThrough] get => SentrySdk.LastEventId; } + /// + /// Forwards the call to . + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Experimental.Logger; } + /// /// Forwards the call to . /// diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 736c06ed12..eb233b2644 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -259,4 +259,16 @@ internal static ITransactionTracer StartTransaction( var transaction = hub.GetTransaction(); return transaction?.IsSampled == true ? transaction : null; } + + internal static Scope? GetScope(this IHub hub) + { + if (hub is Hub fullHub) + { + return fullHub.ScopeManager.GetCurrent().Key; + } + + Scope? current = null; + hub.ConfigureScope(scope => current = scope); + return current; + } } diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index abf722c89d..7232aea817 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -17,6 +17,20 @@ public interface IHub : ISentryClient, ISentryScopeManager /// public SentryId LastEventId { get; } + /// + /// Creates and sends logs to Sentry. + /// This API is experimental and it may change in the future. + /// + /// + /// Available options: + /// + /// + /// + /// + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } + /// /// Starts a transaction. /// diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index 92703ddc87..c5bd026784 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -2,10 +2,8 @@ namespace Sentry.Infrastructure; internal static class DiagnosticId { -#if NET5_0_OR_GREATER /// /// Indicates that the feature is experimental and may be subject to change or removal in future versions. /// internal const string ExperimentalFeature = "SENTRY0001"; -#endif } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs new file mode 100644 index 0000000000..5f6bd64064 --- /dev/null +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -0,0 +1,79 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal; + +internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger +{ + private readonly IHub _hub; + private readonly SentryOptions _options; + private readonly ISystemClock _clock; + + internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock) + { + Debug.Assert(options is { Experimental.EnableLogs: true }); + + _hub = hub; + _options = options; + _clock = clock; + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + var timestamp = _clock.GetUtcNow(); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; + + string message; + try + { + message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } + + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = ImmutableArray.Create(parameters), + ParentSpanId = traceHeader.SpanId, + }; + + try + { + configureLog?.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); + return; + } + + var scope = _hub.GetScope(); + log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance); + + var configuredLog = log; + if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) + { + try + { + configuredLog = beforeSendLog.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); + return; + } + } + + if (configuredLog is not null) + { + //TODO: enqueue in Batch-Processor / Background-Worker + // see https://github.com/getsentry/sentry-dotnet/issues/4132 + _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + } + } +} diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs new file mode 100644 index 0000000000..086f67a1bd --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -0,0 +1,15 @@ +namespace Sentry.Internal; + +internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger +{ + internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger(); + + internal DisabledSentryStructuredLogger() + { + } + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + // disabled + } +} diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index ff9d5de43a..3efd71d896 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -66,6 +66,8 @@ internal Hub( PushScope(); } + Logger = SentryStructuredLogger.Create(this, options, _clock); + #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) { @@ -800,4 +802,7 @@ public void Dispose() } public SentryId LastEventId => CurrentScope.LastEventId; + + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b62dc82c98..d9ac774a60 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -445,6 +445,22 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + // TODO: This is temporary. We don't expect single log messages to become an envelope by themselves since batching is needed + [Experimental(DiagnosticId.ExperimentalFeature)] + internal static Envelope FromLog(SentryLog log) + { + //TODO: allow batching Sentry logs + //see https://github.com/getsentry/sentry-dotnet/issues/4132 + var header = DefaultHeader; + + var items = new[] + { + EnvelopeItem.FromLog(log) + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7c721db581..7da1c7b53a 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -24,6 +24,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable internal const string TypeValueProfile = "profile"; internal const string TypeValueMetric = "statsd"; internal const string TypeValueCodeLocations = "metric_meta"; + internal const string TypeValueLog = "log"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -370,6 +371,21 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + internal static EnvelopeItem FromLog(SentryLog log) + { + //TODO: allow batching Sentry logs + //see https://github.com/getsentry/sentry-dotnet/issues/4132 + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueLog, + ["item_count"] = 1, + ["content_type"] = "application/vnd.sentry.items.log+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(log)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs new file mode 100644 index 0000000000..4a509b5f59 --- /dev/null +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -0,0 +1,192 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +[DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")] +internal readonly struct SentryAttribute +{ + internal static SentryAttribute CreateString(object value) => new(value, "string"); + internal static SentryAttribute CreateBoolean(object value) => new(value, "boolean"); + internal static SentryAttribute CreateInteger(object value) => new(value, "integer"); + internal static SentryAttribute CreateDouble(object value) => new(value, "double"); + + public SentryAttribute(object value) + { + Value = value; + Type = null; + } + + public SentryAttribute(object value, string type) + { + Value = value; + Type = type; + } + + public object? Value { get; } + public string? Type { get; } +} + +internal static class SentryAttributeSerializer +{ + internal static void WriteStringAttribute(Utf8JsonWriter writer, string propertyName, string value) + { + writer.WritePropertyName(propertyName); + writer.WriteStartObject(); + writer.WriteString("value", value); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute, IDiagnosticLogger? logger) + { + if (attribute.Value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, attribute.Value, attribute.Type, logger); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object? value, IDiagnosticLogger? logger) + { + if (value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, value, logger); + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string? type, IDiagnosticLogger? logger) + { + if (type == "string") + { + writer.WriteStartObject(); + writer.WriteString("value", (string)value); + writer.WriteString("type", type); + writer.WriteEndObject(); + } + else + { + WriteAttributeValue(writer, value, logger); + } + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + // covering most built-in types of .NET with C# language support + // for `net7.0` or greater, we could utilize "Generic Math" in the future, if there is demand + // see documentation for supported types: https://develop.sentry.dev/sdk/telemetry/logs/ + if (value is string @string) + { + writer.WriteString("value", @string); + writer.WriteString("type", "string"); + } + else if (value is char @char) + { +#if NET7_0_OR_GREATER + writer.WriteString("value", new ReadOnlySpan(in @char)); +#elif (NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + writer.WriteString("value", MemoryMarshal.CreateReadOnlySpan(ref @char, 1)); +#else + writer.WriteString("value", @char.ToString(CultureInfo.InvariantCulture)); +#endif + writer.WriteString("type", "string"); + } + else if (value is bool boolean) + { + writer.WriteBoolean("value", boolean); + writer.WriteString("type", "boolean"); + } + else if (value is sbyte @sbyte) + { + writer.WriteNumber("value", @sbyte); + writer.WriteString("type", "integer"); + } + else if (value is byte @byte) + { + writer.WriteNumber("value", @byte); + writer.WriteString("type", "integer"); + } + else if (value is short int16) + { + writer.WriteNumber("value", int16); + writer.WriteString("type", "integer"); + } + else if (value is ushort uint16) + { + writer.WriteNumber("value", uint16); + writer.WriteString("type", "integer"); + } + else if (value is int int32) + { + writer.WriteNumber("value", int32); + writer.WriteString("type", "integer"); + } + else if (value is uint uint32) + { + writer.WriteNumber("value", uint32); + writer.WriteString("type", "integer"); + } + else if (value is long int64) + { + writer.WriteNumber("value", int64); + writer.WriteString("type", "integer"); + } + else if (value is ulong uint64) + { + writer.WriteString("value", uint64.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'ulong' (unsigned 64-bit integer) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is nint intPtr) + { + writer.WriteNumber("value", intPtr); + writer.WriteString("type", "integer"); + } + else if (value is nuint uintPtr) + { +#if NET5_0_OR_GREATER + writer.WriteString("value", uintPtr.ToString(NumberFormatInfo.InvariantInfo)); +#else + writer.WriteString("value", uintPtr.ToString()); +#endif + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'nuint' (unsigned platform-dependent integer) is not supported by Sentry-Attributes due to possible overflows on 64-bit processes. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is float single) + { + writer.WriteNumber("value", single); + writer.WriteString("type", "double"); + } + else if (value is double @double) + { + writer.WriteNumber("value", @double); + writer.WriteString("type", "double"); + } + else if (value is decimal @decimal) + { + writer.WriteString("value", @decimal.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'decimal' (128-bit floating-point) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type '{0}' is not supported by Sentry-Attributes. Using 'ToString' and type=string. Please use a supported type instead. To suppress this message, convert the value of this Attribute to type string explicitly.", value.GetType()); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs new file mode 100644 index 0000000000..840bca9967 --- /dev/null +++ b/src/Sentry/SentryLog.cs @@ -0,0 +1,250 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Protocol; + +namespace Sentry; + +/// +/// Represents the Sentry Log protocol. +/// This API is experimental and it may change in the future. +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public sealed class SentryLog : ISentryJsonSerializable +{ + private readonly Dictionary _attributes; + + [SetsRequiredMembers] + internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) + { + Timestamp = timestamp; + TraceId = traceId; + Level = level; + Message = message; + // 7 is the number of built-in attributes, so we start with that. + _attributes = new Dictionary(7); + } + + /// + /// The timestamp of the log. + /// This API is experimental and it may change in the future. + /// + /// + /// Sent as seconds since the Unix epoch. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required DateTimeOffset Timestamp { get; init; } + + /// + /// The trace id of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required SentryId TraceId { get; init; } + + /// + /// The severity level of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required SentryLogLevel Level { get; init; } + + /// + /// The formatted log message. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required string Message { get; init; } + + /// + /// The parameterized template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public string? Template { get; init; } + + /// + /// The parameters to the template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public ImmutableArray Parameters { get; init; } + + /// + /// The span id of the span that was active when the log was collected. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SpanId? ParentSpanId { get; init; } + + /// + /// Gets the attribute value associated with the specified key. + /// This API is experimental and it may change in the future. + /// + /// + /// Returns if the contains an attribute with the specified key and it's value is not . + /// Otherwise . + /// Supported types: + /// + /// + /// Type + /// Range + /// + /// + /// string + /// and + /// + /// + /// boolean + /// and + /// + /// + /// integer + /// 64-bit signed integral numeric types + /// + /// + /// double + /// 64-bit floating-point numeric types + /// + /// + /// Unsupported types: + /// + /// + /// Type + /// Result + /// + /// + /// + /// ToString as "type": "string" + /// + /// + /// Collections + /// ToString as "type": "string" + /// + /// + /// + /// ignored + /// + /// + /// + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public bool TryGetAttribute(string key, [NotNullWhen(true)] out object? value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Value is not null) + { + value = attribute.Value; + return true; + } + + value = null; + return false; + } + + internal bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "string" && attribute.Value is not null) + { + value = (string)attribute.Value; + return true; + } + + value = null; + return false; + } + + /// + /// Set a key-value pair of data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public void SetAttribute(string key, object value) + { + _attributes[key] = new SentryAttribute(value); + } + + internal void SetAttribute(string key, string value) + { + _attributes[key] = new SentryAttribute(value, "string"); + } + + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) + { + var environment = options.SettingLocator.GetEnvironment(); + SetAttribute("sentry.environment", environment); + + var release = options.SettingLocator.GetRelease(); + if (release is not null) + { + SetAttribute("sentry.release", release); + } + + if (sdk.Name is { } name) + { + SetAttribute("sentry.sdk.name", name); + } + if (sdk.Version is { } version) + { + SetAttribute("sentry.sdk.version", version); + } + } + + /// + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + writer.WriteStartObject(); + + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); + + var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(logger); + writer.WriteString("level", severityText); + + writer.WriteString("body", Message); + + writer.WritePropertyName("trace_id"); + TraceId.WriteTo(writer, logger); + + if (severityNumber.HasValue) + { + writer.WriteNumber("severity_number", severityNumber.Value); + } + + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + + if (Template is not null) + { + SentryAttributeSerializer.WriteStringAttribute(writer, "sentry.message.template", Template); + } + + if (!Parameters.IsDefault) + { + for (var index = 0; index < Parameters.Length; index++) + { + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index], logger); + } + } + + foreach (var attribute in _attributes) + { + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); + } + + if (ParentSpanId.HasValue) + { + writer.WritePropertyName("sentry.trace.parent_span_id"); + writer.WriteStartObject(); + writer.WritePropertyName("value"); + ParentSpanId.Value.WriteTo(writer, logger); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + + writer.WriteEndObject(); + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs new file mode 100644 index 0000000000..184fccc548 --- /dev/null +++ b/src/Sentry/SentryLogLevel.cs @@ -0,0 +1,135 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry; + +/// +/// The severity of the structured log. +/// This API is experimental and it may change in the future. +/// +/// +/// The named constants use the value of the lowest severity number per severity level: +/// +/// +/// SeverityNumber +/// SeverityText +/// +/// +/// 1-4 +/// Trace +/// +/// +/// 5-8 +/// Debug +/// +/// +/// 9-12 +/// Info +/// +/// +/// 13-16 +/// Warn +/// +/// +/// 17-20 +/// Error +/// +/// +/// 21-24 +/// Fatal +/// +/// +/// +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public enum SentryLogLevel +{ + /// + /// A fine-grained debugging event. + /// + Trace = 1, + /// + /// A debugging event. + /// + Debug = 5, + /// + /// An informational event. + /// + Info = 9, + /// + /// A warning event. + /// + Warning = 13, + /// + /// An error event. + /// + Error = 17, + /// + /// A fatal error such as application or system crash. + /// + Fatal = 21, +} + +[Experimental(DiagnosticId.ExperimentalFeature)] +internal static class SentryLogLevelExtensions +{ + internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level, IDiagnosticLogger? logger) + { + return (int)level switch + { + <= 0 => Underflow(level, logger), + 1 => ("trace", null), + >= 2 and <= 4 => ("trace", (int)level), + 5 => ("debug", null), + >= 6 and <= 8 => ("debug", (int)level), + 9 => ("info", null), + >= 10 and <= 12 => ("info", (int)level), + 13 => ("warn", null), + >= 14 and <= 16 => ("warn", (int)level), + 17 => ("error", null), + >= 18 and <= 20 => ("error", (int)level), + 21 => ("fatal", null), + >= 22 and <= 24 => ("fatal", (int)level), + >= 25 => Overflow(level, logger), + }; + + static (string, int?) Underflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to minimum value {1} ({2})", level, 1, "trace"); + return ("trace", 1); + } + + static (string, int?) Overflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to maximum value {1} ({2})", level, 24, "fatal"); + return ("fatal", 24); + } + } + + internal static SentryLogLevel FromValue(int value, IDiagnosticLogger? logger) + { + return value switch + { + <= 0 => Underflow(value, logger), + >= 1 and <= 4 => SentryLogLevel.Trace, + >= 5 and <= 8 => SentryLogLevel.Debug, + >= 9 and <= 12 => SentryLogLevel.Info, + >= 13 and <= 16 => SentryLogLevel.Warning, + >= 17 and <= 20 => SentryLogLevel.Error, + >= 21 and <= 24 => SentryLogLevel.Fatal, + >= 25 => Overflow(value, logger), + }; + + static SentryLogLevel Underflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to minimum level {1}", value, SentryLogLevel.Trace); + return SentryLogLevel.Trace; + } + + static SentryLogLevel Overflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to maximum level {1}", value, SentryLogLevel.Fatal); + return SentryLogLevel.Fatal; + } + } +} diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index ceab5113bc..c64bd6a288 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1848,4 +1848,54 @@ internal static List GetDefaultInAppExclude() => "ServiceStack", "Java.Interop", ]; + + /// + /// Experimental Sentry features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryExperimentalOptions Experimental { get; set; } = new(); + + /// + /// Experimental Sentry SDK options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public sealed class SentryExperimentalOptions + { + internal SentryExperimentalOptions() + { + } + + /// + /// When set to , logs are sent to Sentry. + /// Defaults to . + /// This API is experimental and it may change in the future. + /// + /// + public bool EnableLogs { get; set; } = false; + + private Func? _beforeSendLog; + + internal Func? BeforeSendLogInternal => _beforeSendLog; + + /// + /// 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. + /// This API is experimental and it may change in the future. + /// + /// + /// 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 void SetBeforeSendLog(Func beforeSendLog) + { + _beforeSendLog = beforeSendLog; + } + } } diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index d00b002576..6817f1b294 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -280,6 +280,19 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } + /// + /// Experimental Sentry SDK features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public static class Experimental + { + /// + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => CurrentHub.Logger; } + } + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs new file mode 100644 index 0000000000..f61f9e74da --- /dev/null +++ b/src/Sentry/SentryStructuredLogger.cs @@ -0,0 +1,103 @@ +using Sentry.Infrastructure; +using Sentry.Internal; + +namespace Sentry; + +/// +/// Creates and sends logs to Sentry. +/// This API is experimental and it may change in the future. +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public abstract class SentryStructuredLogger +{ + internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock) + { + return options.Experimental.EnableLogs + ? new DefaultSentryStructuredLogger(hub, options, clock) + : DisabledSentryStructuredLogger.Instance; + } + + private protected SentryStructuredLogger() + { + } + + private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Debug, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Info, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Warning, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogError(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Error, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogFatal(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Fatal, template, parameters, configureLog); + } +} diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 68dd553a36..13fee3df88 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -65,6 +65,10 @@ private static KeyValuePair GetDummyBindableValue(Property {$"key1", $"{propertyInfo.Name}value1"}, {$"key2", $"{propertyInfo.Name}value2"} }, + not null when propertyType == typeof(SentryOptions.SentryExperimentalOptions) => new SentryOptions.SentryExperimentalOptions + { + EnableLogs = true, + }, _ => throw new NotSupportedException($"Unsupported property type on property {propertyInfo.Name}") }; return new KeyValuePair(propertyInfo, value); @@ -81,6 +85,11 @@ private static IEnumerable> ToConfigValues(KeyValue yield return new KeyValuePair($"{prop.Name}:{kvp.Key}", kvp.Value); } } + else if (propertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + var experimental = (SentryOptions.SentryExperimentalOptions)value; + yield return new KeyValuePair($"{prop.Name}:{nameof(SentryOptions.SentryExperimentalOptions.EnableLogs)}", Convert.ToString(experimental.EnableLogs, CultureInfo.InvariantCulture)); + } else { yield return new KeyValuePair(prop.Name, Convert.ToString(value, CultureInfo.InvariantCulture)); @@ -115,6 +124,10 @@ protected void AssertContainsExpectedPropertyValues(TOptions actual) { actualValue.Should().BeEquivalentTo(expectedValue); } + else if (prop.PropertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + actualValue.Should().BeEquivalentTo(expectedValue); + } else { actualValue.Should().Be(expectedValue); diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs new file mode 100644 index 0000000000..c173fa7f17 --- /dev/null +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -0,0 +1,65 @@ +#nullable enable + +namespace Sentry.Testing; + +public sealed class InMemorySentryStructuredLogger : SentryStructuredLogger +{ + public List Entries { get; } = new(); + + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + Entries.Add(LogEntry.Create(level, template, parameters)); + } + + public sealed class LogEntry : IEquatable + { + public static LogEntry Create(SentryLogLevel level, string template, object[]? parameters) + { + return new LogEntry(level, template, parameters is null ? ImmutableArray.Empty : ImmutableCollectionsMarshal.AsImmutableArray(parameters)); + } + + private LogEntry(SentryLogLevel level, string template, ImmutableArray parameters) + { + Level = level; + Template = template; + Parameters = parameters; + } + + public SentryLogLevel Level { get; } + public string Template { get; } + public ImmutableArray Parameters { get; } + + public void AssertEqual(SentryLogLevel level, string template, params object[] parameters) + { + var expected = Create(level, template, parameters); + Assert.Equal(expected, this); + } + + public bool Equals(LogEntry? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Level == other.Level + && Template == other.Template + && Parameters.SequenceEqual(other.Parameters); + } + + public override bool Equals(object? obj) + { + return obj is LogEntry other && Equals(other); + } + + public override int GetHashCode() + { + throw new UnreachableException(); + } + } +} diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 82d7cdf7f7..691f760b3a 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -210,6 +210,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -633,6 +635,44 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, object value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -703,6 +743,8 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -788,6 +830,12 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -877,6 +925,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -956,6 +1009,22 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public abstract class SentryStructuredLogger + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1324,6 +1393,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1341,12 +1411,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1393,6 +1466,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 82d7cdf7f7..691f760b3a 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -210,6 +210,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -633,6 +635,44 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, object value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -703,6 +743,8 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -788,6 +830,12 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -877,6 +925,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -956,6 +1009,22 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public abstract class SentryStructuredLogger + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1324,6 +1393,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1341,12 +1411,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1393,6 +1466,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 701eaa25b3..5cb582e2d0 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -198,6 +198,7 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -621,6 +622,28 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + public sealed class SentryLog : Sentry.ISentryJsonSerializable + { + public Sentry.SentryLogLevel Level { get; init; } + public string Message { get; init; } + public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public Sentry.SpanId? ParentSpanId { get; init; } + public string? Template { get; init; } + public System.DateTimeOffset Timestamp { get; init; } + public Sentry.SentryId TraceId { get; init; } + public void SetAttribute(string key, object value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } + } + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -690,6 +713,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -769,6 +793,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -858,6 +887,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -937,6 +970,15 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + public abstract class SentryStructuredLogger + { + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1305,6 +1347,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1322,12 +1365,14 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1374,6 +1419,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e56ff65370..e03f8a82a3 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -35,4 +35,8 @@ public void CaptureEvent_EmptyGuid() [Fact] public async Task FlushAsync_NoOp() => await DisabledHub.Instance.FlushAsync(); + + [Fact] + public void Logger_IsDisabled() + => Assert.IsType(DisabledHub.Instance.Logger); } diff --git a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs index 824b5e08ad..0ddb6a89b2 100644 --- a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs +++ b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs @@ -70,6 +70,18 @@ public void LastEventId_MockInvoked() _ = Hub.Received(1).LastEventId; } + [Fact] + public void Logger_MockInvoked() + { + var logger = new InMemorySentryStructuredLogger(); + Hub.Logger.Returns(logger); + + HubAdapter.Instance.Logger.LogWarning("Message"); + + Assert.Collection(logger.Entries, + element => element.AssertEqual(SentryLogLevel.Warning, "Message")); + } + [Fact] public void EndSession_CrashedStatus_MockInvoked() { diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 864a92dd2d..51197c307d 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1420,6 +1420,72 @@ public async Task CaptureTransaction_WithTransactionProfiler_SendsTransactionWit lines[5].Should().BeEmpty(); } + [Fact] + public void Logger_IsDisabled_DoesNotCaptureLog() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + + // Assert + _fixture.Client.Received(0).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_IsEnabled_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_EnableAfterCreate_HasNoEffect() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = true; + + // Assert + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_DisableAfterCreate_HasNoEffect() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = false; + + // Assert + hub.Logger.Should().BeOfType(); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs new file mode 100644 index 0000000000..36b557ea08 --- /dev/null +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -0,0 +1,152 @@ +namespace Sentry.Tests; + +/// +/// +/// +public class SentryLogLevelTests +{ + private readonly InMemoryDiagnosticLogger _logger; + + public SentryLogLevelTests() + { + _logger = new InMemoryDiagnosticLogger(); + } + +#if NET7_0_OR_GREATER + [Fact] + public void Enum_GetValuesAsUnderlyingType_LowestSeverityNumberPerSeverityRange() + { + var values = Enum.GetValuesAsUnderlyingType(); + + Assert.Collection(values.OfType(), + element => Assert.Equal(1, element), + element => Assert.Equal(5, element), + element => Assert.Equal(9, element), + element => Assert.Equal(13, element), + element => Assert.Equal(17, element), + element => Assert.Equal(21, element)); + } +#endif + + [Theory] + [MemberData(nameof(SeverityTextAndSeverityNumber))] + public void SeverityTextAndSeverityNumber_WithinRange_MatchesProtocol(int level, string text, int? number) + { + var @enum = (SentryLogLevel)level; + + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); + + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(0, "trace", 1, "minimum")] + [InlineData(25, "fatal", 24, "maximum")] + public void SeverityTextAndSeverityNumber_OutOfRange_ClampValue(int level, string text, int? number, string clamp) + { + var @enum = (SentryLogLevel)level; + + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); + + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log level {0} out of range ... clamping to {{clamp}} value {1} ({2})""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([@enum, number, text], entry.Args)); + } + + public static TheoryData SeverityTextAndSeverityNumber() + { + return new TheoryData + { + { 1, "trace", null }, + { 2, "trace", 2 }, + { 3, "trace", 3 }, + { 4, "trace", 4 }, + { 5, "debug", null }, + { 6, "debug", 6 }, + { 7, "debug", 7 }, + { 8, "debug", 8 }, + { 9, "info", null }, + { 10, "info", 10 }, + { 11, "info", 11 }, + { 12, "info", 12 }, + { 13, "warn", null }, + { 14, "warn", 14 }, + { 15, "warn", 15 }, + { 16, "warn", 16 }, + { 17, "error", null }, + { 18, "error", 18 }, + { 19, "error", 19 }, + { 20, "error", 20 }, + { 21, "fatal", null }, + { 22, "fatal", 22 }, + { 23, "fatal", 23 }, + { 24, "fatal", 24 }, + }; + } + + [Theory] + [MemberData(nameof(Create))] + public void Create_WithinRange_UsesLowestSeverityNumberOfRange(int value, SentryLogLevel level) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(0, SentryLogLevel.Trace, "minimum")] + [InlineData(25, SentryLogLevel.Fatal, "maximum")] + public void Create_OutOfRange_ClampValue(int value, SentryLogLevel level, string clamp) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log number {0} out of range ... clamping to {{clamp}} level {1}""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([value, level], entry.Args)); + } + + public static TheoryData Create() + { + return new TheoryData + { + { 1, SentryLogLevel.Trace }, + { 2, SentryLogLevel.Trace }, + { 3, SentryLogLevel.Trace }, + { 4, SentryLogLevel.Trace }, + { 5, SentryLogLevel.Debug }, + { 6, SentryLogLevel.Debug }, + { 7, SentryLogLevel.Debug }, + { 8, SentryLogLevel.Debug }, + { 9, SentryLogLevel.Info }, + { 10, SentryLogLevel.Info }, + { 11, SentryLogLevel.Info }, + { 12, SentryLogLevel.Info }, + { 13, SentryLogLevel.Warning }, + { 14, SentryLogLevel.Warning }, + { 15, SentryLogLevel.Warning }, + { 16, SentryLogLevel.Warning }, + { 17, SentryLogLevel.Error }, + { 18, SentryLogLevel.Error }, + { 19, SentryLogLevel.Error }, + { 20, SentryLogLevel.Error }, + { 21, SentryLogLevel.Fatal }, + { 22, SentryLogLevel.Fatal }, + { 23, SentryLogLevel.Fatal }, + { 24, SentryLogLevel.Fatal }, + }; + } +} diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs new file mode 100644 index 0000000000..4638d5896f --- /dev/null +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -0,0 +1,465 @@ +using System.Text.Encodings.Web; +using Sentry.PlatformAbstractions; + +namespace Sentry.Tests; + +/// +/// +/// +public class SentryLogTests +{ + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, TimeSpan.FromHours(2)); + private static readonly SentryId TraceId = SentryId.Create(); + private static readonly SpanId? ParentSpanId = SpanId.Create(); + + private static readonly ISystemClock Clock = new MockClock(Timestamp); + + private readonly TestOutputDiagnosticLogger _output; + + public SentryLogTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Protocol_Default_VerifyAttributes() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + var sdk = new SdkVersion + { + Name = "Sentry.Test.SDK", + Version = "1.2.3-test+Sentry" + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create("params"), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("attribute", "value"); + log.SetDefaultAttributes(options, sdk); + + log.Timestamp.Should().Be(Timestamp); + log.TraceId.Should().Be(TraceId); + log.Level.Should().Be((SentryLogLevel)24); + log.Message.Should().Be("message"); + log.Template.Should().Be("template"); + log.Parameters.Should().BeEquivalentTo(["params"]); + log.ParentSpanId.Should().Be(ParentSpanId); + + log.TryGetAttribute("attribute", out object attribute).Should().BeTrue(); + attribute.Should().Be("value"); + log.TryGetAttribute("sentry.environment", out string environment).Should().BeTrue(); + environment.Should().Be(options.Environment); + log.TryGetAttribute("sentry.release", out string release).Should().BeTrue(); + release.Should().Be(options.Release); + log.TryGetAttribute("sentry.sdk.name", out string name).Should().BeTrue(); + name.Should().Be(sdk.Name); + log.TryGetAttribute("sentry.sdk.version", out string version).Should().BeTrue(); + version.Should().Be(sdk.Version); + log.TryGetAttribute("not-found", out object notFound).Should().BeFalse(); + notFound.Should().BeNull(); + } + + [Fact] + public void WriteTo_Envelope_MinimalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetDefaultAttributes(options, new SdkVersion()); + + var envelope = Envelope.FromLog(log); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output, Clock); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var header = JsonDocument.Parse(reader.ReadLine()!); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + header.ToIndentedJsonString().Should().Be($$""" + { + "sdk": { + "name": "{{SdkVersion.Instance.Name}}", + "version": "{{SdkVersion.Instance.Version}}" + }, + "sent_at": "{{Timestamp.Format()}}" + } + """); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "level": "trace", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "attributes": { + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create("string", false, 1, 2.2), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("string-attribute", "string-value"); + log.SetAttribute("boolean-attribute", true); + log.SetAttribute("integer-attribute", 3); + log.SetAttribute("double-attribute", 4.4); + log.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); + + var envelope = EnvelopeItem.FromLog(log); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "level": "fatal", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "severity_number": 24, + "attributes": { + "sentry.message.template": { + "value": "template", + "type": "string" + }, + "sentry.message.parameter.0": { + "value": "string", + "type": "string" + }, + "sentry.message.parameter.1": { + "value": false, + "type": "boolean" + }, + "sentry.message.parameter.2": { + "value": 1, + "type": "integer" + }, + "sentry.message.parameter.3": { + "value": {{2.2.Format()}}, + "type": "double" + }, + "string-attribute": { + "value": "string-value", + "type": "string" + }, + "boolean-attribute": { + "value": true, + "type": "boolean" + }, + "integer-attribute": { + "value": 3, + "type": "integer" + }, + "double-attribute": { + "value": {{4.4.Format()}}, + "type": "double" + }, + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + }, + "sentry.sdk.name": { + "value": "Sentry.Test.SDK", + "type": "string" + }, + "sentry.sdk.version": { + "value": "1.2.3-test+Sentry", + "type": "string" + }, + "sentry.trace.parent_span_id": { + "value": "{{ParentSpanId.ToString()}}", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter + [Fact] + public void WriteTo_MessageParameters_AsAttributes() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message") + { + Parameters = + [ + sbyte.MinValue, + byte.MaxValue, + short.MinValue, + ushort.MaxValue, + int.MinValue, + uint.MaxValue, + long.MinValue, + ulong.MaxValue, + nint.MinValue, + nuint.MaxValue, + 1f, + 2d, + 3m, + true, + 'c', + "string", + KeyValuePair.Create("key", "value"), + null, + ], + }; + + ArrayBufferWriter bufferWriter = new(); + using Utf8JsonWriter writer = new(bufferWriter); + log.WriteTo(writer, _output); + writer.Flush(); + + var document = JsonDocument.Parse(bufferWriter.WrittenMemory); + var items = document.RootElement.GetProperty("items"); + items.GetArrayLength().Should().Be(1); + var attributes = items[0].GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sentry.message.parameter.0", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.1", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.2", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.3", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.4", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.5", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.6", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.7", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.8", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.9", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), + property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), + property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), + property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } +#endif + +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter + [Fact] + public void WriteTo_Attributes_AsJson() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetAttribute("sbyte", sbyte.MinValue); + log.SetAttribute("byte", byte.MaxValue); + log.SetAttribute("short", short.MinValue); + log.SetAttribute("ushort", ushort.MaxValue); + log.SetAttribute("int", int.MinValue); + log.SetAttribute("uint", uint.MaxValue); + log.SetAttribute("long", long.MinValue); + log.SetAttribute("ulong", ulong.MaxValue); + log.SetAttribute("nint", nint.MinValue); + log.SetAttribute("nuint", nuint.MaxValue); + log.SetAttribute("float", 1f); + log.SetAttribute("double", 2d); + log.SetAttribute("decimal", 3m); + log.SetAttribute("bool", true); + log.SetAttribute("char", 'c'); + log.SetAttribute("string", "string"); + log.SetAttribute("object", KeyValuePair.Create("key", "value")); + log.SetAttribute("null", null!); + + ArrayBufferWriter bufferWriter = new(); + using Utf8JsonWriter writer = new(bufferWriter); + log.WriteTo(writer, _output); + writer.Flush(); + + var document = JsonDocument.Parse(bufferWriter.WrittenMemory); + var items = document.RootElement.GetProperty("items"); + items.GetArrayLength().Should().Be(1); + var attributes = items[0].GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sbyte", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("byte", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("short", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("ushort", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("int", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("uint", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("long", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("ulong", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("nint", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("nuint", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeDouble("float", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("double", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("decimal", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("bool", json => json.GetBoolean(), true), + property => property.AssertAttributeString("char", json => json.GetString(), "c"), + property => property.AssertAttributeString("string", json => json.GetString(), "string"), + property => property.AssertAttributeString("object", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } +#endif +} + +file static class AssertExtensions +{ + public static void AssertAttributeString(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "string", getValue, value); + } + + public static void AssertAttributeBoolean(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "boolean", getValue, value); + } + + public static void AssertAttributeInteger(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "integer", getValue, value); + } + + public static void AssertAttributeDouble(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "double", getValue, value); + } + + private static void AssertAttribute(this JsonProperty attribute, string name, string type, Func getValue, T value) + { + Assert.Equal(name, attribute.Name); + Assert.Collection(attribute.Value.EnumerateObject().ToArray(), + property => + { + Assert.Equal("value", property.Name); + Assert.Equal(value, getValue(property.Value)); + }, property => + { + Assert.Equal("type", property.Name); + Assert.Equal(type, property.Value.GetString()); + }); + } +} + +file static class JsonFormatterExtensions +{ + public static string Format(this DateTimeOffset value) + { + return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); + } + + public static string Format(this double value) + { + if (SentryRuntime.Current.IsNetFx() || SentryRuntime.Current.IsMono()) + { + // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string + // e.g. on .NET Framework (Windows) + // * 2.2.ToString() -> 2.2000000000000002 + // * 4.4.ToString() -> 4.4000000000000004 + // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ + + var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); + var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); + return Encoding.UTF8.GetString(utf8Bytes); + } + + return value.ToString(NumberFormatInfo.InvariantInfo); + } +} + +file static class JsonDocumentExtensions +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + public static string ToIndentedJsonString(this JsonDocument document) + { + var json = JsonSerializer.Serialize(document, Options); + + // Standardize on \n on all platforms, for consistency in tests. + return IsWindows ? json.Replace("\r\n", "\n") : json; + } +} diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..429fa503b5 --- /dev/null +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,290 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public class SentryStructuredLoggerTests +{ + internal sealed class Fixture + { + public Fixture() + { + DiagnosticLogger = new InMemoryDiagnosticLogger(); + Hub = Substitute.For(); + Options = new SentryOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + }; + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + TraceId = SentryId.Create(); + ParentSpanId = SpanId.Create(); + + var traceHeader = new SentryTraceHeader(TraceId, ParentSpanId.Value, null); + Hub.GetTraceHeader().Returns(traceHeader); + } + + public InMemoryDiagnosticLogger DiagnosticLogger { get; } + public IHub Hub { get; } + public SentryOptions Options { get; } + public ISystemClock Clock { get; } + public SentryId TraceId { get; private set; } + public SpanId? ParentSpanId { get; private set; } + + public void WithoutTraceHeader() + { + Hub.GetTraceHeader().Returns((SentryTraceHeader?)null); + TraceId = SentryId.Empty; + ParentSpanId = SpanId.Empty; + } + + public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, Options, Clock); + } + + private readonly Fixture _fixture; + + public SentryStructuredLoggerTests() + { + _fixture = new Fixture(); + } + + [Fact] + public void Create_Enabled_NewDefaultInstance() + { + _fixture.Options.Experimental.EnableLogs = true; + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().NotBeSameAs(other); + } + + [Fact] + public void Create_Disabled_CachedDisabledInstance() + { + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().BeSameAs(other); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, level); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) + { + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); + var logger = _fixture.GetSut(); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } + + [Fact] + public void Log_WithoutTraceHeader_CapturesEnvelope() + { + _fixture.WithoutTraceHeader(); + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WithBeforeSendLog_InvokesCallback() + { + var invocations = 0; + SentryLog configuredLog = null!; + + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => + { + invocations++; + configuredLog = log; + return log; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + configuredLog.AssertLog(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() + { + var invocations = 0; + + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => + { + invocations++; + return null; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + } + + [Fact] + public void Log_InvalidFormat_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], static (SentryLog log) => throw new InvalidOperationException()); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + private static void ConfigureLog(SentryLog log) + { + log.SetAttribute("attribute-key", "attribute-value"); + } +} + +file static class AssertionExtensions +{ + public static void AssertEnvelope(this Envelope envelope, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + envelope.Header.Should().ContainSingle().Which.Key.Should().Be("sdk"); + var item = envelope.Items.Should().ContainSingle().Which; + + var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; + AssertLog(log, fixture, level); + + Assert.Collection(item.Header, + element => Assert.Equal(CreateHeader("type", "log"), element), + element => Assert.Equal(CreateHeader("item_count", 1), element), + element => Assert.Equal(CreateHeader("content_type", "application/vnd.sentry.items.log+json"), element)); + } + + public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + log.Timestamp.Should().Be(fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(fixture.TraceId); + log.Level.Should().Be(level); + log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); + log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); + log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 }); + log.ParentSpanId.Should().Be(fixture.ParentSpanId); + log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); + value.Should().Be("attribute-value"); + } + + private static KeyValuePair CreateHeader(string name, object? value) + { + return new KeyValuePair(name, value); + } +} + +file static class SentryStructuredLoggerExtensions +{ + public static void Log(this SentryStructuredLogger logger, SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + switch (level) + { + case SentryLogLevel.Trace: + logger.LogTrace(template, parameters, configureLog); + break; + case SentryLogLevel.Debug: + logger.LogDebug(template, parameters, configureLog); + break; + case SentryLogLevel.Info: + logger.LogInfo(template, parameters, configureLog); + break; + case SentryLogLevel.Warning: + logger.LogWarning(template, parameters, configureLog); + break; + case SentryLogLevel.Error: + logger.LogError(template, parameters, configureLog); + break; + case SentryLogLevel.Fatal: + logger.LogFatal(template, parameters, configureLog); + break; + default: + throw new ArgumentOutOfRangeException(nameof(level), level, null); + } + } +} From a936ec657f3a8a195e3acd44c43c6947c47f91fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:39:56 +0200 Subject: [PATCH 091/101] test: change options to Experimental section --- .../SentryLoggingOptionsSetupTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index 8a570a8697..9321b3aa64 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -109,8 +109,8 @@ public void Configure_BindsConfigurationToOptions() ["MinimumEventLevel"] = expected.MinimumEventLevel.ToString(), ["InitializeSdk"] = expected.InitializeSdk.ToString(), - ["EnableLogs"] = expected.Experimental.EnableLogs.ToString(), - ["MinimumLogLevel"] = expected.ExperimentalLogging.MinimumLogLevel.ToString(), + ["Experimental:EnableLogs"] = expected.Experimental.EnableLogs.ToString(), + ["ExperimentalLogging:MinimumLogLevel"] = expected.ExperimentalLogging.MinimumLogLevel.ToString(), }) .Build(); From f20ec27f8361be6b0f5ab0a9e04a0c783d1a1781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:52:57 +0200 Subject: [PATCH 092/101] feat(logs): add Buffering and Batching (#4310) --- ...gBatchProcessorBenchmarks-report-github.md | 18 + .../StructuredLogBatchProcessorBenchmarks.cs | 68 ++++ .../Internal/DefaultSentryStructuredLogger.cs | 30 +- .../DisabledSentryStructuredLogger.cs | 7 + src/Sentry/Internal/DiscardReason.cs | 1 + src/Sentry/Internal/Hub.cs | 3 + .../Internal/StructuredLogBatchBuffer.cs | 309 ++++++++++++++++ .../Internal/StructuredLogBatchProcessor.cs | 145 ++++++++ src/Sentry/Protocol/Envelopes/Envelope.cs | 7 +- src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 6 +- src/Sentry/Protocol/StructuredLog.cs | 37 ++ src/Sentry/SentryLog.cs | 11 +- src/Sentry/SentryStructuredLogger.cs | 35 +- src/Sentry/Threading/ScopedCountdownLock.cs | 155 ++++++++ .../InMemoryDiagnosticLogger.cs | 10 + .../InMemorySentryStructuredLogger.cs | 7 + .../JsonSerializableExtensions.cs | 36 +- ...piApprovalTests.Run.DotNet8_0.verified.txt | 8 +- ...piApprovalTests.Run.DotNet9_0.verified.txt | 8 +- .../ApiApprovalTests.Run.Net4_8.verified.txt | 8 +- test/Sentry.Tests/HubTests.cs | 22 ++ .../Internals/DebugStackTraceTests.verify.cs | 1 + .../StructuredLogBatchBufferTests.cs | 344 ++++++++++++++++++ .../StructuredLogBatchProcessorTests.cs | 257 +++++++++++++ .../Protocol/StructuredLogTests.cs | 58 +++ test/Sentry.Tests/SentryLogTests.cs | 88 ++--- .../SentryStructuredLoggerTests.cs | 72 +++- .../Threading/ScopedCountdownLockTests.cs | 145 ++++++++ 28 files changed, 1814 insertions(+), 82 deletions(-) create mode 100644 benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md create mode 100644 benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs create mode 100644 src/Sentry/Internal/StructuredLogBatchBuffer.cs create mode 100644 src/Sentry/Internal/StructuredLogBatchProcessor.cs create mode 100644 src/Sentry/Protocol/StructuredLog.cs create mode 100644 src/Sentry/Threading/ScopedCountdownLock.cs create mode 100644 test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs create mode 100644 test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs create mode 100644 test/Sentry.Tests/Protocol/StructuredLogTests.cs create mode 100644 test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md new file mode 100644 index 0000000000..befa791365 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md @@ -0,0 +1,18 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 9.0.301 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated | +|---------------- |----------- |-------------------- |------------:|---------:|---------:|-------:|----------:| +| **EnqueueAndFlush** | **10** | **100** | **1,774.5 ns** | **7.57 ns** | **6.71 ns** | **0.6104** | **5 KB** | +| **EnqueueAndFlush** | **10** | **200** | **3,468.5 ns** | **11.16 ns** | **10.44 ns** | **1.2207** | **10 KB** | +| **EnqueueAndFlush** | **10** | **1000** | **17,259.7 ns** | **51.92 ns** | **46.02 ns** | **6.1035** | **50 KB** | +| **EnqueueAndFlush** | **100** | **100** | **857.5 ns** | **4.21 ns** | **3.73 ns** | **0.1469** | **1.2 KB** | +| **EnqueueAndFlush** | **100** | **200** | **1,681.4 ns** | **1.74 ns** | **1.63 ns** | **0.2937** | **2.41 KB** | +| **EnqueueAndFlush** | **100** | **1000** | **8,302.2 ns** | **12.00 ns** | **10.64 ns** | **1.4648** | **12.03 KB** | diff --git a/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs b/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs new file mode 100644 index 0000000000..336d726926 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs @@ -0,0 +1,68 @@ +using BenchmarkDotNet.Attributes; +using NSubstitute; +using Sentry.Extensibility; +using Sentry.Internal; + +namespace Sentry.Benchmarks; + +public class StructuredLogBatchProcessorBenchmarks +{ + private Hub _hub; + private StructuredLogBatchProcessor _batchProcessor; + private SentryLog _log; + + [Params(10, 100)] + public int BatchCount { get; set; } + + [Params(100, 200, 1_000)] + public int OperationsPerInvoke { get; set; } + + [GlobalSetup] + public void Setup() + { + SentryOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + Experimental = + { + EnableLogs = true, + }, + }; + + var batchInterval = Timeout.InfiniteTimeSpan; + + var clientReportRecorder = Substitute.For(); + clientReportRecorder + .When(static recorder => recorder.RecordDiscardedEvent(Arg.Any(), Arg.Any(), Arg.Any())) + .Throw(); + + var diagnosticLogger = Substitute.For(); + diagnosticLogger + .When(static logger => logger.IsEnabled(Arg.Any())) + .Throw(); + diagnosticLogger + .When(static logger => logger.Log(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Throw(); + + _hub = new Hub(options, DisabledHub.Instance); + _batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, diagnosticLogger); + _log = new SentryLog(DateTimeOffset.Now, SentryId.Empty, SentryLogLevel.Trace, "message"); + } + + [Benchmark] + public void EnqueueAndFlush() + { + for (var i = 0; i < OperationsPerInvoke; i++) + { + _batchProcessor.Enqueue(_log); + } + _batchProcessor.Flush(); + } + + [GlobalCleanup] + public void Cleanup() + { + _batchProcessor.Dispose(); + _hub.Dispose(); + } +} diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 5f6bd64064..75a8cee778 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -1,6 +1,5 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Protocol.Envelopes; namespace Sentry.Internal; @@ -10,15 +9,21 @@ internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger private readonly SentryOptions _options; private readonly ISystemClock _clock; - internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock) + private readonly StructuredLogBatchProcessor _batchProcessor; + + internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) { + Debug.Assert(hub.IsEnabled); Debug.Assert(options is { Experimental.EnableLogs: true }); _hub = hub; _options = options; _clock = clock; + + _batchProcessor = new StructuredLogBatchProcessor(hub, batchCount, batchInterval, _options.ClientReportRecorder, _options.DiagnosticLogger); } + /// private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { var timestamp = _clock.GetUtcNow(); @@ -71,9 +76,24 @@ private protected override void CaptureLog(SentryLogLevel level, string template if (configuredLog is not null) { - //TODO: enqueue in Batch-Processor / Background-Worker - // see https://github.com/getsentry/sentry-dotnet/issues/4132 - _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + _batchProcessor.Enqueue(configuredLog); } } + + /// + protected internal override void Flush() + { + _batchProcessor.Flush(); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _batchProcessor.Dispose(); + } + + base.Dispose(disposing); + } } diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs index 086f67a1bd..efe0e65ad2 100644 --- a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -8,8 +8,15 @@ internal DisabledSentryStructuredLogger() { } + /// private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { // disabled } + + /// + protected internal override void Flush() + { + // disabled + } } diff --git a/src/Sentry/Internal/DiscardReason.cs b/src/Sentry/Internal/DiscardReason.cs index 11a35fa2a3..afc71bd3e2 100644 --- a/src/Sentry/Internal/DiscardReason.cs +++ b/src/Sentry/Internal/DiscardReason.cs @@ -11,6 +11,7 @@ namespace Sentry.Internal; public static DiscardReason QueueOverflow = new("queue_overflow"); public static DiscardReason RateLimitBackoff = new("ratelimit_backoff"); public static DiscardReason SampleRate = new("sample_rate"); + public static DiscardReason Backpressure = new("backpressure"); private readonly string _value; diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 666f432246..bfec633662 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -819,6 +819,9 @@ public void Dispose() _memoryMonitor?.Dispose(); #endif + Logger.Flush(); + Logger.Dispose(); + try { CurrentClient.FlushAsync(_options.ShutdownTimeout).ConfigureAwait(false).GetAwaiter().GetResult(); diff --git a/src/Sentry/Internal/StructuredLogBatchBuffer.cs b/src/Sentry/Internal/StructuredLogBatchBuffer.cs new file mode 100644 index 0000000000..7e518253a6 --- /dev/null +++ b/src/Sentry/Internal/StructuredLogBatchBuffer.cs @@ -0,0 +1,309 @@ +using Sentry.Threading; + +namespace Sentry.Internal; + +/// +/// A wrapper over an , intended for reusable buffering. +/// +/// +/// Must be attempted to flush via when either the is reached, +/// or when the is exceeded. +/// +[DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")] +internal sealed class StructuredLogBatchBuffer : IDisposable +{ + private readonly SentryLog[] _array; + private int _additions; + private readonly ScopedCountdownLock _addLock; + + private readonly Timer _timer; + private readonly TimeSpan _timeout; + private readonly Action _timeoutExceededAction; + + private volatile bool _disposed; + + /// + /// Create a new buffer. + /// + /// Length of the new buffer. + /// When the timeout exceeds after an item has been added and the not yet been exceeded, is invoked. + /// The operation to execute when the exceeds if the buffer is neither empty nor full. + /// Name of the new buffer. + public StructuredLogBatchBuffer(int capacity, TimeSpan timeout, Action timeoutExceededAction, string? name = null) + { + ThrowIfLessThanTwo(capacity, nameof(capacity)); + ThrowIfNegativeOrZero(timeout, nameof(timeout)); + + _array = new SentryLog[capacity]; + _additions = 0; + _addLock = new ScopedCountdownLock(); + + _timer = new Timer(OnIntervalElapsed, this, Timeout.Infinite, Timeout.Infinite); + _timeout = timeout; + _timeoutExceededAction = timeoutExceededAction; + + Name = name ?? "default"; + } + + /// + /// Name of the buffer. + /// + internal string Name { get; } + + /// + /// Maximum number of elements that can be added to the buffer. + /// + internal int Capacity => _array.Length; + + /// + /// Gets a value indicating whether this buffer is empty. + /// + internal bool IsEmpty => _additions == 0; + + /// + /// Number of operations in progress. + /// + private int AddCount => _addLock.Count; + + /// + /// Attempt to add one element to the buffer. + /// Is thread-safe. + /// + /// Element attempted to be added. + /// An describing the result of the thread-safe operation. + internal StructuredLogBatchBufferAddStatus Add(SentryLog item) + { + if (_disposed) + { + return StructuredLogBatchBufferAddStatus.IgnoredIsDisposed; + } + + using var scope = _addLock.TryEnterCounterScope(); + if (!scope.IsEntered) + { + return StructuredLogBatchBufferAddStatus.IgnoredIsFlushing; + } + + var count = Interlocked.Increment(ref _additions); + + if (count == 1) + { + EnableTimer(); + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.AddedFirst; + } + + if (count < _array.Length) + { + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.Added; + } + + if (count == _array.Length) + { + DisableTimer(); + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.AddedLast; + } + + Debug.Assert(count > _array.Length); + return StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded; + } + + /// + /// Enters a used to ensure that only a single flush operation is in progress. + /// + /// + /// Must be disposed to exit. + /// + internal FlushScope TryEnterFlushScope() + { + if (_disposed) + { + return new FlushScope(); + } + + var scope = _addLock.TryEnterLockScope(); + if (scope.IsEntered) + { + return new FlushScope(this, scope); + } + + return new FlushScope(); + } + + /// + /// Exits the through . + /// + private void ExitFlushScope() + { + Debug.Assert(_addLock.IsEngaged); + } + + /// + /// Callback when Timer has elapsed after first item has been added and buffer is not full yet. + /// + internal void OnIntervalElapsed(object? state) + { + if (!_disposed) + { + _timeoutExceededAction(this); + } + } + + /// + /// Returns a new Array consisting of the elements successfully added. + /// + /// An Array with Length of successful additions. + private SentryLog[] ToArrayAndClear() + { + var additions = _additions; + var length = _array.Length; + if (additions < length) + { + length = additions; + } + return ToArrayAndClear(length); + } + + /// + /// Returns a new Array consisting of elements successfully added. + /// + /// The Length of the buffer a new Array is created from. + /// An Array with Length of . + private SentryLog[] ToArrayAndClear(int length) + { + Debug.Assert(_addLock.IsSet); + + var array = ToArray(length); + Clear(length); + return array; + } + + private SentryLog[] ToArray(int length) + { + if (length == 0) + { + return Array.Empty(); + } + + var array = new SentryLog[length]; + Array.Copy(_array, array, length); + return array; + } + + private void Clear(int length) + { + if (length == 0) + { + return; + } + + _additions = 0; + Array.Clear(_array, 0, length); + } + + private void EnableTimer() + { + _ = _timer.Change(_timeout, Timeout.InfiniteTimeSpan); + } + + private void DisableTimer() + { + _ = _timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// + public void Dispose() + { + _timer.Dispose(); + _addLock.Dispose(); + _disposed = true; + } + + private static void ThrowIfLessThanTwo(int value, string paramName) + { + if (value < 2) + { + ThrowLessThanTwo(value, paramName); + } + + static void ThrowLessThanTwo(int value, string paramName) + { + throw new ArgumentOutOfRangeException(paramName, value, "Argument must be at least two."); + } + } + + private static void ThrowIfNegativeOrZero(TimeSpan value, string paramName) + { + if (value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) + { + ThrowNegativeOrZero(value, paramName); + } + + static void ThrowNegativeOrZero(TimeSpan value, string paramName) + { + throw new ArgumentOutOfRangeException(paramName, value, "Argument must be a non-negative and non-zero value."); + } + } + + /// + /// A scope than ensures only a single operation is in progress, + /// and blocks the calling thread until all operations have finished. + /// When is , no more can be started, + /// which will then return immediately. + /// + /// + /// Only when scope . + /// + internal ref struct FlushScope : IDisposable + { + private StructuredLogBatchBuffer? _lockObj; + private ScopedCountdownLock.LockScope _scope; + + internal FlushScope(StructuredLogBatchBuffer lockObj, ScopedCountdownLock.LockScope scope) + { + Debug.Assert(scope.IsEntered); + _lockObj = lockObj; + _scope = scope; + } + + internal bool IsEntered => _scope.IsEntered; + + internal SentryLog[] Flush() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _scope.Wait(); + + var array = lockObj.ToArrayAndClear(); + return array; + } + + throw new ObjectDisposedException(nameof(FlushScope)); + } + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitFlushScope(); + } + + _scope.Dispose(); + } + } +} + +internal enum StructuredLogBatchBufferAddStatus : byte +{ + AddedFirst, + Added, + AddedLast, + IgnoredCapacityExceeded, + IgnoredIsFlushing, + IgnoredIsDisposed, +} diff --git a/src/Sentry/Internal/StructuredLogBatchProcessor.cs b/src/Sentry/Internal/StructuredLogBatchProcessor.cs new file mode 100644 index 0000000000..2fe5db924e --- /dev/null +++ b/src/Sentry/Internal/StructuredLogBatchProcessor.cs @@ -0,0 +1,145 @@ +using Sentry.Extensibility; +using Sentry.Protocol; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal; + +/// +/// The Batch Processor for Sentry Logs. +/// +/// +/// Uses a double buffer strategy to achieve synchronous and lock-free adding. +/// Switches the active buffer either when full or timeout exceeded (after first item added). +/// Logs are dropped when both buffers are either full or being flushed. +/// Logs are not enqueued when the Hub is disabled (Hub is being or has been disposed). +/// Flushing blocks the calling thread until all pending add operations have completed. +/// +/// Implementation: +/// - When Hub is disabled (i.e. disposed), does not enqueue log +/// - Try to enqueue log into currently active buffer +/// - when currently active buffer is full, try to enqueue log into the other buffer +/// - when the other buffer is also full, or currently being flushed, then the log is dropped and a discarded event is recorded as a client report +/// - Swap currently active buffer when +/// - buffer is full +/// - timeout has exceeded +/// - Batch and Capture logs after swapping currently active buffer +/// - wait until all pending add/enqueue operations have completed (required for timeout) +/// - flush the buffer and capture an envelope containing the batched logs +/// - After flush, logs can be enqueued again into the buffer +/// +/// +/// Sentry Logs +/// Sentry Batch Processor +/// OpenTelemetry Batch Processor +internal sealed class StructuredLogBatchProcessor : IDisposable +{ + private readonly IHub _hub; + private readonly IClientReportRecorder _clientReportRecorder; + private readonly IDiagnosticLogger? _diagnosticLogger; + + private readonly StructuredLogBatchBuffer _buffer1; + private readonly StructuredLogBatchBuffer _buffer2; + private volatile StructuredLogBatchBuffer _activeBuffer; + + public StructuredLogBatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + { + _hub = hub; + _clientReportRecorder = clientReportRecorder; + _diagnosticLogger = diagnosticLogger; + + _buffer1 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 1"); + _buffer2 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 2"); + _activeBuffer = _buffer1; + } + + internal void Enqueue(SentryLog log) + { + if (!_hub.IsEnabled) + { + return; + } + + var activeBuffer = _activeBuffer; + + if (!TryEnqueue(activeBuffer, log)) + { + activeBuffer = ReferenceEquals(activeBuffer, _buffer1) ? _buffer2 : _buffer1; + if (!TryEnqueue(activeBuffer, log)) + { + _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DataCategory.Default, 1); + _diagnosticLogger?.LogInfo("Log Buffer full ... dropping log"); + } + } + } + + internal void Flush() + { + CaptureLogs(_buffer1); + CaptureLogs(_buffer2); + } + + /// + /// Forces invocation of a Timeout of the active buffer. + /// + /// + /// Intended for Testing only. + /// + internal void OnIntervalElapsed() + { + var activeBuffer = _activeBuffer; + activeBuffer.OnIntervalElapsed(activeBuffer); + } + + private bool TryEnqueue(StructuredLogBatchBuffer buffer, SentryLog log) + { + var status = buffer.Add(log); + + if (status is StructuredLogBatchBufferAddStatus.AddedLast) + { + SwapActiveBuffer(buffer); + CaptureLogs(buffer); + return true; + } + + return status is StructuredLogBatchBufferAddStatus.AddedFirst or StructuredLogBatchBufferAddStatus.Added; + } + + private void SwapActiveBuffer(StructuredLogBatchBuffer currentActiveBuffer) + { + var newActiveBuffer = ReferenceEquals(currentActiveBuffer, _buffer1) ? _buffer2 : _buffer1; + _ = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); + } + + private void CaptureLogs(StructuredLogBatchBuffer buffer) + { + SentryLog[]? logs = null; + + using (var scope = buffer.TryEnterFlushScope()) + { + if (scope.IsEntered) + { + logs = scope.Flush(); + } + } + + if (logs is not null && logs.Length != 0) + { + _ = _hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(logs))); + } + } + + private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) + { + if (!buffer.IsEmpty) + { + SwapActiveBuffer(buffer); + CaptureLogs(buffer); + } + } + + public void Dispose() + { + _buffer1.Dispose(); + _buffer2.Dispose(); + } +} diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index c0b3005aa7..77fcbe83f4 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -451,17 +451,14 @@ internal static Envelope FromClientReport(ClientReport clientReport) internal static Envelope FromAttachment(SentryId eventId, SentryAttachment attachment, IDiagnosticLogger? logger = null) => new(eventId, CreateHeader(eventId), [EnvelopeItem.FromAttachment(attachment)]); - // TODO: This is temporary. We don't expect single log messages to become an envelope by themselves since batching is needed [Experimental(DiagnosticId.ExperimentalFeature)] - internal static Envelope FromLog(SentryLog log) + internal static Envelope FromLog(StructuredLog log) { - //TODO: allow batching Sentry logs - //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = DefaultHeader; var items = new[] { - EnvelopeItem.FromLog(log) + EnvelopeItem.FromLog(log), }; return new Envelope(header, items); diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7da1c7b53a..7528a14d63 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -372,14 +372,12 @@ internal static EnvelopeItem FromClientReport(ClientReport report) } [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - internal static EnvelopeItem FromLog(SentryLog log) + internal static EnvelopeItem FromLog(StructuredLog log) { - //TODO: allow batching Sentry logs - //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = new Dictionary(3, StringComparer.Ordinal) { [TypeKey] = TypeValueLog, - ["item_count"] = 1, + ["item_count"] = log.Length, ["content_type"] = "application/vnd.sentry.items.log+json", }; diff --git a/src/Sentry/Protocol/StructuredLog.cs b/src/Sentry/Protocol/StructuredLog.cs new file mode 100644 index 0000000000..6543d31ffc --- /dev/null +++ b/src/Sentry/Protocol/StructuredLog.cs @@ -0,0 +1,37 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +/// +/// Represents the Sentry Log protocol. +/// +/// +/// Sentry Docs: . +/// Sentry Developer Documentation: . +/// +internal sealed class StructuredLog : ISentryJsonSerializable +{ + private readonly SentryLog[] _items; + + public StructuredLog(SentryLog[] logs) + { + _items = logs; + } + + public int Length => _items.Length; + public ReadOnlySpan Items => _items; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + + foreach (var log in _items) + { + log.WriteTo(writer, logger); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 840bca9967..dab0813c2e 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -9,7 +9,7 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public sealed class SentryLog : ISentryJsonSerializable +public sealed class SentryLog { private readonly Dictionary _attributes; @@ -188,12 +188,9 @@ internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) } } - /// - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); - writer.WriteStartArray("items"); - writer.WriteStartObject(); writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); @@ -241,10 +238,8 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndObject(); } - writer.WriteEndObject(); + writer.WriteEndObject(); // attributes writer.WriteEndObject(); - writer.WriteEndArray(); - writer.WriteEndObject(); } } diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index f61f9e74da..f5b444960e 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -8,12 +8,15 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public abstract class SentryStructuredLogger +public abstract class SentryStructuredLogger : IDisposable { internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock) + => Create(hub, options, clock, 100, TimeSpan.FromSeconds(5)); + + internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) { return options.Experimental.EnableLogs - ? new DefaultSentryStructuredLogger(hub, options, clock) + ? new DefaultSentryStructuredLogger(hub, options, clock, batchCount, batchInterval) : DisabledSentryStructuredLogger.Instance; } @@ -21,8 +24,21 @@ private protected SentryStructuredLogger() { } + /// + /// Buffers a Sentry Log message + /// via the associated Batch Processor. + /// + /// The severity level of the log. + /// The parameterized template string. + /// The parameters to the string. + /// A configuration callback. Will be removed in a future version. private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); + /// + /// Clears all buffers for this logger and causes any buffered logs to be sent by the underlying . + /// + protected internal abstract void Flush(); + /// /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. /// This API is experimental and it may change in the future. @@ -100,4 +116,19 @@ public void LogFatal(string template, object[]? parameters = null, Action + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Override in inherited types to clean up managed and unmanaged resources. + /// + /// Invoked from when ; Invoked from Finalize when . + protected virtual void Dispose(bool disposing) + { + } } diff --git a/src/Sentry/Threading/ScopedCountdownLock.cs b/src/Sentry/Threading/ScopedCountdownLock.cs new file mode 100644 index 0000000000..bc3725f1a9 --- /dev/null +++ b/src/Sentry/Threading/ScopedCountdownLock.cs @@ -0,0 +1,155 @@ +namespace Sentry.Threading; + +/// +/// A synchronization primitive that tracks the amount of s held, +/// and is signaled when the count reaches zero while a is held. +/// +/// +/// Similar to , +/// but allows to increment the current count after the count has reached zero without resetting to the initial count before a is entered. +/// Has a similar API shape to System.Threading.Lock. +/// +[DebuggerDisplay("IsSet = {IsSet}, Count = {Count}, IsEngaged = {IsEngaged}")] +internal sealed class ScopedCountdownLock : IDisposable +{ + private readonly CountdownEvent _event; + private volatile int _isEngaged; + + internal ScopedCountdownLock() + { + _event = new CountdownEvent(1); + _isEngaged = 0; + } + + /// + /// if the event is set/signaled; otherwise, . + /// When , the active can until the reaches . + /// + internal bool IsSet => _event.IsSet; + + /// + /// Gets the number of remaining required to exit in order to set/signal the event while a is active. + /// When and while a is active, no more can be entered. + /// + internal int Count => _isEngaged == 1 ? _event.CurrentCount : _event.CurrentCount - 1; + + /// + /// Returns when a is active and the event can be set/signaled by reaching . + /// Returns when the can only reach the initial count of when no is active any longer. + /// + internal bool IsEngaged => _isEngaged == 1; + + /// + /// No will be entered when the has reached while the lock is engaged via an active . + /// Check via whether the underlying has not been set/signaled yet. + /// To signal the underlying , ensure is called. + /// + /// + /// Must be disposed to exit. + /// + internal CounterScope TryEnterCounterScope() + { + if (_event.TryAddCount(1)) + { + return new CounterScope(this); + } + + return new CounterScope(); + } + + private void ExitCounterScope() + { + _ = _event.Signal(); + } + + /// + /// When successful, the lock , can reach when no is active, and the event can be set/signaled. + /// Check via whether the Lock . + /// Use to block until every active has exited before performing the locked operation. + /// After the locked operation has completed, disengage the Lock via . + /// + /// + /// Must be disposed to exit. + /// + internal LockScope TryEnterLockScope() + { + if (Interlocked.CompareExchange(ref _isEngaged, 1, 0) == 0) + { + _ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all 'CounterScope's have exited + return new LockScope(this); + } + + return new LockScope(); + } + + private void ExitLockScope() + { + if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) == 1) + { + _event.Reset(); // reset the signaled event to the initial count of 1, so that new `CounterScope`s can be entered again + return; + } + + Debug.Fail("The Lock should have not been disengaged without being engaged first."); + } + + /// + public void Dispose() + { + _event.Dispose(); + } + + internal ref struct CounterScope : IDisposable + { + private ScopedCountdownLock? _lockObj; + + internal CounterScope(ScopedCountdownLock lockObj) + { + _lockObj = lockObj; + } + + internal bool IsEntered => _lockObj is not null; + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitCounterScope(); + } + } + } + + internal ref struct LockScope : IDisposable + { + private ScopedCountdownLock? _lockObj; + + internal LockScope(ScopedCountdownLock lockObj) + { + _lockObj = lockObj; + } + + internal bool IsEntered => _lockObj is not null; + + /// + /// Blocks the current thread until the current reaches and the event is set/signaled. + /// The caller will return immediately if the event is currently in a set/signaled state. + /// + internal void Wait() + { + var lockObj = _lockObj; + lockObj?._event.Wait(); + } + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitLockScope(); + } + } + } +} diff --git a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs index 39c554345f..b1715a161f 100644 --- a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs +++ b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs @@ -11,6 +11,16 @@ public void Log(SentryLevel logLevel, string message, Exception exception = null Entries.Enqueue(new Entry(logLevel, message, exception, args)); } + internal Entry Dequeue() + { + if (Entries.TryDequeue(out var entry)) + { + return entry; + } + + throw new InvalidOperationException("Queue is empty."); + } + public record Entry( SentryLevel Level, string Message, diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs index c173fa7f17..b180a776b0 100644 --- a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -6,11 +6,18 @@ public sealed class InMemorySentryStructuredLogger : SentryStructuredLogger { public List Entries { get; } = new(); + /// private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { Entries.Add(LogEntry.Create(level, template, parameters)); } + /// + protected internal override void Flush() + { + // no-op + } + public sealed class LogEntry : IEquatable { public static LogEntry Create(SentryLogLevel level, string template, object[]? parameters) diff --git a/test/Sentry.Testing/JsonSerializableExtensions.cs b/test/Sentry.Testing/JsonSerializableExtensions.cs index a8e92c735d..f71c758355 100644 --- a/test/Sentry.Testing/JsonSerializableExtensions.cs +++ b/test/Sentry.Testing/JsonSerializableExtensions.cs @@ -1,13 +1,15 @@ +#nullable enable + namespace Sentry.Testing; internal static class JsonSerializableExtensions { private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - public static string ToJsonString(this ISentryJsonSerializable serializable, IDiagnosticLogger logger = null, bool indented = false) => + public static string ToJsonString(this ISentryJsonSerializable serializable, IDiagnosticLogger? logger = null, bool indented = false) => WriteToJsonString(writer => writer.WriteSerializableValue(serializable, logger), indented); - public static string ToJsonString(this object @object, IDiagnosticLogger logger = null, bool indented = false) => + public static string ToJsonString(this object @object, IDiagnosticLogger? logger = null, bool indented = false) => WriteToJsonString(writer => writer.WriteDynamicValue(@object, logger), indented); private static string WriteToJsonString(Action writeAction, bool indented) @@ -43,4 +45,34 @@ private static string WriteToJsonString(Action writeAction, bool // Standardize on \n on all platforms, for consistency in tests. return IsWindows ? result.Replace("\r\n", "\n") : result; } + + public static JsonDocument ToJsonDocument(this ISentryJsonSerializable serializable, IDiagnosticLogger? logger = null) => + WriteToJsonDocument(writer => writer.WriteSerializableValue(serializable, logger)); + + public static JsonDocument ToJsonDocument(this T @object, Action serialize, IDiagnosticLogger? logger = null) where T : class => + WriteToJsonDocument(writer => serialize.Invoke(@object, writer, logger)); + + private static JsonDocument WriteToJsonDocument(Action writeAction) + { +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + // This implementation is better, as it uses fewer allocations + var buffer = new ArrayBufferWriter(); + + using var writer = new Utf8JsonWriter(buffer); + writeAction(writer); + writer.Flush(); + + return JsonDocument.Parse(buffer.WrittenMemory); +#else + // This implementation is compatible with older targets + using var stream = new MemoryStream(); + + using var writer = new Utf8JsonWriter(stream); + writeAction(writer); + writer.Flush(); + + stream.Seek(0, SeekOrigin.Begin); + return JsonDocument.Parse(stream); +#endif + } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 37a7702c8b..e826dba774 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -637,7 +637,7 @@ namespace Sentry } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] @@ -661,7 +661,6 @@ namespace Sentry public void SetAttribute(string key, object value) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } - public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public enum SentryLogLevel @@ -1012,8 +1011,11 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public abstract class SentryStructuredLogger + public abstract class SentryStructuredLogger : System.IDisposable { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 37a7702c8b..e826dba774 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -637,7 +637,7 @@ namespace Sentry } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] @@ -661,7 +661,6 @@ namespace Sentry public void SetAttribute(string key, object value) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } - public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public enum SentryLogLevel @@ -1012,8 +1011,11 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public abstract class SentryStructuredLogger + public abstract class SentryStructuredLogger : System.IDisposable { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 2c6df192a0..296ea74dc8 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -622,7 +622,7 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { public Sentry.SentryLogLevel Level { get; init; } public string Message { get; init; } @@ -633,7 +633,6 @@ namespace Sentry public Sentry.SentryId TraceId { get; init; } public void SetAttribute(string key, object value) { } public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } - public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } public enum SentryLogLevel { @@ -972,8 +971,11 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } - public abstract class SentryStructuredLogger + public abstract class SentryStructuredLogger : System.IDisposable { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 80d84930ae..1bbe1b5ebf 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1429,6 +1429,7 @@ public void Logger_IsDisabled_DoesNotCaptureLog() // Act hub.Logger.LogWarning("Message"); + hub.Logger.Flush(); // Assert _fixture.Client.Received(0).CaptureEnvelope( @@ -1448,6 +1449,7 @@ public void Logger_IsEnabled_DoesCaptureLog() // Act hub.Logger.LogWarning("Message"); + hub.Logger.Flush(); // Assert _fixture.Client.Received(1).CaptureEnvelope( @@ -1486,6 +1488,26 @@ public void Logger_DisableAfterCreate_HasNoEffect() hub.Logger.Should().BeOfType(); } + [Fact] + public void Logger_Dispose_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + hub.Dispose(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs index aa4387d9af..42f7e90e02 100644 --- a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs +++ b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs @@ -240,6 +240,7 @@ public Task CreateFrame_ForNativeAOT() IP = 2, }); + Assert.NotNull(frame); return VerifyJson(frame.ToJsonString()); } #endif diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs b/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs new file mode 100644 index 0000000000..77d8d34ebd --- /dev/null +++ b/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs @@ -0,0 +1,344 @@ +#nullable enable + +namespace Sentry.Tests.Internals; + +public class StructuredLogBatchBufferTests +{ + private sealed class Fixture + { + public int Capacity { get; set; } = 2; + public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; + public string? Name { get; set; } + + public List TimeoutExceededInvocations { get; } = []; + + public StructuredLogBatchBuffer GetSut() + { + return new StructuredLogBatchBuffer(Capacity, Timeout, OnTimeoutExceeded, Name); + } + + private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) + { + TimeoutExceededInvocations.Add(buffer); + } + } + + private readonly Fixture _fixture = new(); + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Ctor_CapacityIsOutOfRange_Throws(int capacity) + { + _fixture.Capacity = capacity; + + var ctor = () => _fixture.GetSut(); + + Assert.Throws("capacity", ctor); + } + + [Theory] + [InlineData(-2)] + [InlineData(0)] + public void Ctor_TimeoutIsOutOfRange_Throws(int millisecondsTimeout) + { + _fixture.Timeout = TimeSpan.FromMilliseconds(millisecondsTimeout); + + var ctor = () => _fixture.GetSut(); + + Assert.Throws("timeout", ctor); + } + + [Fact] + public void Ctor() + { + _fixture.Capacity = 9; + _fixture.Name = nameof(Ctor); + + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(_fixture.Capacity); + buffer.IsEmpty.Should().BeTrue(); + buffer.Name.Should().Be(_fixture.Name); + } + + [Fact] + public void Add_CapacityTwo_CanAddTwice() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_CapacityThree_CanAddThrice() + { + _fixture.Capacity = 3; + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(3); + buffer.IsEmpty.Should().BeTrue(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.Added); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_Flushing_CannotAdd() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + var flushScope = buffer.TryEnterFlushScope(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsFlushing); + buffer.IsEmpty.Should().BeTrue(); + + flushScope.Dispose(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_Disposed_CannotAdd() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsDisposed); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsEmpty_EmptyArray() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Should().BeEmpty(); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsNotEmptyNorFull_PartialCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsFull_FullCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one", "two"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_CapacityExceeded_FullCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + buffer.Add("three"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one", "two"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_DoubleFlush_SecondArrayIsEmpty() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var flushScope = buffer.TryEnterFlushScope(); + var first = flushScope.Flush(); + var second = flushScope.Flush(); + + first.Messages().Should().Equal(["one", "two"]); + second.Should().BeEmpty(); + } + + [Fact] + public void Flush_SecondFlush_NoFlushNoClear() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + + using (var flushScope = buffer.TryEnterFlushScope()) + { + flushScope.IsEntered.Should().BeTrue(); + buffer.IsEmpty.Should().BeFalse(); + } + + using (var flushScope = buffer.TryEnterFlushScope()) + { + flushScope.IsEntered.Should().BeTrue(); + flushScope.Flush().Messages().Should().Equal(["one", "two"]); + buffer.IsEmpty.Should().BeTrue(); + } + } + + [Fact] + public void Flush_TryEnterFlushScopeTwice_CanOnlyEnterOnce() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var first = buffer.TryEnterFlushScope(); + using var second = buffer.TryEnterFlushScope(); + + first.IsEntered.Should().BeTrue(); + second.IsEntered.Should().BeFalse(); + + first.Flush().Messages().Should().Equal(["one", "two"]); + AssertFlushThrows(second); + } + + [Fact] + public void Flush_DisposedScope_Throws() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + var flushScope = buffer.TryEnterFlushScope(); + flushScope.Dispose(); + + AssertFlushThrows(flushScope); + } + + [Fact] + public void Flush_DisposedBuffer_CannotEnter() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + using var flushScope = buffer.TryEnterFlushScope(); + + flushScope.IsEntered.Should().BeFalse(); + AssertFlushThrows(flushScope); + } + + [Fact] + public void OnIntervalElapsed_Timeout_InvokesCallback() + { + _fixture.Timeout = Timeout.InfiniteTimeSpan; + using var buffer = _fixture.GetSut(); + + buffer.OnIntervalElapsed(null); + _fixture.TimeoutExceededInvocations.Should().HaveCount(1); + + buffer.OnIntervalElapsed(null); + _fixture.TimeoutExceededInvocations.Should().HaveCount(2); + + _fixture.TimeoutExceededInvocations[0].Should().BeSameAs(buffer); + _fixture.TimeoutExceededInvocations[1].Should().BeSameAs(buffer); + } + + [Fact] + public void OnIntervalElapsed_Disposed_DoesNotInvokeCallback() + { + _fixture.Timeout = Timeout.InfiniteTimeSpan; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + buffer.OnIntervalElapsed(null); + + _fixture.TimeoutExceededInvocations.Should().BeEmpty(); + } + + // cannot use xUnit's Throws() nor Fluent Assertions' ThrowExactly() because the FlushScope is a ref struct + private static void AssertFlushThrows(StructuredLogBatchBuffer.FlushScope flushScope) + where T : Exception + { + Exception? exception = null; + try + { + flushScope.Flush(); + } + catch (Exception e) + { + exception = e; + } + + exception.Should().NotBeNull(); + exception.Should().BeOfType(); + } +} + +file static class StructuredLogBatchBufferHelpers +{ + public static StructuredLogBatchBufferAddStatus Add(this StructuredLogBatchBuffer buffer, string item) + { + SentryLog log = new(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, item); + return buffer.Add(log); + } + + public static string[] Messages(this SentryLog[] logs) + { + return logs.Select(static log => log.Message).ToArray(); + } +} diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs b/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs new file mode 100644 index 0000000000..200050e81d --- /dev/null +++ b/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs @@ -0,0 +1,257 @@ +#nullable enable + +namespace Sentry.Tests.Internals; + +public class StructuredLogBatchProcessorTests : IDisposable +{ + private sealed class Fixture + { + private readonly IHub _hub; + + public ClientReportRecorder ClientReportRecorder { get; } + public InMemoryDiagnosticLogger DiagnosticLogger { get; } + public BlockingCollection CapturedEnvelopes { get; } + + public int ExpectedDiagnosticLogs { get; set; } + + public Fixture() + { + var options = new SentryOptions(); + var clock = new MockClock(); + + _hub = Substitute.For(); + ClientReportRecorder = new ClientReportRecorder(options, clock); + DiagnosticLogger = new InMemoryDiagnosticLogger(); + + CapturedEnvelopes = []; + _hub.CaptureEnvelope(Arg.Do(arg => CapturedEnvelopes.Add(arg))); + _hub.IsEnabled.Returns(true); + + ExpectedDiagnosticLogs = 0; + } + + public void DisableHub() + { + _hub.IsEnabled.Returns(false); + } + + public StructuredLogBatchProcessor GetSut(int batchCount) + { + return new StructuredLogBatchProcessor(_hub, batchCount, Timeout.InfiniteTimeSpan, ClientReportRecorder, DiagnosticLogger); + } + } + + private readonly Fixture _fixture = new(); + + public void Dispose() + { + Assert.Equal(_fixture.ExpectedDiagnosticLogs, _fixture.DiagnosticLogger.Entries.Count); + } + + [Fact] + public void Enqueue_NeitherSizeNorTimeoutReached_DoesNotCaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + + Assert.Empty(_fixture.CapturedEnvelopes); + AssertEnvelope(); + } + + [Fact] + public void Enqueue_SizeReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_TimeoutReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.OnIntervalElapsed(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Enqueue_BothSizeAndTimeoutReached_CaptureEnvelopeOnce() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + processor.OnIntervalElapsed(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_BothTimeoutAndSizeReached_CaptureEnvelopes() + { + using var processor = _fixture.GetSut(2); + + processor.OnIntervalElapsed(); + processor.Enqueue(CreateLog("one")); + processor.OnIntervalElapsed(); + processor.Enqueue(CreateLog("two")); + processor.Enqueue(CreateLog("three")); + + Assert.Equal(2, _fixture.CapturedEnvelopes.Count); + AssertEnvelopes(["one"], ["two", "three"]); + } + + [Fact] + public async Task Enqueue_Concurrency_CaptureEnvelopes() + { + const int batchCount = 5; + const int maxDegreeOfParallelism = 10; + const int logsPerTask = 1_000; + + using var processor = _fixture.GetSut(batchCount); + using var sync = new ManualResetEvent(false); + + var tasks = new Task[maxDegreeOfParallelism]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Factory.StartNew(static state => + { + var (sync, logsPerTask, taskIndex, processor) = ((ManualResetEvent, int, int, StructuredLogBatchProcessor))state!; + sync.WaitOne(5_000); + for (var i = 0; i < logsPerTask; i++) + { + processor.Enqueue(CreateLog($"{taskIndex}-{i}")); + } + }, (sync, logsPerTask, i, processor)); + } + + sync.Set(); + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + processor.Flush(); + _fixture.CapturedEnvelopes.CompleteAdding(); + + var capturedLogs = _fixture.CapturedEnvelopes + .SelectMany(static envelope => envelope.Items) + .Select(static item => item.Payload) + .OfType() + .Select(static payload => payload.Source) + .OfType() + .Sum(log => log.Items.Length); + var droppedLogs = 0; + + if (_fixture.ClientReportRecorder.GenerateClientReport() is { } clientReport) + { + var discardedEvent = Assert.Single(clientReport.DiscardedEvents); + Assert.Equal(new DiscardReasonWithCategory(DiscardReason.Backpressure, DataCategory.Default), discardedEvent.Key); + + droppedLogs = discardedEvent.Value; + _fixture.ExpectedDiagnosticLogs = discardedEvent.Value; + } + + var actualInvocations = maxDegreeOfParallelism * logsPerTask; + if (actualInvocations != capturedLogs + droppedLogs) + { + Assert.Fail($""" + Expected {actualInvocations} combined logs, + but actually received a total of {capturedLogs + droppedLogs} logs, + with {capturedLogs} captured logs and {droppedLogs} dropped logs, + which is a difference of {actualInvocations - capturedLogs - droppedLogs} logs. + """); + } + } + + [Fact] + public void Enqueue_HubDisabled_DoesNotCaptureEnvelope() + { + var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + _fixture.DisableHub(); + processor.Enqueue(CreateLog("two")); + Assert.Empty(_fixture.CapturedEnvelopes); + + processor.Flush(); + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Flush_NeitherSizeNorTimeoutReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Flush(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Dispose_Enqueue_DoesNotCaptureEnvelope() + { + var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Dispose(); + processor.Enqueue(CreateLog("two")); + processor.Flush(); + + Assert.Empty(_fixture.CapturedEnvelopes); + AssertEnvelope(); + _fixture.ExpectedDiagnosticLogs = 1; + } + + private static SentryLog CreateLog(string message) + { + return new SentryLog(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, message); + } + + private void AssertEnvelope(params string[] expected) + { + if (expected.Length == 0) + { + Assert.Empty(_fixture.CapturedEnvelopes); + return; + } + + var envelope = Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope(envelope, expected); + } + + private void AssertEnvelopes(params string[][] expected) + { + if (expected.Length == 0) + { + Assert.Empty(_fixture.CapturedEnvelopes); + return; + } + + Assert.Equal(expected.Length, _fixture.CapturedEnvelopes.Count); + var index = 0; + foreach (var capturedEnvelope in _fixture.CapturedEnvelopes) + { + AssertEnvelope(capturedEnvelope, expected[index]); + index++; + } + } + + private static void AssertEnvelope(Envelope envelope, string[] expected) + { + var item = Assert.Single(envelope.Items); + var payload = Assert.IsType(item.Payload); + var log = payload.Source as StructuredLog; + Assert.NotNull(log); + Assert.Equal(expected, log.Items.ToArray().Select(static item => item.Message)); + } +} diff --git a/test/Sentry.Tests/Protocol/StructuredLogTests.cs b/test/Sentry.Tests/Protocol/StructuredLogTests.cs new file mode 100644 index 0000000000..3c491900e3 --- /dev/null +++ b/test/Sentry.Tests/Protocol/StructuredLogTests.cs @@ -0,0 +1,58 @@ +namespace Sentry.Tests.Protocol; + +/// +/// See . +/// See also . +/// +public class StructuredLogTests +{ + private readonly TestOutputDiagnosticLogger _output; + + public StructuredLogTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Type_IsAssignableFrom_ISentryJsonSerializable() + { + var log = new StructuredLog([]); + + Assert.IsAssignableFrom(log); + } + + [Fact] + public void Length_One_Single() + { + var log = new StructuredLog([CreateLog()]); + + var length = log.Length; + + Assert.Equal(1, length); + } + + [Fact] + public void Items_One_Single() + { + var log = new StructuredLog([CreateLog()]); + + var items = log.Items; + + Assert.Equal(1, items.Length); + } + + [Fact] + public void WriteTo_Empty_AsJson() + { + var log = new StructuredLog([]); + + var document = log.ToJsonDocument(_output); + + Assert.Equal("""{"items":[]}""", document.RootElement.ToString()); + } + + private static SentryLog CreateLog() + { + return new SentryLog(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, "message"); + } +} diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 4638d5896f..4fd355839b 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -4,7 +4,8 @@ namespace Sentry.Tests; /// -/// +/// See . +/// See also . /// public class SentryLogTests { @@ -78,7 +79,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); log.SetDefaultAttributes(options, new SdkVersion()); - var envelope = Envelope.FromLog(log); + var envelope = Envelope.FromLog(new StructuredLog([log])); using var stream = new MemoryStream(); envelope.Serialize(stream, _output, Clock); @@ -156,7 +157,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() log.SetAttribute("double-attribute", 4.4); log.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); - var envelope = EnvelopeItem.FromLog(log); + var envelope = EnvelopeItem.FromLog(new StructuredLog([log])); using var stream = new MemoryStream(); envelope.Serialize(stream, _output); @@ -251,7 +252,6 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() _output.Entries.Should().BeEmpty(); } -#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_MessageParameters_AsAttributes() { @@ -267,58 +267,62 @@ public void WriteTo_MessageParameters_AsAttributes() uint.MaxValue, long.MinValue, ulong.MaxValue, +#if NET5_0_OR_GREATER nint.MinValue, nuint.MaxValue, +#endif 1f, 2d, 3m, true, 'c', "string", +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) KeyValuePair.Create("key", "value"), +#else + new KeyValuePair("key", "value"), +#endif null, ], }; - ArrayBufferWriter bufferWriter = new(); - using Utf8JsonWriter writer = new(bufferWriter); - log.WriteTo(writer, _output); - writer.Flush(); + var currentParameterAttributeIndex = -1; + string GetNextParameterAttributeName() => $"sentry.message.parameter.{++currentParameterAttributeIndex}"; - var document = JsonDocument.Parse(bufferWriter.WrittenMemory); - var items = document.RootElement.GetProperty("items"); - items.GetArrayLength().Should().Be(1); - var attributes = items[0].GetProperty("attributes"); + var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); Assert.Collection(attributes.EnumerateObject().ToArray(), - property => property.AssertAttributeInteger("sentry.message.parameter.0", json => json.GetSByte(), sbyte.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.1", json => json.GetByte(), byte.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.2", json => json.GetInt16(), short.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.3", json => json.GetUInt16(), ushort.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.4", json => json.GetInt32(), int.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.5", json => json.GetUInt32(), uint.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.6", json => json.GetInt64(), long.MinValue), - property => property.AssertAttributeString("sentry.message.parameter.7", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeInteger("sentry.message.parameter.8", json => json.GetInt64(), nint.MinValue), - property => property.AssertAttributeString("sentry.message.parameter.9", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), - property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), - property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), - property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), - property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), - property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif + property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetDouble(), 2d), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean(GetNextParameterAttributeName(), json => json.GetBoolean(), true), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "c"), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "string"), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "[key, value]") ); Assert.Collection(_output.Entries, entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), +#if NET5_0_OR_GREATER entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), +#endif entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } -#endif -#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_Attributes_AsJson() { @@ -331,26 +335,25 @@ public void WriteTo_Attributes_AsJson() log.SetAttribute("uint", uint.MaxValue); log.SetAttribute("long", long.MinValue); log.SetAttribute("ulong", ulong.MaxValue); +#if NET5_0_OR_GREATER log.SetAttribute("nint", nint.MinValue); log.SetAttribute("nuint", nuint.MaxValue); +#endif log.SetAttribute("float", 1f); log.SetAttribute("double", 2d); log.SetAttribute("decimal", 3m); log.SetAttribute("bool", true); log.SetAttribute("char", 'c'); log.SetAttribute("string", "string"); +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) log.SetAttribute("object", KeyValuePair.Create("key", "value")); +#else + log.SetAttribute("object", new KeyValuePair("key", "value")); +#endif log.SetAttribute("null", null!); - ArrayBufferWriter bufferWriter = new(); - using Utf8JsonWriter writer = new(bufferWriter); - log.WriteTo(writer, _output); - writer.Flush(); - - var document = JsonDocument.Parse(bufferWriter.WrittenMemory); - var items = document.RootElement.GetProperty("items"); - items.GetArrayLength().Should().Be(1); - var attributes = items[0].GetProperty("attributes"); + var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); Assert.Collection(attributes.EnumerateObject().ToArray(), property => property.AssertAttributeInteger("sbyte", json => json.GetSByte(), sbyte.MinValue), property => property.AssertAttributeInteger("byte", json => json.GetByte(), byte.MaxValue), @@ -360,8 +363,10 @@ public void WriteTo_Attributes_AsJson() property => property.AssertAttributeInteger("uint", json => json.GetUInt32(), uint.MaxValue), property => property.AssertAttributeInteger("long", json => json.GetInt64(), long.MinValue), property => property.AssertAttributeString("ulong", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER property => property.AssertAttributeInteger("nint", json => json.GetInt64(), nint.MinValue), property => property.AssertAttributeString("nuint", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif property => property.AssertAttributeDouble("float", json => json.GetSingle(), 1f), property => property.AssertAttributeDouble("double", json => json.GetDouble(), 2d), property => property.AssertAttributeString("decimal", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), @@ -372,13 +377,14 @@ public void WriteTo_Attributes_AsJson() ); Assert.Collection(_output.Entries, entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), +#if NET5_0_OR_GREATER entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), +#endif entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } -#endif } file static class AssertExtensions diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 429fa503b5..aa303ba690 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -5,7 +5,7 @@ namespace Sentry.Tests; /// /// /// -public class SentryStructuredLoggerTests +public class SentryStructuredLoggerTests : IDisposable { internal sealed class Fixture { @@ -19,9 +19,13 @@ public Fixture() DiagnosticLogger = DiagnosticLogger, }; Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + BatchSize = 2; + BatchTimeout = Timeout.InfiniteTimeSpan; TraceId = SentryId.Create(); ParentSpanId = SpanId.Create(); + Hub.IsEnabled.Returns(true); + var traceHeader = new SentryTraceHeader(TraceId, ParentSpanId.Value, null); Hub.GetTraceHeader().Returns(traceHeader); } @@ -30,6 +34,8 @@ public Fixture() public IHub Hub { get; } public SentryOptions Options { get; } public ISystemClock Clock { get; } + public int BatchSize { get; set; } + public TimeSpan BatchTimeout { get; set; } public SentryId TraceId { get; private set; } public SpanId? ParentSpanId { get; private set; } @@ -40,7 +46,7 @@ public void WithoutTraceHeader() ParentSpanId = SpanId.Empty; } - public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, Options, Clock); + public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, Options, Clock, BatchSize, BatchTimeout); } private readonly Fixture _fixture; @@ -50,6 +56,11 @@ public SentryStructuredLoggerTests() _fixture = new Fixture(); } + public void Dispose() + { + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); + } + [Fact] public void Create_Enabled_NewDefaultInstance() { @@ -90,6 +101,7 @@ public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); envelope.AssertEnvelope(_fixture, level); @@ -123,6 +135,7 @@ public void Log_WithoutTraceHeader_CapturesEnvelope() _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); @@ -144,6 +157,7 @@ public void Log_WithBeforeSendLog_InvokesCallback() var logger = _fixture.GetSut(); logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); invocations.Should().Be(1); @@ -178,7 +192,7 @@ public void Log_InvalidFormat_DoesNotCaptureEnvelope() logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); - var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Error); entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); entry.Exception.Should().BeOfType(); @@ -194,7 +208,7 @@ public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], static (SentryLog log) => throw new InvalidOperationException()); _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); - var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Error); entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); @@ -211,13 +225,52 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); - var entry = _fixture.DiagnosticLogger.Entries.Should().ContainSingle().Which; + var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Error); entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); entry.Exception.Should().BeOfType(); entry.Args.Should().BeEmpty(); } + [Fact] + public void Flush_AfterLog_CapturesEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Flush(); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + logger.Flush(); + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Dispose_BeforeLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.Dispose(); + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Info); + entry.Message.Should().Be("Log Buffer full ... dropping log"); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEmpty(); + } + private static void ConfigureLog(SentryLog log) { log.SetAttribute("attribute-key", "attribute-value"); @@ -231,7 +284,7 @@ public static void AssertEnvelope(this Envelope envelope, SentryStructuredLogger envelope.Header.Should().ContainSingle().Which.Key.Should().Be("sdk"); var item = envelope.Items.Should().ContainSingle().Which; - var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; + var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; AssertLog(log, fixture, level); Assert.Collection(item.Header, @@ -240,6 +293,13 @@ public static void AssertEnvelope(this Envelope envelope, SentryStructuredLogger element => Assert.Equal(CreateHeader("content_type", "application/vnd.sentry.items.log+json"), element)); } + public static void AssertLog(this StructuredLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + var items = log.Items; + items.Length.Should().Be(1); + AssertLog(items[0], fixture, level); + } + public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) { log.Timestamp.Should().Be(fixture.Clock.GetUtcNow()); diff --git a/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs new file mode 100644 index 0000000000..dad07e1e23 --- /dev/null +++ b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs @@ -0,0 +1,145 @@ +using Sentry.Threading; + +namespace Sentry.Tests.Threading; + +public class ScopedCountdownLockTests : IDisposable +{ + private readonly ScopedCountdownLock _lock = new(); + + public void Dispose() + { + _lock.Dispose(); + } + + [Fact] + public void Init_IsNotEngaged_IsNotSet() + { + AssertDisengaged(false, 0); + } + + [Fact] + public void TryEnterCounterScope_IsNotEngaged_IsNotSet() + { + // increment the count + var counterOne = _lock.TryEnterCounterScope(); + counterOne.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // increment the count + var counterTwo = _lock.TryEnterCounterScope(); + counterTwo.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 2); + + // decrement the count + counterOne.Dispose(); + counterOne.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 1); + + // decrement the count + counterTwo.Dispose(); + counterTwo.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 0); + + // no-op ... already disposed + counterOne.Dispose(); + counterTwo.Dispose(); + AssertDisengaged(false, 0); + + // increment the count + var counterThree = _lock.TryEnterCounterScope(); + counterThree.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // decrement the count + counterThree.Dispose(); + counterThree.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 0); + } + + [Fact] + public void TryEnterLockScope_IsEngaged_IsSet() + { + // successfully enter a CounterScope ... increment the count + var counterOne = _lock.TryEnterCounterScope(); + counterOne.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // successfully enter a LockScope ... engages the lock + var lockOne = _lock.TryEnterLockScope(); + lockOne.IsEntered.Should().BeTrue(); + AssertEngaged(false, 1); + + // cannot enter another LockScope as long as the lock is already engaged by a LockScope + var lockTwo = _lock.TryEnterLockScope(); + lockTwo.IsEntered.Should().BeFalse(); + AssertEngaged(false, 1); + + // no-op ... LockScope is not entered + lockTwo.Wait(); + lockTwo.Dispose(); + AssertEngaged(false, 1); + + // successfully enter another CounterScope ... lock is engaged but not yet set + var counterTwo = _lock.TryEnterCounterScope(); + counterTwo.IsEntered.Should().BeTrue(); + AssertEngaged(false, 2); + + // exit a CounterScope ... decrement the count + counterTwo.Dispose(); + AssertEngaged(false, 1); + + // exit last CounterScope ... count of engaged lock reaches zero ... sets the lock + counterOne.Dispose(); + AssertEngaged(true, 0); + + // cannot enter another CounterScope as long as the engaged lock is set + var counterThree = _lock.TryEnterCounterScope(); + counterThree.IsEntered.Should().BeFalse(); + AssertEngaged(true, 0); + counterThree.Dispose(); + AssertEngaged(true, 0); + + // would block if the count of the engaged lock was not zero + lockOne.Wait(); + + // exit the LockScope ... reset the lock + lockOne.Dispose(); + AssertDisengaged(false, 0); + + // can enter a CounterScope again ... the lock not set + var counterFour = _lock.TryEnterCounterScope(); + counterFour.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + counterFour.Dispose(); + AssertDisengaged(false, 0); + } + + [Fact] + public void Dispose_UseAfterDispose_Throws() + { + _lock.Dispose(); + + Assert.Throws(() => _lock.TryEnterCounterScope()); + Assert.Throws(() => _lock.TryEnterLockScope()); + } + + private void AssertEngaged(bool isSet, int count) + { + using (new AssertionScope()) + { + _lock.IsSet.Should().Be(isSet); + _lock.Count.Should().Be(count); + _lock.IsEngaged.Should().BeTrue(); + } + } + + private void AssertDisengaged(bool isSet, int count) + { + using (new AssertionScope()) + { + _lock.IsSet.Should().Be(isSet); + _lock.Count.Should().Be(count); + _lock.IsEngaged.Should().BeFalse(); + } + } +} From 459e45d082531de1f026a238238af21f2be25973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:03:20 +0200 Subject: [PATCH 093/101] docs: merge CHANGELOG --- CHANGELOG.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b42581548d..bc32c65cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,8 @@ ### Features -- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) -- Add experimental integrations of [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) - - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.Experimental.EnableLogs` - - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.Experimental.EnableLogs` - - `Sentry.Maui`, enabled via `SentryMauiOptions.Experimental.EnableLogs` +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158), [#4193](https://github.com/getsentry/sentry-dotnet/pull/4193), [#4310](https://github.com/getsentry/sentry-dotnet/pull/4310)) + ### Fixes - Native AOT: don't load SentryNative on unsupported platforms ([#4347](https://github.com/getsentry/sentry-dotnet/pull/4347)) @@ -27,7 +24,6 @@ ### Features -- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added StartSpan and GetTransaction methods to the SentrySdk ([#4303](https://github.com/getsentry/sentry-dotnet/pull/4303)) ### Fixes From 92698dd30090f81f3cfb416b908b927528fe5567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:47:33 +0200 Subject: [PATCH 094/101] ref/fix: M.E.L integrations --- ...entryAspNetCoreStructuredLoggerProvider.cs | 18 ++- .../LogLevelExtensions.cs | 15 ++ .../SentryStructuredLogger.cs | 75 +++++---- .../SentryStructuredLoggerProvider.cs | 26 ++- .../Internal/DefaultSentryStructuredLogger.cs | 20 ++- .../DisabledSentryStructuredLogger.cs | 6 + src/Sentry/SentryLog.cs | 16 +- src/Sentry/SentryStructuredLogger.cs | 7 + .../SentryStructuredLoggerProviderTests.cs | 64 +++++++- .../SentryStructuredLoggerTests.cs | 151 +++++++++++++++--- .../InMemorySentryStructuredLogger.cs | 6 + ...piApprovalTests.Run.DotNet8_0.verified.txt | 3 +- ...piApprovalTests.Run.DotNet9_0.verified.txt | 3 +- .../ApiApprovalTests.Run.Net4_8.verified.txt | 3 +- test/Sentry.Tests/SentryLogTests.cs | 81 +++++----- .../SentryStructuredLoggerTests.cs | 2 +- 16 files changed, 379 insertions(+), 117 deletions(-) diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs index cb77cc7c8b..5b06f22955 100644 --- a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Sentry.Extensions.Logging; +using Sentry.Infrastructure; namespace Sentry.AspNetCore; @@ -8,11 +9,24 @@ namespace Sentry.AspNetCore; /// Structured Logger Provider for Sentry. /// [ProviderAlias("SentryLogs")] -[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider { public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) - : base(options, hub) + : this(options.Value, SystemClock.Clock, hub, CreateSdkVersion()) { } + + internal SentryAspNetCoreStructuredLoggerProvider(SentryLoggingOptions options, ISystemClock clock, IHub hub, SdkVersion sdk) + : base(options, clock, hub, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryMiddleware.NameAndVersion.Version, + }; + } } diff --git a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs index e3f862de77..6939ae359d 100644 --- a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs +++ b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs @@ -45,4 +45,19 @@ public static SentryLevel ToSentryLevel(this LogLevel level) _ => SentryLevel.Debug }; } + + public static SentryLogLevel ToSentryLogLevel(this LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => SentryLogLevel.Trace, + LogLevel.Debug => SentryLogLevel.Debug, + LogLevel.Information => SentryLogLevel.Info, + LogLevel.Warning => SentryLogLevel.Warning, + LogLevel.Error => SentryLogLevel.Error, + LogLevel.Critical => SentryLogLevel.Fatal, + LogLevel.None => SentryLogLevel.Trace, + _ => SentryLogLevel.Trace, + }; + } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs index d49e9a6763..257b338f98 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -1,19 +1,23 @@ using Microsoft.Extensions.Logging; +using Sentry.Infrastructure; namespace Sentry.Extensions.Logging; -[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal sealed class SentryStructuredLogger : ILogger { - private readonly string _categoryName; + private readonly string? _categoryName; private readonly SentryLoggingOptions _options; private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; - internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub) + internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) { _categoryName = categoryName; _options = options; + _clock = clock; _hub = hub; + _sdk = sdk; } public IDisposable? BeginScope(TState state) where TState : notnull @@ -36,49 +40,58 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except return; } - // not quite ideal as this is a boxing allocation from Microsoft.Extensions.Logging.FormattedLogValues - /* + var timestamp = _clock.GetUtcNow(); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; + + var level = logLevel.ToSentryLogLevel(); + var message = formatter.Invoke(state, exception); + string? template = null; - object[]? parameters = null; + var parameters = ImmutableArray.CreateBuilder>(); + // see Microsoft.Extensions.Logging.FormattedLogValues if (state is IReadOnlyList> formattedLogValues) { + if (formattedLogValues.Count != 0) + { + parameters.Capacity = formattedLogValues.Count - 1; + } + foreach (var formattedLogValue in formattedLogValues) { if (formattedLogValue.Key == "{OriginalFormat}" && formattedLogValue.Value is string formattedString) { template = formattedString; - break; + } + else if (formattedLogValue.Value is not null) + { + parameters.Add(new KeyValuePair(formattedLogValue.Key, formattedLogValue.Value)); } } } - */ - string message = formatter.Invoke(state, exception); + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = parameters.ToImmutable(), + ParentSpanId = traceHeader.SpanId, + }; + + log.SetDefaultAttributes(_options, _sdk); - switch (logLevel) + if (_categoryName is not null) + { + log.SetAttribute("microsoft.extensions.logging.category_name", _categoryName); + } + if (eventId.Name is not null || eventId.Id != 0) { - case LogLevel.Trace: - _hub.Logger.LogTrace(message); - break; - case LogLevel.Debug: - _hub.Logger.LogDebug(message); - break; - case LogLevel.Information: - _hub.Logger.LogInfo(message); - break; - case LogLevel.Warning: - _hub.Logger.LogWarning(message); - break; - case LogLevel.Error: - _hub.Logger.LogError(message); - break; - case LogLevel.Critical: - _hub.Logger.LogFatal(message); - break; - case LogLevel.None: - default: - break; + log.SetAttribute("microsoft.extensions.logging.event.id", eventId.Id); } + if (eventId.Name is not null) + { + log.SetAttribute("microsoft.extensions.logging.event.name", eventId.Name); + } + + _hub.Logger.CaptureLog(log); } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs index 1f28db6320..fdfe0a527c 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Sentry.Infrastructure; namespace Sentry.Extensions.Logging; @@ -7,26 +8,41 @@ namespace Sentry.Extensions.Logging; /// Sentry Structured Logger Provider. /// [ProviderAlias("SentryLogs")] -[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal class SentryStructuredLoggerProvider : ILoggerProvider { - private readonly IOptions _options; + private readonly SentryLoggingOptions _options; private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; - // TODO: convert this comment into an automated test - // Constructor must be public for Microsoft.Extensions.DependencyInjection public SentryStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryStructuredLoggerProvider(SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) { _options = options; _hub = hub; + _clock = clock; + _sdk = sdk; } public ILogger CreateLogger(string categoryName) { - return new SentryStructuredLogger(categoryName, _options.Value, _hub); + return new SentryStructuredLogger(categoryName, _options, _hub, _clock, _sdk); } public void Dispose() { } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryLoggerProvider.NameAndVersion.Version, + }; + } } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 75a8cee778..9f519c2a6f 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -40,10 +40,21 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } + ImmutableArray> @params = default; + if (parameters is not null && parameters.Length != 0) + { + var builder = ImmutableArray.CreateBuilder>(parameters.Length); + for (var index = 0; index < parameters.Length; index++) + { + builder.Add(new KeyValuePair(index.ToString(), parameters[index])); + } + @params = builder.ToImmutable(); + } + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) { Template = template, - Parameters = ImmutableArray.Create(parameters), + Parameters = @params, ParentSpanId = traceHeader.SpanId, }; @@ -60,7 +71,14 @@ private protected override void CaptureLog(SentryLogLevel level, string template var scope = _hub.GetScope(); log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance); + CaptureLog(log); + } + + /// + protected internal override void CaptureLog(SentryLog log) + { var configuredLog = log; + if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) { try diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs index efe0e65ad2..02fb6fc8f1 100644 --- a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -14,6 +14,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template // disabled } + /// + protected internal override void CaptureLog(SentryLog log) + { + // disabled + } + /// protected internal override void Flush() { diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index dab0813c2e..9f79a02303 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -67,7 +67,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public ImmutableArray Parameters { get; init; } + public ImmutableArray> Parameters { get; init; } /// /// The span id of the span that was active when the log was collected. @@ -167,6 +167,16 @@ internal void SetAttribute(string key, string value) _attributes[key] = new SentryAttribute(value, "string"); } + internal void SetAttribute(string key, char value) + { + _attributes[key] = new SentryAttribute(value.ToString(), "string"); + } + + internal void SetAttribute(string key, int value) + { + _attributes[key] = new SentryAttribute(value, "integer"); + } + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) { var environment = options.SettingLocator.GetEnvironment(); @@ -217,9 +227,9 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (!Parameters.IsDefault) { - for (var index = 0; index < Parameters.Length; index++) + foreach (var parameter in Parameters) { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index], logger); + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{parameter.Key}", parameter.Value, logger); } } diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index f5b444960e..0170c28254 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -34,6 +34,13 @@ private protected SentryStructuredLogger() /// A configuration callback. Will be removed in a future version. private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); + /// + /// Buffers a Sentry Log message + /// via the associated Batch Processor. + /// + /// The log. + protected internal abstract void CaptureLog(SentryLog log); + /// /// Clears all buffers for this logger and causes any buffered logs to be sent by the underlying . /// diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs index 8b2cd79899..dd9a98ef63 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -1,3 +1,6 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -5,19 +8,66 @@ namespace Sentry.Extensions.Logging.Tests; public class SentryStructuredLoggerProviderTests { + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + } + + public SentryStructuredLoggerProvider GetSut() + { + return new SentryStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + [Fact] - public void SmokeTest() + public void Ctor_DependencyInjection_CanCreate() { - var loggingOptions = new SentryLoggingOptions(); - loggingOptions.Experimental.EnableLogs = true; - IOptions options = Options.Create(loggingOptions); - IHub hub = Substitute.For(); + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); - var provider = new SentryStructuredLoggerProvider(options, hub); + var logger = services.GetRequiredService>(); - ILogger logger = provider.CreateLogger("categoryName"); + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); logger.Should().BeOfType(); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); provider.Dispose(); } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs index cca67e6df2..40fac4ba1e 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -1,38 +1,145 @@ #nullable enable using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Sentry.Extensions.Logging.Tests; -public class SentryStructuredLoggerTests +public class SentryStructuredLoggerTests : IDisposable { - [Fact] - public void SmokeTest() + private class Fixture + { + public string CategoryName { get; } + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Queue CapturedLogs { get; } = new(); + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + loggingOptions.Environment = "my-environment"; + loggingOptions.Release = "my-release"; + + CategoryName = nameof(CategoryName); + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + var logger = Substitute.For(); + logger.CaptureLog(Arg.Do(log => CapturedLogs.Enqueue(log))); + Hub.Logger.Returns(logger); + } + + public void EnableHub() => Hub.IsEnabled.Returns(true); + public void DisableHub() => Hub.IsEnabled.Returns(false); + + public void EnableLogs() => Options.Value.Experimental.EnableLogs = true; + public void DisableLogs() => Options.Value.Experimental.EnableLogs = true; + + public void SetMinimumLogLevel(LogLevel logLevel) => Options.Value.ExperimentalLogging.MinimumLogLevel = logLevel; + + public void WithTraceHeader(SentryId traceId, SpanId parentSpanId) + { + var traceHeader = new SentryTraceHeader(traceId, parentSpanId, null); + Hub.GetTraceHeader().Returns(traceHeader); + } + + public SentryStructuredLogger GetSut() + { + return new SentryStructuredLogger(CategoryName, Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + public void Dispose() { - InMemorySentryStructuredLogger inMemory = new(); + _fixture.CapturedLogs.Should().BeEmpty(); + } - string categoryName = "CategoryName"; - SentryLoggingOptions options = new(); - options.Experimental.EnableLogs = true; - IHub hub = Substitute.For(); - hub.IsEnabled.Returns(true); - hub.Logger.Returns(inMemory); + [Theory] + [InlineData(LogLevel.Trace, SentryLogLevel.Trace)] + [InlineData(LogLevel.Debug, SentryLogLevel.Debug)] + [InlineData(LogLevel.Information, SentryLogLevel.Info)] + [InlineData(LogLevel.Warning, SentryLogLevel.Warning)] + [InlineData(LogLevel.Error, SentryLogLevel.Error)] + [InlineData(LogLevel.Critical, SentryLogLevel.Fatal)] + [InlineData(LogLevel.None, default(SentryLogLevel))] + public void Log_LogLevel_(LogLevel logLevel, SentryLogLevel expectedLevel) + { + var traceId = SentryId.Create(); + var parentSpanId = SpanId.Create(); - var logger = new SentryStructuredLogger(categoryName, options, hub); + _fixture.EnableHub(); + _fixture.EnableLogs(); + _fixture.SetMinimumLogLevel(logLevel); + _fixture.WithTraceHeader(traceId, parentSpanId); + var logger = _fixture.GetSut(); - IDisposable? disposable = logger.BeginScope("state"); - disposable.Should().NotBeNull(); + EventId eventId = new(123, "EventName"); + Exception? exception = new InvalidOperationException("message"); + string? message = "Message with {Argument}."; - logger.IsEnabled(LogLevel.Warning).Should().BeTrue(); + logger.Log(logLevel, eventId, exception, message, "argument"); - EventId eventId = new(1, "eventId"); - Exception exception = new InvalidOperationException(); - Func formatter = (string state, Exception? exception) => + if (logLevel == LogLevel.None) { - state.Should().Be("state"); - exception.Should().BeOfType(); - return "Message"; - }; - logger.Log(LogLevel.Warning, eventId, "state", exception, formatter); + _fixture.CapturedLogs.Should().BeEmpty(); + return; + } + + var log = _fixture.CapturedLogs.Dequeue(); + log.Timestamp.Should().Be(_fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(traceId); + log.Level.Should().Be(expectedLevel); + log.Message.Should().Be("Message with argument."); + log.Template.Should().Be(message); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("Argument", "argument") }); + log.ParentSpanId.Should().Be(parentSpanId); + log.AssertAttribute("sentry.environment", "my-environment"); + log.AssertAttribute("sentry.release", "my-release"); + log.AssertAttribute("sentry.sdk.name", "SDK Name"); + log.AssertAttribute("sentry.sdk.version", "SDK Version"); + log.AssertAttribute("microsoft.extensions.logging.category_name", "CategoryName"); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void IsEnabled_() + { + var logger = _fixture.GetSut(); + } + + [Fact] + public void BeginScope_() + { + var logger = _fixture.GetSut(); + } +} + +file static class SentryLogExtensions +{ + public static void AssertAttribute(this SentryLog log, string key, string value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); + } + + public static void AssertAttribute(this SentryLog log, string key, int value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); } } diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs index b180a776b0..440b83cdc7 100644 --- a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -12,6 +12,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template Entries.Add(LogEntry.Create(level, template, parameters)); } + /// + protected internal override void CaptureLog(SentryLog log) + { + throw new NotSupportedException(); + } + /// protected internal override void Flush() { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index e826dba774..eeb85e4dbd 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -646,7 +646,7 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public string Message { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public Sentry.SpanId? ParentSpanId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] @@ -1013,6 +1013,7 @@ namespace Sentry [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index e826dba774..eeb85e4dbd 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -646,7 +646,7 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public string Message { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public Sentry.SpanId? ParentSpanId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] @@ -1013,6 +1013,7 @@ namespace Sentry [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 296ea74dc8..fb49f7c839 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -626,7 +626,7 @@ namespace Sentry { public Sentry.SentryLogLevel Level { get; init; } public string Message { get; init; } - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } public Sentry.SpanId? ParentSpanId { get; init; } public string? Template { get; init; } public System.DateTimeOffset Timestamp { get; init; } @@ -973,6 +973,7 @@ namespace Sentry } public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 4fd355839b..56546e3a16 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -39,7 +39,7 @@ public void Protocol_Default_VerifyAttributes() var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") { Template = "template", - Parameters = ImmutableArray.Create("params"), + Parameters = ImmutableArray.Create(new KeyValuePair("param", "params")), ParentSpanId = ParentSpanId, }; log.SetAttribute("attribute", "value"); @@ -50,7 +50,7 @@ public void Protocol_Default_VerifyAttributes() log.Level.Should().Be((SentryLogLevel)24); log.Message.Should().Be("message"); log.Template.Should().Be("template"); - log.Parameters.Should().BeEquivalentTo(["params"]); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("param", "params"), }); log.ParentSpanId.Should().Be(ParentSpanId); log.TryGetAttribute("attribute", out object attribute).Should().BeTrue(); @@ -148,7 +148,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") { Template = "template", - Parameters = ImmutableArray.Create("string", false, 1, 2.2), + Parameters = ImmutableArray.Create(new KeyValuePair("0", "string"), new KeyValuePair("1", false), new KeyValuePair("2", 1), new KeyValuePair("3", 2.2)), ParentSpanId = ParentSpanId, }; log.SetAttribute("string-attribute", "string-value"); @@ -259,58 +259,55 @@ public void WriteTo_MessageParameters_AsAttributes() { Parameters = [ - sbyte.MinValue, - byte.MaxValue, - short.MinValue, - ushort.MaxValue, - int.MinValue, - uint.MaxValue, - long.MinValue, - ulong.MaxValue, + new KeyValuePair("00", sbyte.MinValue), + new KeyValuePair("01", byte.MaxValue), + new KeyValuePair("02", short.MinValue), + new KeyValuePair("03", ushort.MaxValue), + new KeyValuePair("04", int.MinValue), + new KeyValuePair("05", uint.MaxValue), + new KeyValuePair("06", long.MinValue), + new KeyValuePair("07", ulong.MaxValue), #if NET5_0_OR_GREATER - nint.MinValue, - nuint.MaxValue, + new KeyValuePair("08", nint.MinValue), + new KeyValuePair("09", nuint.MaxValue), #endif - 1f, - 2d, - 3m, - true, - 'c', - "string", + new KeyValuePair("10", 1f), + new KeyValuePair("11", 2d), + new KeyValuePair("12", 3m), + new KeyValuePair("13", true), + new KeyValuePair("14", 'c'), + new KeyValuePair("15", "string"), #if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) - KeyValuePair.Create("key", "value"), + new KeyValuePair("16", KeyValuePair.Create("key", "value")), #else - new KeyValuePair("key", "value"), + new KeyValuePair("16", new KeyValuePair("key", "value")), #endif - null, + new KeyValuePair("17", null), ], }; - var currentParameterAttributeIndex = -1; - string GetNextParameterAttributeName() => $"sentry.message.parameter.{++currentParameterAttributeIndex}"; - var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); var attributes = document.RootElement.GetProperty("attributes"); Assert.Collection(attributes.EnumerateObject().ToArray(), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetSByte(), sbyte.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetByte(), byte.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt16(), short.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt16(), ushort.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt32(), int.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt32(), uint.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), long.MinValue), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.00", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.01", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.02", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.03", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.04", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.05", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.06", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.07", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), #if NET5_0_OR_GREATER - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), nint.MinValue), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.08", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.09", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), #endif - property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetSingle(), 1f), - property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetDouble(), 2d), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeBoolean(GetNextParameterAttributeName(), json => json.GetBoolean(), true), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "c"), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "string"), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "[key, value]") + property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), + property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), + property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), + property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") ); Assert.Collection(_output.Entries, entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index aa303ba690..854daa5262 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -307,7 +307,7 @@ public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fix log.Level.Should().Be(level); log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); - log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 }); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("0", "string"), new("1", true), new("2", 1), new("3", 2.2), }); log.ParentSpanId.Should().Be(fixture.ParentSpanId); log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); value.Should().Be("attribute-value"); From 1277a2ff518fc3b9525fccedb90484412ebb685b Mon Sep 17 00:00:00 2001 From: Flash0ver <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:07:07 +0200 Subject: [PATCH 095/101] fix: M.E.L. formatter may throw when CA2017 is violated --- ...entryAspNetCoreStructuredLoggerProvider.cs | 6 +- .../LogLevelExtensions.cs | 4 +- .../SentryStructuredLogger.cs | 14 +- .../SentryStructuredLoggerTests.cs | 195 ++++++++++++++++-- .../InMemoryDiagnosticLogger.cs | 2 +- 5 files changed, 195 insertions(+), 26 deletions(-) diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs index 5b06f22955..90cb033375 100644 --- a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -12,12 +12,12 @@ namespace Sentry.AspNetCore; internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider { public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) - : this(options.Value, SystemClock.Clock, hub, CreateSdkVersion()) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) { } - internal SentryAspNetCoreStructuredLoggerProvider(SentryLoggingOptions options, ISystemClock clock, IHub hub, SdkVersion sdk) - : base(options, clock, hub, sdk) + internal SentryAspNetCoreStructuredLoggerProvider(SentryAspNetCoreOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) { } diff --git a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs index 6939ae359d..9f1d2f85e2 100644 --- a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs +++ b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs @@ -56,8 +56,8 @@ public static SentryLogLevel ToSentryLogLevel(this LogLevel logLevel) LogLevel.Warning => SentryLogLevel.Warning, LogLevel.Error => SentryLogLevel.Error, LogLevel.Critical => SentryLogLevel.Fatal, - LogLevel.None => SentryLogLevel.Trace, - _ => SentryLogLevel.Trace, + LogLevel.None => default, + _ => default, }; } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs index 257b338f98..ea0e976bdc 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Sentry.Extensibility; using Sentry.Infrastructure; namespace Sentry.Extensions.Logging; @@ -44,7 +45,18 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; var level = logLevel.ToSentryLogLevel(); - var message = formatter.Invoke(state, exception); + Debug.Assert(level != default); + + string message; + try + { + message = formatter.Invoke(state, exception); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } string? template = null; var parameters = ImmutableArray.CreateBuilder>(); diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs index 40fac4ba1e..98e78873fb 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -9,20 +9,24 @@ public class SentryStructuredLoggerTests : IDisposable { private class Fixture { - public string CategoryName { get; } + public string CategoryName { get; internal set; } public IOptions Options { get; } public IHub Hub { get; } public MockClock Clock { get; } public SdkVersion Sdk { get; } public Queue CapturedLogs { get; } = new(); + public InMemoryDiagnosticLogger DiagnosticLogger { get; } = new(); public Fixture() { - var loggingOptions = new SentryLoggingOptions(); - loggingOptions.Experimental.EnableLogs = true; - loggingOptions.Environment = "my-environment"; - loggingOptions.Release = "my-release"; + var loggingOptions = new SentryLoggingOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + Environment = "my-environment", + Release = "my-release", + }; CategoryName = nameof(CategoryName); Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); @@ -37,14 +41,14 @@ public Fixture() var logger = Substitute.For(); logger.CaptureLog(Arg.Do(log => CapturedLogs.Enqueue(log))); Hub.Logger.Returns(logger); - } - public void EnableHub() => Hub.IsEnabled.Returns(true); - public void DisableHub() => Hub.IsEnabled.Returns(false); - - public void EnableLogs() => Options.Value.Experimental.EnableLogs = true; - public void DisableLogs() => Options.Value.Experimental.EnableLogs = true; + EnableHub(true); + EnableLogs(true); + SetMinimumLogLevel(default); + } + public void EnableHub(bool isEnabled) => Hub.IsEnabled.Returns(isEnabled); + public void EnableLogs(bool isEnabled) => Options.Value.Experimental.EnableLogs = isEnabled; public void SetMinimumLogLevel(LogLevel logLevel) => Options.Value.ExperimentalLogging.MinimumLogLevel = logLevel; public void WithTraceHeader(SentryId traceId, SpanId parentSpanId) @@ -64,6 +68,7 @@ public SentryStructuredLogger GetSut() public void Dispose() { _fixture.CapturedLogs.Should().BeEmpty(); + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); } [Theory] @@ -74,22 +79,19 @@ public void Dispose() [InlineData(LogLevel.Error, SentryLogLevel.Error)] [InlineData(LogLevel.Critical, SentryLogLevel.Fatal)] [InlineData(LogLevel.None, default(SentryLogLevel))] - public void Log_LogLevel_(LogLevel logLevel, SentryLogLevel expectedLevel) + public void Log_LogLevel_CaptureLog(LogLevel logLevel, SentryLogLevel expectedLevel) { var traceId = SentryId.Create(); var parentSpanId = SpanId.Create(); - - _fixture.EnableHub(); - _fixture.EnableLogs(); - _fixture.SetMinimumLogLevel(logLevel); _fixture.WithTraceHeader(traceId, parentSpanId); var logger = _fixture.GetSut(); EventId eventId = new(123, "EventName"); Exception? exception = new InvalidOperationException("message"); string? message = "Message with {Argument}."; + object?[] args = ["argument"]; - logger.Log(logLevel, eventId, exception, message, "argument"); + logger.Log(logLevel, eventId, exception, message, args); if (logLevel == LogLevel.None) { @@ -115,15 +117,170 @@ public void Log_LogLevel_(LogLevel logLevel, SentryLogLevel expectedLevel) } [Fact] - public void IsEnabled_() + public void Log_LogLevelNone_DoesNotCaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.None, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + _fixture.CapturedLogs.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutTraceHeader_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TraceId.Should().Be(SentryTraceHeader.Empty.TraceId); + log.ParentSpanId.Should().Be(SentryTraceHeader.Empty.SpanId); + } + + [Fact] + public void Log_WithoutArguments_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message."); + log.Template.Should().Be("Message."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + public void Log_ParameterCountMismatch_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message with {Argument}."); + log.Template.Should().Be("Message with {Argument}."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + public void Log_ParameterCountMismatch_Throws() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {One}{Two}.", "One"); + + _fixture.CapturedLogs.Should().BeEmpty(); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutCategoryName_CaptureLog() + { + _fixture.CategoryName = null!; + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.category_name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutEvent_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.event.id", out object? _).Should().BeFalse(); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutEventId_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(0, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 0); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void Log_WithoutEventName_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Theory] + [InlineData(true, true, LogLevel.Warning, LogLevel.Warning, true)] + [InlineData(false, true, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, false, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, true, LogLevel.Information, LogLevel.Warning, true)] + [InlineData(true, true, LogLevel.Error, LogLevel.Warning, false)] + public void IsEnabled_HubOptionsMinimumLogLevel_Returns(bool isHubEnabled, bool isLogsEnabled, LogLevel minimumLogLevel, LogLevel actualLogLevel, bool expectedIsEnabled) + { + _fixture.EnableHub(isHubEnabled); + _fixture.EnableLogs(isLogsEnabled); + _fixture.SetMinimumLogLevel(minimumLogLevel); + var logger = _fixture.GetSut(); + + var isEnabled = logger.IsEnabled(actualLogLevel); + logger.Log(actualLogLevel, "message"); + + isEnabled.Should().Be(expectedIsEnabled); + if (expectedIsEnabled) + { + _fixture.CapturedLogs.Dequeue().Message.Should().Be("message"); + } + } + + [Fact] + public void BeginScope_Dispose_NoOp() { var logger = _fixture.GetSut(); + + string messageFormat = "Message with {Argument}."; + object?[] args = ["argument"]; + + logger.LogInformation("one"); + using (var scope = logger.BeginScope(messageFormat, args)) + { + logger.LogInformation("two"); + } + logger.LogInformation("three"); + + _fixture.CapturedLogs.Dequeue().Message.Should().Be("one"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("two"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("three"); } [Fact] - public void BeginScope_() + public void BeginScope_Shared_Same() { var logger = _fixture.GetSut(); + + using var scope1 = logger.BeginScope("Message with {Argument}.", "argument"); + using var scope2 = logger.BeginScope("Message with {Argument}.", "argument"); + + scope1.Should().BeSameAs(scope2); } } diff --git a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs index b1715a161f..48077f402b 100644 --- a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs +++ b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs @@ -11,7 +11,7 @@ public void Log(SentryLevel logLevel, string message, Exception exception = null Entries.Enqueue(new Entry(logLevel, message, exception, args)); } - internal Entry Dequeue() + public Entry Dequeue() { if (Entries.TryDequeue(out var entry)) { From 5a880e0d4f616aeb4a7d76f7879465fec5b92a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:43:57 +0200 Subject: [PATCH 096/101] fix: Logger uses .NET MAUI SDK name --- .../SentryMauiStructuredLoggerProvider.cs | 18 ++- ...AspNetCoreStructuredLoggerProviderTests.cs | 105 ++++++++++++++++++ .../SentryStructuredLoggerProviderTests.cs | 33 +++++- ...SentryMauiStructuredLoggerProviderTests.cs | 105 ++++++++++++++++++ 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs create mode 100644 test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs diff --git a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs index 97efa2e9e5..d0525a4b49 100644 --- a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs +++ b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs @@ -1,15 +1,29 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Sentry.Extensions.Logging; +using Sentry.Infrastructure; namespace Sentry.Maui.Internal; [ProviderAlias("SentryLogs")] -[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal sealed class SentryMauiStructuredLoggerProvider : SentryStructuredLoggerProvider { public SentryMauiStructuredLoggerProvider(IOptions options, IHub hub) - : base(options, hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) { } + + internal SentryMauiStructuredLoggerProvider(SentryMauiOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = Constants.SdkVersion, + }; + } } diff --git a/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..9edf8363ac --- /dev/null +++ b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.AspNetCore.Tests; + +public class SentryAspNetCoreStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryAspNetCoreOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryAspNetCoreStructuredLoggerProvider GetSut() + { + return new SentryAspNetCoreStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryAspNetCoreStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryMiddleware.NameAndVersion.Version); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs index dd9a98ef63..bd43dfc668 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -28,6 +28,8 @@ public Fixture() Name = "SDK Name", Version = "SDK Version", }; + + Hub.IsEnabled.Returns(true); } public SentryStructuredLoggerProvider GetSut() @@ -44,6 +46,7 @@ public void Ctor_DependencyInjection_CanCreate() using var services = new ServiceCollection() .AddLogging() .AddSingleton() + .AddSingleton(_fixture.Options) .AddSingleton(_fixture.Hub) .BuildServiceProvider(); @@ -53,7 +56,7 @@ public void Ctor_DependencyInjection_CanCreate() } [Fact] - public void CreateLogger_() + public void CreateLogger_OfType() { var provider = _fixture.GetSut(); @@ -62,6 +65,34 @@ public void CreateLogger_() logger.Should().BeOfType(); } + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryLoggerProvider.NameAndVersion.Version); + } + [Fact] public void Dispose_NoOp() { diff --git a/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..1715498a5b --- /dev/null +++ b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Maui.Internal; + +namespace Sentry.Maui.Tests.Internal; + +public class SentryMauiStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryMauiOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryMauiStructuredLoggerProvider GetSut() + { + return new SentryMauiStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryMauiStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Sentry.Maui.Internal.Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(Sentry.Maui.Internal.Constants.SdkVersion); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} From d6083bd07276a998781a246b313a144fab9aaa12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:44:43 +0200 Subject: [PATCH 097/101] fix: send log timestamp with milliseconds --- src/Sentry/SentryLog.cs | 6 +++++- .../SentryStructuredLoggerTests.cs | 2 +- test/Sentry.Tests/SentryLogTests.cs | 17 +++++++++++++---- .../Sentry.Tests/SentryStructuredLoggerTests.cs | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 9f79a02303..b506b9da6c 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -202,7 +202,11 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); - writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); +#if NET9_0_OR_GREATER + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / (double)TimeSpan.MillisecondsPerSecond); +#else + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / 1_000.0); +#endif var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(logger); writer.WriteString("level", severityText); diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs index 98e78873fb..80cc07153e 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -31,7 +31,7 @@ public Fixture() CategoryName = nameof(CategoryName); Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); Hub = Substitute.For(); - Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); Sdk = new SdkVersion { Name = "SDK Name", diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 56546e3a16..3393137b85 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -9,7 +9,7 @@ namespace Sentry.Tests; /// public class SentryLogTests { - private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, TimeSpan.FromHours(2)); + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2)); private static readonly SentryId TraceId = SentryId.Create(); private static readonly SpanId? ParentSpanId = SpanId.Create(); @@ -114,7 +114,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() { "items": [ { - "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "timestamp": {{Timestamp.GetTimestamp()}}, "level": "trace", "body": "message", "trace_id": "{{TraceId.ToString()}}", @@ -181,7 +181,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() { "items": [ { - "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "timestamp": {{Timestamp.GetTimestamp()}}, "level": "fatal", "body": "message", "trace_id": "{{TraceId.ToString()}}", @@ -422,11 +422,20 @@ private static void AssertAttribute(this JsonProperty attribute, string name, } } +file static class DateTimeOffsetExtensions +{ + public static string GetTimestamp(this DateTimeOffset value) + { + var timestamp = value.ToUnixTimeMilliseconds() / 1_000.0; + return timestamp.ToString(NumberFormatInfo.InvariantInfo); + } +} + file static class JsonFormatterExtensions { public static string Format(this DateTimeOffset value) { - return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); + return value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); } public static string Format(this double value) diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 854daa5262..aeb121badc 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -18,7 +18,7 @@ public Fixture() Debug = true, DiagnosticLogger = DiagnosticLogger, }; - Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); BatchSize = 2; BatchTimeout = Timeout.InfiniteTimeSpan; TraceId = SentryId.Create(); From 353a8debbee1141f1e7a31ec6dcfae06201f5e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:07:31 +0200 Subject: [PATCH 098/101] perf: avoid redundant array copy --- ...tructuredLoggerBenchmarks-report-github.md | 13 +++ .../SentryStructuredLoggerBenchmarks.cs | 90 +++++++++++++++++++ .../Sentry.Benchmarks.csproj | 1 + .../Sentry.Extensions.Logging.csproj | 1 + .../SentryStructuredLogger.cs | 2 +- .../Internal/DefaultSentryStructuredLogger.cs | 2 +- .../ImmutableCollectionsPolyfill.cs | 20 +++++ .../ImmutableCollectionsPolyfillTests.cs | 39 ++++++++ 8 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md create mode 100644 benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs create mode 100644 src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs create mode 100644 test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md new file mode 100644 index 0000000000..172b688e55 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md @@ -0,0 +1,13 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 9.0.301 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|------- |---------:|--------:|--------:|-------:|----------:| +| Log | 288.4 ns | 1.28 ns | 1.20 ns | 0.1163 | 976 B | diff --git a/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs new file mode 100644 index 0000000000..3e18747a65 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs @@ -0,0 +1,90 @@ +#nullable enable + +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Extensions.Logging; +using Sentry.Internal; +using Sentry.Testing; + +namespace Sentry.Benchmarks.Extensions.Logging; + +public class SentryStructuredLoggerBenchmarks +{ + private Hub _hub = null!; + private Sentry.Extensions.Logging.SentryStructuredLogger _logger = null!; + private LogRecord _logRecord = null!; + private SentryLog? _lastLog; + + [GlobalSetup] + public void Setup() + { + SentryLoggingOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + Experimental = + { + EnableLogs = true, + }, + ExperimentalLogging = + { + MinimumLogLevel = LogLevel.Information, + } + }; + options.Experimental.SetBeforeSendLog((SentryLog log) => + { + _lastLog = log; + return null; + }); + + MockClock clock = new(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + SdkVersion sdk = new() + { + Name = "SDK Name", + Version = "SDK Version", + }; + + _hub = new Hub(options, DisabledHub.Instance); + _logger = new Sentry.Extensions.Logging.SentryStructuredLogger("CategoryName", options, _hub, clock, sdk); + _logRecord = new LogRecord(LogLevel.Information, new EventId(2025, "EventName"), new InvalidOperationException("exception-message"), "Number={Number}, Text={Text}", 2018, "message"); + } + + [Benchmark] + public void Log() + { + _logger.Log(_logRecord.LogLevel, _logRecord.EventId, _logRecord.Exception, _logRecord.Message, _logRecord.Args); + } + + [GlobalCleanup] + public void Cleanup() + { + _hub.Dispose(); + + if (_lastLog is null) + { + throw new InvalidOperationException("Last Log is null"); + } + if (_lastLog.Message != "Number=2018, Text=message") + { + throw new InvalidOperationException($"Last Log with Message: '{_lastLog.Message}'"); + } + } + + private sealed class LogRecord + { + public LogRecord(LogLevel logLevel, EventId eventId, Exception? exception, string? message, params object?[] args) + { + LogLevel = logLevel; + EventId = eventId; + Exception = exception; + Message = message; + Args = args; + } + + public LogLevel LogLevel { get; } + public EventId EventId { get; } + public Exception? Exception { get; } + public string? Message { get; } + public object?[] Args { get; } + } +} diff --git a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj index bdb8ed918a..48231469f2 100644 --- a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj +++ b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj index e011099d02..4d52cd091c 100644 --- a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj +++ b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs index ea0e976bdc..87a0daae49 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -84,7 +84,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except SentryLog log = new(timestamp, traceHeader.TraceId, level, message) { Template = template, - Parameters = parameters.ToImmutable(), + Parameters = parameters.DrainToImmutable(), ParentSpanId = traceHeader.SpanId, }; diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 9f519c2a6f..6bc77c7088 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -48,7 +48,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template { builder.Add(new KeyValuePair(index.ToString(), parameters[index])); } - @params = builder.ToImmutable(); + @params = builder.DrainToImmutable(); } SentryLog log = new(timestamp, traceHeader.TraceId, level, message) diff --git a/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs new file mode 100644 index 0000000000..68b44d5dcc --- /dev/null +++ b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs @@ -0,0 +1,20 @@ +// ReSharper disable CheckNamespace +namespace System.Collections.Immutable; + +internal static class ImmutableCollectionsPolyfill +{ +#if !NET8_0_OR_GREATER + internal static ImmutableArray DrainToImmutable(this ImmutableArray.Builder builder) + { + if (builder.Capacity == builder.Count) + { + return builder.MoveToImmutable(); + } + + var result = builder.ToImmutable(); + builder.Count = 0; + builder.Capacity = 0; + return result; + } +#endif +} diff --git a/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs new file mode 100644 index 0000000000..a47b65db1e --- /dev/null +++ b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs @@ -0,0 +1,39 @@ +namespace Sentry.Tests.Polyfilling; + +public class ImmutableCollectionsPolyfillTests +{ + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsNotCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + + builder.Count.Should().Be(1); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(1); + array.Should().BeEquivalentTo(["one"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } + + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + builder.Add("two"); + + builder.Count.Should().Be(2); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(2); + array.Should().BeEquivalentTo(["one", "two"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } +} From 0de2ba9f6aa382c47c870ae30bf3e24f0e5742f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:28:41 +0200 Subject: [PATCH 099/101] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc32c65cb0..4e76e86d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Features -- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158), [#4193](https://github.com/getsentry/sentry-dotnet/pull/4193), [#4310](https://github.com/getsentry/sentry-dotnet/pull/4310)) +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) API ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158), [#4310](https://github.com/getsentry/sentry-dotnet/pull/4310)) + - Add experimental integrations for `Sentry.Extensions.Logging`, `Sentry.AspNetCore` and `Sentry.Maui` ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) ### Fixes From 6a286d1e19bec1e52d38c1b76d6198c9a00f4553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:32:11 +0200 Subject: [PATCH 100/101] fix(logs): flush Logger on UnhandledException that IsTerminating --- .../Internal/DefaultSentryStructuredLogger.cs | 4 +-- .../DisabledSentryStructuredLogger.cs | 2 +- src/Sentry/Internal/Hub.cs | 3 +- .../Internal/StructuredLogBatchBuffer.cs | 11 +++++-- .../Internal/StructuredLogBatchProcessor.cs | 17 +++++++---- src/Sentry/SentryStructuredLogger.cs | 4 ++- .../SentryStructuredLoggerExtensions.cs | 9 ++++++ src/Sentry/Threading/ScopedCountdownLock.cs | 21 ++++++++++--- .../InMemorySentryStructuredLogger.cs | 2 +- ...piApprovalTests.Run.DotNet8_0.verified.txt | 2 +- ...piApprovalTests.Run.DotNet9_0.verified.txt | 2 +- .../ApiApprovalTests.Run.Net4_8.verified.txt | 2 +- test/Sentry.Tests/HubTests.cs | 30 +++++++++++++++++++ 13 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 src/Sentry/SentryStructuredLoggerExtensions.cs diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 6bc77c7088..18dde90e6a 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -99,9 +99,9 @@ protected internal override void CaptureLog(SentryLog log) } /// - protected internal override void Flush() + protected internal override void Flush(TimeSpan timeout) { - _batchProcessor.Flush(); + _batchProcessor.Flush(timeout); } /// diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs index 02fb6fc8f1..136cb0027a 100644 --- a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -21,7 +21,7 @@ protected internal override void CaptureLog(SentryLog log) } /// - protected internal override void Flush() + protected internal override void Flush(TimeSpan timeout) { // disabled } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index bfec633662..1862601f3a 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -786,6 +786,7 @@ public async Task FlushAsync(TimeSpan timeout) { try { + Logger.Flush(TimeSpan.Zero); await CurrentClient.FlushAsync(timeout).ConfigureAwait(false); } catch (Exception e) @@ -819,7 +820,7 @@ public void Dispose() _memoryMonitor?.Dispose(); #endif - Logger.Flush(); + Logger.Flush(Timeout.InfiniteTimeSpan); Logger.Dispose(); try diff --git a/src/Sentry/Internal/StructuredLogBatchBuffer.cs b/src/Sentry/Internal/StructuredLogBatchBuffer.cs index 7e518253a6..e798fabe9f 100644 --- a/src/Sentry/Internal/StructuredLogBatchBuffer.cs +++ b/src/Sentry/Internal/StructuredLogBatchBuffer.cs @@ -248,13 +248,13 @@ static void ThrowNegativeOrZero(TimeSpan value, string paramName) } /// - /// A scope than ensures only a single operation is in progress, + /// A scope than ensures only a single operation is in progress, /// and blocks the calling thread until all operations have finished. /// When is , no more can be started, /// which will then return immediately. /// /// - /// Only when scope . + /// Only when scope . /// internal ref struct FlushScope : IDisposable { @@ -271,11 +271,16 @@ internal FlushScope(StructuredLogBatchBuffer lockObj, ScopedCountdownLock.LockSc internal bool IsEntered => _scope.IsEntered; internal SentryLog[] Flush() + { + return Flush(Timeout.InfiniteTimeSpan); + } + + internal SentryLog[] Flush(TimeSpan timeout) { var lockObj = _lockObj; if (lockObj is not null) { - _scope.Wait(); + _scope.Wait(timeout); var array = lockObj.ToArrayAndClear(); return array; diff --git a/src/Sentry/Internal/StructuredLogBatchProcessor.cs b/src/Sentry/Internal/StructuredLogBatchProcessor.cs index 2fe5db924e..375197d736 100644 --- a/src/Sentry/Internal/StructuredLogBatchProcessor.cs +++ b/src/Sentry/Internal/StructuredLogBatchProcessor.cs @@ -74,8 +74,13 @@ internal void Enqueue(SentryLog log) internal void Flush() { - CaptureLogs(_buffer1); - CaptureLogs(_buffer2); + Flush(Timeout.InfiniteTimeSpan); + } + + internal void Flush(TimeSpan timeout) + { + CaptureLogs(_buffer1, timeout); + CaptureLogs(_buffer2, timeout); } /// @@ -97,7 +102,7 @@ private bool TryEnqueue(StructuredLogBatchBuffer buffer, SentryLog log) if (status is StructuredLogBatchBufferAddStatus.AddedLast) { SwapActiveBuffer(buffer); - CaptureLogs(buffer); + CaptureLogs(buffer, Timeout.InfiniteTimeSpan); return true; } @@ -110,7 +115,7 @@ private void SwapActiveBuffer(StructuredLogBatchBuffer currentActiveBuffer) _ = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); } - private void CaptureLogs(StructuredLogBatchBuffer buffer) + private void CaptureLogs(StructuredLogBatchBuffer buffer, TimeSpan timeout) { SentryLog[]? logs = null; @@ -118,7 +123,7 @@ private void CaptureLogs(StructuredLogBatchBuffer buffer) { if (scope.IsEntered) { - logs = scope.Flush(); + logs = scope.Flush(timeout); } } @@ -133,7 +138,7 @@ private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) if (!buffer.IsEmpty) { SwapActiveBuffer(buffer); - CaptureLogs(buffer); + CaptureLogs(buffer, Timeout.InfiniteTimeSpan); } } diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index 0170c28254..eb84058c99 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -44,7 +44,9 @@ private protected SentryStructuredLogger() /// /// Clears all buffers for this logger and causes any buffered logs to be sent by the underlying . /// - protected internal abstract void Flush(); + /// Used to indicate when flush should be forced. + /// is a negative number other than milliseconds, which represents an infinite time-out -or- is greater than . + protected internal abstract void Flush(TimeSpan timeout); /// /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. diff --git a/src/Sentry/SentryStructuredLoggerExtensions.cs b/src/Sentry/SentryStructuredLoggerExtensions.cs new file mode 100644 index 0000000000..65dd1289f4 --- /dev/null +++ b/src/Sentry/SentryStructuredLoggerExtensions.cs @@ -0,0 +1,9 @@ +namespace Sentry; + +internal static class SentryStructuredLoggerExtensions +{ + internal static void Flush(this SentryStructuredLogger logger) + { + logger.Flush(Timeout.InfiniteTimeSpan); + } +} diff --git a/src/Sentry/Threading/ScopedCountdownLock.cs b/src/Sentry/Threading/ScopedCountdownLock.cs index bc3725f1a9..25d002d4a3 100644 --- a/src/Sentry/Threading/ScopedCountdownLock.cs +++ b/src/Sentry/Threading/ScopedCountdownLock.cs @@ -23,7 +23,7 @@ internal ScopedCountdownLock() /// /// if the event is set/signaled; otherwise, . - /// When , the active can until the reaches . + /// When , the active can until the reaches . /// internal bool IsSet => _event.IsSet; @@ -65,7 +65,7 @@ private void ExitCounterScope() /// /// When successful, the lock , can reach when no is active, and the event can be set/signaled. /// Check via whether the Lock . - /// Use to block until every active has exited before performing the locked operation. + /// Use to block until every active has exited before performing the locked operation. /// After the locked operation has completed, disengage the Lock via . /// /// @@ -133,13 +133,26 @@ internal LockScope(ScopedCountdownLock lockObj) internal bool IsEntered => _lockObj is not null; /// - /// Blocks the current thread until the current reaches and the event is set/signaled. + /// Blocks the current thread indefinitely until the current reaches and the event is set/signaled. /// The caller will return immediately if the event is currently in a set/signaled state. /// internal void Wait() { var lockObj = _lockObj; - lockObj?._event.Wait(); + lockObj?._event.Wait(Timeout.Infinite, CancellationToken.None); + } + + /// + /// Blocks the current thread until the current reaches and the event is set/signaled. + /// The caller will return immediately if the event is currently in a set/signaled state. + /// Uses a to measure the . + /// + /// A that represents the number of milliseconds to wait, or a that represents milliseconds to wait indefinitely. + /// is a negative number other than milliseconds, which represents an infinite time-out -or- is greater than . + internal void Wait(TimeSpan timeout) + { + var lockObj = _lockObj; + lockObj?._event.Wait(timeout, CancellationToken.None); } public void Dispose() diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs index 440b83cdc7..6cbc645e8c 100644 --- a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -19,7 +19,7 @@ protected internal override void CaptureLog(SentryLog log) } /// - protected internal override void Flush() + protected internal override void Flush(TimeSpan timeout) { // no-op } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index eeb85e4dbd..f6593ec56b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1016,7 +1016,7 @@ namespace Sentry protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } - protected abstract void Flush(); + protected abstract void Flush(System.TimeSpan timeout); [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index eeb85e4dbd..f6593ec56b 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1016,7 +1016,7 @@ namespace Sentry protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } - protected abstract void Flush(); + protected abstract void Flush(System.TimeSpan timeout); [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index fb49f7c839..091531fea1 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -976,7 +976,7 @@ namespace Sentry protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } - protected abstract void Flush(); + protected abstract void Flush(System.TimeSpan timeout); public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 1bbe1b5ebf..d06753a0df 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1488,6 +1488,31 @@ public void Logger_DisableAfterCreate_HasNoEffect() hub.Logger.Should().BeOfType(); } + [Fact] + public async Task Logger_FlushAsync_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + await hub.FlushAsync(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + await _fixture.Client.Received(1).FlushAsync( + Arg.Is(timeout => + timeout.Equals(_fixture.Options.FlushTimeout) + ) + ); + hub.Logger.Should().BeOfType(); + } + [Fact] public void Logger_Dispose_DoesCaptureLog() { @@ -1505,6 +1530,11 @@ public void Logger_Dispose_DoesCaptureLog() envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) ) ); + _fixture.Client.Received(1).FlushAsync( + Arg.Is(timeout => + timeout.Equals(_fixture.Options.ShutdownTimeout) + ) + ); hub.Logger.Should().BeOfType(); } From 20eba58bb548795341d63796daf82d7029c7d160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:13:17 +0200 Subject: [PATCH 101/101] docs: add comments --- src/Sentry/Internal/StructuredLogBatchBuffer.cs | 2 ++ test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/Sentry/Internal/StructuredLogBatchBuffer.cs b/src/Sentry/Internal/StructuredLogBatchBuffer.cs index e798fabe9f..f8ff84c862 100644 --- a/src/Sentry/Internal/StructuredLogBatchBuffer.cs +++ b/src/Sentry/Internal/StructuredLogBatchBuffer.cs @@ -8,6 +8,8 @@ namespace Sentry.Internal; /// /// Must be attempted to flush via when either the is reached, /// or when the is exceeded. +/// Utilizes a , basically used as an inverse , +/// allowing multiple threads for or exclusive access for . /// [DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")] internal sealed class StructuredLogBatchBuffer : IDisposable diff --git a/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs index dad07e1e23..83308054cb 100644 --- a/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs +++ b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs @@ -88,6 +88,9 @@ public void TryEnterLockScope_IsEngaged_IsSet() counterTwo.Dispose(); AssertEngaged(false, 1); + // would block if the timeout was infinite because the engaged lock is not yet set + lockOne.Wait(TimeSpan.Zero); + // exit last CounterScope ... count of engaged lock reaches zero ... sets the lock counterOne.Dispose(); AssertEngaged(true, 0);