diff --git a/CHANGELOG.md b/CHANGELOG.md index c727165c..95fd7363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 4.5.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.5.0. + +### Features + +- Limit when SQL query origins are being captured [(#881)](https://github.com/getsentry/sentry-laravel/pull/881) + + We now only capture the origin of a SQL query when the query is slower than 100ms, configurable by the `SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS` environment variable. + +- Add tracing and breadcrumbs for [Notifications](https://laravel.com/docs/11.x/notifications) [(#852)](https://github.com/getsentry/sentry-laravel/pull/852) + +- Add reporter for `Model::preventAccessingMissingAttributes()` [(#824)](https://github.com/getsentry/sentry-laravel/pull/824) + +- Make it easier to enable the debug logger [(#880)](https://github.com/getsentry/sentry-laravel/pull/880) + + You can now enable the debug logger by adding the following to your `config/sentry.php` file: + + ```php + 'logger' => Sentry\Logger\DebugFileLogger::class, // This will log SDK logs to `storage_path('logs/sentry.log')` + ``` + + Only use this in development and testing environments, as it can generate a lot of logs. + +### Bug Fixes + +- Fix Lighthouse operation not detected when query contained a fragment before the operation [(#883)](https://github.com/getsentry/sentry-laravel/pull/883) + +- Fix an exception being thrown when the username extracted from the authenticated user model is not a string [(#887)](https://github.com/getsentry/sentry-laravel/pull/887) + ## 4.4.1 The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.4.1. diff --git a/composer.json b/composer.json index 017b7e6f..dc66ed3e 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": "^7.2 | ^8.0", "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", - "sentry/sentry": "^4.5", + "sentry/sentry": "^4.7", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0", "nyholm/psr7": "^1.0" }, diff --git a/config/sentry.php b/config/sentry.php index ee5fce26..43eba645 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -10,6 +10,9 @@ // @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/ 'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) 'release' => env('SENTRY_RELEASE'), @@ -63,6 +66,9 @@ // Capture HTTP client request information as breadcrumbs 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), ], // Performance monitoring specific configuration @@ -82,6 +88,9 @@ // Capture where the SQL query originated from on the SQL query spans 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + // Capture views rendered as spans 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), @@ -97,6 +106,9 @@ // Capture where the Redis command originated from on the Redis command spans 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + // Enable tracing for requests without a matching route (404's) 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7879f79b..7860608a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -135,6 +135,11 @@ parameters: count: 1 path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + - + message: "#^Method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:extractOperationDefinitionNode\\(\\) has invalid return type GraphQL\\\\Language\\\\AST\\\\OperationDefinitionNode\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + - message: "#^Parameter \\$endExecution of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleEndExecution\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\EndExecution\\.$#" count: 1 @@ -150,6 +155,11 @@ parameters: count: 1 path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + - + message: "#^Parameter \\$query of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:extractOperationDefinitionNode\\(\\) has invalid type GraphQL\\\\Language\\\\AST\\\\DocumentNode\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + - message: "#^Parameter \\$startExecution of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleStartExecution\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\StartExecution\\.$#" count: 1 diff --git a/src/Sentry/Laravel/EventHandler.php b/src/Sentry/Laravel/EventHandler.php index 7a9eddaa..3aeaa843 100644 --- a/src/Sentry/Laravel/EventHandler.php +++ b/src/Sentry/Laravel/EventHandler.php @@ -4,7 +4,6 @@ use Exception; use Illuminate\Auth\Events as AuthEvents; -use Illuminate\Console\Events as ConsoleEvents; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\Container; @@ -21,8 +20,6 @@ use Sentry\Laravel\Tracing\Middleware; use Sentry\SentrySdk; use Sentry\State\Scope; -use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Input\InputInterface; class EventHandler { @@ -35,8 +32,6 @@ class EventHandler LogEvents\MessageLogged::class => 'messageLogged', RoutingEvents\RouteMatched::class => 'routeMatched', DatabaseEvents\QueryExecuted::class => 'queryExecuted', - ConsoleEvents\CommandStarting::class => 'commandStarting', - ConsoleEvents\CommandFinished::class => 'commandFinished', ]; /** @@ -96,13 +91,6 @@ class EventHandler */ private $recordLaravelLogs; - /** - * Indicates if we should add command info to the breadcrumbs. - * - * @var bool - */ - private $recordCommandInfo; - /** * Indicates if we should add tick info to the breadcrumbs. * @@ -137,7 +125,6 @@ public function __construct(Container $container, array $config) $this->recordSqlQueries = ($config['breadcrumbs.sql_queries'] ?? $config['breadcrumbs']['sql_queries'] ?? true) === true; $this->recordSqlBindings = ($config['breadcrumbs.sql_bindings'] ?? $config['breadcrumbs']['sql_bindings'] ?? false) === true; $this->recordLaravelLogs = ($config['breadcrumbs.logs'] ?? $config['breadcrumbs']['logs'] ?? true) === true; - $this->recordCommandInfo = ($config['breadcrumbs.command_info'] ?? $config['breadcrumbs']['command_info'] ?? true) === true; $this->recordOctaneTickInfo = ($config['breadcrumbs.octane_tick_info'] ?? $config['breadcrumbs']['octane_tick_info'] ?? true) === true; $this->recordOctaneTaskInfo = ($config['breadcrumbs.octane_task_info'] ?? $config['breadcrumbs']['octane_task_info'] ?? true) === true; } @@ -287,12 +274,14 @@ private function configureUserScopeFromModel($authUser): void // If the user is a Laravel Eloquent model we try to extract some common fields from it if ($authUser instanceof Model) { + $username = $authUser->getAttribute('username'); + $userData = [ 'id' => $authUser instanceof Authenticatable ? $authUser->getAuthIdentifier() : $authUser->getKey(), 'email' => $authUser->getAttribute('email') ?? $authUser->getAttribute('mail'), - 'username' => $authUser->getAttribute('username'), + 'username' => $username === null ? $username : (string)$username, ]; } @@ -316,68 +305,6 @@ private function configureUserScopeFromModel($authUser): void }); } - protected function commandStartingHandler(ConsoleEvents\CommandStarting $event): void - { - if ($event->command) { - Integration::configureScope(static function (Scope $scope) use ($event): void { - $scope->setTag('command', $event->command); - }); - - if (!$this->recordCommandInfo) { - return; - } - - Integration::addBreadcrumb(new Breadcrumb( - Breadcrumb::LEVEL_INFO, - Breadcrumb::TYPE_DEFAULT, - 'artisan.command', - 'Starting Artisan command: ' . $event->command, - [ - 'input' => $this->extractConsoleCommandInput($event->input), - ] - )); - } - } - - protected function commandFinishedHandler(ConsoleEvents\CommandFinished $event): void - { - if ($this->recordCommandInfo) { - Integration::addBreadcrumb(new Breadcrumb( - Breadcrumb::LEVEL_INFO, - Breadcrumb::TYPE_DEFAULT, - 'artisan.command', - 'Finished Artisan command: ' . $event->command, - [ - 'exit' => $event->exitCode, - 'input' => $this->extractConsoleCommandInput($event->input), - ] - )); - } - - // Flush any and all events that were possibly generated by the command - Integration::flushEvents(); - - Integration::configureScope(static function (Scope $scope): void { - $scope->removeTag('command'); - }); - } - - /** - * Extract the command input arguments if possible. - * - * @param \Symfony\Component\Console\Input\InputInterface|null $input - * - * @return string|null - */ - private function extractConsoleCommandInput(?InputInterface $input): ?string - { - if ($input instanceof ArgvInput) { - return (string)$input; - } - - return null; - } - protected function octaneRequestReceivedHandler(Octane\RequestReceived $event): void { $this->prepareScopeForOctane(); diff --git a/src/Sentry/Laravel/Features/CacheIntegration.php b/src/Sentry/Laravel/Features/CacheIntegration.php index 06fea139..01b88d1e 100644 --- a/src/Sentry/Laravel/Features/CacheIntegration.php +++ b/src/Sentry/Laravel/Features/CacheIntegration.php @@ -108,7 +108,7 @@ public function handleRedisCommand(RedisEvents\CommandExecuted $event): void $commandOrigin = $this->resolveEventOrigin(); if ($commandOrigin !== null) { - $data['db.redis.origin'] = $commandOrigin; + $data = array_merge($data, $commandOrigin); } } diff --git a/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php b/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php index ac9c6198..86a552b6 100644 --- a/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php +++ b/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php @@ -12,11 +12,12 @@ protected function container(): Container return app(); } - protected function resolveEventOrigin(): ?string + protected function resolveEventOrigin(): ?array { $backtraceHelper = $this->makeBacktraceHelper(); - $firstAppFrame = $backtraceHelper->findFirstInAppFrameForBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)); + // We limit the backtrace to 20 frames to prevent too much overhead and we'd reasonable expect the origin to be within the first 20 frames + $firstAppFrame = $backtraceHelper->findFirstInAppFrameForBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20)); if ($firstAppFrame === null) { return null; @@ -24,7 +25,22 @@ protected function resolveEventOrigin(): ?string $filePath = $backtraceHelper->getOriginalViewPathForFrameOfCompiledViewPath($firstAppFrame) ?? $firstAppFrame->getFile(); - return "{$filePath}:{$firstAppFrame->getLine()}"; + return [ + 'code.filepath' => $filePath, + 'code.function' => $firstAppFrame->getFunctionName(), + 'code.lineno' => $firstAppFrame->getLine(), + ]; + } + + protected function resolveEventOriginAsString(): ?string + { + $origin = $this->resolveEventOrigin(); + + if ($origin === null) { + return null; + } + + return "{$origin['code.filepath']}:{$origin['code.lineno']}"; } private function makeBacktraceHelper(): BacktraceHelper diff --git a/src/Sentry/Laravel/Features/ConsoleIntegration.php b/src/Sentry/Laravel/Features/ConsoleIntegration.php index 729dd5b2..a329ee41 100644 --- a/src/Sentry/Laravel/Features/ConsoleIntegration.php +++ b/src/Sentry/Laravel/Features/ConsoleIntegration.php @@ -2,246 +2,88 @@ namespace Sentry\Laravel\Features; -use DateTimeZone; -use Illuminate\Console\Application as ConsoleApplication; -use Illuminate\Console\Scheduling\Event as SchedulingEvent; -use Illuminate\Contracts\Cache\Factory as Cache; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Support\Str; -use RuntimeException; -use Sentry\CheckIn; -use Sentry\CheckInStatus; -use Sentry\Event as SentryEvent; -use Sentry\MonitorConfig; -use Sentry\MonitorSchedule; -use Sentry\SentrySdk; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Console\Events as ConsoleEvents; +use Sentry\Breadcrumb; +use Sentry\Laravel\Integration; +use Sentry\State\Scope; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputInterface; class ConsoleIntegration extends Feature { - /** - * @var array The list of checkins that are currently in progress. - */ - private $checkInStore = []; + private const FEATURE_KEY = 'command_info'; - /** - * @var Cache The cache repository. - */ - private $cache; - - public function register(): void - { - $this->onBootInactive(); - } - public function isApplicable(): bool { - return $this->container()->make(Application::class)->runningInConsole(); + return true; } - public function onBoot(Cache $cache): void + public function onBoot(Dispatcher $events): void { - $this->cache = $cache; - - $startCheckIn = function ( - ?string $slug, - SchedulingEvent $scheduled, - ?int $checkInMargin, - ?int $maxRuntime, - bool $updateMonitorConfig, - ?int $failureIssueThreshold, - ?int $recoveryThreshold - ) { - $this->startCheckIn( - $slug, - $scheduled, - $checkInMargin, - $maxRuntime, - $updateMonitorConfig, - $failureIssueThreshold, - $recoveryThreshold - ); - }; - $finishCheckIn = function (?string $slug, SchedulingEvent $scheduled, CheckInStatus $status) { - $this->finishCheckIn($slug, $scheduled, $status); - }; - - SchedulingEvent::macro('sentryMonitor', function ( - ?string $monitorSlug = null, - ?int $checkInMargin = null, - ?int $maxRuntime = null, - bool $updateMonitorConfig = true, - ?int $failureIssueThreshold = null, - ?int $recoveryThreshold = null - ) use ($startCheckIn, $finishCheckIn) { - /** @var SchedulingEvent $this */ - if ($monitorSlug === null && $this->command === null) { - throw new RuntimeException('The command string is null, please set a slug manually for this scheduled command using the `sentryMonitor(\'your-monitor-slug\')` macro.'); - } - - return $this - ->before(function () use ( - $startCheckIn, - $monitorSlug, - $checkInMargin, - $maxRuntime, - $updateMonitorConfig, - $failureIssueThreshold, - $recoveryThreshold - ) { - /** @var SchedulingEvent $this */ - $startCheckIn( - $monitorSlug, - $this, - $checkInMargin, - $maxRuntime, - $updateMonitorConfig, - $failureIssueThreshold, - $recoveryThreshold - ); - }) - ->onSuccess(function () use ($finishCheckIn, $monitorSlug) { - /** @var SchedulingEvent $this */ - $finishCheckIn($monitorSlug, $this, CheckInStatus::ok()); - }) - ->onFailure(function () use ($finishCheckIn, $monitorSlug) { - /** @var SchedulingEvent $this */ - $finishCheckIn($monitorSlug, $this, CheckInStatus::error()); - }); - }); + $events->listen(ConsoleEvents\CommandStarting::class, [$this, 'commandStarting']); + $events->listen(ConsoleEvents\CommandFinished::class, [$this, 'commandFinished']); } - public function onBootInactive(): void + public function commandStarting(ConsoleEvents\CommandStarting $event): void { - // This is an exact copy of the macro above, but without doing anything so that even when no DSN is configured the user can still use the macro - SchedulingEvent::macro('sentryMonitor', function ( - ?string $monitorSlug = null, - ?int $checkInMargin = null, - ?int $maxRuntime = null, - bool $updateMonitorConfig = true, - ?int $failureIssueThreshold = null, - ?int $recoveryThreshold = null - ) { - return $this; - }); - } - - private function startCheckIn( - ?string $slug, - SchedulingEvent $scheduled, - ?int $checkInMargin, - ?int $maxRuntime, - bool $updateMonitorConfig, - ?int $failureIssueThreshold, - ?int $recoveryThreshold - ): void { - $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); - - $checkIn = $this->createCheckIn($checkInSlug, CheckInStatus::inProgress()); - - if ($updateMonitorConfig || $slug === null) { - $timezone = $scheduled->timezone; - - if ($timezone instanceof DateTimeZone) { - $timezone = $timezone->getName(); - } - - $checkIn->setMonitorConfig(new MonitorConfig( - MonitorSchedule::crontab($scheduled->getExpression()), - $checkInMargin, - $maxRuntime, - $timezone, - $failureIssueThreshold, - $recoveryThreshold - )); - } - - $cacheKey = $this->buildCacheKey($scheduled->mutexName(), $checkInSlug); - - $this->checkInStore[$cacheKey] = $checkIn; - - if ($scheduled->runInBackground) { - $this->cache->store()->put($cacheKey, $checkIn->getId(), $scheduled->expiresAt * 60); - } - - $this->sendCheckIn($checkIn); - } - - private function finishCheckIn(?string $slug, SchedulingEvent $scheduled, CheckInStatus $status): void - { - $mutex = $scheduled->mutexName(); - - $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); - - $cacheKey = $this->buildCacheKey($mutex, $checkInSlug); - - $checkIn = $this->checkInStore[$cacheKey] ?? null; - - if ($checkIn === null && $scheduled->runInBackground) { - $checkInId = $this->cache->store()->get($cacheKey); - - if ($checkInId !== null) { - $checkIn = $this->createCheckIn($checkInSlug, $status, $checkInId); - } - } - - // This should never happen (because we should always start before we finish), but better safe than sorry - if ($checkIn === null) { + if (!$event->command) { return; } - // We don't need to keep the checkIn ID stored since we finished executing the command - unset($this->checkInStore[$mutex]); + Integration::configureScope(static function (Scope $scope) use ($event): void { + $scope->setTag('command', $event->command); + }); - if ($scheduled->runInBackground) { - $this->cache->store()->forget($cacheKey); + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'artisan.command', + 'Starting Artisan command: ' . $event->command, + [ + 'input' => $this->extractConsoleCommandInput($event->input), + ] + )); } - - $checkIn->setStatus($status); - - $this->sendCheckIn($checkIn); } - private function sendCheckIn(CheckIn $checkIn): void + public function commandFinished(ConsoleEvents\CommandFinished $event): void { - $event = SentryEvent::createCheckIn(); - $event->setCheckIn($checkIn); - - SentrySdk::getCurrentHub()->captureEvent($event); - } - - private function createCheckIn(string $slug, CheckInStatus $status, string $id = null): CheckIn - { - $options = SentrySdk::getCurrentHub()->getClient()->getOptions(); + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'artisan.command', + 'Finished Artisan command: ' . $event->command, + [ + 'exit' => $event->exitCode, + 'input' => $this->extractConsoleCommandInput($event->input), + ] + )); + } - return new CheckIn( - $slug, - $status, - $id, - $options->getRelease(), - $options->getEnvironment() - ); - } + // Flush any and all events that were possibly generated by the command + Integration::flushEvents(); - private function buildCacheKey(string $mutex, string $slug): string - { - // We use the mutex name as part of the cache key to avoid collisions between the same commands with the same schedule but with different slugs - return 'sentry:checkIn:' . sha1("{$mutex}:{$slug}"); + Integration::configureScope(static function (Scope $scope): void { + $scope->removeTag('command'); + }); } - private function makeSlugForScheduled(SchedulingEvent $scheduled): string + /** + * Extract the command input arguments if possible. + * + * @param \Symfony\Component\Console\Input\InputInterface|null $input + * + * @return string|null + */ + private function extractConsoleCommandInput(?InputInterface $input): ?string { - $generatedSlug = Str::slug( - str_replace( - // `:` is commonly used in the command name, so we replace it with `-` to avoid it being stripped out by the slug function - ':', - '-', - trim( - // The command string always starts with the PHP binary, so we remove it since it's not relevant to the slug - Str::after($scheduled->command, ConsoleApplication::phpBinary()) - ) - ) - ); + if ($input instanceof ArgvInput) { + return (string)$input; + } - return "scheduled_{$generatedSlug}"; + return null; } } diff --git a/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php b/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php new file mode 100644 index 00000000..54c223f0 --- /dev/null +++ b/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php @@ -0,0 +1,247 @@ + The list of checkins that are currently in progress. + */ + private $checkInStore = []; + + /** + * @var Cache The cache repository. + */ + private $cache; + + public function register(): void + { + $this->onBootInactive(); + } + + public function isApplicable(): bool + { + return $this->container()->make(Application::class)->runningInConsole(); + } + + public function onBoot(Cache $cache): void + { + $this->cache = $cache; + + $startCheckIn = function ( + ?string $slug, + SchedulingEvent $scheduled, + ?int $checkInMargin, + ?int $maxRuntime, + bool $updateMonitorConfig, + ?int $failureIssueThreshold, + ?int $recoveryThreshold + ) { + $this->startCheckIn( + $slug, + $scheduled, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ); + }; + $finishCheckIn = function (?string $slug, SchedulingEvent $scheduled, CheckInStatus $status) { + $this->finishCheckIn($slug, $scheduled, $status); + }; + + SchedulingEvent::macro('sentryMonitor', function ( + ?string $monitorSlug = null, + ?int $checkInMargin = null, + ?int $maxRuntime = null, + bool $updateMonitorConfig = true, + ?int $failureIssueThreshold = null, + ?int $recoveryThreshold = null + ) use ($startCheckIn, $finishCheckIn) { + /** @var SchedulingEvent $this */ + if ($monitorSlug === null && $this->command === null) { + throw new RuntimeException('The command string is null, please set a slug manually for this scheduled command using the `sentryMonitor(\'your-monitor-slug\')` macro.'); + } + + return $this + ->before(function () use ( + $startCheckIn, + $monitorSlug, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ) { + /** @var SchedulingEvent $this */ + $startCheckIn( + $monitorSlug, + $this, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ); + }) + ->onSuccess(function () use ($finishCheckIn, $monitorSlug) { + /** @var SchedulingEvent $this */ + $finishCheckIn($monitorSlug, $this, CheckInStatus::ok()); + }) + ->onFailure(function () use ($finishCheckIn, $monitorSlug) { + /** @var SchedulingEvent $this */ + $finishCheckIn($monitorSlug, $this, CheckInStatus::error()); + }); + }); + } + + public function onBootInactive(): void + { + // This is an exact copy of the macro above, but without doing anything so that even when no DSN is configured the user can still use the macro + SchedulingEvent::macro('sentryMonitor', function ( + ?string $monitorSlug = null, + ?int $checkInMargin = null, + ?int $maxRuntime = null, + bool $updateMonitorConfig = true, + ?int $failureIssueThreshold = null, + ?int $recoveryThreshold = null + ) { + return $this; + }); + } + + private function startCheckIn( + ?string $slug, + SchedulingEvent $scheduled, + ?int $checkInMargin, + ?int $maxRuntime, + bool $updateMonitorConfig, + ?int $failureIssueThreshold, + ?int $recoveryThreshold + ): void { + $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); + + $checkIn = $this->createCheckIn($checkInSlug, CheckInStatus::inProgress()); + + if ($updateMonitorConfig || $slug === null) { + $timezone = $scheduled->timezone; + + if ($timezone instanceof DateTimeZone) { + $timezone = $timezone->getName(); + } + + $checkIn->setMonitorConfig(new MonitorConfig( + MonitorSchedule::crontab($scheduled->getExpression()), + $checkInMargin, + $maxRuntime, + $timezone, + $failureIssueThreshold, + $recoveryThreshold + )); + } + + $cacheKey = $this->buildCacheKey($scheduled->mutexName(), $checkInSlug); + + $this->checkInStore[$cacheKey] = $checkIn; + + if ($scheduled->runInBackground) { + $this->cache->store()->put($cacheKey, $checkIn->getId(), $scheduled->expiresAt * 60); + } + + $this->sendCheckIn($checkIn); + } + + private function finishCheckIn(?string $slug, SchedulingEvent $scheduled, CheckInStatus $status): void + { + $mutex = $scheduled->mutexName(); + + $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); + + $cacheKey = $this->buildCacheKey($mutex, $checkInSlug); + + $checkIn = $this->checkInStore[$cacheKey] ?? null; + + if ($checkIn === null && $scheduled->runInBackground) { + $checkInId = $this->cache->store()->get($cacheKey); + + if ($checkInId !== null) { + $checkIn = $this->createCheckIn($checkInSlug, $status, $checkInId); + } + } + + // This should never happen (because we should always start before we finish), but better safe than sorry + if ($checkIn === null) { + return; + } + + // We don't need to keep the checkIn ID stored since we finished executing the command + unset($this->checkInStore[$mutex]); + + if ($scheduled->runInBackground) { + $this->cache->store()->forget($cacheKey); + } + + $checkIn->setStatus($status); + + $this->sendCheckIn($checkIn); + } + + private function sendCheckIn(CheckIn $checkIn): void + { + $event = SentryEvent::createCheckIn(); + $event->setCheckIn($checkIn); + + SentrySdk::getCurrentHub()->captureEvent($event); + } + + private function createCheckIn(string $slug, CheckInStatus $status, string $id = null): CheckIn + { + $options = SentrySdk::getCurrentHub()->getClient()->getOptions(); + + return new CheckIn( + $slug, + $status, + $id, + $options->getRelease(), + $options->getEnvironment() + ); + } + + private function buildCacheKey(string $mutex, string $slug): string + { + // We use the mutex name as part of the cache key to avoid collisions between the same commands with the same schedule but with different slugs + return 'sentry:checkIn:' . sha1("{$mutex}:{$slug}"); + } + + private function makeSlugForScheduled(SchedulingEvent $scheduled): string + { + $generatedSlug = Str::slug( + str_replace( + // `:` is commonly used in the command name, so we replace it with `-` to avoid it being stripped out by the slug function + ':', + '-', + trim( + // The command string always starts with the PHP binary, so we remove it since it's not relevant to the slug + Str::after($scheduled->command, ConsoleApplication::phpBinary()) + ) + ) + ); + + return "scheduled_{$generatedSlug}"; + } +} diff --git a/src/Sentry/Laravel/Features/NotificationsIntegration.php b/src/Sentry/Laravel/Features/NotificationsIntegration.php new file mode 100644 index 00000000..dc1f5502 --- /dev/null +++ b/src/Sentry/Laravel/Features/NotificationsIntegration.php @@ -0,0 +1,97 @@ +isTracingFeatureEnabled(self::FEATURE_KEY) + || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + } + + public function onBoot(Dispatcher $events): void + { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $events->listen(NotificationSending::class, [$this, 'handleNotificationSending']); + } + + $events->listen(NotificationSent::class, [$this, 'handleNotificationSent']); + } + + public function handleNotificationSending(NotificationSending $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + if ($parentSpan === null) { + return; + } + + $context = (new SpanContext) + ->setOp('notification.send') + ->setData([ + 'id' => $event->notification->id, + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ]) + ->setDescription($event->channel); + + $this->pushSpan($parentSpan->startChild($context)); + } + + public function handleNotificationSent(NotificationSent $event): void + { + $this->finishSpanWithStatus(SpanStatus::ok()); + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'notification.sent', + 'Sent notification', + [ + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ] + )); + } + } + + private function finishSpanWithStatus(SpanStatus $status): void + { + $span = $this->maybePopSpan(); + + if ($span !== null) { + $span->setStatus($status); + $span->finish(); + } + } + + private function formatNotifiable(object $notifiable): string + { + $notifiable = get_class($notifiable); + + if ($notifiable instanceof Model) { + $notifiable .= "({$notifiable->getKey()})"; + } + + return $notifiable; + } +} diff --git a/src/Sentry/Laravel/Integration.php b/src/Sentry/Laravel/Integration.php index 3a7ee025..fcce7c38 100644 --- a/src/Sentry/Laravel/Integration.php +++ b/src/Sentry/Laravel/Integration.php @@ -2,16 +2,13 @@ namespace Sentry\Laravel; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\LazyLoadingViolationException; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Routing\Route; use Sentry\EventHint; use Sentry\EventId; use Sentry\ExceptionMechanism; -use Sentry\Laravel\Features\Concerns\ResolvesEventOrigin; +use Sentry\Laravel\Integration\ModelViolations as ModelViolationReports; use Sentry\SentrySdk; -use Sentry\Severity; use Sentry\Tracing\TransactionSource; use Throwable; use Sentry\Breadcrumb; @@ -241,57 +238,45 @@ public static function captureUnhandledException(Throwable $throwable): ?EventId } /** - * Returns a callback that can be passed to `Model::handleLazyLoadingViolationUsing` to report lazy loading violations to Sentry. + * Returns a callback that can be passed to `Model::handleMissingAttributeViolationUsing` to report missing attribute violations to Sentry. * - * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. * * @return callable */ - public static function lazyLoadingViolationReporter(?callable $callback = null): callable + public static function missingAttributeViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable { - return new class($callback) { - use ResolvesEventOrigin; - - /** @var callable|null $callback */ - private $callback; + return new ModelViolationReports\MissingAttributeModelViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); + } - public function __construct(?callable $callback) - { - $this->callback = $callback; - } + /** + * Returns a callback that can be passed to `Model::handleLazyLoadingViolationUsing` to report lazy loading violations to Sentry. + * + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. + * + * @return callable + */ + public static function lazyLoadingViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable + { + return new ModelViolationReports\LazyLoadingModelViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); + } - public function __invoke(Model $model, string $relation): void - { - // Laravel uses these checks itself to not throw an exception if the model doesn't exist or was just created - // See: https://github.com/laravel/framework/blob/438d02d3a891ab4d73ffea2c223b5d37947b5e93/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php#L563 - if (!$model->exists || $model->wasRecentlyCreated) { - return; - } - - SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($model, $relation) { - $scope->setContext('violation', [ - 'model' => get_class($model), - 'relation' => $relation, - 'origin' => $this->resolveEventOrigin(), - ]); - - SentrySdk::getCurrentHub()->captureEvent( - tap(Event::createEvent(), static function (Event $event) { - $event->setLevel(Severity::warning()); - }), - EventHint::fromArray([ - 'exception' => new LazyLoadingViolationException($model, $relation), - 'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, true), - ]) - ); - }); - - // Forward the violation to the next handler if there is one - if ($this->callback !== null) { - call_user_func($this->callback, $model, $relation); - } - } - }; + /** + * Returns a callback that can be passed to `Model::handleDiscardedAttributeViolationUsing` to report discarded attribute violations to Sentry. + * + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. + * + * @return callable + */ + public static function discardedAttributeViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable + { + return new ModelViolationReports\DiscardedAttributeViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); } /** diff --git a/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php b/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php new file mode 100644 index 00000000..6a8008ae --- /dev/null +++ b/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php @@ -0,0 +1,27 @@ + $property, + 'kind' => 'discarded_attribute', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $property, + get_class($model) + )); + } +} diff --git a/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php b/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php new file mode 100644 index 00000000..60ecf2dc --- /dev/null +++ b/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php @@ -0,0 +1,34 @@ +exists || $model->wasRecentlyCreated) { + return false; + } + + return parent::shouldReport($model, $property); + } + + protected function getViolationContext(Model $model, string $property): array + { + return [ + 'relation' => $property, + 'kind' => 'lazy_loading', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new LazyLoadingViolationException($model, $property); + } +} diff --git a/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php b/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php new file mode 100644 index 00000000..8bdeb208 --- /dev/null +++ b/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php @@ -0,0 +1,23 @@ + $property, + 'kind' => 'missing_attribute', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new MissingAttributeException($model, $property); + } +} diff --git a/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php b/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php new file mode 100644 index 00000000..f2b3f4da --- /dev/null +++ b/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php @@ -0,0 +1,103 @@ + $reportedViolations */ + private $reportedViolations = []; + + public function __construct(?callable $callback, bool $suppressDuplicateReports, bool $reportAfterResponse) + { + $this->callback = $callback; + $this->suppressDuplicateReports = $suppressDuplicateReports; + $this->reportAfterResponse = $reportAfterResponse; + } + + public function __invoke(Model $model, string $property): void + { + if (!$this->shouldReport($model, $property)) { + return; + } + + $this->markAsReported($model, $property); + + $origin = $this->resolveEventOrigin(); + + if ($this->reportAfterResponse) { + app()->terminating(function () use ($model, $property, $origin) { + $this->report($model, $property, $origin); + }); + } else { + $this->report($model, $property, $origin); + } + } + + abstract protected function getViolationContext(Model $model, string $property): array; + + abstract protected function getViolationException(Model $model, string $property): Exception; + + protected function shouldReport(Model $model, string $property): bool + { + if (!$this->suppressDuplicateReports) { + return true; + } + + return !array_key_exists(get_class($model) . $property, $this->reportedViolations); + } + + protected function markAsReported(Model $model, string $property): void + { + if (!$this->suppressDuplicateReports) { + return; + } + + $this->reportedViolations[get_class($model) . $property] = true; + } + + private function report(Model $model, string $property, $origin): void + { + SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($model, $property, $origin) { + $scope->setContext('violation', array_merge([ + 'model' => get_class($model), + 'origin' => $origin, + ], $this->getViolationContext($model, $property))); + + SentrySdk::getCurrentHub()->captureEvent( + tap(Event::createEvent(), static function (Event $event) { + $event->setLevel(Severity::warning()); + }), + EventHint::fromArray([ + 'exception' => $this->getViolationException($model, $property), + 'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, true), + ]) + ); + }); + + // Forward the violation to the next handler if there is one + if ($this->callback !== null) { + call_user_func($this->callback, $model, $property); + } + } +} diff --git a/src/Sentry/Laravel/ServiceProvider.php b/src/Sentry/Laravel/ServiceProvider.php index c000ac4f..aa7a1d56 100644 --- a/src/Sentry/Laravel/ServiceProvider.php +++ b/src/Sentry/Laravel/ServiceProvider.php @@ -25,6 +25,7 @@ use Sentry\Laravel\Http\SetRequestMiddleware; use Sentry\Laravel\Tracing\BacktraceHelper; use Sentry\Laravel\Tracing\ServiceProvider as TracingServiceProvider; +use Sentry\Logger\DebugFileLogger; use Sentry\SentrySdk; use Sentry\Serializer\RepresentationSerializer; use Sentry\State\Hub; @@ -50,6 +51,13 @@ class ServiceProvider extends BaseServiceProvider 'controllers_base_namespace', ]; + /** + * List of options that should be resolved from the container instead of being passed directly to the SDK. + */ + protected const OPTIONS_TO_RESOLVE_FROM_CONTAINER = [ + 'logger', + ]; + /** * List of features that are provided by the SDK. */ @@ -61,7 +69,9 @@ class ServiceProvider extends BaseServiceProvider Features\Storage\Integration::class, Features\HttpClientIntegration::class, Features\FolioPackageIntegration::class, + Features\NotificationsIntegration::class, Features\LivewirePackageIntegration::class, + Features\ConsoleSchedulingIntegration::class, ]; /** @@ -115,6 +125,10 @@ public function register(): void $this->mergeConfigFrom(__DIR__ . '/../../../config/sentry.php', static::$abstract); + $this->app->singleton(DebugFileLogger::class, function () { + return new DebugFileLogger(storage_path('logs/sentry.log')); + }); + $this->configureAndRegisterClient(); $this->registerFeatures(); @@ -273,6 +287,12 @@ protected function configureAndRegisterClient(): void $options['before_send_transaction'] = $wrapBeforeSend($options['before_send_transaction'] ?? null); } + foreach (self::OPTIONS_TO_RESOLVE_FROM_CONTAINER as $option) { + if (isset($options[$option]) && is_string($options[$option])) { + $options[$option] = $this->app->make($options[$option]); + } + } + $clientBuilder = ClientBuilder::create($options); // Set the Laravel SDK identifier and version diff --git a/src/Sentry/Laravel/Tracing/EventHandler.php b/src/Sentry/Laravel/Tracing/EventHandler.php index 0a481a07..39f202c6 100644 --- a/src/Sentry/Laravel/Tracing/EventHandler.php +++ b/src/Sentry/Laravel/Tracing/EventHandler.php @@ -53,7 +53,14 @@ class EventHandler * * @var bool */ - private $traceSqlQueryOrigins; + private $traceSqlQueryOrigin; + + /** + * The threshold in milliseconds to consider a SQL query origin. + * + * @var int + */ + private $traceSqlQueryOriginTreshHoldMs; /** * Indicates if we should trace queue job spans. @@ -90,7 +97,8 @@ public function __construct(array $config) { $this->traceSqlQueries = ($config['sql_queries'] ?? true) === true; $this->traceSqlBindings = ($config['sql_bindings'] ?? true) === true; - $this->traceSqlQueryOrigins = ($config['sql_origin'] ?? true) === true; + $this->traceSqlQueryOrigin = ($config['sql_origin'] ?? true) === true; + $this->traceSqlQueryOriginTreshHoldMs = $config['sql_origin_threshold_ms'] ?? 100; $this->traceQueueJobs = ($config['queue_jobs'] ?? false) === true; $this->traceQueueJobsAsTransactions = ($config['queue_job_transactions'] ?? false) === true; @@ -180,8 +188,8 @@ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): vo ])); } - if ($this->traceSqlQueryOrigins) { - $queryOrigin = $this->resolveQueryOriginFromBacktrace(); + if ($this->traceSqlQueryOrigin && $query->time >= $this->traceSqlQueryOriginTreshHoldMs) { + $queryOrigin = $this->resolveEventOrigin(); if ($queryOrigin !== null) { $context->setData(array_merge($context->getData(), $queryOrigin)); @@ -191,30 +199,6 @@ protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): vo $parentSpan->startChild($context); } - /** - * Try to find the origin of the SQL query that was just executed. - * - * @return string|null - */ - private function resolveQueryOriginFromBacktrace(): ?array - { - $backtraceHelper = $this->makeBacktraceHelper(); - - $firstAppFrame = $backtraceHelper->findFirstInAppFrameForBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)); - - if ($firstAppFrame === null) { - return null; - } - - $filePath = $backtraceHelper->getOriginalViewPathForFrameOfCompiledViewPath($firstAppFrame) ?? $firstAppFrame->getFile(); - - return [ - 'code.filepath' => $filePath, - 'code.function' => $firstAppFrame->getFunctionName(), - 'code.lineno' => $firstAppFrame->getLine(), - ]; - } - protected function responsePreparedHandler(RoutingEvents\ResponsePrepared $event): void { $span = $this->popSpan(); diff --git a/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php b/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php index 46c011d7..789b6dcd 100644 --- a/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php +++ b/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php @@ -125,10 +125,9 @@ public function handleStartExecution(StartExecution $startExecution): void return; } - /** @var \GraphQL\Language\AST\OperationDefinitionNode|null $operationDefinition */ - $operationDefinition = $startExecution->query->definitions[0] ?? null; + $operationDefinition = $this->extractOperationDefinitionNode($startExecution->query); - if (!$operationDefinition instanceof OperationDefinitionNode) { + if ($operationDefinition === null) { return; } @@ -239,6 +238,17 @@ private function extractOperationNames(OperationDefinitionNode $operation): arra return $selectionSet; } + private function extractOperationDefinitionNode(DocumentNode $query): ?OperationDefinitionNode + { + foreach ($query->definitions as $definition) { + if ($definition instanceof OperationDefinitionNode) { + return $definition; + } + } + + return null; + } + private function isApplicable(): bool { if (!class_exists(StartRequest::class) || !class_exists(StartExecution::class)) { diff --git a/src/Sentry/Laravel/Version.php b/src/Sentry/Laravel/Version.php index d14b9ea5..2471488d 100644 --- a/src/Sentry/Laravel/Version.php +++ b/src/Sentry/Laravel/Version.php @@ -5,5 +5,5 @@ final class Version { public const SDK_IDENTIFIER = 'sentry.php.laravel'; - public const SDK_VERSION = '4.4.1'; + public const SDK_VERSION = '4.5.0'; } diff --git a/test/Sentry/EventHandler/AuthEventsTest.php b/test/Sentry/EventHandler/AuthEventsTest.php new file mode 100644 index 00000000..1f5769d2 --- /dev/null +++ b/test/Sentry/EventHandler/AuthEventsTest.php @@ -0,0 +1,79 @@ + true, + ]; + + public function testAuthenticatedEventFillsUserOnScope(): void + { + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + $user->username = 'username'; + $user->email = 'foo@example.com'; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNotNull($scope->getUser()); + + $this->assertEquals($scope->getUser()->getId(), 123); + $this->assertEquals($scope->getUser()->getUsername(), 'username'); + $this->assertEquals($scope->getUser()->getEmail(), 'foo@example.com'); + } + + public function testAuthenticatedEventFillsUserOnScopeWhenUsernameIsNotAString(): void + { + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + $user->username = 456; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNotNull($scope->getUser()); + + $this->assertEquals($scope->getUser()->getId(), 123); + $this->assertEquals($scope->getUser()->getUsername(), '456'); + } + + public function testAuthenticatedEventDoesNotFillUserOnScopeWhenPIIShouldNotBeSent(): void + { + $this->resetApplicationWithConfig([ + 'sentry.send_default_pii' => false, + ]); + + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNull($scope->getUser()); + } +} + +class AuthEventsTestUserModel extends Model implements Authenticatable +{ + use \Illuminate\Auth\Authenticatable; +} diff --git a/test/Sentry/EventHandler/ConsoleEventsTest.php b/test/Sentry/EventHandler/ConsoleEventsTest.php deleted file mode 100644 index c5ad179c..00000000 --- a/test/Sentry/EventHandler/ConsoleEventsTest.php +++ /dev/null @@ -1,51 +0,0 @@ -resetApplicationWithConfig([ - 'sentry.breadcrumbs.command_info' => true, - ]); - - $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.command_info')); - - $this->dispatchCommandStartEvent(); - - $lastBreadcrumb = $this->getLastSentryBreadcrumb(); - - $this->assertEquals('Starting Artisan command: test:command', $lastBreadcrumb->getMessage()); - $this->assertEquals('--foo=bar', $lastBreadcrumb->getMetadata()['input']); - } - - public function testCommandBreadcrumIsNotRecordedWhenDisabled(): void - { - $this->resetApplicationWithConfig([ - 'sentry.breadcrumbs.command_info' => false, - ]); - - $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.command_info')); - - $this->dispatchCommandStartEvent(); - - $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); - } - - private function dispatchCommandStartEvent(): void - { - $this->dispatchLaravelEvent( - new CommandStarting( - 'test:command', - new ArgvInput(['artisan', '--foo=bar']), - new BufferedOutput() - ) - ); - } -} diff --git a/test/Sentry/Features/ConsoleIntegrationTest.php b/test/Sentry/Features/ConsoleIntegrationTest.php index 110c6b99..1f265479 100644 --- a/test/Sentry/Features/ConsoleIntegrationTest.php +++ b/test/Sentry/Features/ConsoleIntegrationTest.php @@ -1,128 +1,51 @@ getScheduler() - ->call(function () {}) - ->sentryMonitor('test-monitor'); + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.command_info' => true, + ]); - $scheduledEvent->run($this->app); + $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.command_info')); - // We expect a total of 2 events to be sent to Sentry: - // 1. The start check-in event - // 2. The finish check-in event - $this->assertSentryCheckInCount(2); + $this->dispatchCommandStartEvent(); - $finishCheckInEvent = $this->getLastSentryEvent(); + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); - $this->assertNotNull($finishCheckInEvent->getCheckIn()); - $this->assertEquals('test-monitor', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); + $this->assertEquals('Starting Artisan command: test:command', $lastBreadcrumb->getMessage()); + $this->assertEquals('--foo=bar', $lastBreadcrumb->getMetadata()['input']); } - /** - * When a timezone was defined on a command this would fail with: - * Sentry\MonitorConfig::__construct(): Argument #4 ($timezone) must be of type ?string, DateTimeZone given - * This test ensures that the timezone is properly converted to a string as expected. - */ - public function testScheduleMacroWithTimeZone(): void + public function testCommandBreadcrumIsNotRecordedWhenDisabled(): void { - $expectedTimezone = 'UTC'; + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.command_info' => false, + ]); - /** @var Event $scheduledEvent */ - $scheduledEvent = $this->getScheduler() - ->call(function () {}) - ->timezone(new DateTimeZone($expectedTimezone)) - ->sentryMonitor('test-timezone-monitor'); + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.command_info')); - $scheduledEvent->run($this->app); + $this->dispatchCommandStartEvent(); - // We expect a total of 2 events to be sent to Sentry: - // 1. The start check-in event - // 2. The finish check-in event - $this->assertSentryCheckInCount(2); - - $finishCheckInEvent = $this->getLastSentryEvent(); - - $this->assertNotNull($finishCheckInEvent->getCheckIn()); - $this->assertEquals($expectedTimezone, $finishCheckInEvent->getCheckIn()->getMonitorConfig()->getTimezone()); - } - - public function testScheduleMacroAutomaticSlug(): void - { - /** @var Event $scheduledEvent */ - $scheduledEvent = $this->getScheduler()->command('inspire')->sentryMonitor(); - - $scheduledEvent->run($this->app); - - // We expect a total of 2 events to be sent to Sentry: - // 1. The start check-in event - // 2. The finish check-in event - $this->assertSentryCheckInCount(2); - - $finishCheckInEvent = $this->getLastSentryEvent(); - - $this->assertNotNull($finishCheckInEvent->getCheckIn()); - $this->assertEquals('scheduled_artisan-inspire', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); - } - - public function testScheduleMacroWithoutSlugOrCommandName(): void - { - $this->expectException(RuntimeException::class); - - $this->getScheduler()->call(function () {})->sentryMonitor(); - } - - /** @define-env envWithoutDsnSet */ - public function testScheduleMacroWithoutDsnSet(): void - { - /** @var Event $scheduledEvent */ - $scheduledEvent = $this->getScheduler()->call(function () {})->sentryMonitor('test-monitor'); - - $scheduledEvent->run($this->app); - - $this->assertSentryCheckInCount(0); - } - - public function testScheduleMacroIsRegistered(): void - { - if (!method_exists(Event::class, 'flushMacros')) { - $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); - } - - Event::flushMacros(); - - $this->refreshApplication(); - - $this->assertTrue(Event::hasMacro('sentryMonitor')); - } - - /** @define-env envWithoutDsnSet */ - public function testScheduleMacroIsRegisteredWithoutDsnSet(): void - { - if (!method_exists(Event::class, 'flushMacros')) { - $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); - } - - Event::flushMacros(); - - $this->refreshApplication(); - - $this->assertTrue(Event::hasMacro('sentryMonitor')); + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); } - private function getScheduler(): Schedule + private function dispatchCommandStartEvent(): void { - return $this->app->make(Schedule::class); + $this->dispatchLaravelEvent( + new CommandStarting( + 'test:command', + new ArgvInput(['artisan', '--foo=bar']), + new BufferedOutput() + ) + ); } } diff --git a/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php new file mode 100644 index 00000000..f1c7bc15 --- /dev/null +++ b/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -0,0 +1,128 @@ +getScheduler() + ->call(function () {}) + ->sentryMonitor('test-monitor'); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals('test-monitor', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); + } + + /** + * When a timezone was defined on a command this would fail with: + * Sentry\MonitorConfig::__construct(): Argument #4 ($timezone) must be of type ?string, DateTimeZone given + * This test ensures that the timezone is properly converted to a string as expected. + */ + public function testScheduleMacroWithTimeZone(): void + { + $expectedTimezone = 'UTC'; + + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler() + ->call(function () {}) + ->timezone(new DateTimeZone($expectedTimezone)) + ->sentryMonitor('test-timezone-monitor'); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals($expectedTimezone, $finishCheckInEvent->getCheckIn()->getMonitorConfig()->getTimezone()); + } + + public function testScheduleMacroAutomaticSlug(): void + { + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler()->command('inspire')->sentryMonitor(); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals('scheduled_artisan-inspire', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); + } + + public function testScheduleMacroWithoutSlugOrCommandName(): void + { + $this->expectException(RuntimeException::class); + + $this->getScheduler()->call(function () {})->sentryMonitor(); + } + + /** @define-env envWithoutDsnSet */ + public function testScheduleMacroWithoutDsnSet(): void + { + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler()->call(function () {})->sentryMonitor('test-monitor'); + + $scheduledEvent->run($this->app); + + $this->assertSentryCheckInCount(0); + } + + public function testScheduleMacroIsRegistered(): void + { + if (!method_exists(Event::class, 'flushMacros')) { + $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); + } + + Event::flushMacros(); + + $this->refreshApplication(); + + $this->assertTrue(Event::hasMacro('sentryMonitor')); + } + + /** @define-env envWithoutDsnSet */ + public function testScheduleMacroIsRegisteredWithoutDsnSet(): void + { + if (!method_exists(Event::class, 'flushMacros')) { + $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); + } + + Event::flushMacros(); + + $this->refreshApplication(); + + $this->assertTrue(Event::hasMacro('sentryMonitor')); + } + + private function getScheduler(): Schedule + { + return $this->app->make(Schedule::class); + } +} diff --git a/test/Sentry/Features/DatabaseIntegrationTest.php b/test/Sentry/Features/DatabaseIntegrationTest.php index 64504bae..9b445d2d 100644 --- a/test/Sentry/Features/DatabaseIntegrationTest.php +++ b/test/Sentry/Features/DatabaseIntegrationTest.php @@ -115,11 +115,46 @@ public function testSqlBindingsAreRecordedWhenDisabled(): void $this->assertFalse(isset($span->getData()['db.sql.bindings'])); } - private function executeQueryAndRetrieveSpan(string $query, array $bindings = []): Span + public function testSqlOriginIsResolvedWhenEnabledAndOverTreshold(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => true, + 'sentry.tracing.sql_origin_threshold_ms' => 10, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 20); + + $this->assertArrayHasKey('code.filepath', $span->getData()); + } + + public function testSqlOriginIsNotResolvedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => false, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1'); + + $this->assertArrayNotHasKey('code.filepath', $span->getData()); + } + + public function testSqlOriginIsNotResolvedWhenUnderThreshold(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => true, + 'sentry.tracing.sql_origin_threshold_ms' => 10, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 5); + + $this->assertArrayNotHasKey('code.filepath', $span->getData()); + } + + private function executeQueryAndRetrieveSpan(string $query, array $bindings = [], int $time = 123): Span { $transaction = $this->startTransaction(); - $this->dispatchLaravelEvent(new QueryExecuted($query, $bindings, 123, DB::connection())); + $this->dispatchLaravelEvent(new QueryExecuted($query, $bindings, $time, DB::connection())); $spans = $transaction->getSpanRecorder()->getSpans(); diff --git a/test/Sentry/Features/NotificationsIntegrationTest.php b/test/Sentry/Features/NotificationsIntegrationTest.php new file mode 100644 index 00000000..dfee35d5 --- /dev/null +++ b/test/Sentry/Features/NotificationsIntegrationTest.php @@ -0,0 +1,102 @@ + false, + ]; + + public function testSpanIsRecorded(): void + { + $span = $this->sendNotificationAndRetrieveSpan(); + + $this->assertEquals('mail', $span->getDescription()); + $this->assertEquals('mail', $span->getData()['channel']); + $this->assertEquals('notification.send', $span->getOp()); + $this->assertEquals(SpanStatus::ok(), $span->getStatus()); + } + + public function testSpanIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.notifications.enabled' => false, + ]); + + $this->sendNotificationAndExpectNoSpan(); + } + + public function testBreadcrumbIsRecorded(): void + { + $this->sendTestNotification(); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $breadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals('notification.sent', $breadcrumb->getCategory()); + } + + public function testBreadcrumbIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.notifications.enabled' => false, + ]); + + $this->sendTestNotification(); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + private function sendTestNotification(): void + { + // We fake the mail so that no actual email is sent but the notification is still sent with all it's events + Mail::fake(); + + Notification::route('mail', 'sentry@example.com')->notifyNow(new NotificationsIntegrationTestNotification); + } + + private function sendNotificationAndRetrieveSpan(): Span + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + + return $spans[1]; + } + + private function sendNotificationAndExpectNoSpan(): void + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(1, $spans); + } +} + +class NotificationsIntegrationTestNotification extends \Illuminate\Notifications\Notification +{ + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + return new \Illuminate\Notifications\Messages\MailMessage; + } +} diff --git a/test/Sentry/Integration/ModelViolationReportersTest.php b/test/Sentry/Integration/ModelViolationReportersTest.php new file mode 100644 index 00000000..2e94d9ad --- /dev/null +++ b/test/Sentry/Integration/ModelViolationReportersTest.php @@ -0,0 +1,70 @@ +markTestSkipped('Laravel introduced model violations in version 9.'); + } + + parent::setUp(); + } + + public function testModelViolationReportersCanBeRegistered(): void + { + $this->expectNotToPerformAssertions(); + + Model::handleLazyLoadingViolationUsing(Integration::lazyLoadingViolationReporter()); + Model::handleMissingAttributeViolationUsing(Integration::missingAttributeViolationReporter()); + Model::handleDiscardedAttributeViolationUsing(Integration::discardedAttributeViolationReporter()); + } + + public function testViolationReporterPassesThroughToCallback(): void + { + $callbackCalled = false; + + $reporter = Integration::missingAttributeViolationReporter(static function () use (&$callbackCalled) { + $callbackCalled = true; + }, false, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertTrue($callbackCalled); + } + + public function testViolationReporterIsNotReportingDuplicateEvents(): void + { + $reporter = Integration::missingAttributeViolationReporter(null, true, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertCount(1, $this->getCapturedSentryEvents()); + } + + public function testViolationReporterIsReportingDuplicateEventsIfConfigured(): void + { + $reporter = Integration::missingAttributeViolationReporter(null, false, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertCount(2, $this->getCapturedSentryEvents()); + } +} + +class ViolationReporterTestModel extends Model +{ +} diff --git a/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php b/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php new file mode 100644 index 00000000..94037e14 --- /dev/null +++ b/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php @@ -0,0 +1,28 @@ +getClient()->getOptions()->getLogger(); + + $this->assertNull($logger); + } + + public function testLoggerIsResolvedFromDefaultSingleton(): void + { + $this->resetApplicationWithConfig([ + 'sentry.logger' => DebugFileLogger::class, + ]); + + $logger = app(HubInterface::class)->getClient()->getOptions()->getLogger(); + + $this->assertInstanceOf(DebugFileLogger::class, $logger); + } +} diff --git a/test/Sentry/Laravel/LaravelIntegrationsOptionTest.php b/test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php similarity index 98% rename from test/Sentry/Laravel/LaravelIntegrationsOptionTest.php rename to test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php index 59571ebe..d604d5c6 100644 --- a/test/Sentry/Laravel/LaravelIntegrationsOptionTest.php +++ b/test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php @@ -10,7 +10,7 @@ use Sentry\Integration\FatalErrorListenerIntegration; use Sentry\Laravel\Tests\TestCase; -class LaravelIntegrationsOptionTest extends TestCase +class LaravelIntegrationsConfigOptionTest extends TestCase { protected function defineEnvironment($app): void { diff --git a/test/Sentry/TestCase.php b/test/Sentry/TestCase.php index b6766421..dd3fe916 100644 --- a/test/Sentry/TestCase.php +++ b/test/Sentry/TestCase.php @@ -31,6 +31,8 @@ abstract class TestCase extends LaravelTestCase // or use the `$this->resetApplicationWithConfig([ /* config */ ]);` helper method ]; + protected $defaultSetupConfig = []; + /** @var array */ protected static $lastSentryEvents = []; @@ -61,6 +63,10 @@ protected function defineEnvironment($app): void $config->set('sentry.dsn', 'https://publickey@sentry.dev/123'); } + foreach ($this->defaultSetupConfig as $key => $value) { + $config->set($key, $value); + } + foreach ($this->setupConfig as $key => $value) { $config->set($key, $value); }