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); + } + } +}