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
122 changes: 122 additions & 0 deletions STRICT_TRACE_CONTINUATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Strict Trace Continuation Feature

## Overview

The `strictTraceContinuation` feature allows the Sentry PHP SDK to control trace continuation from unknown 3rd party services. When enabled, the SDK will only continue traces from the same organization, preventing 3rd party services from affecting your trace sample rates.

## Problem Statement

When a 3rd party accesses your API and sets headers like `traceparent` and `tracestate`, it can trigger trace propagation on your backend even when your trace sample rate is set to 0%. This can lead to:
- Unexpected trace data from external sources
- Affected sampling rates
- Unwanted trace continuation from different organizations

## Solution

The `strictTraceContinuation` option validates the organization ID from incoming trace headers against your configured DSN's organization ID. If they don't match, a new trace is created instead of continuing the existing one.

## Configuration

### Enable Strict Trace Continuation

```php
use Sentry\ClientBuilder;

$client = ClientBuilder::create([
'dsn' => 'https://[email protected]/project-id',
'strictTraceContinuation' => true, // Enable strict validation
])->getClient();
```

### Using with Options Object

```php
use Sentry\Options;

$options = new Options([
'dsn' => 'https://[email protected]/project-id',
]);
$options->enableStrictTraceContinuation(true);
```

## How It Works

1. **Organization ID Extraction**: The SDK extracts the organization ID from your DSN (e.g., `o123` from `o123.ingest.sentry.io`)

2. **Baggage Header Validation**: When receiving trace headers, the SDK checks the `sentry-org_id` entry in the baggage header

3. **Trace Decision**:
- If `strictTraceContinuation` is **disabled** (default): Continues the trace regardless of org ID
- If `strictTraceContinuation` is **enabled**:
- **Matching org IDs**: Continues the existing trace
- **Mismatched org IDs**: Creates a new trace
- **Missing org ID**: Continues the trace (backwards compatibility)

## Example Usage

```php
use Sentry\Tracing\TransactionContext;
use function Sentry\continueTrace;

// Incoming headers from a request
$sentryTrace = $_SERVER['HTTP_SENTRY_TRACE'] ?? '';
$baggage = $_SERVER['HTTP_BAGGAGE'] ?? '';

// Continue or create a new trace based on org ID validation
$transactionContext = continueTrace($sentryTrace, $baggage);

// Start a transaction
$transaction = \Sentry\startTransaction($transactionContext);

// Your application logic here...

// Finish the transaction
$transaction->finish();
```

## Behavior Examples

### Scenario 1: Disabled (Default)
```
strictTraceContinuation: false
Incoming org_id: 456
Local org_id: 123
Result: Trace continues (backwards compatible)
```

### Scenario 2: Enabled with Matching Org
```
strictTraceContinuation: true
Incoming org_id: 123
Local org_id: 123
Result: Trace continues
```

### Scenario 3: Enabled with Mismatched Org
```
strictTraceContinuation: true
Incoming org_id: 456
Local org_id: 123
Result: New trace created
```

## Implementation Details

The feature is implemented in:
- `Options::isStrictTraceContinuationEnabled()` - Check if enabled
- `Options::enableStrictTraceContinuation()` - Enable/disable the feature
- `TransactionContext::fromHeaders()` - Validates org ID for transactions
- `PropagationContext::fromHeaders()` - Validates org ID for propagation
- `continueTrace()` - Main entry point for continuing traces

## Compatibility

- **Default**: Disabled (backwards compatible)
- **Minimum PHP Version**: Same as the SDK requirements
- **Sentry SaaS**: Works with org IDs in DSN format `o{orgId}.ingest.sentry.io`
- **Self-hosted**: Works if org ID is configured in the DSN

## Related Documentation

- [Sentry SDK Specification - strictTraceContinuation](https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation)
- [GitHub Issue #1830](https://github.com/getsentry/sentry-php/issues/1830)
21 changes: 13 additions & 8 deletions src/Options.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

declare(strict_types=1);
Expand Down Expand Up @@ -720,19 +720,24 @@
}

/**
* Returns whether strict trace propagation is enabled or not.
* Returns whether strict trace continuation is enabled or not.
*
* When enabled, the SDK will only continue traces from the same organization
* based on the org ID in the baggage header matching the org ID from the DSN.
*/
public function isStrictTracePropagationEnabled(): bool
public function isStrictTraceContinuationEnabled(): bool
{
return $this->options['strict_trace_propagation'];
return $this->options['strict_trace_continuation'];

Check failure on line 730 in src/Options.php

View workflow job for this annotation

GitHub Actions / PHPStan

Method Sentry\Options::isStrictTraceContinuationEnabled() should return bool but returns mixed.
}

