Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add strict trace continuation support ([#4981](https://github.com/getsentry/sentry-dotnet/pull/4981))

### Fixes

- The SDK now logs a `Warning` instead of an `Error` when being ratelimited ([#4927](https://github.com/getsentry/sentry-dotnet/pull/4927))
Expand Down
26 changes: 24 additions & 2 deletions src/Sentry/Dsn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,28 @@ internal sealed class Dsn
/// </summary>
private Uri ApiBaseUri { get; }

/// <summary>
/// The organization ID parsed from the DSN host (e.g., <c>o1</c> in <c>o1.ingest.us.sentry.io</c> yields <c>"1"</c>).
/// Returns <c>null</c> if no org ID is present in the DSN.
/// </summary>
public string? OrgId { get; internal set; }

private Dsn(
string source,
string projectId,
string? path,
string? secretKey,
string publicKey,
Uri apiBaseUri)
Uri apiBaseUri,
string? orgId = null)
{
Source = source;
ProjectId = projectId;
Path = path;
SecretKey = secretKey;
PublicKey = publicKey;
ApiBaseUri = apiBaseUri;
OrgId = orgId;
}

public Uri GetStoreEndpointUri() => new(ApiBaseUri, "store/");
Expand Down Expand Up @@ -93,6 +101,19 @@ public static Dsn Parse(string dsn)
throw new ArgumentException("Invalid DSN: A Project Id is required.");
}

// Parse org ID from host (e.g., "o1.ingest.us.sentry.io" -> "1")
string? orgId = null;
var hostParts = uri.DnsSafeHost.Split('.');
if (hostParts.Length > 0)
{
var firstPart = hostParts[0];
if (firstPart.Length >= 2 && firstPart[0] == 'o' &&
ulong.TryParse(firstPart.Substring(1), out _))
{
orgId = firstPart.Substring(1);
}
}

var apiBaseUri = new UriBuilder
{
Scheme = uri.Scheme,
Expand All @@ -107,7 +128,8 @@ public static Dsn Parse(string dsn)
path,
secretKey,
publicKey,
apiBaseUri);
apiBaseUri,
orgId);
}

public static Dsn? TryParse(string? dsn)
Expand Down
17 changes: 13 additions & 4 deletions src/Sentry/DynamicSamplingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ private DynamicSamplingContext(SentryId traceId,
string? release = null,
string? environment = null,
string? transactionName = null,
IReplaySession? replaySession = null)
IReplaySession? replaySession = null,
string? orgId = null)
{
// Validate and set required values
if (traceId == SentryId.Empty)
Expand Down Expand Up @@ -95,6 +96,11 @@ private DynamicSamplingContext(SentryId traceId,
items.Add("replay_id", replayId.ToString());
}

if (!string.IsNullOrWhiteSpace(orgId))
{
items.Add("org_id", orgId);
}

_items = items;
}

Expand Down Expand Up @@ -200,7 +206,8 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra
release,
environment,
transactionName,
replaySession);
replaySession,
orgId: options.GetEffectiveOrgId());
}

public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession)
Expand All @@ -225,7 +232,8 @@ public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTra
release,
environment,
transactionName,
replaySession);
replaySession,
orgId: options.GetEffectiveOrgId());
}

public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession)
Expand All @@ -241,7 +249,8 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat
null,
release: release,
environment: environment,
replaySession: replaySession
replaySession: replaySession,
orgId: options.GetEffectiveOrgId()
);
}
}
Expand Down
42 changes: 41 additions & 1 deletion src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,14 @@ public TransactionContext ContinueTrace(
string? name = null,
string? operation = null)
{
var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession);
if (!ShouldContinueTrace(baggageHeader))
{
_options.LogDebug("Not continuing trace due to org ID validation. Starting new trace.");
traceHeader = null;
baggageHeader = null;
}

var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession, _options.GetEffectiveOrgId());
ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext);

return new TransactionContext(
Expand All @@ -368,6 +375,39 @@ public TransactionContext ContinueTrace(
isParentSampled: traceHeader?.IsSampled);
}

internal bool ShouldContinueTrace(BaggageHeader? baggageHeader)
{
var sdkOrgId = _options.GetEffectiveOrgId();

string? baggageOrgId = null;
if (baggageHeader is not null)
{
var sentryMembers = baggageHeader.GetSentryMembers();
sentryMembers.TryGetValue("org_id", out baggageOrgId);
}

// Mismatched org IDs always cause a new trace, regardless of strict mode
if (!string.IsNullOrEmpty(sdkOrgId) && !string.IsNullOrEmpty(baggageOrgId) && sdkOrgId != baggageOrgId)
{
return false;
}

// In strict mode, both must be present and match
if (_options.StrictTraceContinuation)
{
// If both are missing, continue (nothing to compare)
if (string.IsNullOrEmpty(sdkOrgId) && string.IsNullOrEmpty(baggageOrgId))
{
return true;
}

// Both must be present and equal
return sdkOrgId == baggageOrgId;
}

return true;
}

