diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c5c80917..4e76e86d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) API ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158), [#4310](https://github.com/getsentry/sentry-dotnet/pull/4310)) + - Add experimental integrations for `Sentry.Extensions.Logging`, `Sentry.AspNetCore` and `Sentry.Maui` ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + ### Fixes - Native AOT: don't load SentryNative on unsupported platforms ([#4347](https://github.com/getsentry/sentry-dotnet/pull/4347)) @@ -20,7 +25,6 @@ ### Features -- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added StartSpan and GetTransaction methods to the SentrySdk ([#4303](https://github.com/getsentry/sentry-dotnet/pull/4303)) ### Fixes diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md new file mode 100644 index 0000000000..172b688e55 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md @@ -0,0 +1,13 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 9.0.301 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|------- |---------:|--------:|--------:|-------:|----------:| +| Log | 288.4 ns | 1.28 ns | 1.20 ns | 0.1163 | 976 B | diff --git a/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs new file mode 100644 index 0000000000..3e18747a65 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs @@ -0,0 +1,90 @@ +#nullable enable + +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Extensions.Logging; +using Sentry.Internal; +using Sentry.Testing; + +namespace Sentry.Benchmarks.Extensions.Logging; + +public class SentryStructuredLoggerBenchmarks +{ + private Hub _hub = null!; + private Sentry.Extensions.Logging.SentryStructuredLogger _logger = null!; + private LogRecord _logRecord = null!; + private SentryLog? _lastLog; + + [GlobalSetup] + public void Setup() + { + SentryLoggingOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + Experimental = + { + EnableLogs = true, + }, + ExperimentalLogging = + { + MinimumLogLevel = LogLevel.Information, + } + }; + options.Experimental.SetBeforeSendLog((SentryLog log) => + { + _lastLog = log; + return null; + }); + + MockClock clock = new(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + SdkVersion sdk = new() + { + Name = "SDK Name", + Version = "SDK Version", + }; + + _hub = new Hub(options, DisabledHub.Instance); + _logger = new Sentry.Extensions.Logging.SentryStructuredLogger("CategoryName", options, _hub, clock, sdk); + _logRecord = new LogRecord(LogLevel.Information, new EventId(2025, "EventName"), new InvalidOperationException("exception-message"), "Number={Number}, Text={Text}", 2018, "message"); + } + + [Benchmark] + public void Log() + { + _logger.Log(_logRecord.LogLevel, _logRecord.EventId, _logRecord.Exception, _logRecord.Message, _logRecord.Args); + } + + [GlobalCleanup] + public void Cleanup() + { + _hub.Dispose(); + + if (_lastLog is null) + { + throw new InvalidOperationException("Last Log is null"); + } + if (_lastLog.Message != "Number=2018, Text=message") + { + throw new InvalidOperationException($"Last Log with Message: '{_lastLog.Message}'"); + } + } + + private sealed class LogRecord + { + public LogRecord(LogLevel logLevel, EventId eventId, Exception? exception, string? message, params object?[] args) + { + LogLevel = logLevel; + EventId = eventId; + Exception = exception; + Message = message; + Args = args; + } + + public LogLevel LogLevel { get; } + public EventId EventId { get; } + public Exception? Exception { get; } + public string? Message { get; } + public object?[] Args { get; } + } +} diff --git a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj index bdb8ed918a..48231469f2 100644 --- a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj +++ b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs index cb8a4da994..221a10293b 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs +++ b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs @@ -15,6 +15,9 @@ // Log debug information about the Sentry SDK options.Debug = true; #endif + + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; }); var app = builder.Build(); diff --git a/samples/Sentry.Samples.ME.Logging/Program.cs b/samples/Sentry.Samples.ME.Logging/Program.cs index 809db165b4..0178235c03 100644 --- a/samples/Sentry.Samples.ME.Logging/Program.cs +++ b/samples/Sentry.Samples.ME.Logging/Program.cs @@ -23,7 +23,17 @@ // Optionally configure options: The default values are: options.MinimumBreadcrumbLevel = LogLevel.Information; // It requires at least this level to store breadcrumb options.MinimumEventLevel = LogLevel.Error; // This level or above will result in event sent to Sentry + options.ExperimentalLogging.MinimumLogLevel = LogLevel.Trace; // This level or above will result in log sent to Sentry + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => + { + log.SetAttribute("attribute-key", "attribute-value"); + return log; + }); + + // TODO: AddLogEntryFilter // Don't keep as a breadcrumb or send events for messages of level less than Critical with exception of type DivideByZeroException options.AddLogEntryFilter((_, level, _, exception) => level < LogLevel.Critical && exception is DivideByZeroException); diff --git a/samples/Sentry.Samples.Maui/MauiProgram.cs b/samples/Sentry.Samples.Maui/MauiProgram.cs index 625632ecc2..909851b7d4 100644 --- a/samples/Sentry.Samples.Maui/MauiProgram.cs +++ b/samples/Sentry.Samples.Maui/MauiProgram.cs @@ -33,6 +33,7 @@ public static MauiApp CreateMauiApp() options.AttachScreenshot = true; options.Debug = true; + options.Experimental.EnableLogs = true; options.SampleRate = 1.0F; // The Sentry MVVM Community Toolkit integration automatically creates traces for async relay commands, diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs new file mode 100644 index 0000000000..90cb033375 --- /dev/null +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; +using Sentry.Infrastructure; + +namespace Sentry.AspNetCore; + +/// +/// Structured Logger Provider for Sentry. +/// +[ProviderAlias("SentryLogs")] +internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryAspNetCoreStructuredLoggerProvider(SentryAspNetCoreOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryMiddleware.NameAndVersion.Version, + }; + } +} diff --git a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs index c00217368b..2b5f74bf4d 100644 --- a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs @@ -93,10 +93,16 @@ public static IWebHostBuilder UseSentry( _ = logging.Services .AddSingleton, SentryAspNetCoreOptionsSetup>(); _ = logging.Services.AddSingleton(); + _ = logging.Services.AddSingleton(); _ = logging.AddFilter( "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware", LogLevel.None); + _ = logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || (categoryName != "Sentry.ISentryClient" && categoryName != "Sentry.AspNetCore.SentryMiddleware"); + }); var sentryBuilder = logging.Services.AddSentry(); configureSentry?.Invoke(context, sentryBuilder); diff --git a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs index 299e61a21c..7843ad9975 100644 --- a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs @@ -9,11 +9,20 @@ internal class BindableSentryLoggingOptions : BindableSentryOptions public LogLevel? MinimumEventLevel { get; set; } public bool? InitializeSdk { get; set; } + public BindableSentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + internal sealed class BindableSentryLoggingExperimentalOptions + { + public LogLevel? MinimumLogLevel { get; set; } + } + public void ApplyTo(SentryLoggingOptions options) { base.ApplyTo(options); options.MinimumBreadcrumbLevel = MinimumBreadcrumbLevel ?? options.MinimumBreadcrumbLevel; options.MinimumEventLevel = MinimumEventLevel ?? options.MinimumEventLevel; options.InitializeSdk = InitializeSdk ?? options.InitializeSdk; + + options.ExperimentalLogging.MinimumLogLevel = ExperimentalLogging.MinimumLogLevel ?? options.ExperimentalLogging.MinimumLogLevel; } } diff --git a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs index e3f862de77..9f1d2f85e2 100644 --- a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs +++ b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs @@ -45,4 +45,19 @@ public static SentryLevel ToSentryLevel(this LogLevel level) _ => SentryLevel.Debug }; } + + public static SentryLogLevel ToSentryLogLevel(this LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => SentryLogLevel.Trace, + LogLevel.Debug => SentryLogLevel.Debug, + LogLevel.Information => SentryLogLevel.Info, + LogLevel.Warning => SentryLogLevel.Warning, + LogLevel.Error => SentryLogLevel.Error, + LogLevel.Critical => SentryLogLevel.Fatal, + LogLevel.None => default, + _ => default, + }; + } } diff --git a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs index 9b79803de0..f2a4957c11 100644 --- a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs +++ b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs @@ -51,6 +51,7 @@ internal static ILoggingBuilder AddSentry( builder.Services.AddSingleton, SentryLoggingOptionsSetup>(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSentry(); // All logs should flow to the SentryLogger, regardless of level. @@ -58,6 +59,14 @@ internal static ILoggingBuilder AddSentry( // Filtering of breadcrumbs is handled in SentryLogger, using SentryOptions.MinimumBreadcrumbLevel builder.AddFilter(_ => true); + // Logs from the SentryLogger should not flow to the SentryStructuredLogger as this may cause recursive invocations. + // Filtering of logs is handled in SentryStructuredLogger, using SentryOptions.MinimumLogLevel + builder.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || categoryName != "Sentry.ISentryClient"; + }); + return builder; } } diff --git a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj index e011099d02..4d52cd091c 100644 --- a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj +++ b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs index 52bd4a0260..d181b645bf 100644 --- a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs @@ -11,7 +11,9 @@ public class SentryLoggingOptions : SentryOptions /// /// Gets or sets the minimum breadcrumb level. /// - /// Events with this level or higher will be stored as + /// + /// Events with this level or higher will be stored as . + /// /// /// The minimum breadcrumb level. /// @@ -21,7 +23,7 @@ public class SentryLoggingOptions : SentryOptions /// Gets or sets the minimum event level. /// /// - /// Events with this level or higher will be sent to Sentry + /// Events with this level or higher will be sent to Sentry. /// /// /// The minimum event level. @@ -48,4 +50,39 @@ public class SentryLoggingOptions : SentryOptions /// List of callbacks to be invoked when initializing the SDK /// internal Action[] ConfigureScopeCallbacks { get; set; } = Array.Empty>(); + + /// + /// Experimental Sentry Logging features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + /// + /// Experimental Sentry Logging options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public sealed class SentryLoggingExperimentalOptions + { + internal SentryLoggingExperimentalOptions() + { + } + + /// + /// Gets or sets the minimum log level. + /// This API is experimental and it may change in the future. + /// + /// + /// Logs with this level or higher will be stored as . + /// + /// + /// The minimum log level. + /// + public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; + } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs new file mode 100644 index 0000000000..87a0daae49 --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry.Extensions.Logging; + +internal sealed class SentryStructuredLogger : ILogger +{ + private readonly string? _categoryName; + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; + + internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + { + _categoryName = categoryName; + _options = options; + _clock = clock; + _hub = hub; + _sdk = sdk; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return NullDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return _hub.IsEnabled + && _options.Experimental.EnableLogs + && logLevel != LogLevel.None + && logLevel >= _options.ExperimentalLogging.MinimumLogLevel; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var timestamp = _clock.GetUtcNow(); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; + + var level = logLevel.ToSentryLogLevel(); + Debug.Assert(level != default); + + string message; + try + { + message = formatter.Invoke(state, exception); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } + + string? template = null; + var parameters = ImmutableArray.CreateBuilder>(); + // see Microsoft.Extensions.Logging.FormattedLogValues + if (state is IReadOnlyList> formattedLogValues) + { + if (formattedLogValues.Count != 0) + { + parameters.Capacity = formattedLogValues.Count - 1; + } + + foreach (var formattedLogValue in formattedLogValues) + { + if (formattedLogValue.Key == "{OriginalFormat}" && formattedLogValue.Value is string formattedString) + { + template = formattedString; + } + else if (formattedLogValue.Value is not null) + { + parameters.Add(new KeyValuePair(formattedLogValue.Key, formattedLogValue.Value)); + } + } + } + + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = parameters.DrainToImmutable(), + ParentSpanId = traceHeader.SpanId, + }; + + log.SetDefaultAttributes(_options, _sdk); + + if (_categoryName is not null) + { + log.SetAttribute("microsoft.extensions.logging.category_name", _categoryName); + } + if (eventId.Name is not null || eventId.Id != 0) + { + log.SetAttribute("microsoft.extensions.logging.event.id", eventId.Id); + } + if (eventId.Name is not null) + { + log.SetAttribute("microsoft.extensions.logging.event.name", eventId.Name); + } + + _hub.Logger.CaptureLog(log); + } +} + +file sealed class NullDisposable : IDisposable +{ + public static NullDisposable Instance { get; } = new NullDisposable(); + + private NullDisposable() + { + } + + public void Dispose() + { + } +} diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs new file mode 100644 index 0000000000..fdfe0a527c --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Infrastructure; + +namespace Sentry.Extensions.Logging; + +/// +/// Sentry Structured Logger Provider. +/// +[ProviderAlias("SentryLogs")] +internal class SentryStructuredLoggerProvider : ILoggerProvider +{ + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; + + public SentryStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryStructuredLoggerProvider(SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + { + _options = options; + _hub = hub; + _clock = clock; + _sdk = sdk; + } + + public ILogger CreateLogger(string categoryName) + { + return new SentryStructuredLogger(categoryName, _options, _hub, _clock, _sdk); + } + + public void Dispose() + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryLoggerProvider.NameAndVersion.Version, + }; + } +} diff --git a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs new file mode 100644 index 0000000000..d0525a4b49 --- /dev/null +++ b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; +using Sentry.Infrastructure; + +namespace Sentry.Maui.Internal; + +[ProviderAlias("SentryLogs")] +internal sealed class SentryMauiStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryMauiStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryMauiStructuredLoggerProvider(SentryMauiOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = Constants.SdkVersion, + }; + } +} diff --git a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs index 7d71288365..674a7c6535 100644 --- a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs +++ b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs @@ -52,10 +52,17 @@ public static MauiAppBuilder UseSentry(this MauiAppBuilder builder, services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, SentryMauiOptionsSetup>(); services.AddSingleton(); + builder.Logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || categoryName != "Sentry.ISentryClient"; + }); + // Resolve the configured options and register any element event binders from these var options = new SentryMauiOptions(); configureOptions?.Invoke(options); diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index acf6d1de0a..9ca3847e1e 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -53,10 +53,8 @@ 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; } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 75a8cee778..37b03babbd 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -40,10 +40,21 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } + ImmutableArray> @params = default; + if (parameters is { Length: > 0 }) + { + var builder = ImmutableArray.CreateBuilder>(parameters.Length); + for (var index = 0; index < parameters.Length; index++) + { + builder.Add(new KeyValuePair(index.ToString(), parameters[index])); + } + @params = builder.DrainToImmutable(); + } + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) { Template = template, - Parameters = ImmutableArray.Create(parameters), + Parameters = @params, ParentSpanId = traceHeader.SpanId, }; @@ -60,7 +71,14 @@ private protected override void CaptureLog(SentryLogLevel level, string template var scope = _hub.GetScope(); log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance); + CaptureLog(log); + } + + /// + protected internal override void CaptureLog(SentryLog log) + { var configuredLog = log; + if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) { try diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs index efe0e65ad2..02fb6fc8f1 100644 --- a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -14,6 +14,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template // disabled } + /// + protected internal override void CaptureLog(SentryLog log) + { + // disabled + } + /// protected internal override void Flush() { diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index bfec633662..96e6a08b2b 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -847,6 +847,5 @@ public void Dispose() public SentryId LastEventId => CurrentScope.LastEventId; - [Experimental(DiagnosticId.ExperimentalFeature)] public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs new file mode 100644 index 0000000000..68b44d5dcc --- /dev/null +++ b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs @@ -0,0 +1,20 @@ +// ReSharper disable CheckNamespace +namespace System.Collections.Immutable; + +internal static class ImmutableCollectionsPolyfill +{ +#if !NET8_0_OR_GREATER + internal static ImmutableArray DrainToImmutable(this ImmutableArray.Builder builder) + { + if (builder.Capacity == builder.Count) + { + return builder.MoveToImmutable(); + } + + var result = builder.ToImmutable(); + builder.Count = 0; + builder.Capacity = 0; + return result; + } +#endif +} diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 77fcbe83f4..45193ea097 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -451,7 +451,6 @@ internal static Envelope FromClientReport(ClientReport clientReport) internal static Envelope FromAttachment(SentryId eventId, SentryAttachment attachment, IDiagnosticLogger? logger = null) => new(eventId, CreateHeader(eventId), [EnvelopeItem.FromAttachment(attachment)]); - [Experimental(DiagnosticId.ExperimentalFeature)] internal static Envelope FromLog(StructuredLog log) { var header = DefaultHeader; diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7528a14d63..5409ebc0a6 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -371,7 +371,6 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } - [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] internal static EnvelopeItem FromLog(StructuredLog log) { var header = new Dictionary(3, StringComparer.Ordinal) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index dab0813c2e..b506b9da6c 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -67,7 +67,7 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] - public ImmutableArray Parameters { get; init; } + public ImmutableArray> Parameters { get; init; } /// /// The span id of the span that was active when the log was collected. @@ -167,6 +167,16 @@ internal void SetAttribute(string key, string value) _attributes[key] = new SentryAttribute(value, "string"); } + internal void SetAttribute(string key, char value) + { + _attributes[key] = new SentryAttribute(value.ToString(), "string"); + } + + internal void SetAttribute(string key, int value) + { + _attributes[key] = new SentryAttribute(value, "integer"); + } + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) { var environment = options.SettingLocator.GetEnvironment(); @@ -192,7 +202,11 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); - writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); +#if NET9_0_OR_GREATER + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / (double)TimeSpan.MillisecondsPerSecond); +#else + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / 1_000.0); +#endif var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(logger); writer.WriteString("level", severityText); @@ -217,9 +231,9 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) if (!Parameters.IsDefault) { - for (var index = 0; index < Parameters.Length; index++) + foreach (var parameter in Parameters) { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index], logger); + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{parameter.Key}", parameter.Value, logger); } } diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs index 184fccc548..9ccde83f0d 100644 --- a/src/Sentry/SentryLogLevel.cs +++ b/src/Sentry/SentryLogLevel.cs @@ -70,7 +70,6 @@ public enum SentryLogLevel Fatal = 21, } -[Experimental(DiagnosticId.ExperimentalFeature)] internal static class SentryLogLevelExtensions { internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level, IDiagnosticLogger? logger) diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index f5b444960e..0170c28254 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -34,6 +34,13 @@ private protected SentryStructuredLogger() /// A configuration callback. Will be removed in a future version. private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); + /// + /// Buffers a Sentry Log message + /// via the associated Batch Processor. + /// + /// The log. + protected internal abstract void CaptureLog(SentryLog log); + /// /// Clears all buffers for this logger and causes any buffered logs to be sent by the underlying . /// diff --git a/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..9edf8363ac --- /dev/null +++ b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.AspNetCore.Tests; + +public class SentryAspNetCoreStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryAspNetCoreOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryAspNetCoreStructuredLoggerProvider GetSut() + { + return new SentryAspNetCoreStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryAspNetCoreStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryMiddleware.NameAndVersion.Version); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index b438b0af45..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -38,10 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index b438b0af45..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -38,10 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index b438b0af45..e4dd758823 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -38,10 +38,15 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index 778215de16..9321b3aa64 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -57,6 +57,8 @@ public void Configure_BindsConfigurationToOptions() MinimumEventLevel = LogLevel.Error, InitializeSdk = true }; + expected.Experimental.EnableLogs = true; + expected.ExperimentalLogging.MinimumLogLevel = LogLevel.None; var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -106,6 +108,9 @@ public void Configure_BindsConfigurationToOptions() ["MinimumBreadcrumbLevel"] = expected.MinimumBreadcrumbLevel.ToString(), ["MinimumEventLevel"] = expected.MinimumEventLevel.ToString(), ["InitializeSdk"] = expected.InitializeSdk.ToString(), + + ["Experimental:EnableLogs"] = expected.Experimental.EnableLogs.ToString(), + ["ExperimentalLogging:MinimumLogLevel"] = expected.ExperimentalLogging.MinimumLogLevel.ToString(), }) .Build(); @@ -163,6 +168,9 @@ public void Configure_BindsConfigurationToOptions() actual.MinimumBreadcrumbLevel.Should().Be(expected.MinimumBreadcrumbLevel); actual.MinimumEventLevel.Should().Be(expected.MinimumEventLevel); actual.InitializeSdk.Should().Be(expected.InitializeSdk); + + actual.Experimental.EnableLogs.Should().Be(expected.Experimental.EnableLogs); + actual.ExperimentalLogging.MinimumLogLevel.Should().Be(expected.ExperimentalLogging.MinimumLogLevel); } } } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..bd43dfc668 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryStructuredLoggerProvider GetSut() + { + return new SentryStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryLoggerProvider.NameAndVersion.Version); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..65878cdf93 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,317 @@ +#nullable enable + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerTests : IDisposable +{ + private class Fixture + { + public string CategoryName { get; internal set; } + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Queue CapturedLogs { get; } = new(); + public InMemoryDiagnosticLogger DiagnosticLogger { get; } = new(); + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + Environment = "my-environment", + Release = "my-release", + }; + + CategoryName = nameof(CategoryName); + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + var logger = Substitute.For(); + logger.CaptureLog(Arg.Do(log => CapturedLogs.Enqueue(log))); + Hub.Logger.Returns(logger); + + EnableHub(true); + EnableLogs(true); + SetMinimumLogLevel(default); + } + + public void EnableHub(bool isEnabled) => Hub.IsEnabled.Returns(isEnabled); + public void EnableLogs(bool isEnabled) => Options.Value.Experimental.EnableLogs = isEnabled; + public void SetMinimumLogLevel(LogLevel logLevel) => Options.Value.ExperimentalLogging.MinimumLogLevel = logLevel; + + public void WithTraceHeader(SentryId traceId, SpanId parentSpanId) + { + var traceHeader = new SentryTraceHeader(traceId, parentSpanId, null); + Hub.GetTraceHeader().Returns(traceHeader); + } + + public SentryStructuredLogger GetSut() + { + return new SentryStructuredLogger(CategoryName, Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + public void Dispose() + { + _fixture.CapturedLogs.Should().BeEmpty(); + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); + } + + [Theory] + [InlineData(LogLevel.Trace, SentryLogLevel.Trace)] + [InlineData(LogLevel.Debug, SentryLogLevel.Debug)] + [InlineData(LogLevel.Information, SentryLogLevel.Info)] + [InlineData(LogLevel.Warning, SentryLogLevel.Warning)] + [InlineData(LogLevel.Error, SentryLogLevel.Error)] + [InlineData(LogLevel.Critical, SentryLogLevel.Fatal)] + [InlineData(LogLevel.None, default(SentryLogLevel))] + public void Log_LogLevel_CaptureLog(LogLevel logLevel, SentryLogLevel expectedLevel) + { + var traceId = SentryId.Create(); + var parentSpanId = SpanId.Create(); + _fixture.WithTraceHeader(traceId, parentSpanId); + var logger = _fixture.GetSut(); + + EventId eventId = new(123, "EventName"); + Exception? exception = new InvalidOperationException("message"); + string? message = "Message with {Argument}."; + object?[] args = ["argument"]; + + logger.Log(logLevel, eventId, exception, message, args); + + if (logLevel == LogLevel.None) + { + _fixture.CapturedLogs.Should().BeEmpty(); + return; + } + + var log = _fixture.CapturedLogs.Dequeue(); + log.Timestamp.Should().Be(_fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(traceId); + log.Level.Should().Be(expectedLevel); + log.Message.Should().Be("Message with argument."); + log.Template.Should().Be(message); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("Argument", "argument") }); + log.ParentSpanId.Should().Be(parentSpanId); + log.AssertAttribute("sentry.environment", "my-environment"); + log.AssertAttribute("sentry.release", "my-release"); + log.AssertAttribute("sentry.sdk.name", "SDK Name"); + log.AssertAttribute("sentry.sdk.version", "SDK Version"); + log.AssertAttribute("microsoft.extensions.logging.category_name", "CategoryName"); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void Log_LogLevelNone_DoesNotCaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.None, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + _fixture.CapturedLogs.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutTraceHeader_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TraceId.Should().Be(SentryTraceHeader.Empty.TraceId); + log.ParentSpanId.Should().Be(SentryTraceHeader.Empty.SpanId); + } + + [Fact] + public void Log_WithoutArguments_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message."); + log.Template.Should().Be("Message."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + [SuppressMessage("ReSharper", "StructuredMessageTemplateProblem", Justification = "Tests")] + public void Log_ParameterCountMismatch_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message with {Argument}."); + log.Template.Should().Be("Message with {Argument}."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + [SuppressMessage("ReSharper", "StructuredMessageTemplateProblem", Justification = "Tests")] + public void Log_ParameterCountMismatch_Throws() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {One}{Two}.", "One"); + + _fixture.CapturedLogs.Should().BeEmpty(); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutCategoryName_CaptureLog() + { + _fixture.CategoryName = null!; + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.category_name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutMessage_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new InvalidOperationException("message"), null, Array.Empty()); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("[null]"); + log.Template.Should().Be("[null]"); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutEvent_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.event.id", out object? _).Should().BeFalse(); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutEventId_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(0, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 0); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void Log_WithoutEventName_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Theory] + [InlineData(true, true, LogLevel.Warning, LogLevel.Warning, true)] + [InlineData(false, true, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, false, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, true, LogLevel.Information, LogLevel.Warning, true)] + [InlineData(true, true, LogLevel.Error, LogLevel.Warning, false)] + public void IsEnabled_HubOptionsMinimumLogLevel_Returns(bool isHubEnabled, bool isLogsEnabled, LogLevel minimumLogLevel, LogLevel actualLogLevel, bool expectedIsEnabled) + { + _fixture.EnableHub(isHubEnabled); + _fixture.EnableLogs(isLogsEnabled); + _fixture.SetMinimumLogLevel(minimumLogLevel); + var logger = _fixture.GetSut(); + + var isEnabled = logger.IsEnabled(actualLogLevel); + logger.Log(actualLogLevel, "message"); + + isEnabled.Should().Be(expectedIsEnabled); + if (expectedIsEnabled) + { + _fixture.CapturedLogs.Dequeue().Message.Should().Be("message"); + } + } + + [Fact] + public void BeginScope_Dispose_NoOp() + { + var logger = _fixture.GetSut(); + + string messageFormat = "Message with {Argument}."; + object?[] args = ["argument"]; + + logger.LogInformation("one"); + using (var scope = logger.BeginScope(messageFormat, args)) + { + logger.LogInformation("two"); + } + logger.LogInformation("three"); + + _fixture.CapturedLogs.Dequeue().Message.Should().Be("one"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("two"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("three"); + } + + [Fact] + public void BeginScope_Shared_Same() + { + var logger = _fixture.GetSut(); + + using var scope1 = logger.BeginScope("Message with {Argument}.", "argument"); + using var scope2 = logger.BeginScope("Message with {Argument}.", "argument"); + + scope1.Should().BeSameAs(scope2); + } +} + +file static class SentryLogExtensions +{ + public static void AssertAttribute(this SentryLog log, string key, string value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); + } + + public static void AssertAttribute(this SentryLog log, string key, int value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); + } +} diff --git a/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..1715498a5b --- /dev/null +++ b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Maui.Internal; + +namespace Sentry.Maui.Tests.Internal; + +public class SentryMauiStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryMauiOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryMauiStructuredLoggerProvider GetSut() + { + return new SentryMauiStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryMauiStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Sentry.Maui.Internal.Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(Sentry.Maui.Internal.Constants.SdkVersion); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 13fee3df88..b8baf13c8e 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -69,11 +69,21 @@ private static KeyValuePair GetDummyBindableValue(Property { EnableLogs = true, }, + not null when propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions" => CreateSentryLoggingExperimentalOptions(), _ => throw new NotSupportedException($"Unsupported property type on property {propertyInfo.Name}") }; return new KeyValuePair(propertyInfo, value); } + private static object CreateSentryLoggingExperimentalOptions() + { + var options = Activator.CreateInstance("Sentry.Extensions.Logging", "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions", false, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.CreateInstance, null, null, null, null); + var instance = options.Unwrap(); + var property = instance.GetType().GetProperty("MinimumLogLevel", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + property.SetValue(instance, int.MaxValue); + return instance; + } + private static IEnumerable> ToConfigValues(KeyValuePair item) { var (prop, value) = item; @@ -90,6 +100,11 @@ private static IEnumerable> ToConfigValues(KeyValue var experimental = (SentryOptions.SentryExperimentalOptions)value; yield return new KeyValuePair($"{prop.Name}:{nameof(SentryOptions.SentryExperimentalOptions.EnableLogs)}", Convert.ToString(experimental.EnableLogs, CultureInfo.InvariantCulture)); } + else if (propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + var property = value.GetType().GetProperty("MinimumLogLevel"); + yield return new KeyValuePair($"{prop.Name}:MinimumLogLevel", Convert.ToString(property.GetValue(value), CultureInfo.InvariantCulture)); + } else { yield return new KeyValuePair(prop.Name, Convert.ToString(value, CultureInfo.InvariantCulture)); @@ -128,6 +143,10 @@ protected void AssertContainsExpectedPropertyValues(TOptions actual) { actualValue.Should().BeEquivalentTo(expectedValue); } + else if (prop.PropertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + actualValue.Should().BeEquivalentTo(expectedValue); + } else { actualValue.Should().Be(expectedValue); diff --git a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs index b1715a161f..48077f402b 100644 --- a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs +++ b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs @@ -11,7 +11,7 @@ public void Log(SentryLevel logLevel, string message, Exception exception = null Entries.Enqueue(new Entry(logLevel, message, exception, args)); } - internal Entry Dequeue() + public Entry Dequeue() { if (Entries.TryDequeue(out var entry)) { diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs index b180a776b0..440b83cdc7 100644 --- a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -12,6 +12,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template Entries.Add(LogEntry.Create(level, template, parameters)); } + /// + protected internal override void CaptureLog(SentryLog log) + { + throw new NotSupportedException(); + } + /// protected internal override void Flush() { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index e826dba774..eeb85e4dbd 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -646,7 +646,7 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public string Message { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public Sentry.SpanId? ParentSpanId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] @@ -1013,6 +1013,7 @@ namespace Sentry [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index e826dba774..eeb85e4dbd 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -646,7 +646,7 @@ namespace Sentry [System.Runtime.CompilerServices.RequiredMember] public string Message { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public Sentry.SpanId? ParentSpanId { get; init; } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] @@ -1013,6 +1013,7 @@ namespace Sentry [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 296ea74dc8..fb49f7c839 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -626,7 +626,7 @@ namespace Sentry { public Sentry.SentryLogLevel Level { get; init; } public string Message { get; init; } - public System.Collections.Immutable.ImmutableArray Parameters { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } public Sentry.SpanId? ParentSpanId { get; init; } public string? Template { get; init; } public System.DateTimeOffset Timestamp { get; init; } @@ -973,6 +973,7 @@ namespace Sentry } public abstract class SentryStructuredLogger : System.IDisposable { + protected abstract void CaptureLog(Sentry.SentryLog log); public void Dispose() { } protected virtual void Dispose(bool disposing) { } protected abstract void Flush(); diff --git a/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs new file mode 100644 index 0000000000..a47b65db1e --- /dev/null +++ b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs @@ -0,0 +1,39 @@ +namespace Sentry.Tests.Polyfilling; + +public class ImmutableCollectionsPolyfillTests +{ + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsNotCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + + builder.Count.Should().Be(1); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(1); + array.Should().BeEquivalentTo(["one"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } + + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + builder.Add("two"); + + builder.Count.Should().Be(2); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(2); + array.Should().BeEquivalentTo(["one", "two"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } +} diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 4fd355839b..3393137b85 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -9,7 +9,7 @@ namespace Sentry.Tests; /// public class SentryLogTests { - private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, TimeSpan.FromHours(2)); + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2)); private static readonly SentryId TraceId = SentryId.Create(); private static readonly SpanId? ParentSpanId = SpanId.Create(); @@ -39,7 +39,7 @@ public void Protocol_Default_VerifyAttributes() var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") { Template = "template", - Parameters = ImmutableArray.Create("params"), + Parameters = ImmutableArray.Create(new KeyValuePair("param", "params")), ParentSpanId = ParentSpanId, }; log.SetAttribute("attribute", "value"); @@ -50,7 +50,7 @@ public void Protocol_Default_VerifyAttributes() log.Level.Should().Be((SentryLogLevel)24); log.Message.Should().Be("message"); log.Template.Should().Be("template"); - log.Parameters.Should().BeEquivalentTo(["params"]); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("param", "params"), }); log.ParentSpanId.Should().Be(ParentSpanId); log.TryGetAttribute("attribute", out object attribute).Should().BeTrue(); @@ -114,7 +114,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() { "items": [ { - "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "timestamp": {{Timestamp.GetTimestamp()}}, "level": "trace", "body": "message", "trace_id": "{{TraceId.ToString()}}", @@ -148,7 +148,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") { Template = "template", - Parameters = ImmutableArray.Create("string", false, 1, 2.2), + Parameters = ImmutableArray.Create(new KeyValuePair("0", "string"), new KeyValuePair("1", false), new KeyValuePair("2", 1), new KeyValuePair("3", 2.2)), ParentSpanId = ParentSpanId, }; log.SetAttribute("string-attribute", "string-value"); @@ -181,7 +181,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() { "items": [ { - "timestamp": {{Timestamp.ToUnixTimeSeconds()}}, + "timestamp": {{Timestamp.GetTimestamp()}}, "level": "fatal", "body": "message", "trace_id": "{{TraceId.ToString()}}", @@ -259,58 +259,55 @@ public void WriteTo_MessageParameters_AsAttributes() { Parameters = [ - sbyte.MinValue, - byte.MaxValue, - short.MinValue, - ushort.MaxValue, - int.MinValue, - uint.MaxValue, - long.MinValue, - ulong.MaxValue, + new KeyValuePair("00", sbyte.MinValue), + new KeyValuePair("01", byte.MaxValue), + new KeyValuePair("02", short.MinValue), + new KeyValuePair("03", ushort.MaxValue), + new KeyValuePair("04", int.MinValue), + new KeyValuePair("05", uint.MaxValue), + new KeyValuePair("06", long.MinValue), + new KeyValuePair("07", ulong.MaxValue), #if NET5_0_OR_GREATER - nint.MinValue, - nuint.MaxValue, + new KeyValuePair("08", nint.MinValue), + new KeyValuePair("09", nuint.MaxValue), #endif - 1f, - 2d, - 3m, - true, - 'c', - "string", + new KeyValuePair("10", 1f), + new KeyValuePair("11", 2d), + new KeyValuePair("12", 3m), + new KeyValuePair("13", true), + new KeyValuePair("14", 'c'), + new KeyValuePair("15", "string"), #if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) - KeyValuePair.Create("key", "value"), + new KeyValuePair("16", KeyValuePair.Create("key", "value")), #else - new KeyValuePair("key", "value"), + new KeyValuePair("16", new KeyValuePair("key", "value")), #endif - null, + new KeyValuePair("17", null), ], }; - var currentParameterAttributeIndex = -1; - string GetNextParameterAttributeName() => $"sentry.message.parameter.{++currentParameterAttributeIndex}"; - var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); var attributes = document.RootElement.GetProperty("attributes"); Assert.Collection(attributes.EnumerateObject().ToArray(), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetSByte(), sbyte.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetByte(), byte.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt16(), short.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt16(), ushort.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt32(), int.MinValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt32(), uint.MaxValue), - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), long.MinValue), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.00", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.01", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.02", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.03", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.04", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.05", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.06", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.07", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), #if NET5_0_OR_GREATER - property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), nint.MinValue), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeInteger("sentry.message.parameter.08", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.09", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), #endif - property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetSingle(), 1f), - property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetDouble(), 2d), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeBoolean(GetNextParameterAttributeName(), json => json.GetBoolean(), true), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "c"), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "string"), - property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "[key, value]") + property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), + property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), + property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), + property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") ); Assert.Collection(_output.Entries, entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), @@ -425,11 +422,20 @@ private static void AssertAttribute(this JsonProperty attribute, string name, } } +file static class DateTimeOffsetExtensions +{ + public static string GetTimestamp(this DateTimeOffset value) + { + var timestamp = value.ToUnixTimeMilliseconds() / 1_000.0; + return timestamp.ToString(NumberFormatInfo.InvariantInfo); + } +} + file static class JsonFormatterExtensions { public static string Format(this DateTimeOffset value) { - return value.ToString("yyyy-MM-ddTHH:mm:sszzz", DateTimeFormatInfo.InvariantInfo); + return value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); } public static string Format(this double value) diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index aa303ba690..aeb121badc 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -18,7 +18,7 @@ public Fixture() Debug = true, DiagnosticLogger = DiagnosticLogger, }; - Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, TimeSpan.Zero)); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); BatchSize = 2; BatchTimeout = Timeout.InfiniteTimeSpan; TraceId = SentryId.Create(); @@ -307,7 +307,7 @@ public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fix log.Level.Should().Be(level); log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); - log.Parameters.Should().BeEquivalentTo(new object[] { "string", true, 1, 2.2 }); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("0", "string"), new("1", true), new("2", 1), new("3", 2.2), }); log.ParentSpanId.Should().Be(fixture.ParentSpanId); log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); value.Should().Be("attribute-value");