Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure outgoing spans show on the Request dashboard in Sentry #3357

Merged
merged 11 commits into from
May 14, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Fixed SentryHttpMessageHandler and SentryGraphQLHttpMessageHandler not creating spans when there is no active Transaction on the scope ([#3360](https://github.com/getsentry/sentry-dotnet/pull/3360))
- The SDK no longer (wrongly) initializes sentry-native on Blazor WASM builds with `RunAOTCompilation` enabled. ([#3363](https://github.com/getsentry/sentry-dotnet/pull/3363))
- HttpClient requests now show on the Requests dashboard in Sentry ([#3357](https://github.com/getsentry/sentry-dotnet/pull/3357))

### Dependencies

Expand Down
55 changes: 29 additions & 26 deletions samples/Sentry.Samples.Console.Basic/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
*/

// Initialize the Sentry SDK. (It is not necessary to dispose it.)

using System.Net.Http;

SentrySdk.Init(options =>
{
// A Sentry Data Source Name (DSN) is required.
// TODO: Configure a Sentry Data Source Name (DSN).
// See https://docs.sentry.io/product/sentry-basics/dsn-explainer/
// You can set it in the SENTRY_DSN environment variable, or you can set it in code here.
// options.Dsn = "... Your DSN ...";
options.Dsn = "... Your DSN ...";
// When debug is enabled, the Sentry client will emit detailed debugging information to the console.
// This might be helpful, or might interfere with the normal operation of your application.
Expand All @@ -38,30 +41,27 @@
SentrySdk.ConfigureScope(scope => scope.Transaction = transaction);

// Do some work. (This is where you'd have your own application logic.)
await FirstFunctionAsync();
await SecondFunctionAsync();
await ThirdFunctionAsync();
await FirstFunction();
await SecondFunction();
await ThirdFunction();

// Always try to finish the transaction successfully.
// Unhandled exceptions will fail the transaction automatically.
// Optionally, you can try/catch the exception, and call transaction.Finish(exception) on failure.
transaction.Finish();

async Task FirstFunctionAsync()
async Task FirstFunction()
{
// This shows how you might instrument a particular function.
var span = transaction.StartChild("function", nameof(FirstFunctionAsync));

// Simulate doing some work
await Task.Delay(100);

// Finish the span successfully.
span.Finish();
// This is an example of making an HttpRequest. A trace us automatically captured by Sentry for this.
var messageHandler = new SentryHttpMessageHandler();
var httpClient = new HttpClient(messageHandler, true);
var html = await httpClient.GetStringAsync("https://example.com/");
Console.WriteLine(html);
}

async Task SecondFunctionAsync()
async Task SecondFunction()
{
var span = transaction.StartChild("function", nameof(SecondFunctionAsync));
var span = transaction.StartChild("function", nameof(SecondFunction));

try
{
Expand All @@ -81,16 +81,19 @@ async Task SecondFunctionAsync()
span.Finish();
}

async Task ThirdFunctionAsync()
async Task ThirdFunction()
{
var span = transaction.StartChild("function", nameof(ThirdFunctionAsync));

// Simulate doing some work
await Task.Delay(100);

// This is an example of an unhandled exception. It will be captured automatically.
throw new InvalidOperationException("Something happened that crashed the app!");
var span = transaction.StartChild("function", nameof(ThirdFunction));
try
{
// Simulate doing some work
await Task.Delay(100);

// In this case, we can't attempt to finish the span, due to the exception.
// span.Finish();
// This is an example of an unhandled exception. It will be captured automatically.
throw new InvalidOperationException("Something happened that crashed the app!");
}
finally
{
span.Finish();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Sentry\Sentry.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Net.Http" Version="4.3.4" />
jamescrosswell marked this conversation as resolved.
Show resolved Hide resolved
</ItemGroup>

</Project>
37 changes: 22 additions & 15 deletions samples/Sentry.Samples.OpenTelemetry.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,39 @@

SentrySdk.Init(options =>
{
// options.Dsn = "... Your DSN ...";
// TODO: Replace the DSN below with your own. You can find this value in your Sentry project settings.
// https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/#where-to-find-your-dsn
options.Dsn = "... Your DSN ...";
options.TracesSampleRate = 1.0;
options.UseOpenTelemetry(); // <-- Configure Sentry to use OpenTelemetry trace information
options.Debug = true;
});

using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(activitySource.Name)
.AddHttpClientInstrumentation()
.AddSentry() // <-- Configure OpenTelemetry to send traces to Sentry
.Build();

// Finally we can use OpenTelemetry to instrument our code. These activities will be captured as a Sentry transaction.
using var activity = activitySource.StartActivity("Main");
Console.WriteLine("Hello World!");
using (var task = activitySource.StartActivity("Task 1"))
{
task?.SetTag("Answer", 42);
Thread.Sleep(100); // simulate some work
Console.WriteLine("Task 1 completed");
task?.SetStatus(Status.Ok);
}

using (var task = activitySource.StartActivity("Task 2"))
// Finally we can use OpenTelemetry to instrument our code. This activity will be captured as a Sentry transaction.
using (var activity = activitySource.StartActivity("Main"))
{
task?.SetTag("Question", "???");
Thread.Sleep(100); // simulate some more work
Console.WriteLine("Task 2 unresolved");
task?.SetStatus(Status.Error);
// This creates a span called "Task 1" within the transaction
using (var task = activitySource.StartActivity("Task 1"))
{
task?.SetTag("Answer", 42);
Thread.Sleep(100); // simulate some work
Console.WriteLine("Task 1 completed");
task?.SetStatus(Status.Ok);
}

// Since we use `AddHttpClientInstrumentation` when initializing OpenTelemetry, the following Http request will also
// be captured as a Sentry span
var httpClient = new HttpClient();
var html = await httpClient.GetStringAsync("https://example.com/");
Console.WriteLine(html);
}

Console.WriteLine("Goodbye cruel world...");
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<PackageReference Include="OpenTelemetry" Version="1.8.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
</ItemGroup>

<!-- In your own project, this would be a PackageReference to the latest version of Sentry. -->
Expand Down
38 changes: 38 additions & 0 deletions src/Sentry.OpenTelemetry/OpenTelemetryExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Sentry.Internal.Extensions;
using Sentry.Internal.OpenTelemetry;

namespace Sentry.OpenTelemetry;

internal static class OpenTelemetryExtensions
Expand All @@ -16,4 +19,39 @@ public static BaggageHeader AsBaggageHeader(this IEnumerable<KeyValuePair<string
.Select(kvp => (KeyValuePair<string, string>)kvp!),
useSentryPrefix
);

/// <summary>
/// The names that OpenTelemetry gives to attributes, by convention, have changed over time so we often need to
/// check for both the new attribute and any obsolete ones.
/// </summary>
/// <param name="attributes">The attributes to be searched</param>
/// <param name="attributeNames">The list of possible names for the attribute you want to retrieve</param>
/// <typeparam name="T">The expected type of the attribute</typeparam>
/// <returns>The first attribute it finds matching one of the supplied <paramref name="attributeNames"/>
/// or null, if no matching attribute is found
/// </returns>
private static T? GetFirstMatchingAttribute<T>(this IDictionary<string, object?> attributes,
params string[] attributeNames)
{
foreach (var name in attributeNames)
{
if (attributes.TryGetTypedValue(name, out T value))
{
return value;
}
}
return default;
}

public static string? HttpMethodAttribute(this IDictionary<string, object?> attributes) =>
attributes.GetFirstMatchingAttribute<string>(
OtelSemanticConventions.AttributeHttpRequestMethod,
OtelSemanticConventions.AttributeHttpMethod // Fallback pre-1.5.0
);

public static string? UrlFullAttribute(this IDictionary<string, object?> attributes) =>
attributes.GetFirstMatchingAttribute<string>(
OtelSemanticConventions.AttributeUrlFull,
OtelSemanticConventions.AttributeHttpUrl // Fallback pre-1.5.0
);
}
22 changes: 11 additions & 11 deletions src/Sentry.OpenTelemetry/SentrySpanProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ private void CreateRootSpan(Activity data)
bool? isSampled = data.HasRemoteParent ? data.Recorded : null;

// No parent span found - start a new transaction
var transactionContext = new TransactionContext(data.DisplayName,
var transactionContext = new TransactionContext(
data.DisplayName,
data.OperationName,
data.SpanId.AsSentrySpanId(),
data.ParentSpanId.AsSentrySpanId(),
Expand Down Expand Up @@ -164,11 +165,7 @@ public override void OnEnd(Activity data)
// Make a dictionary of the attributes (aka "tags") for faster lookup when used throughout the processor.
var attributes = data.TagObjects.ToDict();

var url =
attributes.TryGetTypedValue(OtelSemanticConventions.AttributeUrlFull, out string? tempUrl) ? tempUrl
: attributes.TryGetTypedValue(OtelSemanticConventions.AttributeHttpUrl, out string? fallbackUrl) ? fallbackUrl // Falling back to pre-1.5.0
: null;

var url = attributes.UrlFullAttribute();
if (!string.IsNullOrEmpty(url) && (_options?.IsSentryRequest(url) ?? false))
{
_options?.DiagnosticLogger?.LogDebug($"Ignoring Activity {data.SpanId} for Sentry request.");
Expand Down Expand Up @@ -326,22 +323,25 @@ private static SpanStatus GetErrorSpanStatus(IDictionary<string, object?> attrib
return SpanStatus.UnknownError;
}

private static (string operation, string description, TransactionNameSource source) ParseOtelSpanDescription(
Activity activity,
IDictionary<string, object?> attributes)
internal static (string operation, string description, TransactionNameSource source) ParseOtelSpanDescription(
Activity activity,
IDictionary<string, object?> attributes)
{
// This function should loosely match the JavaScript implementation at:
// https://github.com/getsentry/sentry-javascript/blob/3487fa3af7aa72ac7fdb0439047cb7367c591e77/packages/opentelemetry-node/src/utils/parseOtelSpanDescription.ts
// However, it should also follow the OpenTelemetry semantic conventions specification, as indicated.

// HTTP span
// https://opentelemetry.io/docs/specs/otel/trace/semantic_conventions/http/
if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeHttpMethod, out string httpMethod))
if (attributes.HttpMethodAttribute() is { } httpMethod)
{
if (activity.Kind == ActivityKind.Client)
{
// Per OpenTelemetry spec, client spans use only the method.
return ("http.client", httpMethod, TransactionNameSource.Custom);
var description = (attributes.UrlFullAttribute() is { } fullUrl)
? $"{httpMethod} {fullUrl}"
: httpMethod;
return ("http.client", description, TransactionNameSource.Custom);
}

if (attributes.TryGetTypedValue(OtelSemanticConventions.AttributeHttpRoute, out string httpRoute))
Expand Down
4 changes: 4 additions & 0 deletions src/Sentry/SentryGraphQLHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ internal SentryGraphQLHttpMessageHandler(IHub? hub, SentryOptions? options,
$"{method} {url}" // e.g. "GET https://example.com"
);
span?.SetExtra(OtelSemanticConventions.AttributeHttpRequestMethod, method);
if (!string.IsNullOrWhiteSpace(request.RequestUri?.Host))
{
span?.SetExtra(OtelSemanticConventions.AttributeServerAddress, request.RequestUri!.Host);
}
return span;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Sentry/SentryHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ internal SentryHttpMessageHandler(IHub? hub, SentryOptions? options, HttpMessage
$"{method} {url}" // e.g. "GET https://example.com"
);
span?.SetExtra(OtelSemanticConventions.AttributeHttpRequestMethod, method);
if (!string.IsNullOrWhiteSpace(request.RequestUri?.Host))
{
span?.SetExtra(OtelSemanticConventions.AttributeServerAddress, request.RequestUri!.Host);
jamescrosswell marked this conversation as resolved.
Show resolved Hide resolved
}
return span;
}

Expand Down
20 changes: 20 additions & 0 deletions test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,24 @@ public void PruneFilteredSpans_RecentlyPruned_DoesNothing()
Assert.True(sut._map.TryGetValue(activity1.SpanId, out var _));
Assert.True(sut._map.TryGetValue(activity2.SpanId, out var _));
}

[Fact]
public void ParseOtelSpanDescription_HttpClient()
{
// Arrange
var data = Tracer.StartActivity("test op", ActivityKind.Client)!;
var attributes = new Dictionary<string, object>()
{
[OtelSemanticConventions.AttributeHttpRequestMethod] = "POST",
[OtelSemanticConventions.AttributeUrlFull] = "https://example.com/foo",
};

// Act
var (operation, description, source) = SentrySpanProcessor.ParseOtelSpanDescription(data, attributes);

// Assert
operation.Should().Be("http.client");
description.Should().Be("POST https://example.com/foo");
source.Should().Be(TransactionNameSource.Custom);
}
}
9 changes: 7 additions & 2 deletions test/Sentry.Tests/SentryGraphQlHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ public void ProcessRequest_SetsSpanData()
var sut = new SentryGraphQLHttpMessageHandler(hub, null);

var method = "POST";
var url = "http://example.com/graphql";
var host = "example.com";
var url = $"https://{host}/graphql";
var query = ValidQuery;
var request = SentryGraphQlTestHelpers.GetRequestQuery(query);
var request = SentryGraphQlTestHelpers.GetRequestQuery(query, url);

// Act
var returnedSpan = sut.ProcessRequest(request, method, url);
Expand All @@ -68,6 +69,10 @@ public void ProcessRequest_SetsSpanData()
kvp.Key == OtelSemanticConventions.AttributeHttpRequestMethod &&
kvp.Value.ToString() == method
);
returnedSpan.Extra.Should().Contain(kvp =>
kvp.Key == OtelSemanticConventions.AttributeServerAddress &&
kvp.Value.ToString() == host
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/Sentry.Tests/SentryGraphQlTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static HttpRequestMessage GetRequestQuery(string query, string url = "htt
return GetRequest(content, url);
}

public static HttpRequestMessage GetRequest(HttpContent content, string url = "http://foo") => new(HttpMethod.Post, url)
public static HttpRequestMessage GetRequest(HttpContent content, string url = "http://foo") => new(HttpMethod.Post, new Uri(url))
{
Content = content
};
Expand Down
12 changes: 9 additions & 3 deletions test/Sentry.Tests/SentryHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,11 @@ public void ProcessRequest_SetsSpanData()
using var innerHandler = new FakeHttpMessageHandler();
var sut = new SentryHttpMessageHandler(hub, _fixture.Options, innerHandler);

const string method = "GET";
const string url = "https://example.com/graphql";
var request = new HttpRequestMessage(HttpMethod.Get, url);
var method = "GET";
var host = "example.com";
var url = $"https://{host}/graphql";
var uri = new Uri(url);
var request = new HttpRequestMessage(HttpMethod.Get, uri);

// Act
var returnedSpan = sut.ProcessRequest(request, method, url);
Expand All @@ -281,6 +283,10 @@ public void ProcessRequest_SetsSpanData()
kvp.Key == OtelSemanticConventions.AttributeHttpRequestMethod &&
Equals(kvp.Value, method)
);
returnedSpan.Extra.Should().Contain(kvp =>
kvp.Key == OtelSemanticConventions.AttributeServerAddress &&
Equals(kvp.Value, host)
);
}

[Fact]
Expand Down
Loading