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/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/Internal/Extensions/SentryTransactionExtensions.cs b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs new file mode 100644 index 0000000000..3d4e12c2a9 --- /dev/null +++ b/src/Sentry/Internal/Extensions/SentryTransactionExtensions.cs @@ -0,0 +1,20 @@ +using Sentry.Protocol.Spans; + +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/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index e3e85da713..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; @@ -504,4 +505,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..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; @@ -299,6 +300,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/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/Spans/SentryLink.cs b/src/Sentry/Protocol/Spans/SentryLink.cs new file mode 100644 index 0000000000..3f1e8a21cf --- /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..bc2245b18a --- /dev/null +++ b/src/Sentry/Protocol/Spans/SpanV2.cs @@ -0,0 +1,123 @@ +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; + 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.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; } + 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/Protocol/Spans/SpanV2Extensions.cs b/src/Sentry/Protocol/Spans/SpanV2Extensions.cs new file mode 100644 index 0000000000..56b5363759 --- /dev/null +++ b/src/Sentry/Protocol/Spans/SpanV2Extensions.cs @@ -0,0 +1,27 @@ +namespace Sentry.Protocol.Spans; + +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/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/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..11bcad10b3 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -1,6 +1,8 @@ using Sentry.Extensibility; using Sentry.Internal; +using Sentry.Internal.Extensions; using Sentry.Protocol.Envelopes; +using Sentry.Protocol.Spans; 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 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/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/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/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/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.Testing/SentryAttributesExtensions.cs b/test/Sentry.Testing/SentryAttributesExtensions.cs new file mode 100644 index 0000000000..a621e9218e --- /dev/null +++ b/test/Sentry.Testing/SentryAttributesExtensions.cs @@ -0,0 +1,17 @@ +namespace Sentry.Testing; + +internal 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 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 64869d4587..111dde75d6 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; } @@ -1300,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, @@ -1327,6 +1333,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..111dde75d6 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; } @@ -1300,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, @@ -1327,6 +1333,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..111dde75d6 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; } @@ -1300,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, @@ -1327,6 +1333,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/Protocol/SpanV2Tests.cs b/test/Sentry.Tests/Protocol/SpanV2Tests.cs new file mode 100644 index 0000000000..8adc63589f --- /dev/null +++ b/test/Sentry.Tests/Protocol/SpanV2Tests.cs @@ -0,0 +1,168 @@ +using Sentry.Protocol.Spans; + +namespace Sentry.Tests.Protocol; + +public class SpanV2Tests +{ + [Fact] + public async Task EnvelopeItem_FromSpans_SerializesHeaderWithContentTypeAndItemCount() + { + var span = new SpanV2(SentryId.Parse("0123456789abcdef0123456789abcdef"), SpanId.Parse("0123456789abcdef"), "db", DateTimeOffset.Parse("2020-01-01T00:00:00Z")) + { + Name = "select 1", + Status = SpanV2Status.Ok, + EndTimestamp = DateTimeOffset.Parse("2020-01-01T00:00:01Z"), + }; + + var spanItems = new SpanV2Items([span]); + using var envelopeItem = EnvelopeItem.FromSpans(spanItems); + + 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\""); + firstLine.Should().Contain("\"item_count\":1"); + firstLine.Should().Contain("\"content_type\":\"application/vnd.sentry.items.span\\u002Bjson\""); + } + + [Fact] + public void Envelope_FromSpans_CreatesSingleItem() + { + SpanV2[] spans = [new(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)]; + + using var envelope = Envelope.FromSpans(spans); + + 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_FromSpan_RespectsMaxSpans() + { + var spans = Enumerable.Range(0, SpanV2.MaxSpansPerEnvelope + 10) + .Select(_ => new SpanV2(SentryId.Create(), SpanId.Create(), "op", DateTimeOffset.UtcNow)) + .ToArray(); + + using var envelope = Envelope.FromSpans(spans); + + envelope.Items.Should().HaveCount(1); + 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()) + { + 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.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 + } + } + + [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 = "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"; + 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()) + { + 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.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"); ??? + } + } +} 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");