/**
* Sets if strict trace propagation should be enabled or not.
* Sets if strict trace continuation should be enabled or not.
*
* @param bool $strictTraceContinuation Whether to enable strict trace continuation
*/
public function enableStrictTracePropagation(bool $strictTracePropagation): self
public function enableStrictTraceContinuation(bool $strictTraceContinuation): self
{
$options = array_merge($this->options, ['strict_trace_propagation' => $strictTracePropagation]);
$options = array_merge($this->options, ['strict_trace_continuation' => $strictTraceContinuation]);

$this->options = $this->resolver->resolve($options);

Expand Down Expand Up @@ -1261,7 +1266,7 @@
return null;
},
'trace_propagation_targets' => null,
'strict_trace_propagation' => false,
'strict_trace_continuation' => false,
'tags' => [],
'error_types' => null,
'max_breadcrumbs' => self::DEFAULT_MAX_BREADCRUMBS,
Expand Down Expand Up @@ -1312,7 +1317,7 @@
$resolver->setAllowedTypes('ignore_exceptions', 'string[]');
$resolver->setAllowedTypes('ignore_transactions', 'string[]');
$resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']);
$resolver->setAllowedTypes('strict_trace_propagation', 'bool');
$resolver->setAllowedTypes('strict_trace_continuation', 'bool');
$resolver->setAllowedTypes('tags', 'string[]');
$resolver->setAllowedTypes('error_types', ['null', 'int']);
$resolver->setAllowedTypes('max_breadcrumbs', 'int');
Expand Down
42 changes: 37 additions & 5 deletions src/Tracing/PropagationContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Sentry\Tracing;

use Sentry\ClientInterface;
use Sentry\SentrySdk;
use Sentry\State\Scope;

Expand Down Expand Up @@ -59,14 +60,14 @@ public static function fromDefaults(): self
return $context;
}

public static function fromHeaders(string $sentryTraceHeader, string $baggageHeader): self
public static function fromHeaders(string $sentryTraceHeader, string $baggageHeader, ?ClientInterface $client = null): self
{
return self::parseTraceparentAndBaggage($sentryTraceHeader, $baggageHeader);
return self::parseTraceparentAndBaggage($sentryTraceHeader, $baggageHeader, $client);
}

public static function fromEnvironment(string $sentryTrace, string $baggage): self
public static function fromEnvironment(string $sentryTrace, string $baggage, ?ClientInterface $client = null): self
{
return self::parseTraceparentAndBaggage($sentryTrace, $baggage);
return self::parseTraceparentAndBaggage($sentryTrace, $baggage, $client);
}

/**
Expand Down Expand Up @@ -183,8 +184,20 @@ public function setSampleRand(?float $sampleRand): self
return $this;
}

public function getParentSampled(): ?bool
{
return $this->parentSampled;
}

public function setParentSampled(?bool $parentSampled): self
{
$this->parentSampled = $parentSampled;

return $this;
}

// TODO add same logic as in TransactionContext
private static function parseTraceparentAndBaggage(string $traceparent, string $baggage): self
private static function parseTraceparentAndBaggage(string $traceparent, string $baggage, ?ClientInterface $client = null): self
{
$context = self::fromDefaults();
$hasSentryTrace = false;
Expand All @@ -208,6 +221,25 @@ private static function parseTraceparentAndBaggage(string $traceparent, string $

$samplingContext = DynamicSamplingContext::fromHeader($baggage);

// Check for org ID mismatch - always validate when both local and remote org IDs are present
if ($client !== null && $hasSentryTrace) {
$options = $client->getOptions();
// Get org ID from either the org_id option or the DSN
$localOrgId = $options->getOrgId();
if ($localOrgId === null && $options->getDsn() !== null) {
$localOrgId = $options->getDsn()->getOrgId();
}
$remoteOrgId = $samplingContext->has('org_id') ? (int) $samplingContext->get('org_id') : null;

// If we have both a local org ID and a remote org ID, and they don't match, create a new trace
if ($localOrgId !== null && $remoteOrgId !== null && $localOrgId !== $remoteOrgId) {
// Create a new propagation context instead of continuing the existing one
$context = self::fromDefaults();

return $context;
}
}

if ($hasSentryTrace && !$samplingContext->hasEntries()) {
// The request comes from an old SDK which does not support Dynamic Sampling.
// Propagate the Dynamic Sampling Context as is, but frozen, even without sentry-* entries.
Expand Down
47 changes: 38 additions & 9 deletions src/Tracing/TransactionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Sentry\Tracing;

use Sentry\ClientInterface;

final class TransactionContext extends SpanContext
{
private const SENTRY_TRACEPARENT_HEADER_REGEX = '/^[ \\t]*(?<trace_id>[0-9a-f]{32})?-?(?<span_id>[0-9a-f]{16})?-?(?<sampled>[01])?[ \\t]*$/i';
Expand Down Expand Up @@ -125,26 +127,28 @@ public function setSource(TransactionSource $transactionSource): self
/**
* Returns a context populated with the data of the given environment variables.
*
* @param string $sentryTrace The sentry-trace value from the environment
* @param string $baggage The baggage header value from the environment
* @param string $sentryTrace The sentry-trace value from the environment
* @param string $baggage The baggage header value from the environment
* @param ClientInterface|null $client The client to use for validation (optional)
*/
public static function fromEnvironment(string $sentryTrace, string $baggage): self
public static function fromEnvironment(string $sentryTrace, string $baggage, ?ClientInterface $client = null): self
{
return self::parseTraceAndBaggage($sentryTrace, $baggage);
return self::parseTraceAndBaggage($sentryTrace, $baggage, $client);
}