public void StartSession()
{
// Attempt to recover persisted session left over from previous run
Expand Down
40 changes: 40 additions & 0 deletions src/Sentry/SentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,24 @@ public string? Dsn
internal Dsn? _parsedDsn;
internal Dsn ParsedDsn => _parsedDsn ??= Sentry.Dsn.Parse(Dsn!);

/// <summary>
/// Returns the effective org ID, preferring <see cref="OrgId"/> if set, otherwise falling back to the DSN-parsed value.
/// </summary>
internal string? GetEffectiveOrgId()
{
if (!string.IsNullOrWhiteSpace(OrgId))
{
return OrgId;
}

if (!string.IsNullOrWhiteSpace(Dsn))
{
return ParsedDsn.OrgId;
}

return null;
}

private readonly Lazy<string> _sentryBaseUrl;

internal bool IsSentryRequest(string? requestUri) =>
Expand Down Expand Up @@ -1032,6 +1050,28 @@ public IList<StringOrRegex> TracePropagationTargets
/// <seealso href="https://develop.sentry.dev/sdk/telemetry/traces/#propagatetraceparent"/>
public bool PropagateTraceparent { get; set; }

/// <summary>
/// Controls trace continuation from third-party services that happen to be instrumented by Sentry.
/// </summary>
/// <remarks>
/// When enabled, the SDK will require org IDs from baggage to match for continuing the trace.
/// If the incoming trace does not contain an org ID and this option is <c>true</c>, a new trace will be started.
/// When disabled (default), incoming traces without org IDs will be continued as normal,
/// but mismatched org IDs will always cause a new trace to be started regardless of this setting.
/// </remarks>
public bool StrictTraceContinuation { get; set; }

/// <summary>
/// Configures the org ID used for trace propagation and features like <see cref="StrictTraceContinuation"/>.
/// </summary>
/// <remarks>
/// In most cases the org ID is already parsed from the DSN (e.g., <c>o1</c> in
/// <c>https://key@o1.ingest.us.sentry.io/123</c> yields org ID <c>"1"</c>).
/// Use this option when non-standard Sentry DSNs are used, such as self-hosted or when using a local Relay.
/// When set, this value overrides the org ID parsed from the DSN.
/// </remarks>
public string? OrgId { get; set; }

internal ITransactionProfilerFactory? TransactionProfilerFactory { get; set; }

private StackTraceMode? _stackTraceMode;
Expand Down
15 changes: 14 additions & 1 deletion src/Sentry/SentryPropagationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public SentryPropagationContext(SentryPropagationContext? other)
_dynamicSamplingContext = other?._dynamicSamplingContext;
}

public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession)
public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession, string? sdkOrgId = null)
{
logger?.LogDebug("Creating a propagation context from headers.");

Expand All @@ -58,6 +58,19 @@ public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logg
return new SentryPropagationContext();
}

// Check for org ID mismatch between SDK configuration and incoming baggage
if (!string.IsNullOrEmpty(sdkOrgId) && baggageHeader is not null)
{
var sentryMembers = baggageHeader.GetSentryMembers();
if (sentryMembers.TryGetValue("org_id", out var baggageOrgId)
&& !string.IsNullOrEmpty(baggageOrgId)
&& sdkOrgId != baggageOrgId)
{
logger?.LogInfo("Org ID mismatch (SDK: {0}, baggage: {1}). Starting new trace.", sdkOrgId, baggageOrgId);
return new SentryPropagationContext();
}
}

var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext(replaySession);
return new SentryPropagationContext(traceHeader.TraceId, traceHeader.SpanId, dynamicSamplingContext);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ namespace Sentry
public int MaxCacheItems { get; set; }
public int MaxQueueItems { get; set; }
public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; }
public string? OrgId { get; set; }
public double? ProfilesSampleRate { get; set; }
public bool PropagateTraceparent { get; set; }
public string? Release { get; set; }
Expand All @@ -838,6 +839,7 @@ namespace Sentry
public System.TimeSpan ShutdownTimeout { get; set; }
public string SpotlightUrl { get; set; }
public Sentry.StackTraceMode StackTraceMode { get; set; }
public bool StrictTraceContinuation { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TagFilters { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TracePropagationTargets { get; set; }
public double? TracesSampleRate { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ namespace Sentry
public int MaxCacheItems { get; set; }
public int MaxQueueItems { get; set; }
public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; }
public string? OrgId { get; set; }
public double? ProfilesSampleRate { get; set; }
public bool PropagateTraceparent { get; set; }
public string? Release { get; set; }
Expand All @@ -838,6 +839,7 @@ namespace Sentry
public System.TimeSpan ShutdownTimeout { get; set; }
public string SpotlightUrl { get; set; }
public Sentry.StackTraceMode StackTraceMode { get; set; }
public bool StrictTraceContinuation { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TagFilters { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TracePropagationTargets { get; set; }
public double? TracesSampleRate { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ namespace Sentry
public int MaxCacheItems { get; set; }
public int MaxQueueItems { get; set; }
public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; }
public string? OrgId { get; set; }
public double? ProfilesSampleRate { get; set; }
public bool PropagateTraceparent { get; set; }
public string? Release { get; set; }
Expand All @@ -838,6 +839,7 @@ namespace Sentry
public System.TimeSpan ShutdownTimeout { get; set; }
public string SpotlightUrl { get; set; }
public Sentry.StackTraceMode StackTraceMode { get; set; }
public bool StrictTraceContinuation { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TagFilters { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TracePropagationTargets { get; set; }
public double? TracesSampleRate { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,7 @@ namespace Sentry
public int MaxCacheItems { get; set; }
public int MaxQueueItems { get; set; }
public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; }
public string? OrgId { get; set; }
public double? ProfilesSampleRate { get; set; }
public bool PropagateTraceparent { get; set; }
public string? Release { get; set; }
Expand All @@ -824,6 +825,7 @@ namespace Sentry
public System.TimeSpan ShutdownTimeout { get; set; }
public string SpotlightUrl { get; set; }
public Sentry.StackTraceMode StackTraceMode { get; set; }
public bool StrictTraceContinuation { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TagFilters { get; set; }
public System.Collections.Generic.IList<Sentry.StringOrRegex> TracePropagationTargets { get; set; }
public double? TracesSampleRate { get; set; }
Expand Down
Loading
Loading