<?php namespace Sentry\Laravel; use DateTimeInterface; use Monolog\DateTimeImmutable; use Monolog\Logger; use Monolog\LogRecord; use Monolog\Formatter\LineFormatter; use Monolog\Formatter\FormatterInterface; use Monolog\Handler\AbstractProcessingHandler; use Sentry\Breadcrumb; use Sentry\Event; use Sentry\Monolog\CompatibilityProcessingHandlerTrait; use Sentry\Severity; use Sentry\State\HubInterface; use Sentry\State\Scope; use Throwable; use TypeError; class SentryHandler extends AbstractProcessingHandler { use CompatibilityProcessingHandlerTrait; /** * @var string the current application environment (staging|preprod|prod) */ protected $environment; /** * @var string should represent the current version of the calling * software. Can be any string (git commit, version number) */ protected $release; /** * @var HubInterface the hub object that sends the message to the server */ protected $hub; /** * @var FormatterInterface The formatter to use for the logs generated via handleBatch() */ protected $batchFormatter; /** * Indicates if we should report exceptions, if `false` this handler will ignore records with an exception set in the context. * * @var bool */ private $reportExceptions; /** * Indicates if we should use the formatted message instead of just the message. * * @var bool */ private $useFormattedMessage; /** * @param HubInterface $hub * @param int $level The minimum logging level at which this handler will be triggered * @param bool $bubble Whether the messages that are handled can bubble up the stack or not * @param bool $reportExceptions * @param bool $useFormattedMessage */ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $reportExceptions = true, bool $useFormattedMessage = false) { parent::__construct($level, $bubble); $this->hub = $hub; $this->reportExceptions = $reportExceptions; $this->useFormattedMessage = $useFormattedMessage; } /** * {@inheritdoc} */ public function handleBatch(array $records): void { $level = $this->level; // filter records based on their level $records = array_filter( $records, function ($record) use ($level) { return $record['level'] >= $level; } ); if (!$records) { return; } // the record with the highest severity is the "main" one $record = array_reduce( $records, function ($highest, $record) { if ($highest === null || $record['level'] > $highest['level']) { return $record; } return $highest; } ); // the other ones are added as a context item $logs = []; foreach ($records as $r) { $logs[] = $this->processRecord($r); } if ($logs) { $record['context']['logs'] = (string)$this->getBatchFormatter()->formatBatch($logs); } $this->handle($record); } /** * Sets the formatter for the logs generated by handleBatch(). * * @param FormatterInterface $formatter * * @return \Sentry\Laravel\SentryHandler */ public function setBatchFormatter(FormatterInterface $formatter): self { $this->batchFormatter = $formatter; return $this; } /** * Gets the formatter for the logs generated by handleBatch(). */ public function getBatchFormatter(): FormatterInterface { if (!$this->batchFormatter) { $this->batchFormatter = $this->getDefaultBatchFormatter(); } return $this->batchFormatter; } /** * Translates Monolog log levels to Sentry Severity. * * @param int $logLevel * * @return \Sentry\Severity */ protected function getLogLevel(int $logLevel): Severity { return $this->getSeverityFromLevel($logLevel); } /** * {@inheritdoc} * @suppress PhanTypeMismatchArgument */ protected function doWrite($record): void { $exception = $record['context']['exception'] ?? null; $isException = $exception instanceof Throwable; unset($record['context']['exception']); if (!$this->reportExceptions && $isException) { return; } $this->hub->withScope( function (Scope $scope) use ($record, $isException, $exception) { $context = !empty($record['context']) && is_array($record['context']) ? $record['context'] : []; if (!empty($context)) { $this->consumeContextAndApplyToScope($scope, $context); } if (!empty($record['extra']) && is_array($record['extra'])) { foreach ($record['extra'] as $key => $extra) { $scope->setExtra($key, $extra); } } $logger = !empty($context['logger']) && is_string($context['logger']) ? $context['logger'] : null; unset($context['logger']); // At this point we consumed everything we could from the context // what remains we add as `log_context` to the event as a whole if (!empty($context)) { $scope->setExtra('log_context', $context); } $scope->addEventProcessor( function (Event $event) use ($record, $logger) { $event->setLevel($this->getLogLevel($record['level'])); $event->setLogger($logger ?? $record['channel']); if (!empty($this->environment) && !$event->getEnvironment()) { $event->setEnvironment($this->environment); } if (!empty($this->release) && !$event->getRelease()) { $event->setRelease($this->release); } if (isset($record['datetime']) && $record['datetime'] instanceof DateTimeInterface) { $event->setTimestamp($record['datetime']->getTimestamp()); } return $event; } ); if ($isException) { $this->hub->captureException($exception); } else { $this->hub->captureMessage( $this->useFormattedMessage || empty($record['message']) ? $record['formatted'] : $record['message'] ); } } ); } /** * {@inheritDoc} */ protected function getDefaultFormatter(): FormatterInterface { return new LineFormatter('[%channel%] %message%'); } /** * Gets the default formatter for the logs generated by handleBatch(). * * @return FormatterInterface */ protected function getDefaultBatchFormatter(): FormatterInterface { return new LineFormatter(); } /** * Set the release. * * @param string $value * * @return self */ public function setRelease($value): self { $this->release = $value; return $this; } /** * Set the current application environment. * * @param string $value * * @return self */ public function setEnvironment($value): self { $this->environment = $value; return $this; } /** * Add a breadcrumb. * * @link https://docs.sentry.io/learn/breadcrumbs/ * * @param \Sentry\Breadcrumb $crumb * * @return \Sentry\Laravel\SentryHandler */ public function addBreadcrumb(Breadcrumb $crumb): self { $this->hub->addBreadcrumb($crumb); return $this; } /** * Consumes the context and applies it to the scope. * * @param \Sentry\State\Scope $scope * @param array $context * * @return void */ private function consumeContextAndApplyToScope(Scope $scope, array &$context): void { if (!empty($context['extra']) && is_array($context['extra'])) { foreach ($context['extra'] as $key => $value) { $scope->setExtra($key, $value); } unset($context['extra']); } if (!empty($context['tags']) && is_array($context['tags'])) { foreach ($context['tags'] as $tag => $value) { // Ignore tags with a value that is not a string or can be casted to a string if (!$this->valueCanBeString($value)) { continue; } $scope->setTag($tag, (string)$value); } unset($context['tags']); } if (!empty($context['fingerprint']) && is_array($context['fingerprint'])) { $scope->setFingerprint($context['fingerprint']); unset($context['fingerprint']); } if (!empty($context['user']) && is_array($context['user'])) { try { $scope->setUser($context['user']); unset($context['user']); } catch (TypeError $e) { // In some cases the context can be invalid, in that case we ignore it and // choose to not send it to Sentry in favor of not breaking the application } } } /** * Check if the value passed can be cast to a string. * * @param mixed $value * * @return bool */ private function valueCanBeString($value): bool { return is_string($value) || is_scalar($value) || (is_object($value) && method_exists($value, '__toString')); } }