From 09ad86bb5c8f31cccfd4d259630e6f8889ca0c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:05:41 +0100 Subject: [PATCH 1/9] perf(logs): avoid string allocation when no parameters are passed to be formatted via the template --- .../Internal/DefaultSentryStructuredLogger.cs | 19 +++++++------ .../SentryStructuredLoggerTests.Format.cs | 27 +++++++++++++++++++ .../SentryStructuredLoggerTests.cs | 10 +++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 1f13191ed2..e1b7cd4679 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -29,15 +29,18 @@ private protected override void CaptureLog(SentryLogLevel level, string template var timestamp = _clock.GetUtcNow(); SentryLog.GetTraceIdAndSpanId(_hub, out var traceId, out var spanId); - string message; - try - { - message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); - } - catch (FormatException e) + string message = template; + if (parameters is { Length: > 0 }) { - _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); - return; + 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 = default; diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs index 87894fed75..0a7936c3b3 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs @@ -83,6 +83,33 @@ 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.Experimental.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(); + } } file static class SentryStructuredLoggerExtensions diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index bee10461ff..d22edf483e 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]; + } } From ac8c9954effd76eb40328b21079bd1b3243b8c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:45:13 +0200 Subject: [PATCH 2/9] test: fix --- test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs index d4f355e09c..1ab5836387 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs @@ -93,7 +93,7 @@ public void Log_ConfigureLog_Disabled_DoesNotCaptureEnvelope(SentryLogLevel leve [InlineData(SentryLogLevel.Fatal)] public void Log_WithoutParameters_DoesNotAttachTemplateAttribute(SentryLogLevel level) { - _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.EnableLogs = true; var logger = _fixture.GetSut(); Envelope envelope = null!; @@ -109,6 +109,9 @@ public void Log_WithoutParameters_DoesNotAttachTemplateAttribute(SentryLogLevel 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(); } } From 91046c12d20c9649fa8cbf713abfc2dc8d5c8600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:45:32 +0200 Subject: [PATCH 3/9] ref: explicit else paths --- src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 0c69d21aa0..abc55c9c07 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -29,7 +29,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template var timestamp = _clock.GetUtcNow(); _hub.GetTraceIdAndSpanId(out var traceId, out var spanId); - string message = template; + var message = template; if (parameters is { Length: > 0 }) { try @@ -42,8 +42,12 @@ private protected override void CaptureLog(SentryLogLevel level, string template return; } } + else + { + template = null!; + } - ImmutableArray> @params = default; + ImmutableArray> @params; if (parameters is { Length: > 0 }) { var builder = ImmutableArray.CreateBuilder>(parameters.Length); @@ -53,6 +57,10 @@ private protected override void CaptureLog(SentryLogLevel level, string template } @params = builder.DrainToImmutable(); } + else + { + @params = ImmutableArray>.Empty; + } SentryLog log = new(timestamp, traceId, level, message) { From 4f7d7c92423e75c3cde1b293e1fc66cf77ef2014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:45:41 +0200 Subject: [PATCH 4/9] perf: add Benchmark --- ...tructuredLoggerBenchmarks-report-github.md | 14 +++++ .../SentryStructuredLoggerBenchmarks.cs | 59 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.SentryStructuredLoggerBenchmarks-report-github.md create mode 100644 benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs 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..d88d2db922 --- /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 | 103.1 ns | 0.93 ns | 0.82 ns | 0.0640 | 0.0001 | 536 B | +| LogWithParameters | 243.6 ns | 0.96 ns | 0.85 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..bc127dd9c5 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs @@ -0,0 +1,59 @@ +#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() + { + _hub.Dispose(); + + if (_lastLog is null) + { + throw new InvalidOperationException("Last Log is null"); + } + } +} From 17a5ffe801843f83a7062b49caa79a7c625fb220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:30:04 +0200 Subject: [PATCH 5/9] perf: avoid ImmutableArray.IsDefault checks --- ...ntryStructuredLoggerBenchmarks-report-github.md | 4 ++-- .../Internal/DefaultSentryStructuredLogger.cs | 14 +++++--------- src/Sentry/SentryLog.cs | 13 +++++++------ 3 files changed, 14 insertions(+), 17 deletions(-) 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 index d88d2db922..efff8ced47 100644 --- 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 @@ -10,5 +10,5 @@ Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores ``` | Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | |--------------------- |---------:|--------:|--------:|-------:|-------:|----------:| -| LogWithoutParameters | 103.1 ns | 0.93 ns | 0.82 ns | 0.0640 | 0.0001 | 536 B | -| LogWithParameters | 243.6 ns | 0.96 ns | 0.85 ns | 0.1087 | - | 912 B | +| 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/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index abc55c9c07..030a406672 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -29,7 +29,9 @@ private protected override void CaptureLog(SentryLogLevel level, string template var timestamp = _clock.GetUtcNow(); _hub.GetTraceIdAndSpanId(out var traceId, out var spanId); - var message = template; + string message; + ImmutableArray> @params; + if (parameters is { Length: > 0 }) { try @@ -41,15 +43,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); return; } - } - else - { - template = null!; - } - ImmutableArray> @params; - if (parameters is { Length: > 0 }) - { var builder = ImmutableArray.CreateBuilder>(parameters.Length); for (var index = 0; index < parameters.Length; index++) { @@ -59,6 +53,8 @@ private protected override void CaptureLog(SentryLogLevel level, string template } else { + message = template; + template = null!; @params = ImmutableArray>.Empty; } diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 315b0a8a56..2f7f60cf68 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; } /// @@ -190,6 +192,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 +226,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) From afebf0a6261658c7825af33b0935b8da143c1d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:50:04 +0200 Subject: [PATCH 6/9] ref: internally enforce no default instance of ImmutableArray --- src/Sentry/SentryLog.cs | 10 +++++++++- test/Sentry.Tests/SentryLogTests.cs | 22 ++++++++++++++++++++++ test/Sentry.Tests/SentryMetricTests.cs | 14 ++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 2f7f60cf68..1e27b2e74a 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -60,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 as .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. 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() { From 5d1493d747cf6b9059f87234ef38f94c076b6378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:52:32 +0200 Subject: [PATCH 7/9] docs: re-prhase comment --- src/Sentry/SentryLog.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 1e27b2e74a..3cb503cdba 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -65,7 +65,7 @@ public ImmutableArray> Parameters get; init { - Debug.Assert(!value.IsDefault); // DEBUG-only check as .ctor is internal and set-accessor is init-only + Debug.Assert(!value.IsDefault); // DEBUG-only check, because .ctor is internal and set-accessor is init-only field = value; } } From ab926d9666360feff146bd689676f0bb31c8ccec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:15:42 +0200 Subject: [PATCH 8/9] docs: add comment --- src/Sentry/Internal/DefaultSentryStructuredLogger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 030a406672..d65f3ca9aa 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -54,7 +54,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template else { message = template; - template = null!; + template = null!; // SentryLog.Template is declared nullable (string?) @params = ImmutableArray>.Empty; } From f355ae94e692986635dfd8ec1ccf3a34287a69ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:56:40 +0200 Subject: [PATCH 9/9] perf: Dispose DefaultSentryStructuredLogger --- benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs b/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs index bc127dd9c5..8343f9ad24 100644 --- a/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs +++ b/benchmarks/Sentry.Benchmarks/SentryStructuredLoggerBenchmarks.cs @@ -49,6 +49,7 @@ public void LogWithParameters() [GlobalCleanup] public void Cleanup() { + (_logger as IDisposable)?.Dispose(); _hub.Dispose(); if (_lastLog is null)