diff --git a/composer.json b/composer.json index 34e38c5..f01a24d 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,14 @@ "spiral/boot": "^3.0", "spiral/snapshots": "^3.0", "sentry/sentry": "^4.0", + "psr/http-factory": "^1.0.1", + "psr/http-message": "^1.0.1 || ^2.0", "php-http/curl-client": "^2.3.1" }, "require-dev": { - "phpunit/phpunit": "^9.5.5", - "mockery/mockery": "^1.5", "vimeo/psalm": "^5.17", "psr/log": "^3.0", - "spiral/testing": "^2.2" + "spiral/testing": "^2.6" }, "autoload": { "psr-4": { @@ -34,5 +34,10 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "config": { + "allow-plugins": { + "php-http/discovery": true + } + } } diff --git a/src/Bootloader/ClientBootloader.php b/src/Bootloader/ClientBootloader.php index e65e850..cf8c5be 100644 --- a/src/Bootloader/ClientBootloader.php +++ b/src/Bootloader/ClientBootloader.php @@ -6,41 +6,148 @@ use Sentry\ClientBuilder; use Sentry\ClientInterface; +use Sentry\Integration\RequestFetcherInterface; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; +use Sentry\State\HubInterface; +use Sentry\State\Scope; use Spiral\Boot\Bootloader\Bootloader; +use Spiral\Boot\DirectoriesInterface; use Spiral\Boot\EnvironmentInterface; +use Spiral\Boot\FinalizerInterface; use Spiral\Config\ConfiguratorInterface; use Spiral\Sentry\Config\SentryConfig; +use Spiral\Sentry\Http\RequestScope; use Spiral\Sentry\Version; +use Sentry\Integration as SdkIntegration; class ClientBootloader extends Bootloader { - protected const SINGLETONS = [ - ClientInterface::class => [self::class, 'createClient'], - ]; + /** @var SdkIntegration\IntegrationInterface[] */ + private array $integrations = []; public function __construct( - private readonly ConfiguratorInterface $config + private readonly ConfiguratorInterface $config, ) { } - public function init(EnvironmentInterface $env): void + public function defineSingletons(): array + { + return [ + Options::class => [self::class, 'createOptions'], + HubInterface::class => [self::class, 'createHub'], + ClientInterface::class => [self::class, 'getClient'], + RequestFetcherInterface::class => RequestScope::class, + ]; + } + + /** + * Register a new integration to be added to the Sentry SDK. + */ + public function addIntegration(SdkIntegration\IntegrationInterface $integration): void + { + $this->integrations[] = $integration; + } + + public function init(EnvironmentInterface $env, FinalizerInterface $finalizer): void { $this->config->setDefaults('sentry', [ - 'dsn' => \trim($env->get('SENTRY_DSN', ''), "\n\t\r \"'") // typical typos + 'dsn' => \trim($env->get('SENTRY_DSN', ''), "\n\t\r \"'"), // typical typos + 'environment' => $env->get('SENTRY_ENVIRONMENT') ?? null, + 'release' => $env->get('SENTRY_RELEASE') ?? null, + 'sample_rate' => $env->get('SENTRY_SAMPLE_RATE') === null + ? 1.0 + : (float)$env->get('SENTRY_SAMPLE_RATE'), + 'traces_sample_rate' => $env->get('SENTRY_TRACES_SAMPLE_RATE') === null + ? null + : (float)$env->get('SENTRY_TRACES_SAMPLE_RATE'), + 'send_default_pii' => (bool)$env->get('SENTRY_SEND_DEFAULT_PII'), ]); } - private function createClient(SentryConfig $config): ClientInterface + /** + * Create the Sentry SDK options. + */ + private function createOptions( + SentryConfig $config, + DirectoriesInterface $dirs, + EnvironmentInterface $env, + RequestFetcherInterface $requestScope, + ): Options { + $options = new Options([ + 'dsn' => $config->getDSN(), + 'environment' => $config->getEnvironment(), + 'release' => $config->getRelease(), + ]); + + $options->setSampleRate($config->getSampleRate()); + $options->setTracesSampleRate($config->getTracesSampleRate()); + $options->setSendDefaultPii($config->isSendDefaultPii()); + + $options->setPrefixes([ + $dirs->get('root'), + ]); + + $options->setInAppExcludedPaths([ + $dirs->get('root') . '/vendor', + ]); + + if ($config->getEnvironment() === null) { + $options->setEnvironment($env->get('APP_ENV')); + } + + if ($config->getRelease() === null) { + $options->setRelease($env->get('APP_VERSION')); + } + + $options->setIntegrations(function (array $integrations) use ($options, $requestScope): array { + if ($options->hasDefaultIntegrations()) { + // Remove the default error and fatal exception listeners to let Spiral handle those + // itself. These event are still bubbling up through the documented changes in the users + // `ExceptionHandler` of their application or through the log channel integration to Sentry + $integrations = \array_filter( + $integrations, + static function (SdkIntegration\IntegrationInterface $integration): bool { + if ($integration instanceof SdkIntegration\ErrorListenerIntegration) { + return false; + } + + if ($integration instanceof SdkIntegration\ExceptionListenerIntegration) { + return false; + } + + if ($integration instanceof SdkIntegration\FatalErrorListenerIntegration) { + return false; + } + + // We also remove the default request integration so it can be readded + // after with a Laravel specific request fetcher. This way we can resolve + // the request from Laravel instead of constructing it from the global state + if ($integration instanceof SdkIntegration\RequestIntegration) { + return false; + } + + return true; + }, + ); + + $integrations[] = new SdkIntegration\RequestIntegration($requestScope); + } + + return [...$integrations, ...$this->integrations]; + }); + + return $options; + } + + private function createHub(Options $options, FinalizerInterface $finalizer): HubInterface { /** * @psalm-suppress InternalClass * @psalm-suppress InternalMethod */ - $builder = ClientBuilder::create([ - 'dsn' => $config->getDSN(), - ]); + $builder = new ClientBuilder($options); /** @psalm-suppress InternalMethod */ $builder->setSdkIdentifier(Version::SDK_IDENTIFIER); @@ -49,10 +156,21 @@ private function createClient(SentryConfig $config): ClientInterface $builder->setSdkVersion(Version::SDK_VERSION); /** @psalm-suppress InternalMethod */ - $client = $builder->getClient(); + $hub = new Hub($builder->getClient()); + + SentrySdk::setCurrentHub($hub); - SentrySdk::setCurrentHub(new Hub($client)); + $finalizer->addFinalizer(static function () use ($hub): void { + $hub->configureScope(function (Scope $scope): void { + $scope->clear(); + }); + }); - return $client; + return $hub; + } + + private function getClient(HubInterface $hub): ?ClientInterface + { + return $hub->getClient(); } } diff --git a/src/Bootloader/SentryBootloader.php b/src/Bootloader/SentryBootloader.php index 87fe8f0..6abf8f8 100644 --- a/src/Bootloader/SentryBootloader.php +++ b/src/Bootloader/SentryBootloader.php @@ -10,11 +10,17 @@ final class SentryBootloader extends Bootloader { - protected const DEPENDENCIES = [ - ClientBootloader::class, - ]; + public function defineDependencies(): array + { + return [ + ClientBootloader::class, + ]; + } - protected const BINDINGS = [ - SnapshotterInterface::class => SentrySnapshotter::class, - ]; + public function defineBindings(): array + { + return [ + SnapshotterInterface::class => SentrySnapshotter::class, + ]; + } } diff --git a/src/Bootloader/SentryReporterBootloader.php b/src/Bootloader/SentryReporterBootloader.php index ab2cbfe..b090da8 100644 --- a/src/Bootloader/SentryReporterBootloader.php +++ b/src/Bootloader/SentryReporterBootloader.php @@ -11,9 +11,12 @@ final class SentryReporterBootloader extends Bootloader { - protected const DEPENDENCIES = [ - ClientBootloader::class, - ]; + public function defineDependencies(): array + { + return [ + ClientBootloader::class, + ]; + } public function init(AbstractKernel $kernel): void { diff --git a/src/Client.php b/src/Client.php index 65bae44..9ebf417 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,8 +7,8 @@ use Psr\Container\ContainerInterface; use Psr\Log\LogLevel; use Sentry\Breadcrumb; -use Sentry\ClientInterface; use Sentry\EventId; +use Sentry\State\HubInterface; use Sentry\State\Scope; use Spiral\Debug\StateInterface; use Spiral\Logger\Event\LogEvent; @@ -16,27 +16,27 @@ final class Client { public function __construct( - private readonly ClientInterface $client, + private readonly HubInterface $hub, private readonly ContainerInterface $container, ) { } public function send(\Throwable $exception): ?EventId { - $scope = new Scope(); - if ($this->container->has(StateInterface::class)) { $state = $this->container->get(StateInterface::class); - $scope->setTags($state->getTags()); - $scope->setExtras($state->getVariables()); + $this->hub->configureScope(function (Scope $scope) use ($state): void { + $scope->setTags($state->getTags()); + $scope->setExtras($state->getVariables()); - foreach ($state->getLogEvents() as $event) { - $scope->addBreadcrumb($this->makeBreadcrumb($event)); - } + foreach ($state->getLogEvents() as $event) { + $scope->addBreadcrumb($this->makeBreadcrumb($event)); + } + }); } - return $this->client->captureException($exception, $scope); + return $this->hub->captureException($exception); } private function makeBreadcrumb(LogEvent $event): Breadcrumb @@ -68,7 +68,7 @@ private function makeBreadcrumb(LogEvent $event): Breadcrumb 'default', $event->getChannel(), $event->getMessage(), - $event->getContext() + $event->getContext(), ); } } diff --git a/src/Config/SentryConfig.php b/src/Config/SentryConfig.php index 7523fe3..d32def7 100644 --- a/src/Config/SentryConfig.php +++ b/src/Config/SentryConfig.php @@ -15,10 +15,71 @@ final class SentryConfig extends InjectableConfig protected array $config = [ 'dsn' => '', + 'environment' => null, + 'release' => null, + 'sample_rate' => 1.0, + 'traces_sample_rate' => null, + 'send_default_pii' => false, ]; + /** + * @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + */ public function getDSN(): string { return $this->config['dsn']; } -} + + /** + * This string is freeform and set to production by default. A release can be associated with + * more than one environment to separate them in the UI (think staging vs production or similar). + */ + public function getEnvironment(): ?string + { + return $this->config['environment'] ?? $_SERVER['SENTRY_ENVIRONMENT'] ?? null; + } + + /** + * The release version of your application. + * Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + */ + public function getRelease(): ?string + { + return $this->config['release'] ?? $_SERVER['SENTRY_RELEASE'] ?? null; + } + + /** + * Configures the sample rate for error events, in the range of 0.0 to 1.0. The default is 1.0 which means that + * 100% of error events are sent. If set to 0.1 only 10% of error events will be sent. Events are picked randomly. + * + * @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample-rate + */ + public function getSampleRate(): float + { + return $this->config['sample_rate']; + } + + /** + * A number between 0 and 1, controlling the percentage chance a given transaction will be sent to Sentry. + * (0 represents 0% while 1 represents 100%.) Applies equally to all transactions created in the app. Either this + * or traces_sampler must be defined to enable tracingThe process of logging the events that took place during a + * request, often across multiple services.. + * + * @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate + */ + public function getTracesSampleRate(): ?float + { + return $this->config['traces_sample_rate']; + } + + /** + * If this flag is enabled, certain personally identifiable information (PII) is added by active integrations. + * By default, no such data is sent. + * + * @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii + */ + public function isSendDefaultPii(): bool + { + return $this->config['send_default_pii'] ?? false; + } +} \ No newline at end of file diff --git a/src/Http/RequestScope.php b/src/Http/RequestScope.php new file mode 100644 index 0000000..57cb1a7 --- /dev/null +++ b/src/Http/RequestScope.php @@ -0,0 +1,26 @@ +container->has(ServerRequestInterface::class)) { + return null; + } + + return $this->container->get(ServerRequestInterface::class); + } +} \ No newline at end of file diff --git a/src/Http/SetRequestIpMiddleware.php b/src/Http/SetRequestIpMiddleware.php new file mode 100644 index 0000000..e72592b --- /dev/null +++ b/src/Http/SetRequestIpMiddleware.php @@ -0,0 +1,35 @@ +options->shouldSendDefaultPii()) { + $this->hub->configureScope(function (Scope $scope) use ($request) { + $scope->setUser([ + 'ip_address' => $request->getServerParams()['REMOTE_ADDR'] ?? null, + ]); + }); + } + + return $handler->handle($request); + } +} \ No newline at end of file diff --git a/src/SentrySnapshotter.php b/src/SentrySnapshotter.php index 506c11b..2bbbfe9 100644 --- a/src/SentrySnapshotter.php +++ b/src/SentrySnapshotter.php @@ -11,7 +11,7 @@ final class SentrySnapshotter implements SnapshotterInterface { public function __construct( - private readonly Client $client + private readonly Client $client, ) { } @@ -19,12 +19,10 @@ public function register(\Throwable $e): SnapshotInterface { $eventId = $this->client->send($e); - $snapshot = new Snapshot( - $eventId ? (string) $eventId : $this->getID($e), - $e + return new Snapshot( + $eventId ? (string)$eventId : $this->getID($e), + $e, ); - - return $snapshot; } protected function getID(\Throwable $e): string diff --git a/tests/Sentry/ClientBootloaderTest.php b/tests/Sentry/ClientBootloaderTest.php index 4337ade..da846be 100644 --- a/tests/Sentry/ClientBootloaderTest.php +++ b/tests/Sentry/ClientBootloaderTest.php @@ -4,6 +4,13 @@ use Sentry\ClientInterface; use Sentry\Client; +use Sentry\Integration\RequestFetcherInterface; +use Sentry\Options; +use Sentry\State\Hub; +use Sentry\State\HubInterface; +use Spiral\Sentry\Config\SentryConfig; +use Spiral\Sentry\Http\RequestScope; +use Spiral\Testing\Attribute\Env; use Spiral\Tests\TestCase; final class ClientBootloaderTest extends TestCase @@ -12,4 +19,89 @@ public function testClientBound(): void { $this->assertContainerBoundAsSingleton(ClientInterface::class, Client::class); } + + public function testHubBound(): void + { + $this->assertContainerBoundAsSingleton(HubInterface::class, Hub::class); + } + + public function testOptionsBound(): void + { + $this->assertContainerBoundAsSingleton(Options::class, Options::class); + } + + public function testRequestScopeBound(): void + { + $this->assertContainerBoundAsSingleton(RequestFetcherInterface::class, RequestScope::class); + } + + public function testDefaultConfig(): void + { + $config = $this->getConfig(SentryConfig::CONFIG); + + $this->assertSame('', $config['dsn']); + $this->assertNull($config['environment']); + $this->assertNull($config['release']); + $this->assertSame(1.0, $config['sample_rate']); + $this->assertSame(null, $config['traces_sample_rate']); + $this->assertFalse($config['send_default_pii']); + } + + #[Env('SENTRY_DSN', 'http://example.com')] + #[Env('SENTRY_ENVIRONMENT', 'testing')] + #[Env('SENTRY_RELEASE', '1.0.1')] + #[Env('SENTRY_SAMPLE_RATE', '0.5')] + #[Env('SENTRY_TRACES_SAMPLE_RATE', '0.7')] + #[Env('SENTRY_SEND_DEFAULT_PII', 'true')] + public function testConfigWithEnv(): void + { + $config = $this->getConfig(SentryConfig::CONFIG); + + $this->assertSame('http://example.com', $config['dsn']); + $this->assertSame('testing', $config['environment']); + $this->assertSame('1.0.1', $config['release']); + $this->assertSame(0.5, $config['sample_rate']); + $this->assertSame(0.7, $config['traces_sample_rate']); + $this->assertTrue($config['send_default_pii']); + } + + #[Env('APP_ENV', 'foo')] + public function testDetectEnvironmentFromAppEnv(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertSame('foo', $options->getEnvironment()); + } + + #[Env('SENTRY_ENVIRONMENT', 'bar')] + public function testDetectEnvironmentFromSentryEnv(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertSame('bar', $options->getEnvironment()); + } + + public function testDefaultEnvironment(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertNull($options->getEnvironment()); + } + + #[Env('APP_VERSION', '1.0.0')] + public function testDetectReleaseFromAppEnv(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertSame('1.0.0', $options->getRelease()); + } + + #[Env('SENTRY_RELEASE', '1.0.1')] + public function testDetectReleaseFromSentryEnv(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertSame('1.0.1', $options->getRelease()); + } + + public function testDefaultRelease(): void + { + $options = $this->getContainer()->get(Options::class); + $this->assertNull($options->getRelease()); + } } \ No newline at end of file diff --git a/tests/Sentry/ClientTest.php b/tests/Sentry/ClientTest.php index 69cebef..c628bc7 100644 --- a/tests/Sentry/ClientTest.php +++ b/tests/Sentry/ClientTest.php @@ -2,8 +2,8 @@ namespace Spiral\Tests\Sentry; +use Sentry\State\HubInterface; use Spiral\Tests\TestCase; -use Sentry\ClientInterface; use Sentry\EventId; use Spiral\Core\Container; use Spiral\Debug\State; @@ -19,19 +19,22 @@ public function testSend(): void $container->bindSingleton(StateInterface::class, new State()); $mainClient = new Client( - $client = m::mock(ClientInterface::class), - $container + $client = m::mock(HubInterface::class), + $container, ); $errorException = new \ErrorException('Test exception'); + $client->shouldReceive('configureScope')->once(); + $client->shouldReceive('captureException') ->once() ->withArgs(function (\Throwable $exception) use ($errorException) { - return $errorException === $exception; + $this->assertSame($errorException, $exception); + return true; }) ->andReturn( - $eventId = new EventId('c8c46e00bf53942206fd2ad9546daac2') + $eventId = new EventId('c8c46e00bf53942206fd2ad9546daac2'), ); $this->assertSame($eventId, $mainClient->send($errorException)); @@ -40,8 +43,8 @@ public function testSend(): void public function testSendWithoutState(): void { $mainClient = new Client( - $client = m::mock(ClientInterface::class), - new Container() + $client = m::mock(HubInterface::class), + new Container(), ); $errorException = new \ErrorException('Test exception'); @@ -49,10 +52,11 @@ public function testSendWithoutState(): void $client->shouldReceive('captureException') ->once() ->withArgs(function (\Throwable $exception) use ($errorException) { - return $errorException === $exception; + $this->assertSame($errorException, $exception); + return true; }) ->andReturn( - $eventId = new EventId('c8c46e00bf53942206fd2ad9546daac2') + $eventId = new EventId('c8c46e00bf53942206fd2ad9546daac2'), ); $this->assertSame($eventId, $mainClient->send($errorException)); diff --git a/tests/Sentry/ConfigTest.php b/tests/Sentry/ConfigTest.php index 6d8d19a..957c6ff 100644 --- a/tests/Sentry/ConfigTest.php +++ b/tests/Sentry/ConfigTest.php @@ -14,4 +14,49 @@ public function testConfig(): void $cfg = new SentryConfig(['dsn' => 'value']); $this->assertSame('value', $cfg->getDSN()); } + + public function testEnvironment(): void + { + $cfg = new SentryConfig(); + $this->assertNull($cfg->getEnvironment()); + + $cfg = new SentryConfig(['environment' => 'value']); + $this->assertSame('value', $cfg->getEnvironment()); + } + + public function testRelease(): void + { + $cfg = new SentryConfig(); + $this->assertNull($cfg->getRelease()); + + $cfg = new SentryConfig(['release' => 'value']); + $this->assertSame('value', $cfg->getRelease()); + } + + public function testSampleRate(): void + { + $cfg = new SentryConfig(); + $this->assertSame(1.0, $cfg->getSampleRate()); + + $cfg = new SentryConfig(['sample_rate' => 0.5]); + $this->assertSame(0.5, $cfg->getSampleRate()); + } + + public function testTracesSampleRate(): void + { + $cfg = new SentryConfig(); + $this->assertNull($cfg->getTracesSampleRate()); + + $cfg = new SentryConfig(['traces_sample_rate' => 0.5]); + $this->assertSame(0.5, $cfg->getTracesSampleRate()); + } + + public function testSendDefaultPii(): void + { + $cfg = new SentryConfig(); + $this->assertFalse($cfg->isSendDefaultPii()); + + $cfg = new SentryConfig(['send_default_pii' => true]); + $this->assertTrue($cfg->isSendDefaultPii()); + } } diff --git a/tests/Sentry/SentrySnapshotterTest.php b/tests/Sentry/SentrySnapshotterTest.php index 81aa3cc..298b85e 100644 --- a/tests/Sentry/SentrySnapshotterTest.php +++ b/tests/Sentry/SentrySnapshotterTest.php @@ -5,8 +5,7 @@ namespace Spiral\Tests\Sentry; use Mockery as m; -use Psr\Container\ContainerInterface; -use Sentry\ClientInterface; +use Sentry\State\HubInterface; use Spiral\Core\Container; use Spiral\Debug\State; use Spiral\Debug\StateInterface; @@ -19,13 +18,15 @@ final class SentrySnapshotterTest extends TestCase { public function testRegister(): void { - $client = m::mock(ClientInterface::class); - $client->expects('captureException'); + $hub = m::mock(HubInterface::class); + + $hub->expects('configureScope'); + $hub->expects('captureException'); $container = new Container(); $container->bindSingleton(StateInterface::class, new State()); - $sentry = new SentrySnapshotter(new Client($client, $container)); + $sentry = new SentrySnapshotter(new Client($hub, $container)); $this->assertInstanceOf(SnapshotInterface::class, $sentry->register(new \Error('hello world'))); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 90b5ef6..3d6208c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,6 @@ namespace Spiral\Tests; -use Spiral\Sentry\Bootloader\ClientBootloader; use Spiral\Sentry\Bootloader\SentryBootloader; class TestCase extends \Spiral\Testing\TestCase @@ -10,7 +9,6 @@ class TestCase extends \Spiral\Testing\TestCase public function defineBootloaders(): array { return [ - ClientBootloader::class, SentryBootloader::class, ]; }