From a4910751dcb1f9efa141cdacc54e2c99b55a3789 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 11 Feb 2026 16:35:48 +1300 Subject: [PATCH 1/9] Added protocol classes --- src/Sentry/Protocol/Envelopes/Envelope.cs | 16 ++ src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 15 ++ src/Sentry/Protocol/SpanV2.cs | 145 ++++++++++++++++++ test/Sentry.Tests/Protocol/SpanV2Tests.cs | 53 +++++++ 4 files changed, 229 insertions(+) create mode 100644 src/Sentry/Protocol/SpanV2.cs create mode 100644 test/Sentry.Tests/Protocol/SpanV2Tests.cs diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index e3e85da713..6a34b02802 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -504,4 +504,20 @@ internal Envelope WithItem(EnvelopeItem item) items.Add(item); return new Envelope(_eventId, Header, items); } + + /// + /// Creates an envelope that contains one or more Span v2 items. + /// + internal static Envelope FromSpans(IReadOnlyCollection spans) + { + var header = DefaultHeader; + + var spanItems = new SpanV2Items(spans); + + var items = spanItems.Length > 0 + ? new List(1) { EnvelopeItem.FromSpans(spanItems) } + : new List(0); + + return new Envelope(header, items); + } } diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 21ab3ba25d..18ea089f8d 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -299,6 +299,21 @@ public static EnvelopeItem FromSession(SessionUpdate sessionUpdate) return new EnvelopeItem(header, new JsonSerializable(sessionUpdate)); } + /// + /// Creates an from one or more . + /// + internal static EnvelopeItem FromSpans(SpanV2Items spans) + { + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueSpan, + ["item_count"] = spans.Length, + ["content_type"] = "application/vnd.sentry.items.span+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(spans)); + } + /// /// Creates an from . /// diff --git a/src/Sentry/Protocol/SpanV2.cs b/src/Sentry/Protocol/SpanV2.cs new file mode 100644 index 0000000000..39a594627e --- /dev/null +++ b/src/Sentry/Protocol/SpanV2.cs @@ -0,0 +1,145 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; +using Sentry.Protocol.Metrics; + +namespace Sentry.Protocol; + +/// +/// Represents a single Span (Span v2 protocol) to be sent in a dedicated span envelope item. +/// +/// +/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ +/// +internal sealed class SpanV2 : ISentryJsonSerializable +{ + public const int MaxSpansPerEnvelope = 100; + + public SpanV2( + SentryId traceId, + SpanId spanId, + string operation, + DateTimeOffset startTimestamp) + { + TraceId = traceId; + SpanId = spanId; + Operation = operation; + StartTimestamp = startTimestamp; + } + + public SentryId TraceId { get; } + public SpanId SpanId { get; } + public SpanId? ParentSpanId { get; set; } + + /// + /// The span operation. + /// + public string Operation { get; set; } + + public string? Description { get; set; } + public SpanStatus? Status { get; set; } + + public DateTimeOffset StartTimestamp { get; } + public DateTimeOffset? EndTimestamp { get; set; } + + public string? Origin { get; set; } + + public string? SegmentId { get; set; } + + public bool? IsSampled { get; set; } + + private Dictionary? _tags; + public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); + + private Dictionary? _data; + public IReadOnlyDictionary Data => _data ??= new Dictionary(); + + private Dictionary? _measurements; + public IReadOnlyDictionary Measurements => _measurements ??= new Dictionary(); + + private MetricsSummary? _metricsSummary; + + public static SpanV2 FromSpan(ISpan span) => new(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) + { + ParentSpanId = span.ParentSpanId, + Description = span.Description, + Status = span.Status, + EndTimestamp = span.EndTimestamp, + Origin = span.Origin, + IsSampled = span.IsSampled, + SegmentId = null, // reserved for future SDK behavior + _tags = span.Tags.ToDict(), + _data = span.Data.ToDict(), + _measurements = span.Measurements.ToDict(), + }; + + public void SetTag(string key, string value) => (_tags ??= new Dictionary())[key] = value; + public void SetData(string key, object? value) => (_data ??= new Dictionary())[key] = value; + public void SetMeasurement(string name, Measurement measurement) => (_measurements ??= new Dictionary())[name] = measurement; + internal void SetMetricsSummary(MetricsSummary summary) => _metricsSummary = summary; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteSerializable("trace_id", TraceId, logger); + writer.WriteSerializable("span_id", SpanId, logger); + writer.WriteSerializableIfNotNull("parent_span_id", ParentSpanId, logger); + + writer.WriteStringIfNotWhiteSpace("op", Operation); + writer.WriteStringIfNotWhiteSpace("description", Description); + writer.WriteStringIfNotWhiteSpace("status", Status?.ToString().ToSnakeCase()); + + // Span v2 uses the same timestamp format as other payloads in this SDK. + writer.WriteString("start_timestamp", StartTimestamp); + writer.WriteStringIfNotNull("timestamp", EndTimestamp); + + writer.WriteStringIfNotWhiteSpace("origin", Origin); + writer.WriteStringIfNotWhiteSpace("segment_id", SegmentId); + + if (IsSampled is { } sampled) + { + writer.WriteBoolean("sampled", sampled); + } + + writer.WriteStringDictionaryIfNotEmpty("tags", _tags!); + writer.WriteDictionaryIfNotEmpty("data", _data!, logger); + writer.WriteDictionaryIfNotEmpty("measurements", _measurements, logger); + writer.WriteSerializableIfNotNull("_metrics_summary", _metricsSummary, logger); + + writer.WriteEndObject(); + } +} + +/// +/// Span v2 envelope item payload. +/// +/// +/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ +/// +internal sealed class SpanV2Items : ISentryJsonSerializable +{ + private readonly IReadOnlyCollection _spans; + + public SpanV2Items(IReadOnlyCollection spans) + { + _spans = (spans.Count > SpanV2.MaxSpansPerEnvelope) + ? [..spans.Take(SpanV2.MaxSpansPerEnvelope)] + : spans; + } + + public int Length => _spans.Count; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + + foreach (var span in _spans) + { + span.WriteTo(writer, logger); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/test/Sentry.Tests/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs new file mode 100644 index 0000000000..e8cc1b8b77 --- /dev/null +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -0,0 +1,53 @@ +using Sentry.Protocol.Spans; + +namespace Sentry.Tests.Protocol.Envelopes; + +public class SpanV2EnvelopeTests +{ + [Fact] + public async Task EnvelopeItem_FromSpanV2_SerializesHeaderWithContentTypeAndItemCount() + { + var span = new SpanV2(SentryId.Parse("0123456789abcdef0123456789abcdef"), SpanId.Parse("0123456789abcdef"), "db", DateTimeOffset.Parse("2020-01-01T00:00:00Z")) + { + Description = "select 1", + Status = SpanStatus.Ok, + EndTimestamp = DateTimeOffset.Parse("2020-01-01T00:00:01Z"), + }; + + using var envelopeItem = EnvelopeItem.FromSpanV2(span); + + using var stream = new MemoryStream(); + await envelopeItem.SerializeAsync(stream, null); + stream.Position = 0; + using var reader = new StreamReader(stream); + var output = await reader.ReadToEndAsync(); + + var firstLine = output.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0]; + firstLine.Should().Contain("\"type\":\"span_v2\""); + firstLine.Should().Contain("\"item_count\":1"); + firstLine.Should().Contain("\"content_type\":\"application/vnd.sentry.items.span\\u002Bjson\""); + } + + [Fact] + public void Envelope_FromSpanV2_ThrowsWhenMoreThan100Spans() + { + var spans = Enumerable.Range(0, 101).Select(_ => + new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)); + + Action act = () => Envelope.FromSpanV2(spans); + + act.Should().Throw(); + } + + [Fact] + public void Envelope_FromSpanV2_CreatesUpTo100Items() + { + var spans = Enumerable.Range(0, 100).Select(_ => + new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)).ToList(); + + using var envelope = Envelope.FromSpanV2(spans); + + envelope.Items.Should().HaveCount(100); + envelope.Items.All(i => i.TryGetType() == "span_v2").Should().BeTrue(); + } +} From fc0cebc0d38687b22493c5786d689c680c27d41c Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 11 Feb 2026 16:47:21 +1300 Subject: [PATCH 2/9] Unit tests --- test/Sentry.Tests/Protocol/SpanV2Tests.cs | 38 ++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/test/Sentry.Tests/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs index e8cc1b8b77..104b04f663 100644 --- a/test/Sentry.Tests/Protocol/SpanV2Tests.cs +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -1,11 +1,9 @@ -using Sentry.Protocol.Spans; +namespace Sentry.Tests.Protocol; -namespace Sentry.Tests.Protocol.Envelopes; - -public class SpanV2EnvelopeTests +public class SpanV2Tests { [Fact] - public async Task EnvelopeItem_FromSpanV2_SerializesHeaderWithContentTypeAndItemCount() + public async Task EnvelopeItem_FromSpans_SerializesHeaderWithContentTypeAndItemCount() { var span = new SpanV2(SentryId.Parse("0123456789abcdef0123456789abcdef"), SpanId.Parse("0123456789abcdef"), "db", DateTimeOffset.Parse("2020-01-01T00:00:00Z")) { @@ -14,7 +12,8 @@ public async Task EnvelopeItem_FromSpanV2_SerializesHeaderWithContentTypeAndItem EndTimestamp = DateTimeOffset.Parse("2020-01-01T00:00:01Z"), }; - using var envelopeItem = EnvelopeItem.FromSpanV2(span); + var spanItems = new SpanV2Items([span]); + using var envelopeItem = EnvelopeItem.FromSpans(spanItems); using var stream = new MemoryStream(); await envelopeItem.SerializeAsync(stream, null); @@ -23,31 +22,34 @@ public async Task EnvelopeItem_FromSpanV2_SerializesHeaderWithContentTypeAndItem var output = await reader.ReadToEndAsync(); var firstLine = output.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0]; - firstLine.Should().Contain("\"type\":\"span_v2\""); + firstLine.Should().Contain("\"type\":\"span\""); firstLine.Should().Contain("\"item_count\":1"); firstLine.Should().Contain("\"content_type\":\"application/vnd.sentry.items.span\\u002Bjson\""); } [Fact] - public void Envelope_FromSpanV2_ThrowsWhenMoreThan100Spans() + public void Envelope_FromSpans_CreatesSingleItem() { - var spans = Enumerable.Range(0, 101).Select(_ => - new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)); + SpanV2[] spans = [new(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)]; - Action act = () => Envelope.FromSpanV2(spans); + using var envelope = Envelope.FromSpans(spans); - act.Should().Throw(); + envelope.Items.Should().HaveCount(1); + envelope.Items[0].TryGetType().Should().Be("span"); + envelope.Items[0].Header.GetValueOrDefault("item_count").Should().Be(1); } [Fact] - public void Envelope_FromSpanV2_CreatesUpTo100Items() + public void Envelope_FromSpan_RespectsMaxSpans() { - var spans = Enumerable.Range(0, 100).Select(_ => - new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)).ToList(); + var spans = Enumerable.Range(0, SpanV2.MaxSpansPerEnvelope + 10) + .Select(_ => new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)) + .ToArray(); - using var envelope = Envelope.FromSpanV2(spans); + using var envelope = Envelope.FromSpans(spans); - envelope.Items.Should().HaveCount(100); - envelope.Items.All(i => i.TryGetType() == "span_v2").Should().BeTrue(); + envelope.Items.Should().HaveCount(1); + envelope.Items[0].TryGetType().Should().Be("span"); + envelope.Items[0].Header.GetValueOrDefault("item_count").Should().Be(SpanV2.MaxSpansPerEnvelope); } } From 6a023d08476090b92e55e6f58d2c7173a13bdf2a Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 11 Feb 2026 04:06:42 +0000 Subject: [PATCH 3/9] Format code --- src/Sentry/Protocol/SpanV2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry/Protocol/SpanV2.cs b/src/Sentry/Protocol/SpanV2.cs index 39a594627e..d7cf92bb0c 100644 --- a/src/Sentry/Protocol/SpanV2.cs +++ b/src/Sentry/Protocol/SpanV2.cs @@ -123,7 +123,7 @@ internal sealed class SpanV2Items : ISentryJsonSerializable public SpanV2Items(IReadOnlyCollection spans) { _spans = (spans.Count > SpanV2.MaxSpansPerEnvelope) - ? [..spans.Take(SpanV2.MaxSpansPerEnvelope)] + ? [.. spans.Take(SpanV2.MaxSpansPerEnvelope)] : spans; } From 3baa855cb57e05a0ca7b8b4391b88313793e9758 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 16 Feb 2026 12:10:27 +1300 Subject: [PATCH 4/9] Added TraceLifeCycle to SentryOptions --- src/Sentry/BindableSentryOptions.cs | 2 ++ src/Sentry/SentryOptions.cs | 10 ++++++++++ src/Sentry/TraceLifeCycle.cs | 16 ++++++++++++++++ .../ApiApprovalTests.Run.DotNet10_0.verified.txt | 6 ++++++ .../ApiApprovalTests.Run.DotNet8_0.verified.txt | 6 ++++++ .../ApiApprovalTests.Run.DotNet9_0.verified.txt | 6 ++++++ 6 files changed, 46 insertions(+) create mode 100644 src/Sentry/TraceLifeCycle.cs diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 52abdf7df9..0aa5b10d97 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -41,6 +41,7 @@ internal partial class BindableSentryOptions public TimeSpan? InitCacheFlushTimeout { get; set; } public Dictionary? DefaultTags { get; set; } public bool? EnableTracing { get; set; } + public TraceLifeCycle? TraceLifeCycle { get; set; } public double? TracesSampleRate { get; set; } public List? TracePropagationTargets { get; set; } public bool? PropagateTraceparent { get; set; } @@ -93,6 +94,7 @@ public void ApplyTo(SentryOptions options) options.DisableFileWrite = DisableFileWrite ?? options.DisableFileWrite; options.InitCacheFlushTimeout = InitCacheFlushTimeout ?? options.InitCacheFlushTimeout; options.DefaultTags = DefaultTags ?? options.DefaultTags; + options.TraceLifeCycle = TraceLifeCycle ?? options.TraceLifeCycle; options.TracesSampleRate = TracesSampleRate ?? options.TracesSampleRate; options.ProfilesSampleRate = ProfilesSampleRate ?? options.ProfilesSampleRate; options.TracePropagationTargets = TracePropagationTargets?.Select(s => new StringOrRegex(s)).ToList() ?? options.TracePropagationTargets; diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 7fc69a600f..8cce487d05 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1156,6 +1156,16 @@ public StackTraceMode StackTraceMode /// internal Instrumenter Instrumenter { get; set; } = Instrumenter.Sentry; + /// + /// + /// Determines the used to send spans to Sentry. + /// + /// + /// Defaults to + /// + /// + public TraceLifeCycle TraceLifeCycle { get; set; } = TraceLifeCycle.Static; + /// /// /// Set to `true` to prevents Sentry from automatically registering . diff --git a/src/Sentry/TraceLifeCycle.cs b/src/Sentry/TraceLifeCycle.cs new file mode 100644 index 0000000000..4835f09152 --- /dev/null +++ b/src/Sentry/TraceLifeCycle.cs @@ -0,0 +1,16 @@ +namespace Sentry; + +/// +/// Describes how spans should be sent to Sentry. +/// +public enum TraceLifeCycle +{ + /// + /// Spans are sent in a + /// + Static, + /// + /// Spans are sent streamed to Sentry as they are finished. + /// + Stream +} diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 64869d4587..96577aecbc 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -839,6 +839,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public Sentry.TraceLifeCycle TraceLifeCycle { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } @@ -1327,6 +1328,11 @@ namespace Sentry public static Sentry.StringOrRegex op_Implicit(System.Text.RegularExpressions.Regex regex) { } public static Sentry.StringOrRegex op_Implicit(string stringOrRegex) { } } + public enum TraceLifeCycle + { + Static = 0, + Stream = 1, + } public class TransactionContext : Sentry.SpanContext, Sentry.ITransactionContext, Sentry.Protocol.ITraceContext { public TransactionContext(string name, string operation, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, Sentry.SentryId? traceId = default, string? description = "", Sentry.SpanStatus? status = default, bool? isSampled = default, bool? isParentSampled = default, Sentry.TransactionNameSource nameSource = 0) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 64869d4587..96577aecbc 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -839,6 +839,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public Sentry.TraceLifeCycle TraceLifeCycle { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } @@ -1327,6 +1328,11 @@ namespace Sentry public static Sentry.StringOrRegex op_Implicit(System.Text.RegularExpressions.Regex regex) { } public static Sentry.StringOrRegex op_Implicit(string stringOrRegex) { } } + public enum TraceLifeCycle + { + Static = 0, + Stream = 1, + } public class TransactionContext : Sentry.SpanContext, Sentry.ITransactionContext, Sentry.Protocol.ITraceContext { public TransactionContext(string name, string operation, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, Sentry.SentryId? traceId = default, string? description = "", Sentry.SpanStatus? status = default, bool? isSampled = default, bool? isParentSampled = default, Sentry.TransactionNameSource nameSource = 0) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 64869d4587..96577aecbc 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -839,6 +839,7 @@ namespace Sentry public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } + public Sentry.TraceLifeCycle TraceLifeCycle { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } public System.Func? TracesSampler { get; set; } @@ -1327,6 +1328,11 @@ namespace Sentry public static Sentry.StringOrRegex op_Implicit(System.Text.RegularExpressions.Regex regex) { } public static Sentry.StringOrRegex op_Implicit(string stringOrRegex) { } } + public enum TraceLifeCycle + { + Static = 0, + Stream = 1, + } public class TransactionContext : Sentry.SpanContext, Sentry.ITransactionContext, Sentry.Protocol.ITraceContext { public TransactionContext(string name, string operation, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, Sentry.SentryId? traceId = default, string? description = "", Sentry.SpanStatus? status = default, bool? isSampled = default, bool? isParentSampled = default, Sentry.TransactionNameSource nameSource = 0) { } From 1ed499ea31bece65a9b143b57f07bf33dd86d23f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Mon, 16 Feb 2026 13:35:42 +1300 Subject: [PATCH 5/9] Use traceLifeCycle in SentryClient --- .../Extensions/SentryTransactionExtensions.cs | 20 ++++++++++ .../Internal/Extensions/SpanV2Extensions.cs | 29 +++++++++++++++ src/Sentry/Protocol/SpanV2.cs | 37 ++++++++++++++++++- src/Sentry/Sentry.csproj | 4 ++ src/Sentry/SentryClient.cs | 27 +++++++++++++- 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs create mode 100644 src/Sentry/Internal/Extensions/SpanV2Extensions.cs diff --git a/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs new file mode 100644 index 0000000000..2ac12ece22 --- /dev/null +++ b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs @@ -0,0 +1,20 @@ +using Sentry.Protocol; + +namespace Sentry.Internal.Extensions; + +internal static class SentryTransactionExtensions +{ + /// + /// Allows us to convert a Transaction and its chiled spans to the new SpanV2 format during the transition period. + /// This is temporary - we can remove it once transactions have been deprecated. + /// + public static IEnumerable ToSpanV2Spans(this SentryTransaction transaction) + { + // Collect spans: transaction span + child spans. + yield return new SpanV2(transaction); + foreach (var span in transaction.Spans) + { + yield return new SpanV2(span); + } + } +} diff --git a/src/Sentry/Internal/Extensions/SpanV2Extensions.cs b/src/Sentry/Internal/Extensions/SpanV2Extensions.cs new file mode 100644 index 0000000000..dbb8d7f711 --- /dev/null +++ b/src/Sentry/Internal/Extensions/SpanV2Extensions.cs @@ -0,0 +1,29 @@ +using Sentry.Protocol; + +namespace Sentry.Internal.Extensions; + +internal static class SpanV2Extensions +{ + /// + /// Quick and dirty batching mechanism to ensure we don't exceed the maximum number of spans per envelope when + /// sending SpanV2s them to Sentry. This is temporary - we'll remove it once we implement a Span Buffer or a + /// Telemetry Processor. + /// + public static IEnumerable> QuickBatch(this IEnumerable spans) + { + var batch = new List(SpanV2.MaxSpansPerEnvelope); + foreach (var span in spans) + { + batch.Add(span); + if (batch.Count == SpanV2.MaxSpansPerEnvelope) + { + yield return batch; + batch = new List(SpanV2.MaxSpansPerEnvelope); + } + } + if (batch.Count > 0) + { + yield return batch; + } + } +} diff --git a/src/Sentry/Protocol/SpanV2.cs b/src/Sentry/Protocol/SpanV2.cs index d7cf92bb0c..c80fd123db 100644 --- a/src/Sentry/Protocol/SpanV2.cs +++ b/src/Sentry/Protocol/SpanV2.cs @@ -12,7 +12,7 @@ namespace Sentry.Protocol; /// internal sealed class SpanV2 : ISentryJsonSerializable { - public const int MaxSpansPerEnvelope = 100; + public const int MaxSpansPerEnvelope = 1000; public SpanV2( SentryId traceId, @@ -26,6 +26,41 @@ public SpanV2( StartTimestamp = startTimestamp; } + /// + /// Converts a to a . + /// + /// This is a temporary method. We can remove it once transactions have been deprecated + internal SpanV2(SentryTransaction transaction) : this(transaction.TraceId, transaction.SpanId, + transaction.Operation, transaction.StartTimestamp) + { + ParentSpanId = transaction.ParentSpanId; + Description = transaction.Name; + Status = transaction.Status; + EndTimestamp = transaction.EndTimestamp; + Origin = transaction.Origin; + IsSampled = transaction.IsSampled; + _tags = transaction.Tags.ToDict(); + _data = transaction.Data.ToDict(); + _measurements = transaction.Measurements.ToDict(); + } + + /// + /// Converts a to a . + /// + /// This is a temporary method. We can remove it once transactions have been deprecated + internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) + { + ParentSpanId = span.ParentSpanId; + Description = span.Description; + Status = span.Status; + EndTimestamp = span.EndTimestamp; + Origin = span.Origin; + IsSampled = span.IsSampled; + _tags = span.Tags.ToDict(); + _data = span.Data.ToDict(); + _measurements = span.Measurements.ToDict(); + } + public SentryId TraceId { get; } public SpanId SpanId { get; } public SpanId? ParentSpanId { get; set; } diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index a2b75db0ae..245bc25bac 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -191,6 +191,10 @@ PrivateAssets="all" /> + + + + $(BeforePack);PackSentryCompilerExtensions diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 905070861e..56f3ea4cac 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -1,5 +1,7 @@ using Sentry.Extensibility; using Sentry.Internal; +using Sentry.Internal.Extensions; +using Sentry.Protocol; using Sentry.Protocol.Envelopes; namespace Sentry; @@ -207,7 +209,30 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent processedTransaction.Redact(); } - CaptureEnvelope(Envelope.FromTransaction(processedTransaction)); + if (_options.TraceLifeCycle is TraceLifeCycle.Static) + { + CaptureEnvelope(Envelope.FromTransaction(processedTransaction)); + } + else + { + CaptureSpansV2(processedTransaction); + } + } + + /// + /// Sends a SentryTransaction as SpanV2 envelopes when TraceLifeCycle is set to . + /// + /// + internal void CaptureSpansV2(SentryTransaction transaction) + { + // Span-first approach: send spans as Span v2 envelopes. + // Docs: https://develop.sentry.dev/sdk/telemetry/spans/implementation/ + // TODO: Span Attachments: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#span-attachments + foreach (var batch in transaction.ToSpanV2Spans().QuickBatch()) + { + using var envelope = Envelope.FromSpans(batch); + CaptureEnvelope(envelope); + } } #if NET6_0_OR_GREATER From c36fb39525c2454d6fbcd866d36ab4ca5778a7c3 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 17 Feb 2026 13:17:10 +1300 Subject: [PATCH 6/9] Added logic to convert from static spans --- .../Extensions/SentryTransactionExtensions.cs | 2 +- src/Sentry/Protocol/Envelopes/Envelope.cs | 1 + src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 1 + src/Sentry/Protocol/SentryAttributes.cs | 176 +++++++++++++++++ src/Sentry/Protocol/SpanV2.cs | 180 ------------------ src/Sentry/Protocol/Spans/SentryLink.cs | 31 +++ src/Sentry/Protocol/Spans/SpanV2.cs | 87 +++++++++ src/Sentry/Protocol/Spans/SpanV2Attributes.cs | 58 ++++++ .../Spans}/SpanV2Extensions.cs | 4 +- src/Sentry/Protocol/Spans/SpanV2Items.cs | 37 ++++ src/Sentry/SentryClient.cs | 2 +- src/Sentry/SentryMetric.Factory.cs | 6 +- src/Sentry/SentryMetric.cs | 109 +---------- src/Sentry/SentryTransaction.cs | 1 - src/Sentry/SpanV2Status.cs | 16 ++ .../SentryAttributesExtensions.cs | 10 + test/Sentry.Tests/Protocol/SpanV2Tests.cs | 119 +++++++++++- test/Sentry.Tests/SentryMetricTests.cs | 8 +- 18 files changed, 550 insertions(+), 298 deletions(-) create mode 100644 src/Sentry/Protocol/SentryAttributes.cs delete mode 100644 src/Sentry/Protocol/SpanV2.cs create mode 100644 src/Sentry/Protocol/Spans/SentryLink.cs create mode 100644 src/Sentry/Protocol/Spans/SpanV2.cs create mode 100644 src/Sentry/Protocol/Spans/SpanV2Attributes.cs rename src/Sentry/{Internal/Extensions => Protocol/Spans}/SpanV2Extensions.cs (93%) create mode 100644 src/Sentry/Protocol/Spans/SpanV2Items.cs create mode 100644 src/Sentry/SpanV2Status.cs create mode 100644 test/Sentry.Testing/SentryAttributesExtensions.cs diff --git a/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs index 2ac12ece22..3d4e12c2a9 100644 --- a/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs +++ b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs @@ -1,4 +1,4 @@ -using Sentry.Protocol; +using Sentry.Protocol.Spans; namespace Sentry.Internal.Extensions; diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 6a34b02802..7714a4805a 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -3,6 +3,7 @@ using Sentry.Internal; using Sentry.Internal.Extensions; using Sentry.Protocol.Metrics; +using Sentry.Protocol.Spans; namespace Sentry.Protocol.Envelopes; diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 18ea089f8d..1336d885c0 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -2,6 +2,7 @@ using Sentry.Internal; using Sentry.Internal.Extensions; using Sentry.Protocol.Metrics; +using Sentry.Protocol.Spans; namespace Sentry.Protocol.Envelopes; 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/Protocol/SpanV2.cs b/src/Sentry/Protocol/SpanV2.cs deleted file mode 100644 index c80fd123db..0000000000 --- a/src/Sentry/Protocol/SpanV2.cs +++ /dev/null @@ -1,180 +0,0 @@ -using Sentry.Extensibility; -using Sentry.Internal.Extensions; -using Sentry.Protocol.Metrics; - -namespace Sentry.Protocol; - -/// -/// Represents a single Span (Span v2 protocol) to be sent in a dedicated span envelope item. -/// -/// -/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ -/// -internal sealed class SpanV2 : ISentryJsonSerializable -{ - public const int MaxSpansPerEnvelope = 1000; - - public SpanV2( - SentryId traceId, - SpanId spanId, - string operation, - DateTimeOffset startTimestamp) - { - TraceId = traceId; - SpanId = spanId; - Operation = operation; - StartTimestamp = startTimestamp; - } - - /// - /// Converts a to a . - /// - /// This is a temporary method. We can remove it once transactions have been deprecated - internal SpanV2(SentryTransaction transaction) : this(transaction.TraceId, transaction.SpanId, - transaction.Operation, transaction.StartTimestamp) - { - ParentSpanId = transaction.ParentSpanId; - Description = transaction.Name; - Status = transaction.Status; - EndTimestamp = transaction.EndTimestamp; - Origin = transaction.Origin; - IsSampled = transaction.IsSampled; - _tags = transaction.Tags.ToDict(); - _data = transaction.Data.ToDict(); - _measurements = transaction.Measurements.ToDict(); - } - - /// - /// Converts a to a . - /// - /// This is a temporary method. We can remove it once transactions have been deprecated - internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) - { - ParentSpanId = span.ParentSpanId; - Description = span.Description; - Status = span.Status; - EndTimestamp = span.EndTimestamp; - Origin = span.Origin; - IsSampled = span.IsSampled; - _tags = span.Tags.ToDict(); - _data = span.Data.ToDict(); - _measurements = span.Measurements.ToDict(); - } - - public SentryId TraceId { get; } - public SpanId SpanId { get; } - public SpanId? ParentSpanId { get; set; } - - /// - /// The span operation. - /// - public string Operation { get; set; } - - public string? Description { get; set; } - public SpanStatus? Status { get; set; } - - public DateTimeOffset StartTimestamp { get; } - public DateTimeOffset? EndTimestamp { get; set; } - - public string? Origin { get; set; } - - public string? SegmentId { get; set; } - - public bool? IsSampled { get; set; } - - private Dictionary? _tags; - public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); - - private Dictionary? _data; - public IReadOnlyDictionary Data => _data ??= new Dictionary(); - - private Dictionary? _measurements; - public IReadOnlyDictionary Measurements => _measurements ??= new Dictionary(); - - private MetricsSummary? _metricsSummary; - - public static SpanV2 FromSpan(ISpan span) => new(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) - { - ParentSpanId = span.ParentSpanId, - Description = span.Description, - Status = span.Status, - EndTimestamp = span.EndTimestamp, - Origin = span.Origin, - IsSampled = span.IsSampled, - SegmentId = null, // reserved for future SDK behavior - _tags = span.Tags.ToDict(), - _data = span.Data.ToDict(), - _measurements = span.Measurements.ToDict(), - }; - - public void SetTag(string key, string value) => (_tags ??= new Dictionary())[key] = value; - public void SetData(string key, object? value) => (_data ??= new Dictionary())[key] = value; - public void SetMeasurement(string name, Measurement measurement) => (_measurements ??= new Dictionary())[name] = measurement; - internal void SetMetricsSummary(MetricsSummary summary) => _metricsSummary = summary; - - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) - { - writer.WriteStartObject(); - - writer.WriteSerializable("trace_id", TraceId, logger); - writer.WriteSerializable("span_id", SpanId, logger); - writer.WriteSerializableIfNotNull("parent_span_id", ParentSpanId, logger); - - writer.WriteStringIfNotWhiteSpace("op", Operation); - writer.WriteStringIfNotWhiteSpace("description", Description); - writer.WriteStringIfNotWhiteSpace("status", Status?.ToString().ToSnakeCase()); - - // Span v2 uses the same timestamp format as other payloads in this SDK. - writer.WriteString("start_timestamp", StartTimestamp); - writer.WriteStringIfNotNull("timestamp", EndTimestamp); - - writer.WriteStringIfNotWhiteSpace("origin", Origin); - writer.WriteStringIfNotWhiteSpace("segment_id", SegmentId); - - if (IsSampled is { } sampled) - { - writer.WriteBoolean("sampled", sampled); - } - - writer.WriteStringDictionaryIfNotEmpty("tags", _tags!); - writer.WriteDictionaryIfNotEmpty("data", _data!, logger); - writer.WriteDictionaryIfNotEmpty("measurements", _measurements, logger); - writer.WriteSerializableIfNotNull("_metrics_summary", _metricsSummary, logger); - - writer.WriteEndObject(); - } -} - -/// -/// Span v2 envelope item payload. -/// -/// -/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ -/// -internal sealed class SpanV2Items : ISentryJsonSerializable -{ - private readonly IReadOnlyCollection _spans; - - public SpanV2Items(IReadOnlyCollection spans) - { - _spans = (spans.Count > SpanV2.MaxSpansPerEnvelope) - ? [.. spans.Take(SpanV2.MaxSpansPerEnvelope)] - : spans; - } - - public int Length => _spans.Count; - - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) - { - writer.WriteStartObject(); - writer.WriteStartArray("items"); - - foreach (var span in _spans) - { - span.WriteTo(writer, logger); - } - - writer.WriteEndArray(); - writer.WriteEndObject(); - } -} diff --git a/src/Sentry/Protocol/Spans/SentryLink.cs b/src/Sentry/Protocol/Spans/SentryLink.cs new file mode 100644 index 0000000000..22d27b4159 --- /dev/null +++ b/src/Sentry/Protocol/Spans/SentryLink.cs @@ -0,0 +1,31 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol.Spans; + +/// +/// Links connect spans to other spans or traces, enabling distributed tracing +/// +internal readonly struct SentryLink(SentryId traceId, SpanId spanId, bool sampled) : ISentryJsonSerializable +{ + private readonly SentryAttributes _attributes = new (); + + public SpanId SpanId { get; } = spanId; + public SentryId TraceId { get; } = traceId; + public bool Sampled { get; } = sampled; + public IReadOnlyDictionary Attributes => _attributes; + + /// + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteSerializableIfNotNull("span_id", SpanId.NullIfDefault(), logger); + writer.WriteSerializableIfNotNull("trace_id", TraceId.NullIfDefault(), logger); + writer.WriteBoolean("sampled", Sampled); + + _attributes.WriteTo(writer, logger); + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Protocol/Spans/SpanV2.cs b/src/Sentry/Protocol/Spans/SpanV2.cs new file mode 100644 index 0000000000..5b0f52daa6 --- /dev/null +++ b/src/Sentry/Protocol/Spans/SpanV2.cs @@ -0,0 +1,87 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol.Spans; + +/// +/// Represents a single Span (Span v2 protocol) to be sent in a dedicated span envelope item. +/// +/// +/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ +/// +internal sealed class SpanV2 : ISentryJsonSerializable +{ + public const int MaxSpansPerEnvelope = 1000; + + private readonly SentryAttributes _attributes = new (); + + public SpanV2( + SentryId traceId, + SpanId spanId, + string name, + DateTimeOffset startTimestamp) + { + TraceId = traceId; + SpanId = spanId; + Name = name; + StartTimestamp = startTimestamp; + } + + /// + /// Converts a to a . + /// + /// This is a temporary method. We can remove it once transactions have been deprecated + internal SpanV2(SentryTransaction transaction) : this(transaction.TraceId, transaction.SpanId, + transaction.Name, transaction.StartTimestamp) + { + ParentSpanId = transaction.ParentSpanId; + EndTimestamp = transaction.EndTimestamp; + } + + /// + /// Converts a to a . + /// + /// This is a temporary method. We can remove it once transactions have been deprecated + internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) + { + ParentSpanId = span.ParentSpanId; + EndTimestamp = span.EndTimestamp; + } + + public SentryId TraceId { get; } + public SpanId SpanId { get; } + public SpanId? ParentSpanId { get; set; } + public string Name { get; set; } + public SpanV2Status Status { get; set; } + public bool IsSegment { get; set; } + public DateTimeOffset StartTimestamp { get; } + public DateTimeOffset? EndTimestamp { get; set; } + + public SentryAttributes Attributes => _attributes; + public List Links { get;} = []; + + // TODO: Attachments - see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#span-attachments + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + writer.WriteSerializable("trace_id", TraceId, logger); + writer.WriteSerializable("span_id", SpanId, logger); + writer.WriteSerializableIfNotNull("parent_span_id", ParentSpanId, logger); + writer.WriteStringIfNotWhiteSpace("status", Status.ToString().ToSnakeCase()); + writer.WriteString("start_timestamp", StartTimestamp); + writer.WriteStringIfNotNull("timestamp", EndTimestamp); + + _attributes.WriteTo(writer, logger); + + writer.WriteStartArray("links"); + foreach (var link in Links) + { + link.WriteTo(writer, logger); + } + writer.WriteEndArray(); + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Protocol/Spans/SpanV2Attributes.cs b/src/Sentry/Protocol/Spans/SpanV2Attributes.cs new file mode 100644 index 0000000000..4a07feacbb --- /dev/null +++ b/src/Sentry/Protocol/Spans/SpanV2Attributes.cs @@ -0,0 +1,58 @@ +namespace Sentry.Protocol.Spans; + +internal static class SpanV2Attributes +{ + /// The span op (e.g., "http.client", "db.query") of the span + public const string Operation = "sentry.op"; + + /// The release version of the application + public const string Release = "sentry.release"; + + /// The environment name (e.g., "production", "staging", "development") + public const string Environment = "sentry.environment"; + + /// The segment name (e.g., "GET /users") + public const string SegmentName = "sentry.segment.name"; + + /// The segment span id + public const string SegmentId = "sentry.segment.id"; + + /// The source of the span name. MUST be set on segment spans, MAY be set on child spans. + public const string Source = "sentry.span.source"; + + /// The id of the currently running profiler (continuous profiling) + public const string ProfilerId = "sentry.profiler_id"; + + /// The id of the currently running replay (if available) + public const string ReplayId = "sentry.replay_id"; + + /// The operating system name (e.g., "Linux", "Windows", "macOS") + public const string OSName = "os.name"; + + /// The browser name (e.g., "Chrome", "Firefox", "Safari") + public const string BrowserName = "browser.name"; + + /// The user ID (gated by sendDefaultPii) + public const string UserId = "user.id"; + + /// The user email (gated by sendDefaultPii) + public const string UserEmail = "user.email"; + + /// The user IP address (gated by sendDefaultPii) + public const string UserIpAddress = "user.ip_address"; + + /// The user username (gated by sendDefaultPii) + public const string UserName = "user.name"; + + /// The thread ID + public const string ThreadId = "thread.id"; + + /// The thread name + public const string ThreadName = "thread.name"; + + /// Name of the Sentry SDK (e.g., "sentry.php", "sentry.javascript") + public const string SDKName = "sentry.sdk.name"; + + /// Version of the Sentry SDK + public const string SDKVersion = "sentry.sdk.version"; +} diff --git a/src/Sentry/Internal/Extensions/SpanV2Extensions.cs b/src/Sentry/Protocol/Spans/SpanV2Extensions.cs similarity index 93% rename from src/Sentry/Internal/Extensions/SpanV2Extensions.cs rename to src/Sentry/Protocol/Spans/SpanV2Extensions.cs index dbb8d7f711..56b5363759 100644 --- a/src/Sentry/Internal/Extensions/SpanV2Extensions.cs +++ b/src/Sentry/Protocol/Spans/SpanV2Extensions.cs @@ -1,6 +1,4 @@ -using Sentry.Protocol; - -namespace Sentry.Internal.Extensions; +namespace Sentry.Protocol.Spans; internal static class SpanV2Extensions { diff --git a/src/Sentry/Protocol/Spans/SpanV2Items.cs b/src/Sentry/Protocol/Spans/SpanV2Items.cs new file mode 100644 index 0000000000..ec5961e8b3 --- /dev/null +++ b/src/Sentry/Protocol/Spans/SpanV2Items.cs @@ -0,0 +1,37 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol.Spans; + +/// +/// Span v2 envelope item payload. +/// +/// +/// Developer docs: https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/ +/// +internal sealed class SpanV2Items : ISentryJsonSerializable +{ + private readonly IReadOnlyCollection _spans; + + public SpanV2Items(IReadOnlyCollection spans) + { + _spans = (spans.Count > SpanV2.MaxSpansPerEnvelope) + ? [.. spans.Take(SpanV2.MaxSpansPerEnvelope)] + : spans; + } + + public int Length => _spans.Count; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + + foreach (var span in _spans) + { + span.WriteTo(writer, logger); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 56f3ea4cac..11bcad10b3 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -1,8 +1,8 @@ using Sentry.Extensibility; using Sentry.Internal; using Sentry.Internal.Extensions; -using Sentry.Protocol; using Sentry.Protocol.Envelopes; +using Sentry.Protocol.Spans; namespace Sentry; 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/src/Sentry/SentryTransaction.cs b/src/Sentry/SentryTransaction.cs index 386891ec01..940964108e 100644 --- a/src/Sentry/SentryTransaction.cs +++ b/src/Sentry/SentryTransaction.cs @@ -265,7 +265,6 @@ public SentryTransaction(ITransactionTracer tracer) Fingerprint = tracer.Fingerprint; _breadcrumbs = tracer.Breadcrumbs.ToList(); _tags = tracer.Tags.ToDict(); - _spans = FromTracerSpans(tracer); _measurements = tracer.Measurements.ToDict(); diff --git a/src/Sentry/SpanV2Status.cs b/src/Sentry/SpanV2Status.cs new file mode 100644 index 0000000000..9e38d63885 --- /dev/null +++ b/src/Sentry/SpanV2Status.cs @@ -0,0 +1,16 @@ +namespace Sentry; + +/// +/// Indicated the status of the span. +/// +public enum SpanV2Status +{ + /// + /// Ok + /// + Ok, + /// + /// Error. + /// + Error +} diff --git a/test/Sentry.Testing/SentryAttributesExtensions.cs b/test/Sentry.Testing/SentryAttributesExtensions.cs new file mode 100644 index 0000000000..ba7c949371 --- /dev/null +++ b/test/Sentry.Testing/SentryAttributesExtensions.cs @@ -0,0 +1,10 @@ +namespace Sentry.Testing; + +internal static class SentryAttributesExtensions +{ + internal static void ShouldContain(this SentryAttributes attributes, string key, T expected) + { + attributes.TryGetValue(key, out var value).Should().BeTrue(); + value.Should().Be(expected); + } +} diff --git a/test/Sentry.Tests/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs index 104b04f663..2493d9819b 100644 --- a/test/Sentry.Tests/Protocol/SpanV2Tests.cs +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -1,3 +1,5 @@ +using Sentry.Protocol.Spans; + namespace Sentry.Tests.Protocol; public class SpanV2Tests @@ -7,8 +9,8 @@ public async Task EnvelopeItem_FromSpans_SerializesHeaderWithContentTypeAndItemC { var span = new SpanV2(SentryId.Parse("0123456789abcdef0123456789abcdef"), SpanId.Parse("0123456789abcdef"), "db", DateTimeOffset.Parse("2020-01-01T00:00:00Z")) { - Description = "select 1", - Status = SpanStatus.Ok, + Name = "select 1", + Status = SpanV2Status.Ok, EndTimestamp = DateTimeOffset.Parse("2020-01-01T00:00:01Z"), }; @@ -52,4 +54,117 @@ public void Envelope_FromSpan_RespectsMaxSpans() envelope.Items[0].TryGetType().Should().Be("span"); envelope.Items[0].Header.GetValueOrDefault("item_count").Should().Be(SpanV2.MaxSpansPerEnvelope); } + + [Fact] + public void SpanV2_FromTransaction_CopiesFields() + { + // Arrange + const string name = "txn-name"; + const string operation = "txn-op"; + const string origin = "manual.test"; + var traceId = SentryId.Parse("0123456789abcdef0123456789abcdef"); + var spanId = SpanId.Parse("0123456789abcdef"); + var parentSpanId = SpanId.Parse("fedcba9876543210"); + var start = DateTimeOffset.Parse("2020-01-01T00:00:00Z"); + var end = DateTimeOffset.Parse("2020-01-01T00:00:01Z"); + const string tagKey = "tag-key"; + const string tagVal = "tag-val"; + const string dataKey = "data-key"; + const int dataVal = 123; + + var context = new TransactionContext(name, operation, spanId, parentSpanId, isSampled: true); + var tracer = new TransactionTracer(DisabledHub.Instance, context); + tracer.SetMeasurement("m", new Measurement(42, MeasurementUnit.Duration.Millisecond)); + tracer.Contexts.Trace.TraceId = traceId; + tracer.Contexts.Trace.SpanId = spanId; + tracer.Contexts.Trace.ParentSpanId = parentSpanId; + tracer.Contexts.Trace.Operation = operation; + tracer.StartTimestamp = start; + tracer.EndTimestamp = end; + tracer.Contexts.Trace.Status = SpanStatus.FailedPrecondition; + tracer.Contexts.Trace.Origin = origin; + tracer.SetTag(tagKey, tagVal); + tracer.Contexts.Trace.SetData(dataKey, dataVal); + + var transaction = new SentryTransaction(tracer); + + var spanV2 = new SpanV2(transaction); + + using (new AssertionScope()) + { + spanV2.TraceId.Should().Be(traceId); + spanV2.SpanId.Should().Be(spanId); + spanV2.ParentSpanId.Should().Be(parentSpanId); + spanV2.Name.Should().Be(name); + spanV2.Status.Should().Be(SpanV2Status.Error); + spanV2.StartTimestamp.Should().Be(start); + spanV2.EndTimestamp.Should().Be(end); + + // TODO: not yet sure if this is how they should be mapped from transaction properties. + spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); + spanV2.Attributes.ShouldContain(SpanV2Attributes.Source, origin); + spanV2.Attributes.ShouldContain(tagKey, tagVal); + spanV2.Attributes.ShouldContain(dataKey, dataVal); + // TODO: spanV2.Measurements.Should().ContainKey("m"); ??? + + // TODO: Attachments - see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#span-attachments + } + } + + [Fact] + public void SpanV2_FromSpan_CopiesFields() + { + var txSpanId = SpanId.Parse("0123456789abcdef"); + var txParentSpanId = SpanId.Parse("fedcba9876543210"); + + var parentSpanId = SpanId.Parse("fedcba9876543210"); + var traceId = SentryId.Parse("0123456789abcdef0123456789abcdef"); + const string description = "desc"; + const string operation = "db"; + const string origin = "test-origin"; + var start = DateTimeOffset.Parse("2020-01-02T00:00:00Z"); + var end = DateTimeOffset.Parse("2020-01-02T00:00:01Z"); + const string tagKey = "tag-key"; + const string tagVal = "tag-val"; + const string dataKey = "data-key"; + const int dataVal = 123; + + var context = new TransactionContext("Foo", "Bar", txSpanId, txParentSpanId, isSampled: true); + var txTracer = new TransactionTracer(DisabledHub.Instance, context); + var tracer = new SpanTracer(DisabledHub.Instance, txTracer, parentSpanId, traceId, operation); + tracer.StartTimestamp = start; + tracer.EndTimestamp = end; + tracer.Status = SpanStatus.Cancelled; + tracer.Description = description; + tracer.Origin = origin; + tracer.IsSampled = true; + tracer.SetTag("tag-key", "tag-val"); + tracer.SetData("data-key", 123); + tracer.SetMeasurement("m", new Measurement(42, MeasurementUnit.Duration.Millisecond)); + + var span = new SentrySpan(tracer); + + + var spanV2 = new SpanV2(span); + + using (new AssertionScope()) + { + spanV2.TraceId.Should().Be(traceId); + spanV2.SpanId.Should().Be(span.SpanId); + spanV2.ParentSpanId.Should().Be(span.ParentSpanId); + spanV2.Name.Should().Be(span.Description); + spanV2.StartTimestamp.Should().Be(span.StartTimestamp); + spanV2.EndTimestamp.Should().Be(span.EndTimestamp); + spanV2.Status.Should().Be(SpanV2Status.Error); + + // TODO: not yet sure if this is how they should be mapped from transaction properties. + spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); + spanV2.Attributes.ShouldContain(SpanV2Attributes.Source, origin); + spanV2.Attributes.ShouldContain(tagKey, tagVal); + spanV2.Attributes.ShouldContain(dataKey, dataVal); + spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); + spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); + // TODO: spanV2.Measurements.Should().ContainKey("m"); ??? + } + } } diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs index 2614c99989..869815b47b 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); @@ -76,7 +76,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 +154,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 +360,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 0f09f57f7bc8ec9a15b7df4752fb42285f88e724 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 17 Feb 2026 00:33:53 +0000 Subject: [PATCH 7/9] Format code --- modules/sentry-native | 2 +- src/Sentry/Protocol/Spans/SentryLink.cs | 2 +- src/Sentry/Protocol/Spans/SpanV2.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/sentry-native b/modules/sentry-native index df22b444ee..b549d9d471 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit df22b444eed240f22022f77232f2e2acb86f8cee +Subproject commit b549d9d471d4e1cd7b919333fcfe7c2886b567bd diff --git a/src/Sentry/Protocol/Spans/SentryLink.cs b/src/Sentry/Protocol/Spans/SentryLink.cs index 22d27b4159..3f1e8a21cf 100644 --- a/src/Sentry/Protocol/Spans/SentryLink.cs +++ b/src/Sentry/Protocol/Spans/SentryLink.cs @@ -8,7 +8,7 @@ namespace Sentry.Protocol.Spans; /// internal readonly struct SentryLink(SentryId traceId, SpanId spanId, bool sampled) : ISentryJsonSerializable { - private readonly SentryAttributes _attributes = new (); + private readonly SentryAttributes _attributes = new(); public SpanId SpanId { get; } = spanId; public SentryId TraceId { get; } = traceId; diff --git a/src/Sentry/Protocol/Spans/SpanV2.cs b/src/Sentry/Protocol/Spans/SpanV2.cs index 5b0f52daa6..a69ffd03bf 100644 --- a/src/Sentry/Protocol/Spans/SpanV2.cs +++ b/src/Sentry/Protocol/Spans/SpanV2.cs @@ -13,7 +13,7 @@ internal sealed class SpanV2 : ISentryJsonSerializable { public const int MaxSpansPerEnvelope = 1000; - private readonly SentryAttributes _attributes = new (); + private readonly SentryAttributes _attributes = new(); public SpanV2( SentryId traceId, @@ -58,7 +58,7 @@ internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Operatio public DateTimeOffset? EndTimestamp { get; set; } public SentryAttributes Attributes => _attributes; - public List Links { get;} = []; + public List Links { get; } = []; // TODO: Attachments - see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#span-attachments From aa8266d3d5beb2013be560aabadfdfe303142cbc Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 17 Feb 2026 17:29:57 +1300 Subject: [PATCH 8/9] Fixed tests --- src/Sentry/Protocol/Spans/SpanV2.cs | 38 ++++++++++++++- src/Sentry/SentrySpan.cs | 1 + .../SentryAttributesExtensions.cs | 9 +++- ...iApprovalTests.Run.DotNet10_0.verified.txt | 5 ++ ...piApprovalTests.Run.DotNet8_0.verified.txt | 5 ++ ...piApprovalTests.Run.DotNet9_0.verified.txt | 5 ++ test/Sentry.Tests/Protocol/SpanV2Tests.cs | 48 +++++++++---------- 7 files changed, 84 insertions(+), 27 deletions(-) diff --git a/src/Sentry/Protocol/Spans/SpanV2.cs b/src/Sentry/Protocol/Spans/SpanV2.cs index a69ffd03bf..37ab3c34d5 100644 --- a/src/Sentry/Protocol/Spans/SpanV2.cs +++ b/src/Sentry/Protocol/Spans/SpanV2.cs @@ -36,16 +36,52 @@ internal SpanV2(SentryTransaction transaction) : this(transaction.TraceId, trans { ParentSpanId = transaction.ParentSpanId; EndTimestamp = transaction.EndTimestamp; + Status = transaction.Status is SpanStatus.Ok ? SpanV2Status.Ok : SpanV2Status.Error; + Attributes.SetAttribute(SpanV2Attributes.Operation, transaction.Operation); + if (transaction.Origin is {} origin) + { + Attributes.SetAttribute(SpanV2Attributes.Source, origin); + } + foreach (var tag in transaction.Tags) + { + Attributes.SetAttribute(tag.Key, tag.Value); + } + + foreach (var data in transaction.Data) + { + if (data.Value is not null) + { + Attributes.SetAttribute(data.Key, data.Value); + } + } } /// /// Converts a to a . /// /// This is a temporary method. We can remove it once transactions have been deprecated - internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Operation, span.StartTimestamp) + internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Description ?? span.Operation, span.StartTimestamp) { ParentSpanId = span.ParentSpanId; EndTimestamp = span.EndTimestamp; + Status = span.Status is SpanStatus.Ok ? SpanV2Status.Ok : SpanV2Status.Error; + Attributes.SetAttribute(SpanV2Attributes.Operation, span.Operation); + if (span.Origin is {} origin) + { + Attributes.SetAttribute(SpanV2Attributes.Source, origin); + } + foreach (var tag in span.Tags) + { + Attributes.SetAttribute(tag.Key, tag.Value); + } + + foreach (var data in span.Data) + { + if (data.Value is not null) + { + Attributes.SetAttribute(data.Key, data.Value); + } + } } public SentryId TraceId { get; } diff --git a/src/Sentry/SentrySpan.cs b/src/Sentry/SentrySpan.cs index 30eb53ef80..7d59b4ff0f 100644 --- a/src/Sentry/SentrySpan.cs +++ b/src/Sentry/SentrySpan.cs @@ -109,6 +109,7 @@ public SentrySpan(ISpan tracer) StartTimestamp = tracer.StartTimestamp; EndTimestamp = tracer.EndTimestamp; Description = tracer.Description; + Origin = tracer.Origin; Status = tracer.Status; IsSampled = tracer.IsSampled; _data = tracer.Data.ToDict(); diff --git a/test/Sentry.Testing/SentryAttributesExtensions.cs b/test/Sentry.Testing/SentryAttributesExtensions.cs index ba7c949371..a621e9218e 100644 --- a/test/Sentry.Testing/SentryAttributesExtensions.cs +++ b/test/Sentry.Testing/SentryAttributesExtensions.cs @@ -4,7 +4,14 @@ internal static class SentryAttributesExtensions { internal static void ShouldContain(this SentryAttributes attributes, string key, T expected) { - attributes.TryGetValue(key, out var value).Should().BeTrue(); + attributes.TryGetAttribute(key, out var value).Should().BeTrue(); value.Should().Be(expected); } + + internal static void AssertContains(this SentryAttributes attributes, string key, T expected) + { + var hasAttribute = attributes.TryGetAttribute(key, out var value); + Assert.True(hasAttribute); + Assert.Equal(expected, value); + } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 96577aecbc..111dde75d6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -1301,6 +1301,11 @@ namespace Sentry public Sentry.ISpan StartChild(string operation) { } public void UnsetTag(string key) { } } + public enum SpanV2Status + { + Ok = 0, + Error = 1, + } public enum StackTraceMode { Original = 0, diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 96577aecbc..111dde75d6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -1301,6 +1301,11 @@ namespace Sentry public Sentry.ISpan StartChild(string operation) { } public void UnsetTag(string key) { } } + public enum SpanV2Status + { + Ok = 0, + Error = 1, + } public enum StackTraceMode { Original = 0, diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 96577aecbc..111dde75d6 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -1301,6 +1301,11 @@ namespace Sentry public Sentry.ISpan StartChild(string operation) { } public void UnsetTag(string key) { } } + public enum SpanV2Status + { + Ok = 0, + Error = 1, + } public enum StackTraceMode { Original = 0, diff --git a/test/Sentry.Tests/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs index 2493d9819b..ce41f8d7f8 100644 --- a/test/Sentry.Tests/Protocol/SpanV2Tests.cs +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -92,19 +92,19 @@ public void SpanV2_FromTransaction_CopiesFields() using (new AssertionScope()) { - spanV2.TraceId.Should().Be(traceId); - spanV2.SpanId.Should().Be(spanId); - spanV2.ParentSpanId.Should().Be(parentSpanId); - spanV2.Name.Should().Be(name); - spanV2.Status.Should().Be(SpanV2Status.Error); - spanV2.StartTimestamp.Should().Be(start); - spanV2.EndTimestamp.Should().Be(end); +Assert.Equal(traceId, spanV2.TraceId); + Assert.Equal(transaction.SpanId, spanV2.SpanId); + Assert.Equal(transaction.ParentSpanId, spanV2.ParentSpanId); + Assert.Equal(transaction.Name, spanV2.Name); + Assert.Equal(transaction.StartTimestamp, spanV2.StartTimestamp); + Assert.Equal(transaction.EndTimestamp, spanV2.EndTimestamp); + Assert.Equal(SpanV2Status.Error, spanV2.Status); // TODO: not yet sure if this is how they should be mapped from transaction properties. - spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); - spanV2.Attributes.ShouldContain(SpanV2Attributes.Source, origin); - spanV2.Attributes.ShouldContain(tagKey, tagVal); - spanV2.Attributes.ShouldContain(dataKey, dataVal); + spanV2.Attributes.AssertContains(SpanV2Attributes.Operation, operation); + spanV2.Attributes.AssertContains(SpanV2Attributes.Source, origin); + spanV2.Attributes.AssertContains(tagKey, tagVal); + spanV2.Attributes.AssertContains(dataKey, dataVal); // TODO: spanV2.Measurements.Should().ContainKey("m"); ??? // TODO: Attachments - see https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#span-attachments @@ -121,7 +121,7 @@ public void SpanV2_FromSpan_CopiesFields() var traceId = SentryId.Parse("0123456789abcdef0123456789abcdef"); const string description = "desc"; const string operation = "db"; - const string origin = "test-origin"; + const string origin = "manual.test"; var start = DateTimeOffset.Parse("2020-01-02T00:00:00Z"); var end = DateTimeOffset.Parse("2020-01-02T00:00:01Z"); const string tagKey = "tag-key"; @@ -149,21 +149,19 @@ public void SpanV2_FromSpan_CopiesFields() using (new AssertionScope()) { - spanV2.TraceId.Should().Be(traceId); - spanV2.SpanId.Should().Be(span.SpanId); - spanV2.ParentSpanId.Should().Be(span.ParentSpanId); - spanV2.Name.Should().Be(span.Description); - spanV2.StartTimestamp.Should().Be(span.StartTimestamp); - spanV2.EndTimestamp.Should().Be(span.EndTimestamp); - spanV2.Status.Should().Be(SpanV2Status.Error); + Assert.Equal(traceId, spanV2.TraceId); + Assert.Equal(span.SpanId, spanV2.SpanId); + Assert.Equal(span.ParentSpanId, spanV2.ParentSpanId); + Assert.Equal(span.Description, spanV2.Name); + Assert.Equal(span.StartTimestamp, spanV2.StartTimestamp); + Assert.Equal(span.EndTimestamp, spanV2.EndTimestamp); + Assert.Equal(SpanV2Status.Error, spanV2.Status); // TODO: not yet sure if this is how they should be mapped from transaction properties. - spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); - spanV2.Attributes.ShouldContain(SpanV2Attributes.Source, origin); - spanV2.Attributes.ShouldContain(tagKey, tagVal); - spanV2.Attributes.ShouldContain(dataKey, dataVal); - spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); - spanV2.Attributes.ShouldContain(SpanV2Attributes.Operation, operation); + spanV2.Attributes.AssertContains(SpanV2Attributes.Operation, operation); + spanV2.Attributes.AssertContains(SpanV2Attributes.Source, origin); + spanV2.Attributes.AssertContains(tagKey, tagVal); + spanV2.Attributes.AssertContains(dataKey, dataVal); // TODO: spanV2.Measurements.Should().ContainKey("m"); ??? } } From 28f0b7527bcfca1d7bb522b7de196933ac637242 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 17 Feb 2026 04:41:44 +0000 Subject: [PATCH 9/9] Format code --- src/Sentry/Protocol/Spans/SpanV2.cs | 4 ++-- test/Sentry.Tests/Protocol/SpanV2Tests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Sentry/Protocol/Spans/SpanV2.cs b/src/Sentry/Protocol/Spans/SpanV2.cs index 37ab3c34d5..bc2245b18a 100644 --- a/src/Sentry/Protocol/Spans/SpanV2.cs +++ b/src/Sentry/Protocol/Spans/SpanV2.cs @@ -38,7 +38,7 @@ internal SpanV2(SentryTransaction transaction) : this(transaction.TraceId, trans EndTimestamp = transaction.EndTimestamp; Status = transaction.Status is SpanStatus.Ok ? SpanV2Status.Ok : SpanV2Status.Error; Attributes.SetAttribute(SpanV2Attributes.Operation, transaction.Operation); - if (transaction.Origin is {} origin) + if (transaction.Origin is { } origin) { Attributes.SetAttribute(SpanV2Attributes.Source, origin); } @@ -66,7 +66,7 @@ internal SpanV2(SentrySpan span) : this(span.TraceId, span.SpanId, span.Descript EndTimestamp = span.EndTimestamp; Status = span.Status is SpanStatus.Ok ? SpanV2Status.Ok : SpanV2Status.Error; Attributes.SetAttribute(SpanV2Attributes.Operation, span.Operation); - if (span.Origin is {} origin) + if (span.Origin is { } origin) { Attributes.SetAttribute(SpanV2Attributes.Source, origin); } diff --git a/test/Sentry.Tests/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs index ce41f8d7f8..8adc63589f 100644 --- a/test/Sentry.Tests/Protocol/SpanV2Tests.cs +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -92,7 +92,7 @@ public void SpanV2_FromTransaction_CopiesFields() using (new AssertionScope()) { -Assert.Equal(traceId, spanV2.TraceId); + Assert.Equal(traceId, spanV2.TraceId); Assert.Equal(transaction.SpanId, spanV2.SpanId); Assert.Equal(transaction.ParentSpanId, spanV2.ParentSpanId); Assert.Equal(transaction.Name, spanV2.Name);