Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,28 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
InjectTraceContext(propagationActivity, request.Headers);
InjectBaggage(propagationActivity, request.Headers);

var response = await base.SendAsync(request, cancellationToken);
HttpResponseMessage response;
try
{
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Only the synthesized client span gets exception metadata. When no listener is
// attached, propagation falls back to the ambient activity and there is no extra
// HTTP client span to annotate.
TUnit.Core.TUnitActivitySource.RecordException(activity, ex);
throw;
}

if (activity is not null)
{
activity.SetTag("http.response.status_code", (int)response.StatusCode);
if (!response.IsSuccessStatusCode)
var statusCode = (int)response.StatusCode;
activity.SetTag("http.response.status_code", statusCode);
if (statusCode >= 400)
{
activity.SetStatus(ActivityStatusCode.Error);
activity.SetTag("error.type", statusCode.ToString());
}
}

Expand All @@ -68,9 +82,11 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage

private static Activity? StartHttpActivity(HttpRequestMessage request)
{
var path = request.RequestUri?.AbsolutePath ?? request.RequestUri?.ToString() ?? "unknown";
var method = string.IsNullOrWhiteSpace(request.Method.Method)
? "HTTP"
: request.Method.Method;
return HttpActivitySource.StartActivity(
$"HTTP {request.Method} {path}",
method,
ActivityKind.Client);
}

Expand Down Expand Up @@ -103,6 +119,7 @@ private static void CopyBaggage(Activity? source, Activity destination)

foreach (var (key, value) in source.Baggage)
{
// Preserve baggage already attached to the synthesized client span itself.
if (key is null || destination.GetBaggageItem(key) is not null)
{
continue;
Expand Down
152 changes: 145 additions & 7 deletions TUnit.AspNetCore.Tests/ActivityPropagationHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;

namespace TUnit.AspNetCore.Tests;

[NotInParallel(nameof(ActivityPropagationHandlerTests))]
public class ActivityPropagationHandlerTests
{
[Test]
Expand All @@ -25,9 +28,16 @@ public async Task SendAsync_InjectsTraceContext_WhenHelperSpanIsCreated()
var traceparent = captured.LastRequest!.Headers.GetValues("traceparent").First();
var parts = traceparent.Split('-');
var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First();
var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem();

await AssertValidW3CTraceparent(traceparent);
await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString());
await Assert.That(parts[2]).IsEqualTo(clientSpan.SpanId);
await Assert.That(parts[2]).IsNotEqualTo(activity.SpanId.ToString());
await Assert.That(clientSpan.TraceId).IsEqualTo(activity.TraceId.ToString());
await Assert.That(clientSpan.ParentSpanId).IsEqualTo(activity.SpanId.ToString());
await Assert.That(clientSpan.Kind).IsEqualTo(ActivityKind.Client);
await Assert.That(clientSpan.DisplayName).IsEqualTo("GET");
await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId);
await Assert.That(baggageHeader).Contains("my-test-context-id");
}
Expand All @@ -50,6 +60,7 @@ public async Task SendAsync_FallsBackToActivityCurrent_WhenHelperSpanIsNotCreate
var parts = traceparent.Split('-');
var baggageHeader = captured.LastRequest.Headers.GetValues("baggage").First();

await AssertValidW3CTraceparent(traceparent);
await Assert.That(parts[1]).IsEqualTo(activity.TraceId.ToString());
await Assert.That(parts[2]).IsEqualTo(activity.SpanId.ToString());
await Assert.That(baggageHeader).Contains(TUnitActivitySource.TagTestId);
Expand All @@ -72,42 +83,169 @@ public async Task SendAsync_DoesNotInjectTraceContext_WhenNoAmbientActivityExist
await Assert.That(captured.LastRequest.Headers.Contains("baggage")).IsFalse();
}

[Test]
public async Task SendAsync_ClientSpan_3xxStatus_LeavesStatusUnset()
{
Activity.Current = null;
using var listenerScope = new ActivityListenerScope();
using var activity = new Activity("test-redirect").Start();

var captured = new CaptureHandler(HttpStatusCode.Redirect);
var handler = CreateHandler();
handler.InnerHandler = captured;
using var client = new HttpClient(handler);

using var response = await client.GetAsync("http://localhost/test");

await Assert.That((int)response.StatusCode).IsEqualTo(302);

var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem();
await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("302");
await Assert.That(clientSpan.Tags.ContainsKey("error.type")).IsFalse();
await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Unset);
}

