diff --git a/src/Sentry/Laravel/Integration.php b/src/Sentry/Laravel/Integration.php index 89c76703..fcce7c38 100644 --- a/src/Sentry/Laravel/Integration.php +++ b/src/Sentry/Laravel/Integration.php @@ -2,17 +2,13 @@ namespace Sentry\Laravel; -use Illuminate\Database\Eloquent\MissingAttributeException; -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; @@ -244,105 +240,43 @@ public static function captureUnhandledException(Throwable $throwable): ?EventId /** * 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 missingAttributeViolationReporter(?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; - - public function __construct(?callable $callback) - { - $this->callback = $callback; - } - - public function __invoke(Model $model, string $attribute): void - { - SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($model, $attribute) { - $scope->setContext('violation', [ - 'model' => get_class($model), - 'attribute' => $attribute, - 'origin' => $this->resolveEventOrigin(), - 'kind' => 'missing_attribute', - ]); - - SentrySdk::getCurrentHub()->captureEvent( - tap(Event::createEvent(), static function (Event $event) { - $event->setLevel(Severity::warning()); - }), - EventHint::fromArray([ - 'exception' => new MissingAttributeException($model, $attribute), - '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, $attribute); - } - } - }; + return new ModelViolationReports\MissingAttributeModelViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); } /** * 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 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 lazyLoadingViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable { - return new class($callback) { - use ResolvesEventOrigin; - - /** @var callable|null $callback */ - private $callback; - - public function __construct(?callable $callback) - { - $this->callback = $callback; - } + 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#L559-L561 - 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->resolveEventOriginAsString(), - 'kind' => 'lazy_loading', - ]); - - 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/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 +{ +}