Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 |
Comment on lines +11 to +14
Copy link
Copy Markdown
Member Author

@Flash0ver Flash0ver Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: perf comparison

Before this change:

Method Mean Error StdDev Median Gen0 Allocated
LogWithoutParameters 122.2 ns 2.31 ns 5.87 ns 120.5 ns 0.0696 584 B
LogWithParameters 242.6 ns 0.79 ns 0.74 ns 242.5 ns 0.1087 912 B

After this change:

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

Result:

  • A tiny pessimization in time (IMO neglectable) with params/args
    • one more jump and assignment
  • A notable optimization (a bit of time and 48 bytes in space) without params/args
    • no invocation of System.String.Format

Original file line number Diff line number Diff line change
@@ -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");
}
}
}
27 changes: 17 additions & 10 deletions src/Sentry/Internal/DefaultSentryStructuredLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,33 @@ 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<KeyValuePair<string, object>> @params;

ImmutableArray<KeyValuePair<string, object>> @params = default;
if (parameters is { Length: > 0 })
{
try
{
message = string.Format(CultureInfo.InvariantCulture, template, parameters);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Breaking Change?

This is a behavioral change:

Previously:

System.String.Format was always invoked,
leading SentrySdk.Logger.LogInfo("Text {0} Text", []); (with composite format, but without arguments)
to throwing a System.FormatException,
resulting in this log not being emitted.

With this change:

System.String.Format is only invoked when at least one argument is passed,
leading SentrySdk.Logger.LogInfo("Text {0} Text", []); (with composite format, but without arguments)
to not throwing a System.FormatException,
resulting in this log actually being emitted.

TL;DR

Should e.g. SentrySdk.Logger.LogInfo("Text {0} Text", []);
emit a log or not emit a log?

}
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<KeyValuePair<string, object>>(parameters.Length);
for (var index = 0; index < parameters.Length; index++)
{
builder.Add(new KeyValuePair<string, object>(index.ToString(), parameters[index]));
}
@params = builder.DrainToImmutable();
}
else
{
message = template;
template = null!;
@params = ImmutableArray<KeyValuePair<string, object>>.Empty;
Comment on lines +56 to +58
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Breaking Change?

This improvement is - technically speaking - also a behavioral (breaking?) change, when Logging a plain message without positional arguments, observable by user code through the SetBeforeSendLog callback:

options.SetBeforeSendLog(static (SentryLog log) =>
{
    _ = (log.Message, log.Template, log.Parameters);
    return log;
});

No changes to the resulting serialized payload, though - we have a check in place that does not serialize a sentry.message.template when there is no sentry.message.parameter ... but there are user-code observable changes to the SentryLog.Template and SentryLog.Parameters properties.


when Logging without positional Parameters: e.g. SentrySdk.Logger.LogError("Without parameters");

  • Before
    • log.Template == log.Message
    • log.Parameters.IsDefault == true
  • After
    • log.Template == null
    • log.Parameters.IsDefault == false
  • Justification
    • SentryLog.Template was already declared nullable (string?)
      • no contractual change
      • and this now also mirrors what is actually serialized for the payload
    • the ImmutableArray`1 of Parameters may no longer be default
      • when an ImmutableArray`1 is default, invoking any instance member other than IsDefault or IsDefaultOrEmpty will throw a NullReferenceException, because the underlying Array is null
      • this makes user-callbacks "safer"
      • but also the down-stream sentry-unity SDK

Another benefit of ImmutableArray<KeyValuePair<string, object>> Parameters no longer being default when logging without positional arguments is that we can omit IsDefault and IsDefaultOrEmpty checks in the ISentryJsonSerializable.WriteTo serialization logic, which makes it slightly faster, too.


when Logging with positional Parameters: e.g. SentrySdk.Logger.LogError("With parameters {0} {1} {2}", 1, 2, 3);

  • no observable changes

See also https://develop.sentry.dev/sdk/telemetry/logs/#default-attributes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and a follow-up question:

Shall we note this in the CHANGELOG?

}

