diff --git a/src/sentry/publish/sentry.php b/src/sentry/publish/sentry.php index fb99e720a..6bffc3422 100644 --- a/src/sentry/publish/sentry.php +++ b/src/sentry/publish/sentry.php @@ -72,6 +72,12 @@ 'enable_queue_metrics' => env('SENTRY_ENABLE_QUEUE_METRICS', true), 'metrics_interval' => (int) env('SENTRY_METRICS_INTERVAL', 10), + // The maximum number of spans allowed within a single transaction. Once the + // budget is exhausted new spans are skipped (the callable still runs) to + // prevent the span tree of a long-lived coroutine from growing unboundedly. + // A value of 0 (or negative) disables the limit. + 'max_spans' => (int) env('SENTRY_MAX_SPANS', 1000), + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', true), diff --git a/src/sentry/src/Feature.php b/src/sentry/src/Feature.php index de7f25995..4696ca843 100644 --- a/src/sentry/src/Feature.php +++ b/src/sentry/src/Feature.php @@ -82,6 +82,11 @@ public function getMetricsInterval(int $default = 10): int return $interval; } + public function getMaxSpans(int $default = 1000): int + { + return (int) $this->config->get('sentry.max_spans', $default); + } + public function isTracingEnabled(string $key, bool $default = true): bool { return (bool) $this->config->get('sentry.tracing.' . $key, $default); diff --git a/src/sentry/src/Tracing/SpanBudget.php b/src/sentry/src/Tracing/SpanBudget.php new file mode 100644 index 000000000..6fb8de203 --- /dev/null +++ b/src/sentry/src/Tracing/SpanBudget.php @@ -0,0 +1,70 @@ +limit <= 0) { + return true; + } + + $count = Context::getOrSet(self::CONTEXT_KEY, fn () => 0); + + if ($count >= $this->limit) { + return false; + } + + Context::set(self::CONTEXT_KEY, $count + 1); + + return true; + } + + /** + * Return the number of spans already acquired in the current context. + */ + public function count(): int + { + return (int) Context::get(self::CONTEXT_KEY, 0); + } +} diff --git a/src/sentry/src/Tracing/Tracer.php b/src/sentry/src/Tracing/Tracer.php index 77a6a546f..4bab9ab90 100644 --- a/src/sentry/src/Tracing/Tracer.php +++ b/src/sentry/src/Tracing/Tracer.php @@ -11,6 +11,7 @@ namespace FriendsOfHyperf\Sentry\Tracing; +use FriendsOfHyperf\Sentry\Feature; use Hyperf\Engine\Coroutine as Co; use Sentry\SentrySdk; use Sentry\State\Scope; @@ -26,11 +27,17 @@ class Tracer { + public function __construct(private Feature $feature) + { + } + /** * Starts a new Transaction and returns it. This is the entry point to manual tracing instrumentation. */ public function startTransaction(TransactionContext $transactionContext, array $customSamplingContext = []): Transaction { + (new SpanBudget($this->feature->getMaxSpans()))->reset(); + $hub = SentrySdk::getCurrentHub(); $hub->pushScope(); $hub->configureScope(static fn (Scope $scope) => $scope->clearBreadcrumbs()); @@ -79,6 +86,15 @@ public function trace(callable $trace, SpanContext $context) $context->setData(['coroutine.id' => Co::id()] + $context->getData()); + $hub = SentrySdk::getCurrentHub(); + + // The budget only constrains spans created inside a transaction: when the + // budget is exhausted we skip creating the span and execute the callable + // directly to bound the memory used by the transaction span tree. + if ($hub->getSpan() !== null && ! (new SpanBudget($this->feature->getMaxSpans()))->tryAcquire()) { + return $hub->configureScope(static fn (Scope $scope) => $trace($scope)); + } + return trace( function (Scope $scope) use ($trace) { try { diff --git a/tests/Sentry/Tracing/SpanBudgetTest.php b/tests/Sentry/Tracing/SpanBudgetTest.php new file mode 100644 index 000000000..493a6c3e5 --- /dev/null +++ b/tests/Sentry/Tracing/SpanBudgetTest.php @@ -0,0 +1,88 @@ +group('sentry'); + +beforeEach(function () { + Context::destroy(SpanBudget::CONTEXT_KEY); +}); + +test('limit of 3 allows only the first three acquisitions', function () { + $budget = new SpanBudget(3); + + expect($budget->tryAcquire())->toBeTrue() + ->and($budget->tryAcquire())->toBeTrue() + ->and($budget->tryAcquire())->toBeTrue() + ->and($budget->count())->toBe(3) + ->and($budget->tryAcquire())->toBeFalse(); +}); + +test('reset allows acquiring again', function () { + $budget = new SpanBudget(3); + + $budget->tryAcquire(); + $budget->tryAcquire(); + $budget->tryAcquire(); + + expect($budget->tryAcquire())->toBeFalse(); + + $budget->reset(); + + expect($budget->tryAcquire())->toBeTrue() + ->and($budget->count())->toBe(1); +}); + +test('limit of 0 means unlimited', function () { + $budget = new SpanBudget(0); + + for ($i = 0; $i < 100; ++$i) { + expect($budget->tryAcquire())->toBeTrue(); + } + + expect($budget->count())->toBe(0); +}); + +test('counter is isolated between coroutines', function () { + Swoole\Coroutine\run(function () { + $budget = new SpanBudget(3); + $channel = new Channel(2); + $results = []; + + Coroutine::create(function () use ($budget, $channel, &$results) { + $results['co1'] = [ + $budget->tryAcquire(), + $budget->tryAcquire(), + $budget->tryAcquire(), + $budget->tryAcquire(), + $budget->count(), + ]; + $channel->push(true); + }); + + Coroutine::create(function () use ($budget, $channel, &$results) { + $results['co2'] = [ + $budget->tryAcquire(), + $budget->count(), + ]; + $channel->push(true); + }); + + $channel->pop(); + $channel->pop(); + + expect($results['co1'])->toBe([true, true, true, false, 3]) + ->and($results['co2'])->toBe([true, 1]); + }); +});