From 8ec35ea362f6ff40e896859e8ca3297e7b0548f2 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 20 Feb 2026 11:51:06 +1300 Subject: [PATCH 1/3] Refactored SentryAttributes into a separate class --- src/Sentry/Protocol/SentryAttributes.cs | 176 ++++++++++++++++++ src/Sentry/SentryMetric.Factory.cs | 6 +- src/Sentry/SentryMetric.cs | 109 +---------- .../SentryAttributesExtensions.cs | 16 ++ test/Sentry.Tests/SentryMetricTests.cs | 26 +-- 5 files changed, 211 insertions(+), 122 deletions(-) create mode 100644 src/Sentry/Protocol/SentryAttributes.cs create mode 100644 test/Sentry.Testing/SentryAttributesExtensions.cs diff --git a/src/Sentry/Protocol/SentryAttributes.cs b/src/Sentry/Protocol/SentryAttributes.cs new file mode 100644 index 0000000000..8c33e02abc --- /dev/null +++ b/src/Sentry/Protocol/SentryAttributes.cs @@ -0,0 +1,176 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +internal class SentryAttributes : Dictionary, ISentryJsonSerializable +{ + public SentryAttributes() : base(StringComparer.Ordinal) + { + } + + public SentryAttributes(int capacity) : base(capacity, StringComparer.Ordinal) + { + } + + /// + /// Gets the attribute value associated with the specified key. + /// + /// + /// Returns if this contains an attribute with the specified key which is of type and it's value is not . + /// Otherwise . + /// Supported types: + /// + /// + /// Type + /// Range + /// + /// + /// string + /// and + /// + /// + /// boolean + /// and + /// + /// + /// integer + /// 64-bit signed integral numeric types + /// + /// + /// double + /// 64-bit floating-point numeric types + /// + /// + /// Unsupported types: + /// + /// + /// Type + /// Result + /// + /// + /// + /// ToString as "type": "string" + /// + /// + /// Collections + /// ToString as "type": "string" + /// + /// + /// + /// ignored + /// + /// + /// + /// + public bool TryGetAttribute(string key, [MaybeNullWhen(false)] out TAttribute value) + { + if (TryGetValue(key, out var attribute) && attribute.Value is TAttribute attributeValue) + { + value = attributeValue; + return true; + } + + value = default; + return false; + } + + /// + /// Set a key-value pair of data attached to the metric. + /// + public void SetAttribute(string key, TAttribute value) where TAttribute : notnull + { + if (value is null) + { + return; + } + + this[key] = new SentryAttribute(value); + } + + internal void SetAttribute(string key, string value) + { + this[key] = new SentryAttribute(value, "string"); + } + + internal void SetAttribute(string key, char value) + { + this[key] = new SentryAttribute(value.ToString(), "string"); + } + + internal void SetAttribute(string key, int value) + { + this[key] = new SentryAttribute(value, "integer"); + } + + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) + { + var environment = options.SettingLocator.GetEnvironment(); + SetAttribute("sentry.environment", environment); + + var release = options.SettingLocator.GetRelease(); + if (release is not null) + { + SetAttribute("sentry.release", release); + } + + if (sdk.Name is { } name) + { + SetAttribute("sentry.sdk.name", name); + } + if (sdk.Version is { } version) + { + SetAttribute("sentry.sdk.version", version); + } + } + + internal void SetAttributes(IEnumerable>? attributes) + { + if (attributes is null) + { + return; + } + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (attributes.TryGetNonEnumeratedCount(out var count)) + { + _ = EnsureCapacity(Count + count); + } +#endif + + foreach (var attribute in attributes) + { + this[attribute.Key] = new SentryAttribute(attribute.Value); + } + } + + internal void SetAttributes(ReadOnlySpan> attributes) + { + if (attributes.IsEmpty) + { + return; + } + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + _ = EnsureCapacity(Count + attributes.Length); +#endif + + foreach (var attribute in attributes) + { + this[attribute.Key] = new SentryAttribute(attribute.Value); + } + } + + /// + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + + foreach (var attribute in this) + { + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryMetric.Factory.cs b/src/Sentry/SentryMetric.Factory.cs index fe6d0e7b2d..7c9621ce84 100644 --- a/src/Sentry/SentryMetric.Factory.cs +++ b/src/Sentry/SentryMetric.Factory.cs @@ -20,7 +20,7 @@ private static SentryMetric CreateCore(IHub hub, SentryOptions options, IS }; scope ??= hub.GetScope(); - metric.SetDefaultAttributes(options, scope?.Sdk ?? SdkVersion.Instance); + metric.Attributes.SetDefaultAttributes(options, scope?.Sdk ?? SdkVersion.Instance); return metric; } @@ -28,14 +28,14 @@ private static SentryMetric CreateCore(IHub hub, SentryOptions options, IS internal static SentryMetric Create(IHub hub, SentryOptions options, ISystemClock clock, SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct { var metric = CreateCore(hub, options, clock, type, name, value, unit, scope); - metric.SetAttributes(attributes); + metric.Attributes.SetAttributes(attributes); return metric; } internal static SentryMetric Create(IHub hub, SentryOptions options, ISystemClock clock, SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct { var metric = CreateCore(hub, options, clock, type, name, value, unit, scope); - metric.SetAttributes(attributes); + metric.Attributes.SetAttributes(attributes); return metric; } diff --git a/src/Sentry/SentryMetric.cs b/src/Sentry/SentryMetric.cs index ae56f0e019..c74dfaf360 100644 --- a/src/Sentry/SentryMetric.cs +++ b/src/Sentry/SentryMetric.cs @@ -14,8 +14,6 @@ namespace Sentry; [DebuggerDisplay(@"SentryMetric \{ Type = {Type}, Name = '{Name}', Value = {Value} \}")] public abstract partial class SentryMetric { - private readonly Dictionary _attributes; - [SetsRequiredMembers] private protected SentryMetric(DateTimeOffset timestamp, SentryId traceId, SentryMetricType type, string name) { @@ -24,7 +22,7 @@ private protected SentryMetric(DateTimeOffset timestamp, SentryId traceId, Sentr Type = type; Name = name; // 7 is the number of built-in attributes, so we start with that. - _attributes = new Dictionary(7); + Attributes = new SentryAttributes(7); } /// @@ -114,6 +112,8 @@ private protected SentryMetric(DateTimeOffset timestamp, SentryId traceId, Sentr /// public string? Unit { get; init; } + internal SentryAttributes Attributes { get; } + /// /// Gets the metric value if it is of the specified type . /// @@ -174,102 +174,13 @@ private protected SentryMetric(DateTimeOffset timestamp, SentryId traceId, Sentr /// /// public bool TryGetAttribute(string key, [MaybeNullWhen(false)] out TAttribute value) - { - if (_attributes.TryGetValue(key, out var attribute) && attribute.Value is TAttribute attributeValue) - { - value = attributeValue; - return true; - } - - value = default; - return false; - } + => Attributes.TryGetAttribute(key, out value); /// /// Set a key-value pair of data attached to the metric. /// public void SetAttribute(string key, TAttribute value) where TAttribute : notnull - { - if (value is null) - { - return; - } - - _attributes[key] = new SentryAttribute(value); - } - - 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(); - SetAttribute("sentry.environment", environment); - - var release = options.SettingLocator.GetRelease(); - if (release is not null) - { - SetAttribute("sentry.release", release); - } - - if (sdk.Name is { } name) - { - SetAttribute("sentry.sdk.name", name); - } - if (sdk.Version is { } version) - { - SetAttribute("sentry.sdk.version", version); - } - } - - internal void SetAttributes(IEnumerable>? attributes) - { - if (attributes is null) - { - return; - } - -#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER - if (attributes.TryGetNonEnumeratedCount(out var count)) - { - _ = _attributes.EnsureCapacity(_attributes.Count + count); - } -#endif - - foreach (var attribute in attributes) - { - _attributes[attribute.Key] = new SentryAttribute(attribute.Value); - } - } - - internal void SetAttributes(ReadOnlySpan> attributes) - { - if (attributes.IsEmpty) - { - return; - } - -#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER - _ = _attributes.EnsureCapacity(_attributes.Count + attributes.Length); -#endif - - foreach (var attribute in attributes) - { - _attributes[attribute.Key] = new SentryAttribute(attribute.Value); - } - } + => Attributes.SetAttribute(key, value); /// internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) @@ -300,15 +211,7 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteString("unit", Unit); } - writer.WritePropertyName("attributes"); - writer.WriteStartObject(); - - foreach (var attribute in _attributes) - { - SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); - } - - writer.WriteEndObject(); + Attributes.WriteTo(writer, logger); writer.WriteEndObject(); } diff --git a/test/Sentry.Testing/SentryAttributesExtensions.cs b/test/Sentry.Testing/SentryAttributesExtensions.cs new file mode 100644 index 0000000000..2d39c0f011 --- /dev/null +++ b/test/Sentry.Testing/SentryAttributesExtensions.cs @@ -0,0 +1,16 @@ +namespace Sentry.Testing; + +public static class SentryAttributesExtensions +{ + internal static void ShouldContain(this SentryAttributes attributes, string key, T expected) + { + attributes.TryGetAttribute(key, out var value).Should().BeTrue(); + value.Should().Be(expected); + } + + internal static void ShouldNotContain(this SentryAttributes attributes, string key, T expected) + { + attributes.TryGetAttribute(key, out var value).Should().BeFalse(); + value.Should().Be(default(T)); + } +} diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs index 2614c99989..a7b1406277 100644 --- a/test/Sentry.Tests/SentryMetricTests.cs +++ b/test/Sentry.Tests/SentryMetricTests.cs @@ -42,7 +42,7 @@ public void Protocol_Default_VerifyAttributes() Unit = "test_unit", }; metric.SetAttribute("attribute", "value"); - metric.SetDefaultAttributes(options, sdk); + metric.Attributes.SetDefaultAttributes(options, sdk); metric.Timestamp.Should().Be(Timestamp); metric.TraceId.Should().Be(TraceId); @@ -52,18 +52,12 @@ public void Protocol_Default_VerifyAttributes() metric.SpanId.Should().Be(SpanId); metric.Unit.Should().BeEquivalentTo("test_unit"); - metric.TryGetAttribute("attribute", out var attribute).Should().BeTrue(); - attribute.Should().Be("value"); - metric.TryGetAttribute("sentry.environment", out var environment).Should().BeTrue(); - environment.Should().Be(options.Environment); - metric.TryGetAttribute("sentry.release", out var release).Should().BeTrue(); - release.Should().Be(options.Release); - metric.TryGetAttribute("sentry.sdk.name", out var name).Should().BeTrue(); - name.Should().Be(sdk.Name); - metric.TryGetAttribute("sentry.sdk.version", out var version).Should().BeTrue(); - version.Should().Be(sdk.Version); - metric.TryGetAttribute("not-found", out var notFound).Should().BeFalse(); - notFound.Should().BeNull(); + metric.Attributes.ShouldContain("attribute", "value"); + metric.Attributes.ShouldContain("sentry.environment", options.Environment); + metric.Attributes.ShouldContain("sentry.release", options.Release); + metric.Attributes.ShouldContain("sentry.sdk.name", sdk.Name); + metric.Attributes.ShouldContain("sentry.sdk.version", sdk.Version); + metric.Attributes.ShouldNotContain("not-found", "value"); } [Fact] @@ -76,7 +70,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryMetric() }; var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); - metric.SetDefaultAttributes(options, new SdkVersion()); + metric.Attributes.SetDefaultAttributes(options, new SdkVersion()); var envelope = Envelope.FromMetric(new TraceMetric([metric])); @@ -154,7 +148,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryMetric() metric.SetAttribute("boolean-attribute", true); metric.SetAttribute("integer-attribute", 3); metric.SetAttribute("double-attribute", 4.4); - metric.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); + metric.Attributes.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); var envelope = EnvelopeItem.FromMetric(new TraceMetric([metric])); @@ -360,7 +354,7 @@ public void WriteTo_Attributes_AsJson() #else metric.SetAttribute("object", new KeyValuePair("key", "value")); #endif - metric.SetAttribute("null", null!); + metric.Attributes.SetAttribute("null", null!); var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); var attributes = document.RootElement.GetProperty("attributes"); From 012ba110fa7371f78b97fcaf1bd6b0ab94172fc9 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 23 Feb 2026 10:25:23 +1300 Subject: [PATCH 2/3] Review feedback --- test/Sentry.Testing/SentryAttributesExtensions.cs | 2 +- test/Sentry.Tests/SentryMetricTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Testing/SentryAttributesExtensions.cs b/test/Sentry.Testing/SentryAttributesExtensions.cs index 2d39c0f011..d022138c2c 100644 --- a/test/Sentry.Testing/SentryAttributesExtensions.cs +++ b/test/Sentry.Testing/SentryAttributesExtensions.cs @@ -8,7 +8,7 @@ internal static void ShouldContain(this SentryAttributes attributes, string k value.Should().Be(expected); } - internal static void ShouldNotContain(this SentryAttributes attributes, string key, T expected) + internal static void ShouldNotContain(this SentryAttributes attributes, string key) { attributes.TryGetAttribute(key, out var value).Should().BeFalse(); value.Should().Be(default(T)); diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs index a7b1406277..5ca62feea9 100644 --- a/test/Sentry.Tests/SentryMetricTests.cs +++ b/test/Sentry.Tests/SentryMetricTests.cs @@ -57,7 +57,7 @@ public void Protocol_Default_VerifyAttributes() metric.Attributes.ShouldContain("sentry.release", options.Release); metric.Attributes.ShouldContain("sentry.sdk.name", sdk.Name); metric.Attributes.ShouldContain("sentry.sdk.version", sdk.Version); - metric.Attributes.ShouldNotContain("not-found", "value"); + metric.Attributes.ShouldNotContain("not-found"); } [Fact] From e67baee1c88b63e3ee032ccf481f38c39577678b Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 23 Feb 2026 10:58:33 +1300 Subject: [PATCH 3/3] Review feedback --- src/Sentry/Protocol/SentryAttributes.cs | 1 - src/Sentry/SentryMetric.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/Protocol/SentryAttributes.cs b/src/Sentry/Protocol/SentryAttributes.cs index 8c33e02abc..211a3591c5 100644 --- a/src/Sentry/Protocol/SentryAttributes.cs +++ b/src/Sentry/Protocol/SentryAttributes.cs @@ -163,7 +163,6 @@ internal void SetAttributes(ReadOnlySpan> attribute /// public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { - writer.WritePropertyName("attributes"); writer.WriteStartObject(); foreach (var attribute in this) diff --git a/src/Sentry/SentryMetric.cs b/src/Sentry/SentryMetric.cs index c74dfaf360..a9c0214944 100644 --- a/src/Sentry/SentryMetric.cs +++ b/src/Sentry/SentryMetric.cs @@ -211,6 +211,7 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteString("unit", Unit); } + writer.WritePropertyName("attributes"); Attributes.WriteTo(writer, logger); writer.WriteEndObject();