diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.SentryStructuredLoggerBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.SentryStructuredLoggerBenchmarks-report-github.md new file mode 100644 index 0000000000..efff8ced47 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.SentryStructuredLoggerBenchmarks-report-github.md @@ -0,0 +1,14 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 26.3.1 (a) (25D771280a) [Darwin 25.3.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.201 + [Host] : .NET 9.0.8 (9.0.825.36511), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 9.0.8 (9.0.825.36511), Arm64 RyuJIT AdvSIMD + + +``` +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|--------------------- |---------:|--------:|--------:|-------:|-------:|----------:| +| LogWithoutParameters | 102.3 ns | 1.28 ns | 1.19 ns | 0.0640 | 0.0001 | 536 B | +| LogWithParameters | 248.5 ns | 4.86 ns | 5.40 ns | 0.1087 | - | 912 B | diff --git a/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs b/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs new file mode 100644 index 0000000000..8343f9ad24 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs @@ -0,0 +1,60 @@ +#nullable enable + +using BenchmarkDotNet.Attributes; +using Sentry.Extensibility; +using Sentry.Internal; +using Sentry.Testing; + +namespace Sentry.Benchmarks; + +public class SentryStructuredLoggerBenchmarks +{ + private Hub _hub = null!; + private SentryStructuredLogger _logger = null!; + + private SentryLog? _lastLog; + + [GlobalSetup] + public void Setup() + { + SentryOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + EnableLogs = true, + }; + options.SetBeforeSendLog((SentryLog log) => + { + _lastLog = log; + return null; + }); + + MockClock clock = new(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + + _hub = new Hub(options, DisabledHub.Instance); + _logger = SentryStructuredLogger.Create(_hub, options, clock); + } + + [Benchmark] + public void LogWithoutParameters() + { + _logger.LogInfo("Message Text"); + } + + [Benchmark] + public void LogWithParameters() + { + _logger.LogInfo("Template string with arguments: {0}, {1}, {2}, {3}", "string", true, 1, 2.2); + } + + [GlobalCleanup] + public void Cleanup() + { + (_logger as IDisposable)?.Dispose(); + _hub.Dispose(); + + if (_lastLog is null) + { + throw new InvalidOperationException("Last Log is null"); + } + } +} diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 1d74b6dc5e..d65f3ca9aa 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -30,19 +30,20 @@ private protected override void CaptureLog(SentryLogLevel level, string template _hub.GetTraceIdAndSpanId(out var traceId, out var spanId); 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; - } + ImmutableArray> @params; - ImmutableArray> @params = default; if (parameters is { Length: > 0 }) { + 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; + } + var builder = ImmutableArray.CreateBuilder>(parameters.Length); for (var index = 0; index < parameters.Length; index++) { @@ -50,6 +51,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template } @params = builder.DrainToImmutable(); } + else + { + message = template; + template = null!; // SentryLog.Template is declared nullable (string?) + @params = ImmutableArray>.Empty; + } SentryLog log = new(timestamp, traceId, level, message) { diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 315b0a8a56..3cb503cdba 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -25,6 +25,8 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le Message = message; // 7 is the number of built-in attributes, so we start with that. _attributes = new Dictionary(7); + // ensure the ImmutableArray`1 is not default, so we can omit IsDefault checks before accessing other members + Parameters = ImmutableArray>.Empty; } /// @@ -58,7 +60,15 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le /// /// The parameters to the template string. /// - public ImmutableArray> Parameters { get; init; } + public ImmutableArray> Parameters + { + get; + init + { + Debug.Assert(!value.IsDefault); // DEBUG-only check, because .ctor is internal and set-accessor is init-only + field = value; + } + } /// /// The span id of the span that was active when the log was collected. @@ -190,6 +200,8 @@ internal void SetOrigin(string origin) internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { + Debug.Assert(!Parameters.IsDefault); + writer.WriteStartObject(); #if NET9_0_OR_GREATER @@ -222,17 +234,14 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) // the SDK MUST NOT attach a sentry.message.template attribute if there are no parameters // https://develop.sentry.dev/sdk/telemetry/logs/#default-attributes - if (Template is not null && !Parameters.IsDefaultOrEmpty) + if (Template is not null && !Parameters.IsEmpty) { SentryAttributeSerializer.WriteStringAttribute(writer, "sentry.message.template", Template); } - if (!Parameters.IsDefault) + foreach (var parameter in Parameters) { - foreach (var parameter in Parameters) - { - SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{parameter.Key}", parameter.Value, logger); - } + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{parameter.Key}", parameter.Value, logger); } foreach (var attribute in _attributes) diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index e6b3ccdcb3..4293735de0 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -22,6 +22,28 @@ public SentryLogTests(ITestOutputHelper output) _output = new TestOutputDiagnosticLogger(output); } + [Fact] + public void Create_Default_HasMinimalSpecification() + { + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message"); + + 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(null); + log.Parameters.Should().BeEmpty(); + log.SpanId.Should().Be(null); + +#if DEBUG + var assignDefault = static () => new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Parameters = default, + }; + assignDefault.Should().Throw("Disallow default ImmutableArray"); +#endif + } + [Fact] public void Protocol_Default_VerifyAttributes() { diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs index 2614c99989..e7c025278b 100644 --- a/test/Sentry.Tests/SentryMetricTests.cs +++ b/test/Sentry.Tests/SentryMetricTests.cs @@ -22,6 +22,20 @@ public SentryMetricTests(ITestOutputHelper output) _output = new TestOutputDiagnosticLogger(output); } + [Fact] + public void Create_Default_HasMinimalSpecification() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + metric.Timestamp.Should().Be(Timestamp); + metric.TraceId.Should().Be(TraceId); + metric.Type.Should().Be(SentryMetricType.Counter); + metric.Name.Should().Be("sentry_tests.sentry_metric_tests.counter"); + metric.Value.Should().Be(1); + metric.SpanId.Should().Be(null); + metric.Unit.Should().BeEquivalentTo(null); + } + [Fact] public void Protocol_Default_VerifyAttributes() { diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs index 31aec367e3..1ab5836387 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs @@ -83,6 +83,36 @@ public void Log_ConfigureLog_Disabled_DoesNotCaptureEnvelope(SentryLogLevel leve _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_WithoutParameters_DoesNotAttachTemplateAttribute(SentryLogLevel level) + { + _fixture.Options.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Log(level, "Message Text"); + logger.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + var log = envelope.ShouldContainSingleLog(); + + log.Level.Should().Be(level); + log.Message.Should().Be("Message Text"); + log.Template.Should().BeNull(); + log.Parameters.Should().BeEmpty(); + + log.TryGetAttribute("sentry.message.template", out object? template).Should().BeFalse(); + template.Should().BeNull(); + } } file static class SentryStructuredLoggerExtensions diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 9a2def8c9f..3fbca3197a 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -301,4 +301,14 @@ public static void AssertLog(this SentryStructuredLoggerTests.Fixture fixture, S { return new KeyValuePair(name, value); } + + public static SentryLog ShouldContainSingleLog(this Envelope envelope) + { + var envelopeItem = envelope.Items.Should().ContainSingle().Which; + var serializable = envelopeItem.Payload.Should().BeOfType().Which; + var log = serializable.Source.Should().BeOfType().Which; + + log.Items.Length.Should().Be(1); + return log.Items[0]; + } }