diff --git a/CHANGELOG.md b/CHANGELOG.md index b40ed69f63..55cdefbcea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## vNext - Add support for dynamic transaction sampling. (#753) @Tyrrrz +- Integrate trace headers. (#758) @Tyrrrz - Renamed Option `DiagnosticsLevel` to `DiagnosticLevel` (#759) @bruno-garcia ## 3.0.0-beta.0 diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index 4e9cbf922a..f870979c43 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -65,7 +65,7 @@ public ITransaction StartTransaction( /// /// Returns null. /// - public SentryTraceHeader? GetSentryTrace() => null; + public SentryTraceHeader? GetTraceHeader() => null; /// /// No-Op. @@ -82,7 +82,7 @@ public void BindClient(ISentryClient client) /// /// No-Op. /// - public void CaptureTransaction(Transaction transaction) + public void CaptureTransaction(ITransaction transaction) { } diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index dad8d90278..ab3a0075d0 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -84,7 +84,7 @@ public ITransaction StartTransaction( /// Forwards the call to . /// [DebuggerStepThrough] - public SentryTraceHeader? GetSentryTrace() + public SentryTraceHeader? GetTraceHeader() => SentrySdk.GetTraceHeader(); /// @@ -153,7 +153,7 @@ public SentryId CaptureEvent(SentryEvent evt, Scope? scope) /// [DebuggerStepThrough] [EditorBrowsable(EditorBrowsableState.Never)] - public void CaptureTransaction(Transaction transaction) + public void CaptureTransaction(ITransaction transaction) => SentrySdk.CaptureTransaction(transaction); /// diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 4670db541e..2f1a8802c2 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -42,6 +42,27 @@ public static ITransaction StartTransaction( return transaction; } + /// + /// Starts a transaction from the specified trace header. + /// + public static ITransaction StartTransaction( + this IHub hub, + string name, + string operation, + SentryTraceHeader traceHeader) + { + var context = new TransactionContext( + // SpanId from the header becomes ParentSpanId on this transaction + traceHeader.SpanId, + traceHeader.TraceId, + name, + operation, + traceHeader.IsSampled + ); + + return hub.StartTransaction(context); + } + /// /// Adds a breadcrumb to the current scope. /// diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 6bfb0c5887..4caa331e4f 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -31,8 +31,8 @@ ITransaction StartTransaction( ); /// - /// Gets the sentry trace header. + /// Gets the Sentry trace header for the last active span. /// - SentryTraceHeader? GetSentryTrace(); + SentryTraceHeader? GetTraceHeader(); } } diff --git a/src/Sentry/ISentryClient.cs b/src/Sentry/ISentryClient.cs index 47e412e479..c8f68f9f46 100644 --- a/src/Sentry/ISentryClient.cs +++ b/src/Sentry/ISentryClient.cs @@ -31,8 +31,12 @@ public interface ISentryClient /// /// Captures a transaction. /// + /// + /// Note: this method is NOT meant to be called from user code! + /// Instead, call on the transaction. + /// /// The transaction. - void CaptureTransaction(Transaction transaction); + void CaptureTransaction(ITransaction transaction); /// /// Flushes events queued up. diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 224a1a50da..18b86057c9 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -156,10 +156,10 @@ public ITransaction StartTransaction( return transaction; } - public SentryTraceHeader? GetSentryTrace() + public SentryTraceHeader? GetTraceHeader() { var (currentScope, _) = ScopeManager.GetCurrent(); - return currentScope.Transaction?.GetTraceHeader(); + return currentScope.GetSpan()?.GetTraceHeader(); } public SentryId CaptureEvent(SentryEvent evt, Scope? scope = null) @@ -191,11 +191,20 @@ public void CaptureUserFeedback(UserFeedback userFeedback) } } - public void CaptureTransaction(Transaction transaction) + public void CaptureTransaction(ITransaction transaction) { try { _ownedClient.CaptureTransaction(transaction); + + // Clear the transaction from the scope + ScopeManager.WithScope(scope => + { + if (scope.Transaction == transaction) + { + scope.Transaction = null; + } + }); } catch (Exception e) { diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b500fa4c7e..c319d1f38d 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -115,7 +115,7 @@ public static Envelope FromUserFeedback(UserFeedback sentryUserFeedback) /// /// Creates an envelope that contains a single transaction. /// - public static Envelope FromTransaction(Transaction transaction) + public static Envelope FromTransaction(ITransaction transaction) { var header = new Dictionary(StringComparer.Ordinal) { diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 9b63004875..e300f44b52 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -144,14 +144,14 @@ public static EnvelopeItem FromUserFeedback(UserFeedback sentryUserFeedback) /// /// Creates an envelope item from transaction. /// - public static EnvelopeItem FromTransaction(Transaction transaction) + public static EnvelopeItem FromTransaction(ITransaction transaction) { var header = new Dictionary(StringComparer.Ordinal) { [TypeKey] = TypeValueTransaction }; - return new EnvelopeItem(header, new JsonSerializable(transaction)); + return new EnvelopeItem(header, new JsonSerializable((IJsonSerializable)transaction)); } /// diff --git a/src/Sentry/Protocol/IJsonSerializable.cs b/src/Sentry/Protocol/IJsonSerializable.cs index e3ac8f3f61..1ab4fafcdf 100644 --- a/src/Sentry/Protocol/IJsonSerializable.cs +++ b/src/Sentry/Protocol/IJsonSerializable.cs @@ -13,6 +13,10 @@ internal interface IJsonSerializable /// /// Writes the object as JSON. /// + /// + /// Note: this method is meant only for internal use and is exposed due to a language limitation. + /// Avoid relying on this method in user code. + /// void WriteTo(Utf8JsonWriter writer); } diff --git a/src/Sentry/Protocol/ISpan.cs b/src/Sentry/Protocol/ISpan.cs index db7be3f1e9..f182b48ea1 100644 --- a/src/Sentry/Protocol/ISpan.cs +++ b/src/Sentry/Protocol/ISpan.cs @@ -35,6 +35,11 @@ public interface ISpan : ISpanContext, IHasTags, IHasExtra /// DateTimeOffset? EndTimestamp { get; } + /// + /// Whether the span is finished. + /// + bool IsFinished { get; } + /// /// Starts a child span. /// @@ -44,6 +49,11 @@ public interface ISpan : ISpanContext, IHasTags, IHasExtra /// Finishes the span. /// void Finish(); + + /// + /// Get Sentry trace header. + /// + SentryTraceHeader GetTraceHeader(); } /// diff --git a/src/Sentry/Protocol/ITransaction.cs b/src/Sentry/Protocol/ITransaction.cs index 29b2bd9ed6..65e34d0c3d 100644 --- a/src/Sentry/Protocol/ITransaction.cs +++ b/src/Sentry/Protocol/ITransaction.cs @@ -24,8 +24,8 @@ public interface ITransaction : ISpan, ITransactionContext, IEventLike IReadOnlyCollection Spans { get; } /// - /// Get Sentry trace header. + /// Gets the last active (not finished) span in this transaction. /// - SentryTraceHeader GetTraceHeader(); + ISpan? GetLastActiveSpan(); } } diff --git a/src/Sentry/Protocol/SentryTraceHeader.cs b/src/Sentry/Protocol/SentryTraceHeader.cs index e08d4aaaec..85fc12ee34 100644 --- a/src/Sentry/Protocol/SentryTraceHeader.cs +++ b/src/Sentry/Protocol/SentryTraceHeader.cs @@ -7,24 +7,37 @@ namespace Sentry.Protocol /// public class SentryTraceHeader { - private readonly SentryId _traceId; - private readonly SpanId _spanId; - private readonly bool? _isSampled; + internal const string HttpHeaderName = "sentry-trace"; + + /// + /// Trace ID. + /// + public SentryId TraceId { get; } + + /// + /// Span ID. + /// + public SpanId SpanId { get; } + + /// + /// Whether the trace is sampled. + /// + public bool? IsSampled { get; } /// /// Initializes an instance of . /// - public SentryTraceHeader(SentryId traceId, SpanId spanId, bool? isSampled) + public SentryTraceHeader(SentryId traceId, SpanId spanSpanId, bool? isSampled) { - _traceId = traceId; - _spanId = spanId; - _isSampled = isSampled; + TraceId = traceId; + SpanId = spanSpanId; + IsSampled = isSampled; } /// - public override string ToString() => _isSampled is {} isSampled - ? $"{_traceId}-{_spanId}-{(isSampled ? 1 : 0)}" - : $"{_traceId}-{_spanId}"; + public override string ToString() => IsSampled is {} isSampled + ? $"{TraceId}-{SpanId}-{(isSampled ? 1 : 0)}" + : $"{TraceId}-{SpanId}"; /// /// Parses from string. diff --git a/src/Sentry/Protocol/Span.cs b/src/Sentry/Protocol/Span.cs index 02271c0358..4a45a9675c 100644 --- a/src/Sentry/Protocol/Span.cs +++ b/src/Sentry/Protocol/Span.cs @@ -33,6 +33,9 @@ public class Span : ISpan, IJsonSerializable /// public DateTimeOffset? EndTimestamp { get; private set; } + /// + public bool IsFinished => EndTimestamp is not null; + /// public string Operation { get; set; } @@ -86,6 +89,13 @@ public ISpan StartChild(string operation) => /// public void Finish() => EndTimestamp = DateTimeOffset.UtcNow; + /// + public SentryTraceHeader GetTraceHeader() => new( + TraceId, + SpanId, + IsSampled + ); + /// public void WriteTo(Utf8JsonWriter writer) { diff --git a/src/Sentry/Protocol/Transaction.cs b/src/Sentry/Protocol/Transaction.cs index 3d05d18181..5f8fb4e2af 100644 --- a/src/Sentry/Protocol/Transaction.cs +++ b/src/Sentry/Protocol/Transaction.cs @@ -65,10 +65,10 @@ public SentryId TraceId public string Name { get; set; } /// - public DateTimeOffset StartTimestamp { get; private set; } = DateTimeOffset.UtcNow; + public DateTimeOffset StartTimestamp { get; internal set; } = DateTimeOffset.UtcNow; /// - public DateTimeOffset? EndTimestamp { get; private set; } + public DateTimeOffset? EndTimestamp { get; internal set; } /// public string Operation @@ -170,6 +170,9 @@ public IReadOnlyList Fingerprint /// public IReadOnlyCollection Spans => _spansLazy.Value; + /// + public bool IsFinished => EndTimestamp is not null; + // This constructor is used for deserialization purposes. // It's required because some of the fields are mapped on 'contexts.trace'. // When deserializing, we don't parse those fields explicitly, but @@ -251,6 +254,9 @@ public void Finish() _client.CaptureTransaction(this); } + /// + public ISpan? GetLastActiveSpan() => Spans.LastOrDefault(s => !s.IsFinished); + /// public SentryTraceHeader GetTraceHeader() => new( TraceId, diff --git a/src/Sentry/Protocol/TransactionContext.cs b/src/Sentry/Protocol/TransactionContext.cs index 8221359955..63a2e7b43d 100644 --- a/src/Sentry/Protocol/TransactionContext.cs +++ b/src/Sentry/Protocol/TransactionContext.cs @@ -29,11 +29,12 @@ public TransactionContext( /// Initializes an instance of . /// public TransactionContext( + SpanId? parentSpanId, + SentryId traceId, string name, string operation, - string description, bool? isSampled) - : this(SpanId.Create(), null, SentryId.Create(), name, operation, description, null, isSampled) + : this(SpanId.Create(), parentSpanId, traceId, name, operation, "", null, isSampled) { } @@ -44,7 +45,7 @@ public TransactionContext( string name, string operation, bool? isSampled) - : this(name, operation, "", isSampled) + : this(null, SentryId.Create(), name, operation, isSampled) { } diff --git a/src/Sentry/Scope.cs b/src/Sentry/Scope.cs index 0b4c3a23da..75c5a68193 100644 --- a/src/Sentry/Scope.cs +++ b/src/Sentry/Scope.cs @@ -382,5 +382,11 @@ internal void Evaluate() } } } + + /// + /// Gets the currently ongoing (not finished) span or null if none available. + /// This relies on the transactions being manually set on the scope via . + /// + public ISpan? GetSpan() => Transaction?.GetLastActiveSpan() ?? Transaction; } } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 89dd6e23bf..50026b7e42 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -103,7 +103,7 @@ public void CaptureUserFeedback(UserFeedback userFeedback) } /// - public void CaptureTransaction(Transaction transaction) + public void CaptureTransaction(ITransaction transaction) { if (_disposed) { @@ -129,8 +129,23 @@ public void CaptureTransaction(Transaction transaction) return; } + // Unfinished transaction can only happen if the user calls this method instead of + // transaction.Finish(). + // We still send these transactions over, but warn the user not to do it. + if (!transaction.IsFinished) + { + _options.DiagnosticLogger?.LogWarning( + "Capturing a transaction which has not been finished. " + + "Please call transaction.Finish() instead of hub.CaptureTransaction(transaction) " + + "to properly finalize the transaction and send it to Sentry." + ); + } + // Sampling decision MUST have been made at this point - Debug.Assert(transaction.IsSampled != null, "Attempt to capture transaction without sampling decision."); + Debug.Assert( + transaction.IsSampled != null, + "Attempt to capture transaction without sampling decision." + ); if (transaction.IsSampled != true) { diff --git a/src/Sentry/SentryHttpMessageHandler.cs b/src/Sentry/SentryHttpMessageHandler.cs new file mode 100644 index 0000000000..405f582706 --- /dev/null +++ b/src/Sentry/SentryHttpMessageHandler.cs @@ -0,0 +1,72 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Sentry.Extensibility; +using Sentry.Protocol; + +namespace Sentry +{ + /// + /// Special HTTP message handler that can be used to propagate Sentry headers and other contextual information. + /// + public class SentryHttpMessageHandler : HttpMessageHandler + { + private readonly HttpMessageInvoker _httpMessageInvoker; + private readonly IHub _hub; + + private SentryHttpMessageHandler(HttpMessageInvoker httpMessageInvoker, IHub hub) + { + _httpMessageInvoker = httpMessageInvoker; + _hub = hub; + } + + /// + /// Initializes an instance of . + /// + public SentryHttpMessageHandler(HttpMessageHandler innerHandler, IHub hub) + : this(new HttpMessageInvoker(innerHandler, false), hub) {} + + /// + /// Initializes an instance of . + /// + public SentryHttpMessageHandler(HttpMessageHandler innerHandler) + : this(innerHandler, HubAdapter.Instance) {} + + /// + /// Initializes an instance of . + /// + public SentryHttpMessageHandler(IHub hub) + : this(new HttpMessageInvoker(new HttpClientHandler(), true), hub) {} + + /// + /// Initializes an instance of . + /// + public SentryHttpMessageHandler() + : this(HubAdapter.Instance) {} + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Set trace header if it hasn't already been set + if (!request.Headers.Contains(SentryTraceHeader.HttpHeaderName) && + _hub.GetTraceHeader() is {} traceHeader) + { + request.Headers.Add( + SentryTraceHeader.HttpHeaderName, + traceHeader.ToString() + ); + } + + return _httpMessageInvoker.SendAsync(request, cancellationToken); + } + + /// + protected override void Dispose(bool disposing) + { + _httpMessageInvoker.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index d38ae20693..b94f5b80f9 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -312,7 +312,7 @@ public static void CaptureUserFeedback(SentryId eventId, string email, string co /// Captures a transaction. /// [DebuggerStepThrough] - public static void CaptureTransaction(Transaction transaction) + public static void CaptureTransaction(ITransaction transaction) => _hub.CaptureTransaction(transaction); /// @@ -345,11 +345,18 @@ public static ITransaction StartTransaction(string name, string operation) public static ITransaction StartTransaction(string name, string operation, string description) => _hub.StartTransaction(name, operation, description); + /// + /// Starts a transaction. + /// + [DebuggerStepThrough] + public static ITransaction StartTransaction(string name, string operation, SentryTraceHeader traceHeader) + => _hub.StartTransaction(name, operation, traceHeader); + /// /// Gets the Sentry trace header. /// [DebuggerStepThrough] public static SentryTraceHeader? GetTraceHeader() - => _hub.GetSentryTrace(); + => _hub.GetTraceHeader(); } } diff --git a/test/Sentry.Testing/HttpClientExtensions.cs b/test/Sentry.Testing/HttpClientExtensions.cs index 0675bb80f2..484651d653 100644 --- a/test/Sentry.Testing/HttpClientExtensions.cs +++ b/test/Sentry.Testing/HttpClientExtensions.cs @@ -38,17 +38,20 @@ public static async Task CloneAsync(this HttpRequestMessage } // Content - var cloneContentStream = new MemoryStream(); + if (source.Content != null) + { + var cloneContentStream = new MemoryStream(); - await source.Content.CopyToAsync(cloneContentStream).ConfigureAwait(false); - cloneContentStream.Position = 0; + await source.Content.CopyToAsync(cloneContentStream).ConfigureAwait(false); + cloneContentStream.Position = 0; - clone.Content = new StreamContent(cloneContentStream); + clone.Content = new StreamContent(cloneContentStream); - // Content headers - foreach (var (key, value) in source.Content.Headers) - { - clone.Content.Headers.Add(key, value); + // Content headers + foreach (var (key, value) in source.Content.Headers) + { + clone.Content.Headers.Add(key, value); + } } return clone; diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index f78fa3ae13..2d5f822ff3 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -162,6 +162,30 @@ public void StartTransaction_NameOpDescription_Works() transaction.Description.Should().Be("description"); } + [Fact] + public void StartTransaction_FromTraceHeader_Works() + { + // Arrange + var hub = new Hub(new SentryOptions + { + Dsn = DsnSamples.ValidDsnWithSecret + }); + + var traceHeader = new SentryTraceHeader( + SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"), + SpanId.Parse("2000000000000000"), + true + ); + + // Act + var transaction = hub.StartTransaction("name", "operation", traceHeader); + + // Assert + transaction.TraceId.Should().Be(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + transaction.ParentSpanId.Should().Be(SpanId.Parse("2000000000000000")); + transaction.IsSampled.Should().BeTrue(); + } + [Fact] public void StartTransaction_StaticSampling_SampledIn() { @@ -305,5 +329,53 @@ public void StartTransaction_DynamicSampling_FallbackToStatic_SampledOut() // Assert transaction.IsSampled.Should().BeFalse(); } + + [Fact] + public void GetTraceHeader_ReturnsHeaderForActiveSpan() + { + // Arrange + var hub = new Hub(new SentryOptions + { + Dsn = DsnSamples.ValidDsnWithSecret + }); + + var transaction = hub.StartTransaction("foo", "bar"); + + // Act + hub.WithScope(scope => + { + scope.Transaction = transaction; + + var header = hub.GetTraceHeader(); + + // Assert + header.Should().NotBeNull(); + header?.SpanId.Should().Be(transaction.SpanId); + header?.TraceId.Should().Be(transaction.TraceId); + header?.IsSampled.Should().Be(transaction.IsSampled); + }); + } + + [Fact] + public void CaptureTransaction_AfterTransactionFinishes_ResetsTransactionOnScope() + { + // Arrange + var client = Substitute.For(); + + var hub = new Hub(client, new SentryOptions + { + Dsn = DsnSamples.ValidDsnWithSecret + }); + + var transaction = hub.StartTransaction("foo", "bar"); + + hub.WithScope(scope => scope.Transaction = transaction); + + // Act + transaction.Finish(); + + // Assert + hub.WithScope(scope => scope.Transaction.Should().BeNull()); + } } } diff --git a/test/Sentry.Tests/Protocol/SentryTraceHeaderTests.cs b/test/Sentry.Tests/Protocol/SentryTraceHeaderTests.cs new file mode 100644 index 0000000000..af1b0c6e69 --- /dev/null +++ b/test/Sentry.Tests/Protocol/SentryTraceHeaderTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol +{ + public class SentryTraceHeaderTests + { + [Fact] + public void Parse_WithoutSampled_Works() + { + // Arrange + const string headerValue = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000"; + + // Act + var header = SentryTraceHeader.Parse(headerValue); + + // Assert + header.TraceId.Should().Be(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + header.SpanId.Should().Be(SpanId.Parse("1000000000000000")); + header.IsSampled.Should().BeNull(); + } + + [Fact] + public void Parse_WithSampledTrue_Works() + { + // Arrange + const string headerValue = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1"; + + // Act + var header = SentryTraceHeader.Parse(headerValue); + + // Assert + header.TraceId.Should().Be(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + header.SpanId.Should().Be(SpanId.Parse("1000000000000000")); + header.IsSampled.Should().BeTrue(); + } + + [Fact] + public void Parse_WithSampledFalse_Works() + { + // Arrange + const string headerValue = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0"; + + // Act + var header = SentryTraceHeader.Parse(headerValue); + + // Assert + header.TraceId.Should().Be(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + header.SpanId.Should().Be(SpanId.Parse("1000000000000000")); + header.IsSampled.Should().BeFalse(); + } + } +} diff --git a/test/Sentry.Tests/ScopeTests.cs b/test/Sentry.Tests/ScopeTests.cs index bee736a7ed..e8e14c9652 100644 --- a/test/Sentry.Tests/ScopeTests.cs +++ b/test/Sentry.Tests/ScopeTests.cs @@ -157,5 +157,58 @@ public void TransactionName_TransactionStarted_NameReturnsActualTransactionName( scope.TransactionName.Should().Be("foo"); scope.TransactionName.Should().Be(scope.Transaction?.Name); } + + [Fact] + public void GetSpan_NoSpans_ReturnsTransaction() + { + // Arrange + var scope = new Scope(); + var transaction = new Transaction(DisabledHub.Instance, "foo", "_"); + scope.Transaction = transaction; + + // Act + var span = scope.GetSpan(); + + // Assert + span.Should().Be(transaction); + } + + [Fact] + public void GetSpan_FinishedSpans_ReturnsTransaction() + { + // Arrange + var scope = new Scope(); + + var transaction = new Transaction(DisabledHub.Instance, "foo", "_"); + transaction.StartChild("123").Finish(); + transaction.StartChild("456").Finish(); + + scope.Transaction = transaction; + + // Act + var span = scope.GetSpan(); + + // Assert + span.Should().Be(transaction); + } + + [Fact] + public void GetSpan_ActiveSpans_ReturnsSpan() + { + // Arrange + var scope = new Scope(); + + var transaction = new Transaction(DisabledHub.Instance, "foo", "_"); + var activeSpan = transaction.StartChild("123"); + transaction.StartChild("456").Finish(); + + scope.Transaction = transaction; + + // Act + var span = scope.GetSpan(); + + // Assert + span.Should().Be(activeSpan); + } } } diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 85a380d0bc..c10ff7b717 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -352,7 +352,11 @@ public void CaptureTransaction_SampledOut_Dropped() sut, "test name", "test operation" - ) {IsSampled = false}); + ) + { + IsSampled = false, + EndTimestamp = DateTimeOffset.Now // finished + }); // Assert _ = sut.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); @@ -370,7 +374,11 @@ public void CaptureTransaction_ValidTransaction_Sent() sut, "test name", "test operation" - ) {IsSampled = true} + ) + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + } ); // Assert @@ -387,7 +395,11 @@ public void CaptureTransaction_NoSpanId_Ignored() sut, "test name", "test operation" - ) {IsSampled = true}; + ) + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + }; transaction.Contexts.Trace.SpanId = SpanId.Empty; @@ -410,7 +422,11 @@ public void CaptureTransaction_NoName_Ignored() sut, null!, "test operation" - ) {IsSampled = true} + ) + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + } ); // Assert @@ -429,13 +445,40 @@ public void CaptureTransaction_NoOperation_Ignored() sut, "test name", null! - ) {IsSampled = true} + ) + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now // finished + } ); // Assert _ = sut.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); } + [Fact] + public void CaptureTransaction_NotFinished_Sent() + { + // Arrange + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction( + new Transaction( + sut, + "test name", + "test operation" + ) + { + IsSampled = true, + EndTimestamp = null // not finished + } + ); + + // Assert + _ = sut.Worker.Received(1).EnqueueEnvelope(Arg.Any()); + } + [Fact] public void CaptureTransaction_DisposedClient_ThrowsObjectDisposedException() { diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs new file mode 100644 index 0000000000..2d73e8f64f --- /dev/null +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using NSubstitute; +using Sentry.Protocol; +using Sentry.Testing; +using Xunit; + +namespace Sentry.Tests +{ + public class SentryHttpMessageHandlerTests + { + [Fact] + public async Task SendAsync_SentryTraceHeaderNotSet_SetsHeader() + { + // Arrange + var hub = Substitute.For(); + + hub.GetTraceHeader().ReturnsForAnyArgs( + SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0") + ); + + using var innerHandler = new FakeHttpClientHandler(); + using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); + using var client = new HttpClient(sentryHandler); + + // Act + await client.GetAsync("https://example.com"); + + using var request = innerHandler.GetRequests().Single(); + + // Assert + request.Headers.Should().Contain(h => + h.Key == "sentry-trace" && + string.Concat(h.Value) == "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0" + ); + } + + [Fact] + public async Task SendAsync_SentryTraceHeaderAlreadySet_NotOverwritten() + { + // Arrange + var hub = Substitute.For(); + + hub.GetTraceHeader().ReturnsForAnyArgs( + SentryTraceHeader.Parse("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0") + ); + + using var innerHandler = new FakeHttpClientHandler(); + using var sentryHandler = new SentryHttpMessageHandler(innerHandler, hub); + using var client = new HttpClient(sentryHandler); + + client.DefaultRequestHeaders.Add("sentry-trace", "foobar"); + + // Act + await client.GetAsync("https://example.com"); + + using var request = innerHandler.GetRequests().Single(); + + // Assert + request.Headers.Should().Contain(h => + h.Key == "sentry-trace" && + string.Concat(h.Value) == "foobar" + ); + } + } +}