/**
* Returns a context populated with the data of the given headers.
*
* @param string $sentryTraceHeader The sentry-trace header from an incoming request
* @param string $baggageHeader The baggage header from an incoming request
* @param string $sentryTraceHeader The sentry-trace header from an incoming request
* @param string $baggageHeader The baggage header from an incoming request
* @param ClientInterface|null $client The client to use for validation (optional)
*/
public static function fromHeaders(string $sentryTraceHeader, string $baggageHeader): self
public static function fromHeaders(string $sentryTraceHeader, string $baggageHeader, ?ClientInterface $client = null): self
{
return self::parseTraceAndBaggage($sentryTraceHeader, $baggageHeader);
return self::parseTraceAndBaggage($sentryTraceHeader, $baggageHeader, $client);
}

private static function parseTraceAndBaggage(string $sentryTrace, string $baggage): self
private static function parseTraceAndBaggage(string $sentryTrace, string $baggage, ?ClientInterface $client = null): self
{
$context = new self();
$hasSentryTrace = false;
Expand All @@ -168,6 +172,31 @@ private static function parseTraceAndBaggage(string $sentryTrace, string $baggag

$samplingContext = DynamicSamplingContext::fromHeader($baggage);

// Check for org ID mismatch - always validate when both local and remote org IDs are present
if ($client !== null && $hasSentryTrace) {
$options = $client->getOptions();
// Get org ID from either the org_id option or the DSN
$localOrgId = $options->getOrgId();
if ($localOrgId === null && $options->getDsn() !== null) {
$localOrgId = $options->getDsn()->getOrgId();
}
$remoteOrgId = $samplingContext->has('org_id') ? (int) $samplingContext->get('org_id') : null;

// If we have both a local org ID and a remote org ID, and they don't match, create a new trace
if ($localOrgId !== null && $remoteOrgId !== null && $localOrgId !== $remoteOrgId) {
// Create a new trace context instead of continuing the existing one
$context = new self();
$context->traceId = TraceId::generate();
$context->parentSpanId = null;
$context->parentSampled = null;

// Generate a new sample rand since we're starting a new trace
$context->getMetadata()->setSampleRand(round(mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax(), 6));

return $context;
}
}

if ($hasSentryTrace && !$samplingContext->hasEntries()) {
// The request comes from an old SDK which does not support Dynamic Sampling.
// Propagate the Dynamic Sampling Context as is, but frozen, even without sentry-* entries.
Expand Down
8 changes: 5 additions & 3 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,14 @@ function getBaggage(): string
function continueTrace(string $sentryTrace, string $baggage): TransactionContext
{
$hub = SentrySdk::getCurrentHub();
$hub->configureScope(function (Scope $scope) use ($sentryTrace, $baggage) {
$propagationContext = PropagationContext::fromHeaders($sentryTrace, $baggage);
$client = $hub->getClient();

$hub->configureScope(function (Scope $scope) use ($sentryTrace, $baggage, $client) {
$propagationContext = PropagationContext::fromHeaders($sentryTrace, $baggage, $client);
$scope->setPropagationContext($propagationContext);
});

return TransactionContext::fromHeaders($sentryTrace, $baggage);
return TransactionContext::fromHeaders($sentryTrace, $baggage, $client);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions tests/OptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,10 @@ static function (): void {},
];

yield [
'strict_trace_propagation',
'strict_trace_continuation',
true,
'isStrictTracePropagationEnabled',
'enableStrictTracePropagation',
'isStrictTraceContinuationEnabled',
'enableStrictTraceContinuation',
];

yield [
Expand Down
Loading
Loading