[Test]
public async Task SendAsync_ClientSpan_4xxStatus_SetsErrorStatus()
{
Activity.Current = null;
using var listenerScope = new ActivityListenerScope();
using var activity = new Activity("test-not-found").Start();

var captured = new CaptureHandler(HttpStatusCode.NotFound);
var handler = CreateHandler();
handler.InnerHandler = captured;
using var client = new HttpClient(handler);

using var response = await client.GetAsync("http://localhost/test");

await Assert.That((int)response.StatusCode).IsEqualTo(404);

var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem();
await Assert.That(clientSpan.Tags.GetValueOrDefault("http.response.status_code")).IsEqualTo("404");
await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).IsEqualTo("404");
await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error);
}

[Test]
public async Task SendAsync_ClientSpan_RecordsException_WhenInnerHandlerThrows()
{
Activity.Current = null;
using var listenerScope = new ActivityListenerScope();
using var activity = new Activity("test-transport-error").Start();

var handler = CreateHandler();
handler.InnerHandler = new ThrowingHandler(new HttpRequestException("boom"));
using var client = new HttpClient(handler);

HttpRequestException? thrown = null;
try
{
await client.GetAsync("http://localhost/test");
}
catch (HttpRequestException ex)
{
thrown = ex;
}

await Assert.That(thrown).IsNotNull();

var clientSpan = await Assert.That(listenerScope.StoppedActivities).HasSingleItem();
await Assert.That(clientSpan.Tags.GetValueOrDefault("error.type")).Contains(nameof(HttpRequestException));
await Assert.That(clientSpan.EventNames).Contains("exception");
await Assert.That(clientSpan.Status).IsEqualTo(ActivityStatusCode.Error);
}

// Pass static _ => null to simulate no helper span; null uses the real StartHttpActivity default.
private static DelegatingHandler CreateHandler(Func<HttpRequestMessage, Activity?>? startActivity = null)
{
return startActivity is null
? new ActivityPropagationHandler()
: new ActivityPropagationHandler(startActivity);
}

private static async Task AssertValidW3CTraceparent(string traceparent)
{
var parts = traceparent.Split('-');

await Assert.That(parts.Length).IsEqualTo(4);
await Assert.That(parts[0]).IsEqualTo("00");
await Assert.That(parts[1].Length).IsEqualTo(32);
await Assert.That(parts[1].All(static c => Uri.IsHexDigit(c))).IsTrue();
await Assert.That(parts[2].Length).IsEqualTo(16);
await Assert.That(parts[2].All(static c => Uri.IsHexDigit(c))).IsTrue();
await Assert.That(parts[3] is "00" or "01").IsTrue();
}

private sealed class ActivityListenerScope : IDisposable
{
private readonly ActivityListener _listener = new()
{
ShouldListenTo = static source => source.Name == "TUnit.AspNetCore.Http",
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllDataAndRecorded
};
private readonly ConcurrentQueue<RecordedActivity> _stoppedActivities = new();
private readonly ActivityListener _listener;

public ActivityListenerScope()
{
_listener = new ActivityListener
{
ShouldListenTo = static source => source.Name == TUnitActivitySource.AspNetCoreHttpSourceName,
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity => _stoppedActivities.Enqueue(new RecordedActivity(
activity.TraceId.ToString(),
activity.SpanId.ToString(),
activity.ParentSpanId == default ? null : activity.ParentSpanId.ToString(),
activity.DisplayName,
activity.Kind,
activity.Status,
activity.TagObjects.ToDictionary(static t => t.Key, static t => t.Value?.ToString()),
activity.Events.Select(static e => e.Name).ToArray()))
};

ActivitySource.AddActivityListener(_listener);
}

public RecordedActivity[] StoppedActivities => _stoppedActivities.ToArray();

public void Dispose()
{
_listener.Dispose();
}
}

private sealed record RecordedActivity(
string TraceId,
string SpanId,
string? ParentSpanId,
string DisplayName,
ActivityKind Kind,
ActivityStatusCode Status,
IReadOnlyDictionary<string, string?> Tags,
string[] EventNames);

private sealed class CaptureHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;

public CaptureHandler(HttpStatusCode statusCode = HttpStatusCode.OK)
{
_statusCode = statusCode;
}

public HttpRequestMessage? LastRequest { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
return Task.FromResult(new HttpResponseMessage(_statusCode));
}
}

private sealed class ThrowingHandler(Exception exception) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromException<HttpResponseMessage>(exception);
}
}
}
Loading
Loading