diff --git a/src/SentrySdk.php b/src/SentrySdk.php index 9adce8c28..e28697b15 100644 --- a/src/SentrySdk.php +++ b/src/SentrySdk.php @@ -10,6 +10,7 @@ use Sentry\State\HubInterface; use Sentry\State\RuntimeContext; use Sentry\State\RuntimeContextManager; +use Sentry\State\Scope; /** * This class is the main entry point for all the most common SDK features. @@ -23,6 +24,11 @@ final class SentrySdk */ private static $currentHub; + /** + * @var Scope|null The process-global scope + */ + private static $globalScope; + /** * @var RuntimeContextManager|null */ @@ -41,10 +47,12 @@ private function __construct() */ public static function init(?ClientInterface $client = null): HubInterface { - if ($client === null) { - $client = new NoOpClient(); + $hubClient = $client ?? new NoOpClient(); + + if ($client !== null) { + self::getGlobalScope()->setClient($client); } - self::$currentHub = new Hub($client); + self::$currentHub = new Hub($hubClient); self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); return self::getCurrentHub(); @@ -79,6 +87,31 @@ public static function setCurrentHub(HubInterface $hub): HubInterface return $hub; } + public static function getGlobalScope(): Scope + { + if (self::$globalScope === null) { + self::$globalScope = new Scope(); + } + + return self::$globalScope; + } + + public static function getIsolationScope(): Scope + { + return self::getCurrentRuntimeContext()->getIsolationScope(); + } + + public static function getClient(): ClientInterface + { + $client = self::getIsolationScope()->getClient(); + + if (!$client instanceof NoOpClient) { + return $client; + } + + return self::getGlobalScope()->getClient(); + } + public static function startContext(): void { self::getRuntimeContextManager()->startContext(); diff --git a/src/State/RuntimeContext.php b/src/State/RuntimeContext.php index 6910cae60..6268ddc2b 100644 --- a/src/State/RuntimeContext.php +++ b/src/State/RuntimeContext.php @@ -27,6 +27,11 @@ final class RuntimeContext */ private $hub; + /** + * @var Scope + */ + private $isolationScope; + /** * @var LogsAggregator */ @@ -37,10 +42,11 @@ final class RuntimeContext */ private $metricsAggregator; - public function __construct(string $id, HubInterface $hub) + public function __construct(string $id, HubInterface $hub, ?Scope $isolationScope = null) { $this->id = $id; $this->hub = $hub; + $this->isolationScope = $isolationScope ?? new Scope(); $this->logsAggregator = new LogsAggregator(); $this->metricsAggregator = new MetricsAggregator(); } @@ -60,6 +66,16 @@ public function setHub(HubInterface $hub): void $this->hub = $hub; } + public function getIsolationScope(): Scope + { + return $this->isolationScope; + } + + public function setIsolationScope(Scope $isolationScope): void + { + $this->isolationScope = $isolationScope; + } + public function getLogsAggregator(): LogsAggregator { return $this->logsAggregator; diff --git a/src/State/Scope.php b/src/State/Scope.php index 7ab43086c..0a53b3686 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -6,9 +6,12 @@ use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; +use Sentry\ClientInterface; use Sentry\Event; use Sentry\EventHint; +use Sentry\EventId; use Sentry\EventType; +use Sentry\NoOpClient; use Sentry\Options; use Sentry\Severity; use Sentry\Tracing\DynamicSamplingContext; @@ -36,6 +39,16 @@ class Scope */ private $propagationContext; + /** + * @var ClientInterface The client bound to this scope + */ + private $client; + + /** + * @var EventId|null The ID of the last captured event + */ + private $lastEventId; + /** * @var Breadcrumb[] The list of breadcrumbs recorded in this scope */ @@ -110,6 +123,7 @@ class Scope public function __construct(?PropagationContext $propagationContext = null) { $this->propagationContext = $propagationContext ?? PropagationContext::fromDefaults(); + $this->client = new NoOpClient(); } /** @@ -143,6 +157,42 @@ public static function mergeScopes(self $globalScope, self $isolationScope): sel return $mergedScope; } + /** + * Returns the client bound to this scope. + */ + public function getClient(): ClientInterface + { + return $this->client; + } + + /** + * Sets the client bound to this scope. + * + * @return $this + */ + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + /** + * Returns the ID of the last captured event. + */ + public function getLastEventId(): ?EventId + { + return $this->lastEventId; + } + + /** + * @internal + */ + public function setLastEventId(?EventId $lastEventId): void + { + $this->lastEventId = $lastEventId; + } + /** * @param array> $globalFlags * @param array> $isolationFlags @@ -161,7 +211,7 @@ private static function mergeFlags(array $globalFlags, array $isolationFlags): a } unset($flagsByKey[$flagKey]); - $flagsByKey[$flagKey] = (bool) current($flag); + $flagsByKey[$flagKey] = current($flag); } $flagsByKey = \array_slice($flagsByKey, -self::MAX_FLAGS, self::MAX_FLAGS, true); @@ -483,7 +533,8 @@ public static function getExternalPropagationContext(): ?array } /** - * Clears the scope and resets any data it contains. + * Clears event payload data from the scope. The client binding and last + * event ID are preserved. * * @return $this */ diff --git a/src/functions.php b/src/functions.php index 0935d739f..1549bf9fe 100644 --- a/src/functions.php +++ b/src/functions.php @@ -74,7 +74,7 @@ function init(array $options = []): void { $client = ClientBuilder::create($options)->getClient(); - SentrySdk::init()->bindClient($client); + SentrySdk::init($client); } /** diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 4611e6054..ba349bde9 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -60,6 +60,23 @@ public function testInit(): void init(['default_integrations' => false]); $this->assertNotNull(SentrySdk::getCurrentHub()->getClient()); + $this->assertSame(SentrySdk::getCurrentHub()->getClient(), SentrySdk::getClient()); + } + + public function testInitPreservesGlobalScope(): void + { + $globalScope = SentrySdk::getGlobalScope(); + $globalScope->setTag('baseline', 'yes'); + + init(['default_integrations' => false]); + + $this->assertSame($globalScope, SentrySdk::getGlobalScope()); + $this->assertSame(SentrySdk::getCurrentHub()->getClient(), $globalScope->getClient()); + + $event = $globalScope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame(['baseline' => 'yes'], $event->getTags()); } /** diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index b50bc2ed8..76857eb51 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -22,6 +22,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(SentrySdk::class, 'globalScope'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(SentrySdk::class, 'runtimeContextManager'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/SentrySdkTest.php b/tests/SentrySdkTest.php index 3ce3d6f12..7e9573f82 100644 --- a/tests/SentrySdkTest.php +++ b/tests/SentrySdkTest.php @@ -47,6 +47,87 @@ public function testSetCurrentHub(): void $this->assertSame($hub, SentrySdk::getCurrentHub()); } + public function testGetGlobalScope(): void + { + $scope = SentrySdk::getGlobalScope(); + + $this->assertSame($scope, SentrySdk::getGlobalScope()); + } + + public function testGetIsolationScope(): void + { + $scope = SentrySdk::getIsolationScope(); + + $this->assertSame($scope, SentrySdk::getIsolationScope()); + } + + public function testGetClientReturnsCachedNoOpFallbackBeforeInit(): void + { + $client = SentrySdk::getClient(); + + $this->assertInstanceOf(NoOpClient::class, $client); + $this->assertSame($client, SentrySdk::getClient()); + } + + public function testGetClientReturnsGlobalScopeClient(): void + { + $client = $this->createMock(ClientInterface::class); + + SentrySdk::getGlobalScope()->setClient($client); + + $this->assertSame($client, SentrySdk::getClient()); + } + + public function testGetClientReturnsIsolationScopeClientBeforeGlobalScopeClient(): void + { + $globalClient = $this->createMock(ClientInterface::class); + $isolationClient = $this->createMock(ClientInterface::class); + + SentrySdk::getGlobalScope()->setClient($globalClient); + SentrySdk::getIsolationScope()->setClient($isolationClient); + + $this->assertSame($isolationClient, SentrySdk::getClient()); + } + + public function testStartContextUsesSeparateIsolationScope(): void + { + $globalIsolationScope = SentrySdk::getIsolationScope(); + + SentrySdk::startContext(); + + $contextIsolationScope = SentrySdk::getIsolationScope(); + + $this->assertNotSame($globalIsolationScope, $contextIsolationScope); + + SentrySdk::endContext(); + + $this->assertSame($globalIsolationScope, SentrySdk::getIsolationScope()); + } + + public function testInitWithClientSetsGlobalScopeClient(): void + { + $client = $this->createMock(ClientInterface::class); + + SentrySdk::init($client); + + $this->assertSame($client, SentrySdk::getClient()); + } + + public function testInitDoesNotResetGlobalScope(): void + { + $globalScope = SentrySdk::getGlobalScope(); + $globalScope->setTag('baseline', 'yes'); + + SentrySdk::init(); + + $this->assertSame($globalScope, SentrySdk::getGlobalScope()); + + $event = $globalScope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame(['baseline' => 'yes'], $event->getTags()); + } + public function testStartAndEndContextIsolateScopeData(): void { SentrySdk::init(); diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index 1dd6174a4..be9362868 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -7,8 +7,11 @@ use PHPUnit\Framework\TestCase; use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; +use Sentry\ClientInterface; use Sentry\Event; use Sentry\EventHint; +use Sentry\EventId; +use Sentry\NoOpClient; use Sentry\Options; use Sentry\Severity; use Sentry\State\Scope; @@ -24,6 +27,46 @@ final class ScopeTest extends TestCase { + public function testGetAndSetClient(): void + { + $scope = new Scope(); + + $this->assertInstanceOf(NoOpClient::class, $scope->getClient()); + + $client = $this->createMock(ClientInterface::class); + + $this->assertSame($scope, $scope->setClient($client)); + $this->assertSame($client, $scope->getClient()); + } + + public function testClonedScopeKeepsClientShared(): void + { + $client = $this->createMock(ClientInterface::class); + + $scope = new Scope(); + $scope->setClient($client); + + $clonedScope = clone $scope; + + $this->assertSame($client, $clonedScope->getClient()); + } + + public function testGetAndSetLastEventId(): void + { + $scope = new Scope(); + + $this->assertNull($scope->getLastEventId()); + + $eventId = EventId::generate(); + $scope->setLastEventId($eventId); + + $this->assertSame($eventId, $scope->getLastEventId()); + + $scope->setLastEventId(null); + + $this->assertNull($scope->getLastEventId()); + } + public function testSetTag(): void { $scope = new Scope(); @@ -443,7 +486,11 @@ public function testClear(): void { $scope = new Scope(); $breadcrumb = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + $client = $this->createMock(ClientInterface::class); + $eventId = EventId::generate(); + $scope->setClient($client); + $scope->setLastEventId($eventId); $scope->setLevel(Severity::info()); $scope->addBreadcrumb($breadcrumb); $scope->setFingerprint(['foo']); @@ -463,6 +510,8 @@ public function testClear(): void $this->assertEmpty($event->getTags()); $this->assertEmpty($event->getUser()); $this->assertArrayNotHasKey('flags', $event->getContexts()); + $this->assertSame($client, $scope->getClient()); + $this->assertSame($eventId, $scope->getLastEventId()); } public function testApplyToEvent(): void @@ -649,6 +698,18 @@ public function testMergeScopesUsesGlobalLevelWhenIsolationLevelIsUnset(): void $this->assertTrue($event->getLevel()->isEqualTo(Severity::error())); } + public function testMergeScopesCarriesIsolationClient(): void + { + $globalScope = new Scope(); + $globalScope->setClient($this->createMock(ClientInterface::class)); + + $isolationClient = $this->createMock(ClientInterface::class); + $isolationScope = new Scope(); + $isolationScope->setClient($isolationClient); + + $this->assertSame($isolationClient, Scope::mergeScopes($globalScope, $isolationScope)->getClient()); + } + public function testMergeScopesCapsBreadcrumbsAndFlags(): void { $globalScope = new Scope();