SentryLog log = new(timestamp, traceId, level, message)
{
Expand Down
23 changes: 16 additions & 7 deletions src/Sentry/SentryLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SentryAttribute>(7);
// ensure the ImmutableArray`1 is not default, so we can omit IsDefault checks before accessing other members
Parameters = ImmutableArray<KeyValuePair<string, object>>.Empty;
Comment on lines +28 to +29
Copy link
Copy Markdown
Member Author

@Flash0ver Flash0ver Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this ensures that the ImmutableArray`1 of Parameters is never default

In sentry-dotnet, there is currently no code path that would require this, as we always set the instance to at least "Empty".

But in the down-stream sentry-unity, there actually is a code path where it may be default.

With this assignment, we ensure that it's never default, and both us and users can omit IsDefault/IsDefaultOrEmpty checks.

}

/// <summary>
Expand Down Expand Up @@ -58,7 +60,15 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le
/// <summary>
/// The parameters to the template string.
/// </summary>
public ImmutableArray<KeyValuePair<string, object>> Parameters { get; init; }
public ImmutableArray<KeyValuePair<string, object>> Parameters
{
get;
init
{
Debug.Assert(!value.IsDefault); // DEBUG-only check, because .ctor is internal and set-accessor is init-only
field = value;
}
}

/// <summary>
/// The span id of the span that was active when the log was collected.
Expand Down Expand Up @@ -190,6 +200,8 @@ internal void SetOrigin(string origin)

internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
{
Debug.Assert(!Parameters.IsDefault);
Copy link
Copy Markdown
Member Author

@Flash0ver Flash0ver Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: omit default checks

Since we ensured in the internal .ctor, as well as the init-only property, that the ImmutableArray is never default, and with that the underlying array never null, we can omit further IsDefault and IsDefaultOrEmpty checks.


writer.WriteStartObject();

#if NET9_0_OR_GREATER
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions test/Sentry.Tests/SentryLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exception>("Disallow default ImmutableArray");
#endif
}

[Fact]
public void Protocol_Default_VerifyAttributes()
{
Expand Down
14 changes: 14 additions & 0 deletions test/Sentry.Tests/SentryMetricTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ public SentryMetricTests(ITestOutputHelper output)
_output = new TestOutputDiagnosticLogger(output);
}

[Fact]
public void Create_Default_HasMinimalSpecification()
Comment on lines +25 to +26
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I wanted to keep the tests for both Logs and Metrics in sync

{
var metric = new SentryMetric<int>(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()
{
Expand Down
30 changes: 30 additions & 0 deletions test/Sentry.Tests/SentryStructuredLoggerTests.Format.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,36 @@ public void Log_ConfigureLog_Disabled_DoesNotCaptureEnvelope(SentryLogLevel leve

_fixture.Hub.Received(0).CaptureEnvelope(Arg.Any<Envelope>());
}

[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<Envelope>(arg => envelope = arg));

logger.Log(level, "Message Text");
logger.Flush();

_fixture.Hub.Received(1).CaptureEnvelope(Arg.Any<Envelope>());
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
Expand Down
10 changes: 10 additions & 0 deletions test/Sentry.Tests/SentryStructuredLoggerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,14 @@ public static void AssertLog(this SentryStructuredLoggerTests.Fixture fixture, S
{
return new KeyValuePair<string, object?>(name, value);
}

public static SentryLog ShouldContainSingleLog(this Envelope envelope)
{
var envelopeItem = envelope.Items.Should().ContainSingle().Which;
var serializable = envelopeItem.Payload.Should().BeOfType<JsonSerializable>().Which;
var log = serializable.Source.Should().BeOfType<StructuredLog>().Which;

log.Items.Length.Should().Be(1);
return log.Items[0];
}
}
Loading