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

Support Dynamic Sampling #1953

Merged
merged 30 commits into from
Oct 4, 2022
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8db993e
Update existing text unrelated to dynamic sampling
mattjohnsonpint Sep 13, 2022
ab7ad3d
Add baggage header and DSC
mattjohnsonpint Sep 28, 2022
c42d148
Update tracing middleware for ASP.NET Core
mattjohnsonpint Sep 28, 2022
532b720
Update tests
mattjohnsonpint Sep 28, 2022
3a7595b
Update verify tests for Windows build
mattjohnsonpint Sep 28, 2022
9ad0fe1
mark verify tests
mattjohnsonpint Sep 28, 2022
8585888
Update CHANGELOG.md
mattjohnsonpint Sep 28, 2022
5bd3504
Apply baggage headers
mattjohnsonpint Sep 28, 2022
39fc013
Simplify baggage header
mattjohnsonpint Sep 28, 2022
382da98
More baggage header tests
mattjohnsonpint Sep 29, 2022
3b84f03
More baggage and DSC
mattjohnsonpint Sep 29, 2022
7d2594d
Don't make DSC public
mattjohnsonpint Sep 29, 2022
fa363f8
Refactor
mattjohnsonpint Sep 29, 2022
5c9300c
DSC tests
mattjohnsonpint Sep 29, 2022
9b42274
Add ASP.NET support
mattjohnsonpint Sep 29, 2022
0f457b2
Add baggage propagation test
mattjohnsonpint Sep 29, 2022
2188438
DSC end-to-end test
mattjohnsonpint Sep 29, 2022
09c86fb
Merge branch 'main' into dynamic-sampling
mattjohnsonpint Oct 1, 2022
abe26e5
Add some logging
mattjohnsonpint Oct 1, 2022
34ddb3c
misc
mattjohnsonpint Oct 1, 2022
34f997d
Get logger once in static initialization
mattjohnsonpint Oct 3, 2022
cec6271
Add extension method for getting options
mattjohnsonpint Oct 3, 2022
60287d4
Add TracePropogationTargets
mattjohnsonpint Oct 3, 2022
3a7e919
Update API verifications
mattjohnsonpint Oct 3, 2022
9bf673b
Refactor and add tests
mattjohnsonpint Oct 4, 2022
9940846
Add tests
mattjohnsonpint Oct 4, 2022
77812f4
Add tests
mattjohnsonpint Oct 4, 2022
c7e92ad
Support setting option via json config file
mattjohnsonpint Oct 4, 2022
3409cdf
Update API verifications
mattjohnsonpint Oct 4, 2022
6474ccc
Update TracePropagationTargets implementation
mattjohnsonpint Oct 4, 2022
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Add `User.Segment` property ([#1920](https://github.com/getsentry/sentry-dotnet/pull/1920))
- Add support for custom `JsonConverter`s ([#1934](https://github.com/getsentry/sentry-dotnet/pull/1934))
- Support more types for message template tags in SentryLogger ([#1945](https://github.com/getsentry/sentry-dotnet/pull/1945))
- Support Dynamic Sampling ([#1953](https://github.com/getsentry/sentry-dotnet/pull/1953))

## Fixes

Expand Down
66 changes: 58 additions & 8 deletions src/Sentry.AspNet/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Collections.Specialized;
using System.ComponentModel;
using System.Web;
using Sentry.Extensibility;

namespace Sentry.AspNet;

Expand All @@ -12,20 +12,47 @@ public static class HttpContextExtensions
{
private const string HttpContextTransactionItemName = "__SentryTransaction";

private static SentryTraceHeader? TryGetTraceHeader(NameValueCollection headers)
private static SentryTraceHeader? TryGetSentryTraceHeader(HttpContext context, SentryOptions? options)
{
var traceHeader = headers.Get(SentryTraceHeader.HttpHeaderName);
if (traceHeader == null)
var value = context.Request.Headers.Get(SentryTraceHeader.HttpHeaderName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}

options?.LogDebug("Received Sentry trace header '{0}'.", value);

try
{
return SentryTraceHeader.Parse(value);
}
catch (Exception ex)
{
options?.LogError("Invalid Sentry trace header '{0}'.", ex, value);
return null;
}
}

private static BaggageHeader? TryGetBaggageHeader(HttpContext context, SentryOptions? options)
{
var value = context.Request.Headers.Get(SentryTraceHeader.HttpHeaderName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}

// Note: If there are multiple baggage headers, they will be joined with comma delimiters,
// and can thus be treated as a single baggage header.

options?.LogDebug("Received baggage header '{0}'.", value);

try
{
return SentryTraceHeader.Parse(traceHeader);
return BaggageHeader.TryParse(value, onlySentry: true);
}
catch
catch (Exception ex)
{
options?.LogError("Invalid baggage header '{0}'.", ex, value);
return null;
}
}
Expand All @@ -37,8 +64,9 @@ public static ITransaction StartSentryTransaction(this HttpContext httpContext)
{
var method = httpContext.Request.HttpMethod;
var path = httpContext.Request.Path;
var options = SentrySdk.CurrentOptions;
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

var traceHeader = TryGetTraceHeader(httpContext.Request.Headers);
var traceHeader = TryGetSentryTraceHeader(httpContext, options);

var transactionName = $"{method} {path}";
const string transactionOperation = "http.server";
Expand All @@ -47,7 +75,29 @@ public static ITransaction StartSentryTransaction(this HttpContext httpContext)
? new TransactionContext(transactionName, transactionOperation, traceHeader, TransactionNameSource.Url)
: new TransactionContext(transactionName, transactionOperation, TransactionNameSource.Url);

var transaction = SentrySdk.StartTransaction(transactionContext);
var customSamplingContext = new Dictionary<string, object?>(3, StringComparer.Ordinal)
{
["__HttpMethod"] = method,
["__HttpPath"] = path,
["__HttpContext"] = httpContext,
};

// Set the Dynamic Sampling Context from the baggage header, if it exists.
var baggageHeader = TryGetBaggageHeader(httpContext, options);
var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext();

if (traceHeader is { } && baggageHeader is null)
{
// We received a sentry-trace header without a baggage header, which indicates the request
// originated from an older SDK that doesn't support dynamic sampling.
// Set DynamicSamplingContext.Empty to "freeze" the DSC on the transaction.
// See:
// https://develop.sentry.dev/sdk/performance/dynamic-sampling-context/#freezing-dynamic-sampling-context
// https://develop.sentry.dev/sdk/performance/dynamic-sampling-context/#unified-propagation-mechanism
dynamicSamplingContext = DynamicSamplingContext.Empty;
}

var transaction = SentrySdk.StartTransaction(transactionContext, customSamplingContext, dynamicSamplingContext);

SentrySdk.ConfigureScope(scope => scope.Transaction = transaction);
httpContext.Items[HttpContextTransactionItemName] = transaction;
Expand Down
58 changes: 47 additions & 11 deletions src/Sentry.AspNetCore/SentryTracingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;
using Sentry.AspNetCore.Extensions;
using Sentry.Extensibility;
using Sentry.Internal;

namespace Sentry.AspNetCore
{
Expand Down Expand Up @@ -50,6 +51,30 @@ public SentryTracingMiddleware(
}
}

private BaggageHeader? TryGetBaggageHeader(HttpContext context)
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
{
var value = context.Request.Headers.GetValueOrDefault(BaggageHeader.HttpHeaderName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}

// Note: If there are multiple baggage headers, they will be joined with comma delimiters,
// and can thus be treated as a single baggage header.

_options.LogDebug("Received baggage header '{0}'.", value);

try
{
return BaggageHeader.TryParse(value, onlySentry: true);
}
catch (Exception ex)
{
_options.LogError("Invalid baggage header '{0}'.", ex, value);
return null;
}
}

private ITransaction? TryStartTransaction(HttpContext context)
{
if (context.Request.Method == HttpMethod.Options.Method)
Expand All @@ -65,18 +90,14 @@ public SentryTracingMiddleware(
// Attempt to start a transaction from the trace header if it exists
var traceHeader = TryGetSentryTraceHeader(context);

// It's important to try and set the transaction name
// to some value here so that it's available for use
// in sampling.
// At a later stage, we will try to get the transaction name
// again, to account for the other middlewares that may have
// ran after ours.
var transactionName =
context.TryGetTransactionName();
// It's important to try and set the transaction name to some value here so that it's available for use
// in sampling. At a later stage, we will try to get the transaction name again, to account for the
// other middlewares that may have ran after ours.
var transactionName = context.TryGetTransactionName() ?? string.Empty;

var transactionContext = traceHeader is not null
? new TransactionContext(transactionName ?? string.Empty, OperationName, traceHeader, TransactionNameSource.Route)
: new TransactionContext(transactionName ?? string.Empty, OperationName, TransactionNameSource.Route);
? new TransactionContext(transactionName, OperationName, traceHeader, TransactionNameSource.Route)
: new TransactionContext(transactionName, OperationName, TransactionNameSource.Route);

var customSamplingContext = new Dictionary<string, object?>(4, StringComparer.Ordinal)
{
Expand All @@ -86,7 +107,22 @@ public SentryTracingMiddleware(
[SamplingExtensions.KeyForHttpContext] = context,
};

var transaction = hub.StartTransaction(transactionContext, customSamplingContext);
// Set the Dynamic Sampling Context from the baggage header, if it exists.
var baggageHeader = TryGetBaggageHeader(context);
var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext();

if (traceHeader is { } && baggageHeader is null)
{
// We received a sentry-trace header without a baggage header, which indicates the request
// originated from an older SDK that doesn't support dynamic sampling.
// Set DynamicSamplingContext.Empty to "freeze" the DSC on the transaction.
// See:
// https://develop.sentry.dev/sdk/performance/dynamic-sampling-context/#freezing-dynamic-sampling-context
// https://develop.sentry.dev/sdk/performance/dynamic-sampling-context/#unified-propagation-mechanism
dynamicSamplingContext = DynamicSamplingContext.Empty;
}

var transaction = hub.StartTransaction(transactionContext, customSamplingContext, dynamicSamplingContext);

_options.LogInfo(
"Started transaction with span ID '{0}' and trace ID '{1}'.",
Expand Down
135 changes: 135 additions & 0 deletions src/Sentry/BaggageHeader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Sentry.Internal.Extensions;

namespace Sentry
{
/// <summary>
/// Baggage Header for dynamic sampling.
/// </summary>
/// <seealso href="https://develop.sentry.dev/sdk/performance/dynamic-sampling-context"/>
/// <seealso href="https://www.w3.org/TR/baggage"/>
internal class BaggageHeader
{
internal const string HttpHeaderName = "baggage";
internal const string SentryKeyPrefix = "sentry-";

// https://www.w3.org/TR/baggage/#baggage-string
// "Uniqueness of keys between multiple list-members in a baggage-string is not guaranteed."
// "The order of duplicate entries SHOULD be preserved when mutating the list."

public IReadOnlyList<KeyValuePair<string, string>> Members { get; }

private BaggageHeader(IEnumerable<KeyValuePair<string, string>> members) =>
Members = members.ToList().AsReadOnly();
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved

// We can safely return a dictionary of Sentry members, as we are in control over the keys added.
// Just to be safe though, we'll group by key and only take the first of each one.
public IReadOnlyDictionary<string, string> GetSentryMembers() =>
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
Members
.Where(kvp => kvp.Key.StartsWith(SentryKeyPrefix))
.GroupBy(kvp => kvp.Key, kvp => kvp.Value)
.ToDictionary(
#if NETCOREAPP || NETSTANDARD2_1
g => g.Key[SentryKeyPrefix.Length..],
#else
g => g.Key.Substring(SentryKeyPrefix.Length),
#endif
g => g.First());

/// <summary>
/// Creates the baggage header string based on the members of this instance.
/// </summary>
/// <returns>The baggage header string.</returns>
public override string ToString()
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
{
// The keys do not require special encoding. The values are percent-encoded.
// The results should not be sorted, as the baggage spec says original ordering should be preserved.
var members = Members.Select(x => $"{x.Key}={Uri.EscapeDataString(x.Value)}");

// Whitespace after delimiter is optional by the spec, but typical by convention.
return string.Join(", ", members);
}

/// <summary>
/// Parses a baggage header string.
/// </summary>
/// <param name="baggage">The string to parse.</param>
/// <param name="onlySentry">
/// When <c>false</c>, the resulting object includes all list members present in the baggage header string.
/// When <c>true</c>, the resulting object includes only members prefixed with <c>"sentry-"</c>.
/// </param>
/// <returns>
/// An object representing the members baggage header, or <c>null</c> if there are no members parsed.
/// </returns>
public static BaggageHeader? TryParse(string baggage, bool onlySentry = false)
{
// Example from W3C baggage spec:
// "key1=value1;property1;property2, key2 = value2, key3=value3; propertyKey=propertyValue"

var items = baggage.Split(',', StringSplitOptions.RemoveEmptyEntries);
var members = new List<KeyValuePair<string, string>>(items.Length);

foreach (var item in items)
{
// Per baggage spec, the value may contain = characters, so limit the split to 2 parts.
var parts = item.Split('=', 2);
if (parts.Length != 2)
{
// malformed, missing separator, key, or value
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
continue;
}

var key = parts[0].Trim();
var value = parts[1].Trim();
if (key.Length == 0 || value.Length == 0)
{
// malformed, key or value found empty
continue;
}

if (!onlySentry || key.StartsWith(SentryKeyPrefix))
{
// Values are percent-encoded. Decode them before storing.
members.Add(key, Uri.UnescapeDataString(value));
}
}

return members.Count == 0 ? null : new BaggageHeader(members);
}

public static BaggageHeader Create(
IEnumerable<KeyValuePair<string, string>> items,
bool useSentryPrefix = false)
{
var members = items.Where(member => IsValidKey(member.Key));

if (useSentryPrefix)
{
members = members.Select(kvp => new KeyValuePair<string, string>(SentryKeyPrefix + kvp.Key, kvp.Value));
}

return new BaggageHeader(members);
}

public static BaggageHeader Merge(IEnumerable<BaggageHeader> baggageHeaders) =>
new(baggageHeaders.SelectMany(x => x.Members));

private static bool IsValidKey(string key)
{
if (key.Length == 0)
{
return false;
}

// The rules are the same as for HTTP headers.
// TODO: Is this public somewhere in .NET we can just call?
mattjohnsonpint marked this conversation as resolved.
Show resolved Hide resolved
// https://www.w3.org/TR/baggage/#key
// https://www.rfc-editor.org/rfc/rfc7230#section-3.2.6
// https://source.dot.net/#System.Net.Http/System/Net/Http/HttpRuleParser.cs,21
const string delimiters = @"""(),/:;<=>?@[\]{}";
return key.All(c => c >= 33 && c != 127 && !delimiters.Contains(c));
}
}
}
Loading