From 105cd5557ef1beb17a8b71091037528c285b41a8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 23 Jun 2026 14:48:11 +0200 Subject: [PATCH] feat(scope): dont use hub for capturing events anymore --- src/State/BreadcrumbRecorder.php | 44 ++++ src/State/EventCapturer.php | 107 +++++++++ src/State/HubAdapter.php | 63 +---- src/functions.php | 27 ++- tests/FunctionsTest.php | 318 +++++++++++++++++++++---- tests/State/BreadcrumbRecorderTest.php | 132 ++++++++++ tests/State/EventCapturerTest.php | 205 ++++++++++++++++ 7 files changed, 782 insertions(+), 114 deletions(-) create mode 100644 src/State/BreadcrumbRecorder.php create mode 100644 src/State/EventCapturer.php create mode 100644 tests/State/BreadcrumbRecorderTest.php create mode 100644 tests/State/EventCapturerTest.php diff --git a/src/State/BreadcrumbRecorder.php b/src/State/BreadcrumbRecorder.php new file mode 100644 index 000000000..37b315725 --- /dev/null +++ b/src/State/BreadcrumbRecorder.php @@ -0,0 +1,44 @@ +getOptions(); + $maxBreadcrumbs = $options->getMaxBreadcrumbs(); + + if ($maxBreadcrumbs <= 0) { + return false; + } + + $breadcrumb = ($options->getBeforeBreadcrumbCallback())($breadcrumb); + + if ($breadcrumb !== null) { + $scope->addBreadcrumb($breadcrumb, $maxBreadcrumbs); + } + + return $breadcrumb !== null; + } +} diff --git a/src/State/EventCapturer.php b/src/State/EventCapturer.php new file mode 100644 index 000000000..2511a0013 --- /dev/null +++ b/src/State/EventCapturer.php @@ -0,0 +1,107 @@ +captureMessage($message, $level, $captureScope, $hint); + }); + } + + public static function captureException(\Throwable $exception, ?EventHint $hint = null): ?EventId + { + return self::capture(static function (ClientInterface $client, Scope $captureScope) use ($exception, $hint): ?EventId { + return $client->captureException($exception, $captureScope, $hint); + }); + } + + public static function captureEvent(Event $event, ?EventHint $hint = null): ?EventId + { + return self::capture(static function (ClientInterface $client, Scope $captureScope) use ($event, $hint): ?EventId { + return $client->captureEvent($event, $hint, $captureScope); + }); + } + + public static function captureLastError(?EventHint $hint = null): ?EventId + { + return self::capture(static function (ClientInterface $client, Scope $captureScope) use ($hint): ?EventId { + return $client->captureLastError($captureScope, $hint); + }); + } + + /** + * @param int|float|null $duration + */ + public static function captureCheckIn(string $slug, CheckInStatus $status, $duration = null, ?MonitorConfig $monitorConfig = null, ?string $checkInId = null): ?string + { + $isolationScope = SentrySdk::getIsolationScope(); + $client = SentrySdk::getClient($isolationScope); + + if ($client instanceof NoOpClient) { + return null; + } + + $options = $client->getOptions(); + $event = Event::createCheckIn(); + $checkIn = new CheckIn( + $slug, + $status, + $checkInId, + $options->getRelease(), + $options->getEnvironment(), + $duration, + $monitorConfig + ); + $event->setCheckIn($checkIn); + + self::captureWithScope($client, $isolationScope, static function (ClientInterface $client, Scope $captureScope) use ($event): ?EventId { + return $client->captureEvent($event, null, $captureScope); + }); + + return $checkIn->getId(); + } + + /** + * @param callable(ClientInterface, Scope): ?EventId $capture + */ + private static function capture(callable $capture): ?EventId + { + $isolationScope = SentrySdk::getIsolationScope(); + + return self::captureWithScope(SentrySdk::getClient($isolationScope), $isolationScope, $capture); + } + + /** + * @param callable(ClientInterface, Scope): ?EventId $capture + */ + private static function captureWithScope(ClientInterface $client, Scope $isolationScope, callable $capture): ?EventId + { + $eventId = $capture($client, Scope::mergeScopes(SentrySdk::getGlobalScope(), $isolationScope)); + $isolationScope->setLastEventId($eventId); + + return $eventId; + } +} diff --git a/src/State/HubAdapter.php b/src/State/HubAdapter.php index a5aca9f5b..b376b4cd7 100644 --- a/src/State/HubAdapter.php +++ b/src/State/HubAdapter.php @@ -97,10 +97,7 @@ public function bindClient(ClientInterface $client): void */ public function captureMessage(string $message, ?Severity $level = null, ?EventHint $hint = null): ?EventId { - $eventId = SentrySdk::getClient()->captureMessage($message, $level, SentrySdk::getIsolationScope(), $hint); - SentrySdk::getIsolationScope()->setLastEventId($eventId); - - return $eventId; + return EventCapturer::captureMessage($message, $level, $hint); } /** @@ -108,10 +105,7 @@ public function captureMessage(string $message, ?Severity $level = null, ?EventH */ public function captureException(\Throwable $exception, ?EventHint $hint = null): ?EventId { - $eventId = SentrySdk::getClient()->captureException($exception, SentrySdk::getIsolationScope(), $hint); - SentrySdk::getIsolationScope()->setLastEventId($eventId); - - return $eventId; + return EventCapturer::captureException($exception, $hint); } /** @@ -119,10 +113,7 @@ public function captureException(\Throwable $exception, ?EventHint $hint = null) */ public function captureEvent(Event $event, ?EventHint $hint = null): ?EventId { - $eventId = SentrySdk::getClient()->captureEvent($event, $hint, SentrySdk::getIsolationScope()); - SentrySdk::getIsolationScope()->setLastEventId($eventId); - - return $eventId; + return EventCapturer::captureEvent($event, $hint); } /** @@ -130,10 +121,7 @@ public function captureEvent(Event $event, ?EventHint $hint = null): ?EventId */ public function captureLastError(?EventHint $hint = null): ?EventId { - $eventId = SentrySdk::getClient()->captureLastError(SentrySdk::getIsolationScope(), $hint); - SentrySdk::getIsolationScope()->setLastEventId($eventId); - - return $eventId; + return EventCapturer::captureLastError($hint); } /** @@ -143,27 +131,7 @@ public function captureLastError(?EventHint $hint = null): ?EventId */ public function captureCheckIn(string $slug, CheckInStatus $status, $duration = null, ?MonitorConfig $monitorConfig = null, ?string $checkInId = null): ?string { - $client = SentrySdk::getClient(); - - if ($client instanceof NoOpClient) { - return null; - } - - $options = $client->getOptions(); - $event = Event::createCheckIn(); - $checkIn = new \Sentry\CheckIn( - $slug, - $status, - $checkInId, - $options->getRelease(), - $options->getEnvironment(), - $duration, - $monitorConfig - ); - $event->setCheckIn($checkIn); - $this->captureEvent($event); - - return $checkIn->getId(); + return EventCapturer::captureCheckIn($slug, $status, $duration, $monitorConfig, $checkInId); } /** @@ -171,26 +139,9 @@ public function captureCheckIn(string $slug, CheckInStatus $status, $duration = */ public function addBreadcrumb(Breadcrumb $breadcrumb): bool { - $client = SentrySdk::getClient(); - - if ($client instanceof NoOpClient) { - return false; - } - - $options = $client->getOptions(); - $maxBreadcrumbs = $options->getMaxBreadcrumbs(); - - if ($maxBreadcrumbs <= 0) { - return false; - } - - $breadcrumb = ($options->getBeforeBreadcrumbCallback())($breadcrumb); - - if ($breadcrumb !== null) { - SentrySdk::getIsolationScope()->addBreadcrumb($breadcrumb, $maxBreadcrumbs); - } + $scope = SentrySdk::getIsolationScope(); - return $breadcrumb !== null; + return BreadcrumbRecorder::record(SentrySdk::getClient($scope), $scope, $breadcrumb); } /** diff --git a/src/functions.php b/src/functions.php index 845bfd594..9d3cd51e9 100644 --- a/src/functions.php +++ b/src/functions.php @@ -10,6 +10,8 @@ use Sentry\Integration\OTLPIntegration; use Sentry\Logs\Logs; use Sentry\Metrics\TraceMetrics; +use Sentry\State\BreadcrumbRecorder; +use Sentry\State\EventCapturer; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; use Sentry\Tracing\SpanContext; @@ -101,7 +103,7 @@ function getClient(): ClientInterface */ function captureMessage(string $message, ?Severity $level = null, ?EventHint $hint = null): ?EventId { - return SentrySdk::getCurrentHub()->captureMessage($message, $level, $hint); + return EventCapturer::captureMessage($message, $level, $hint); } /** @@ -112,7 +114,7 @@ function captureMessage(string $message, ?Severity $level = null, ?EventHint $hi */ function captureException(\Throwable $exception, ?EventHint $hint = null): ?EventId { - return SentrySdk::getCurrentHub()->captureException($exception, $hint); + return EventCapturer::captureException($exception, $hint); } /** @@ -123,7 +125,7 @@ function captureException(\Throwable $exception, ?EventHint $hint = null): ?Even */ function captureEvent(Event $event, ?EventHint $hint = null): ?EventId { - return SentrySdk::getCurrentHub()->captureEvent($event, $hint); + return EventCapturer::captureEvent($event, $hint); } /** @@ -133,7 +135,7 @@ function captureEvent(Event $event, ?EventHint $hint = null): ?EventId */ function captureLastError(?EventHint $hint = null): ?EventId { - return SentrySdk::getCurrentHub()->captureLastError($hint); + return EventCapturer::captureLastError($hint); } /** @@ -147,7 +149,7 @@ function captureLastError(?EventHint $hint = null): ?EventId */ function captureCheckIn(string $slug, CheckInStatus $status, $duration = null, ?MonitorConfig $monitorConfig = null, ?string $checkInId = null): ?string { - return SentrySdk::getCurrentHub()->captureCheckIn($slug, $status, $duration, $monitorConfig, $checkInId); + return EventCapturer::captureCheckIn($slug, $status, $duration, $monitorConfig, $checkInId); } /** @@ -161,7 +163,7 @@ function captureCheckIn(string $slug, CheckInStatus $status, $duration = null, ? */ function withMonitor(string $slug, callable $callback, ?MonitorConfig $monitorConfig = null) { - $checkInId = SentrySdk::getCurrentHub()->captureCheckIn($slug, CheckInStatus::inProgress(), null, $monitorConfig); + $checkInId = captureCheckIn($slug, CheckInStatus::inProgress(), null, $monitorConfig); $status = CheckInStatus::ok(); $duration = 0; @@ -177,7 +179,7 @@ function withMonitor(string $slug, callable $callback, ?MonitorConfig $monitorCo throw $e; } finally { - SentrySdk::getCurrentHub()->captureCheckIn($slug, $status, $duration, $monitorConfig, $checkInId); + captureCheckIn($slug, $status, $duration, $monitorConfig, $checkInId); } } @@ -195,11 +197,12 @@ function withMonitor(string $slug, callable $callback, ?MonitorConfig $monitorCo */ function addBreadcrumb($category, ?string $message = null, array $metadata = [], string $level = Breadcrumb::LEVEL_INFO, string $type = Breadcrumb::TYPE_DEFAULT, ?float $timestamp = null): void { - SentrySdk::getCurrentHub()->addBreadcrumb( - $category instanceof Breadcrumb - ? $category - : new Breadcrumb($level, $type, $category, $message, $metadata, $timestamp) - ); + $scope = SentrySdk::getIsolationScope(); + $breadcrumb = $category instanceof Breadcrumb + ? $category + : new Breadcrumb($level, $type, $category, $message, $metadata, $timestamp); + + BreadcrumbRecorder::record(SentrySdk::getClient($scope), $scope, $breadcrumb); } /** diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 2d1d47a7c..577951b91 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -86,18 +86,18 @@ public function testCaptureMessage(array $functionCallArgs, array $expectedFunct $eventId = EventId::generate(); $message = $expectedFunctionCallArgs[0]; $level = $expectedFunctionCallArgs[1]; - $scope = $this->isInstanceOf(Scope::class); $hint = $expectedFunctionCallArgs[2]; $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('captureMessage') - ->with($message, $level, $scope, $hint) + ->with($message, $level, $this->captureScopeConstraint($scope), $hint) ->willReturn($eventId); - SentrySdk::getGlobalScope()->setClient($client); - $this->assertSame($eventId, captureMessage(...$functionCallArgs)); + $this->assertSame($eventId, $scope->getLastEventId()); } public static function captureMessageDataProvider(): \Generator @@ -136,14 +136,15 @@ public function testCaptureException(array $functionCallArgs, array $expectedFun $eventId = EventId::generate(); $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('captureException') - ->with($expectedFunctionCallArgs[0], $this->isInstanceOf(Scope::class), $expectedFunctionCallArgs[1]) + ->with($expectedFunctionCallArgs[0], $this->captureScopeConstraint($scope), $expectedFunctionCallArgs[1]) ->willReturn($eventId); - SentrySdk::getGlobalScope()->setClient($client); - $this->assertSame($eventId, captureException(...$functionCallArgs)); + $this->assertSame($eventId, $scope->getLastEventId()); } public static function captureExceptionDataProvider(): \Generator @@ -176,14 +177,15 @@ public function testCaptureEvent(): void $hint = new EventHint(); $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('captureEvent') - ->with($event, $hint, $this->isInstanceOf(Scope::class)) + ->with($event, $hint, $this->captureScopeConstraint($scope)) ->willReturn($event->getId()); - SentrySdk::getGlobalScope()->setClient($client); - $this->assertSame($event->getId(), captureEvent($event, $hint)); + $this->assertSame($event->getId(), $scope->getLastEventId()); } /** @@ -194,16 +196,17 @@ public function testCaptureLastError(array $functionCallArgs, array $expectedFun $eventId = EventId::generate(); $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('captureLastError') - ->with($this->isInstanceOf(Scope::class), $expectedFunctionCallArgs[0]) + ->with($this->captureScopeConstraint($scope), $expectedFunctionCallArgs[0]) ->willReturn($eventId); - SentrySdk::getGlobalScope()->setClient($client); - @trigger_error('foo', \E_USER_NOTICE); $this->assertSame($eventId, captureLastError(...$functionCallArgs)); + $this->assertSame($eventId, $scope->getLastEventId()); } public static function captureLastErrorDataProvider(): \Generator @@ -219,9 +222,72 @@ public static function captureLastErrorDataProvider(): \Generator ]; } + public function testCaptureMessageClearsLastEventIdWhenClientReturnsNull(): void + { + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $scope->setLastEventId(EventId::generate()); + + $client->expects($this->once()) + ->method('captureMessage') + ->with('foo', null, $this->captureScopeConstraint($scope), null) + ->willReturn(null); + + $this->assertNull(captureMessage('foo')); + $this->assertNull($scope->getLastEventId()); + } + + public function testCaptureExceptionClearsLastEventIdWhenClientReturnsNull(): void + { + $exception = new \RuntimeException('foo'); + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $scope->setLastEventId(EventId::generate()); + + $client->expects($this->once()) + ->method('captureException') + ->with($exception, $this->captureScopeConstraint($scope), null) + ->willReturn(null); + + $this->assertNull(captureException($exception)); + $this->assertNull($scope->getLastEventId()); + } + + public function testCaptureEventClearsLastEventIdWhenClientReturnsNull(): void + { + $event = Event::createEvent(); + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $scope->setLastEventId(EventId::generate()); + + $client->expects($this->once()) + ->method('captureEvent') + ->with($event, null, $this->captureScopeConstraint($scope)) + ->willReturn(null); + + $this->assertNull(captureEvent($event)); + $this->assertNull($scope->getLastEventId()); + } + + public function testCaptureLastErrorClearsLastEventIdWhenClientReturnsNull(): void + { + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $scope->setLastEventId(EventId::generate()); + + $client->expects($this->once()) + ->method('captureLastError') + ->with($this->captureScopeConstraint($scope), null) + ->willReturn(null); + + $this->assertNull(captureLastError()); + $this->assertNull($scope->getLastEventId()); + } + public function testCaptureCheckIn(): void { $checkInId = SentryUid::generate(); + $eventId = EventId::generate(); $monitorConfig = new MonitorConfig( MonitorSchedule::crontab('*/5 * * * *'), 5, @@ -230,19 +296,29 @@ public function testCaptureCheckIn(): void ); $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('getOptions') - ->willReturn(new Options()); + ->willReturn(new Options([ + 'environment' => Event::DEFAULT_ENVIRONMENT, + 'release' => '1.0.0', + ])); $client->expects($this->once()) ->method('captureEvent') - ->with($this->callback(static function (Event $event) use ($checkInId): bool { + ->with($this->callback(static function (Event $event) use ($checkInId, $monitorConfig): bool { $checkIn = $event->getCheckIn(); - return $checkIn !== null && $checkIn->getId() === $checkInId; - }), null, $this->isInstanceOf(Scope::class)) - ->willReturn(EventId::generate()); - - SentrySdk::getGlobalScope()->setClient($client); + return $checkIn !== null + && $checkIn->getId() === $checkInId + && $checkIn->getMonitorSlug() === 'test-crontab' + && $checkIn->getStatus() == CheckInStatus::ok() + && $checkIn->getRelease() === '1.0.0' + && $checkIn->getEnvironment() === Event::DEFAULT_ENVIRONMENT + && $checkIn->getDuration() === 10 + && $checkIn->getMonitorConfig() === $monitorConfig; + }), null, $this->captureScopeConstraint($scope)) + ->willReturn($eventId); $this->assertSame($checkInId, captureCheckIn( 'test-crontab', @@ -251,11 +327,29 @@ public function testCaptureCheckIn(): void $monitorConfig, $checkInId )); + $this->assertSame($eventId, $scope->getLastEventId()); + } + + public function testCaptureCheckInReturnsNullForNoOpClient(): void + { + SentrySdk::init(new NoOpClient()); + + $this->assertNull(captureCheckIn('test-crontab', CheckInStatus::ok())); } public function testWithMonitor(): void { + $events = []; + $monitorConfig = new MonitorConfig( + new MonitorSchedule(MonitorSchedule::TYPE_CRONTAB, '*/5 * * * *'), + 5, + 30, + 'UTC' + ); + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->exactly(2)) ->method('getOptions') ->willReturn(new Options()); @@ -268,63 +362,149 @@ public function testWithMonitor(): void && $checkIn->getMonitorSlug() === 'test-crontab' && $checkIn->getMonitorConfig() !== null && $checkIn->getMonitorConfig()->getSchedule()->getValue() === '*/5 * * * *'; - }), null, $this->isInstanceOf(Scope::class)); + }), null, $this->captureScopeConstraint($scope)) + ->willReturnCallback(static function (Event $event, ?EventHint $hint = null, ?Scope $scope = null) use (&$events): EventId { + $events[] = $event; - SentrySdk::getGlobalScope()->setClient($client); + return EventId::generate(); + }); - withMonitor('test-crontab', static function () { + $result = withMonitor('test-crontab', static function (): string { // Do something... - }, new MonitorConfig( - new MonitorSchedule(MonitorSchedule::TYPE_CRONTAB, '*/5 * * * *'), - 5, - 30, - 'UTC' - )); + return 'done'; + }, $monitorConfig); + + $this->assertSame('done', $result); + $this->assertCount(2, $events); + $this->assertSame(CheckInStatus::inProgress(), $events[0]->getCheckIn()->getStatus()); + $this->assertSame(CheckInStatus::ok(), $events[1]->getCheckIn()->getStatus()); + $this->assertSame($events[0]->getCheckIn()->getId(), $events[1]->getCheckIn()->getId()); } public function testWithMonitorCallableThrows(): void { - $this->expectException(\Exception::class); + $events = []; $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->exactly(2)) ->method('getOptions') ->willReturn(new Options()); $client->expects($this->exactly(2)) ->method('captureEvent') - ->with($this->isInstanceOf(Event::class), null, $this->isInstanceOf(Scope::class)); + ->with($this->isInstanceOf(Event::class), null, $this->captureScopeConstraint($scope)) + ->willReturnCallback(static function (Event $event, ?EventHint $hint = null, ?Scope $scope = null) use (&$events): EventId { + $events[] = $event; - SentrySdk::getGlobalScope()->setClient($client); + return EventId::generate(); + }); - withMonitor('test-crontab', static function () { - throw new \Exception(); - }, new MonitorConfig( - new MonitorSchedule(MonitorSchedule::TYPE_CRONTAB, '*/5 * * * *'), - 5, - 30, - 'UTC' - )); + try { + withMonitor('test-crontab', static function (): void { + throw new \Exception('monitor failed'); + }, new MonitorConfig( + new MonitorSchedule(MonitorSchedule::TYPE_CRONTAB, '*/5 * * * *'), + 5, + 30, + 'UTC' + )); + + $this->fail('The callback exception should be rethrown.'); + } catch (\Exception $exception) { + $this->assertSame('monitor failed', $exception->getMessage()); + } + + $this->assertCount(2, $events); + $this->assertSame(CheckInStatus::inProgress(), $events[0]->getCheckIn()->getStatus()); + $this->assertSame(CheckInStatus::error(), $events[1]->getCheckIn()->getStatus()); + $this->assertSame($events[0]->getCheckIn()->getId(), $events[1]->getCheckIn()->getId()); } public function testAddBreadcrumb(): void { $breadcrumb = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + $otherScope = new Scope(); /** @var ClientInterface&MockObject $client */ $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + $client->expects($this->once()) ->method('getOptions') ->willReturn(new Options(['default_integrations' => false])); - SentrySdk::getCurrentHub()->bindClient($client); + addBreadcrumb($breadcrumb); + + $this->assertScopeBreadcrumbs($scope, [$breadcrumb]); + $this->assertScopeBreadcrumbs($otherScope, []); + } + + public function testAddBreadcrumbDoesNothingIfMaxBreadcrumbsLimitIsZero(): void + { + $breadcrumb = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options(['max_breadcrumbs' => 0])); addBreadcrumb($breadcrumb); - configureScope(function (Scope $scope) use ($breadcrumb): void { - $event = $scope->applyToEvent(Event::createEvent()); - $this->assertNotNull($event); - $this->assertSame([$breadcrumb], $event->getBreadcrumbs()); - }); + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testAddBreadcrumbDoesNothingForNoOpClient(): void + { + SentrySdk::init(new NoOpClient()); + $scope = SentrySdk::getIsolationScope(); + + addBreadcrumb(new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting')); + + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testAddBreadcrumbDoesNothingWhenBeforeBreadcrumbCallbackReturnsNull(): void + { + $breadcrumb = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'before_breadcrumb' => static function () { + return null; + }, + ])); + + addBreadcrumb($breadcrumb); + + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testAddBreadcrumbStoresBreadcrumbReturnedByBeforeBreadcrumbCallback(): void + { + $breadcrumb1 = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + $breadcrumb2 = new Breadcrumb(Breadcrumb::LEVEL_WARNING, Breadcrumb::TYPE_DEFAULT, 'custom'); + + $client = $this->createMock(ClientInterface::class); + $scope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'before_breadcrumb' => static function () use ($breadcrumb2): Breadcrumb { + return $breadcrumb2; + }, + ])); + + addBreadcrumb($breadcrumb1); + + $this->assertScopeBreadcrumbs($scope, [$breadcrumb2]); } public function testWithScope(): void @@ -763,4 +943,50 @@ public function testContinueTraceWhenOrgMatch(): void $this->assertSame('1', $dynamicSamplingContext->get('org_id')); }); } + + private function setClientAndIsolationScope(ClientInterface $client): Scope + { + SentrySdk::init(); + + SentrySdk::getGlobalScope()->clear(); + SentrySdk::getGlobalScope()->setTag('scope', 'global'); + SentrySdk::getGlobalScope()->setTag('global', 'yes'); + SentrySdk::getGlobalScope()->setClient($client); + + $scope = new Scope(); + $scope->setTag('scope', 'isolation'); + $scope->setTag('isolation', 'yes'); + SentrySdk::getCurrentRuntimeContext()->setIsolationScope($scope); + + return $scope; + } + + private function captureScopeConstraint(Scope $isolationScope) + { + return $this->callback(function (Scope $captureScope) use ($isolationScope): bool { + $this->assertNotSame($isolationScope, $captureScope); + + $event = $captureScope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame([ + 'scope' => 'isolation', + 'global' => 'yes', + 'isolation' => 'yes', + ], $event->getTags()); + + return true; + }); + } + + /** + * @param Breadcrumb[] $expectedBreadcrumbs + */ + private function assertScopeBreadcrumbs(Scope $scope, array $expectedBreadcrumbs): void + { + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedBreadcrumbs, $event->getBreadcrumbs()); + } } diff --git a/tests/State/BreadcrumbRecorderTest.php b/tests/State/BreadcrumbRecorderTest.php new file mode 100644 index 000000000..121825aae --- /dev/null +++ b/tests/State/BreadcrumbRecorderTest.php @@ -0,0 +1,132 @@ +createMock(ClientInterface::class); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options()); + + $this->assertTrue(BreadcrumbRecorder::record($client, $scope, $breadcrumb)); + $this->assertScopeBreadcrumbs($scope, [$breadcrumb]); + $this->assertScopeBreadcrumbs($otherScope, []); + } + + public function testRecordReturnsFalseForNoOpClient(): void + { + $scope = new Scope(); + + $this->assertFalse(BreadcrumbRecorder::record( + new NoOpClient(), + $scope, + new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting') + )); + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testRecordReturnsFalseWhenMaxBreadcrumbsLimitIsZero(): void + { + $scope = new Scope(); + $client = $this->createMock(ClientInterface::class); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options(['max_breadcrumbs' => 0])); + + $this->assertFalse(BreadcrumbRecorder::record( + $client, + $scope, + new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting') + )); + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testRecordReturnsFalseWhenBeforeBreadcrumbCallbackReturnsNull(): void + { + $scope = new Scope(); + $client = $this->createMock(ClientInterface::class); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'before_breadcrumb' => static function () { + return null; + }, + ])); + + $this->assertFalse(BreadcrumbRecorder::record( + $client, + $scope, + new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting') + )); + $this->assertScopeBreadcrumbs($scope, []); + } + + public function testRecordStoresBreadcrumbReturnedByBeforeBreadcrumbCallback(): void + { + $breadcrumb1 = new Breadcrumb(Breadcrumb::LEVEL_ERROR, Breadcrumb::TYPE_ERROR, 'error_reporting'); + $breadcrumb2 = new Breadcrumb(Breadcrumb::LEVEL_WARNING, Breadcrumb::TYPE_DEFAULT, 'custom'); + $scope = new Scope(); + $client = $this->createMock(ClientInterface::class); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'before_breadcrumb' => static function () use ($breadcrumb2): Breadcrumb { + return $breadcrumb2; + }, + ])); + + $this->assertTrue(BreadcrumbRecorder::record($client, $scope, $breadcrumb1)); + $this->assertScopeBreadcrumbs($scope, [$breadcrumb2]); + } + + public function testRecordRespectsMaxBreadcrumbsLimit(): void + { + $breadcrumb1 = new Breadcrumb(Breadcrumb::LEVEL_INFO, Breadcrumb::TYPE_DEFAULT, 'one'); + $breadcrumb2 = new Breadcrumb(Breadcrumb::LEVEL_INFO, Breadcrumb::TYPE_DEFAULT, 'two'); + $breadcrumb3 = new Breadcrumb(Breadcrumb::LEVEL_INFO, Breadcrumb::TYPE_DEFAULT, 'three'); + $scope = new Scope(); + $client = $this->createMock(ClientInterface::class); + + $client->expects($this->exactly(3)) + ->method('getOptions') + ->willReturn(new Options(['max_breadcrumbs' => 2])); + + BreadcrumbRecorder::record($client, $scope, $breadcrumb1); + BreadcrumbRecorder::record($client, $scope, $breadcrumb2); + BreadcrumbRecorder::record($client, $scope, $breadcrumb3); + + $this->assertScopeBreadcrumbs($scope, [$breadcrumb2, $breadcrumb3]); + } + + /** + * @param Breadcrumb[] $expectedBreadcrumbs + */ + private function assertScopeBreadcrumbs(Scope $scope, array $expectedBreadcrumbs): void + { + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedBreadcrumbs, $event->getBreadcrumbs()); + } +} diff --git a/tests/State/EventCapturerTest.php b/tests/State/EventCapturerTest.php new file mode 100644 index 000000000..8e9123cda --- /dev/null +++ b/tests/State/EventCapturerTest.php @@ -0,0 +1,205 @@ +createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('captureMessage') + ->with('foo', Severity::debug(), $this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + }), $hint) + ->willReturn($eventId); + + $this->assertSame($eventId, EventCapturer::captureMessage('foo', Severity::debug(), $hint)); + $this->assertSame($eventId, $isolationScope->getLastEventId()); + } + + public function testCaptureExceptionPassesMergedScopeAndStoresLastEventIdOnIsolationScope(): void + { + $eventId = EventId::generate(); + $exception = new \RuntimeException('foo'); + $hint = new EventHint(); + $client = $this->createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('captureException') + ->with($exception, $this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + }), $hint) + ->willReturn($eventId); + + $this->assertSame($eventId, EventCapturer::captureException($exception, $hint)); + $this->assertSame($eventId, $isolationScope->getLastEventId()); + } + + public function testCaptureEventPassesMergedScopeAndStoresLastEventIdOnIsolationScope(): void + { + $event = Event::createEvent(); + $hint = new EventHint(); + $client = $this->createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('captureEvent') + ->with($event, $hint, $this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + })) + ->willReturn($event->getId()); + + $this->assertSame($event->getId(), EventCapturer::captureEvent($event, $hint)); + $this->assertSame($event->getId(), $isolationScope->getLastEventId()); + } + + public function testCaptureLastErrorPassesMergedScopeAndStoresLastEventIdOnIsolationScope(): void + { + $eventId = EventId::generate(); + $hint = new EventHint(); + $client = $this->createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('captureLastError') + ->with($this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + }), $hint) + ->willReturn($eventId); + + $this->assertSame($eventId, EventCapturer::captureLastError($hint)); + $this->assertSame($eventId, $isolationScope->getLastEventId()); + } + + public function testCaptureEventClearsLastEventIdWhenClientReturnsNull(): void + { + $event = Event::createEvent(); + $client = $this->createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + $isolationScope->setLastEventId(EventId::generate()); + + $client->expects($this->once()) + ->method('captureEvent') + ->with($event, null, $this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + })) + ->willReturn(null); + + $this->assertNull(EventCapturer::captureEvent($event)); + $this->assertNull($isolationScope->getLastEventId()); + } + + public function testCaptureCheckInCreatesEventAndStoresLastEventId(): void + { + $checkInId = SentryUid::generate(); + $eventId = EventId::generate(); + $monitorConfig = new MonitorConfig( + MonitorSchedule::crontab('*/5 * * * *'), + 5, + 30, + 'UTC' + ); + $client = $this->createMock(ClientInterface::class); + $isolationScope = $this->setClientAndIsolationScope($client); + + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'environment' => Event::DEFAULT_ENVIRONMENT, + 'release' => '1.0.0', + ])); + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->callback(static function (Event $event) use ($checkInId, $monitorConfig): bool { + $checkIn = $event->getCheckIn(); + + return $checkIn !== null + && $checkIn->getId() === $checkInId + && $checkIn->getMonitorSlug() === 'test-crontab' + && $checkIn->getStatus() == CheckInStatus::ok() + && $checkIn->getRelease() === '1.0.0' + && $checkIn->getEnvironment() === Event::DEFAULT_ENVIRONMENT + && $checkIn->getDuration() === 10 + && $checkIn->getMonitorConfig() === $monitorConfig; + }), null, $this->callback(function (Scope $scope) use ($isolationScope): bool { + return $this->isMergedCaptureScope($scope, $isolationScope); + })) + ->willReturn($eventId); + + $this->assertSame($checkInId, EventCapturer::captureCheckIn( + 'test-crontab', + CheckInStatus::ok(), + 10, + $monitorConfig, + $checkInId + )); + $this->assertSame($eventId, $isolationScope->getLastEventId()); + } + + public function testCaptureCheckInReturnsNullForNoOpClient(): void + { + SentrySdk::init(new NoOpClient()); + $eventId = EventId::generate(); + SentrySdk::getIsolationScope()->setLastEventId($eventId); + + $this->assertNull(EventCapturer::captureCheckIn('test-crontab', CheckInStatus::ok())); + $this->assertSame($eventId, SentrySdk::getIsolationScope()->getLastEventId()); + } + + private function setClientAndIsolationScope(ClientInterface $client): Scope + { + SentrySdk::init(); + + SentrySdk::getGlobalScope()->clear(); + SentrySdk::getGlobalScope()->setTag('scope', 'global'); + SentrySdk::getGlobalScope()->setTag('global', 'yes'); + SentrySdk::getGlobalScope()->setClient($client); + + $scope = new Scope(); + $scope->setTag('scope', 'isolation'); + $scope->setTag('isolation', 'yes'); + SentrySdk::getCurrentRuntimeContext()->setIsolationScope($scope); + + return $scope; + } + + private function isMergedCaptureScope(Scope $captureScope, Scope $isolationScope): bool + { + $this->assertNotSame($isolationScope, $captureScope); + + $event = $captureScope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame([ + 'scope' => 'isolation', + 'global' => 'yes', + 'isolation' => 'yes', + ], $event->getTags()); + + return true; + } +}