raven-8.x-2.x-dev/src/Logger/Raven.php
src/Logger/Raven.php
<?php
namespace Drupal\raven\Logger;
use Drupal\Component\ClassFinder\ClassFinder;
use Drupal\Component\Utility\Html;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\EventSubscriber\ExceptionLoggingSubscriber;
use Drupal\Core\Logger\LogMessageParserInterface;
use Drupal\Core\Logger\LoggerChannel;
use Drupal\Core\Logger\RfcLoggerTrait;
use Drupal\Core\Mail\MailFormatHelper;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Site\Settings;
use Drupal\raven\Event\AttributesAlter;
use Drupal\raven\Event\OptionsAlter;
use Drupal\raven\Exception\RateLimitException;
use Drupal\raven\Integration\RemoveExceptionFrameVarsIntegration;
use Drupal\raven\Integration\SanitizeIntegration;
use Drupal\raven\LogLevel;
use Drush\Drush;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;
use Sentry\Breadcrumb;
use Sentry\ClientInterface;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\ExceptionMechanism;
use Sentry\Integration\EnvironmentIntegration;
use Sentry\Integration\FatalErrorListenerIntegration;
use Sentry\Integration\FrameContextifierIntegration;
use Sentry\Integration\ModulesIntegration;
use Sentry\Integration\RequestFetcherInterface;
use Sentry\Integration\RequestIntegration;
use Sentry\Integration\TransactionIntegration;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Sentry\Tracing\SpanContext;
use Sentry\UserDataBag;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/**
* Logs events to Sentry.
*/
class Raven implements LoggerInterface, RavenInterface {
use DependencySerializationTrait;
use RfcLoggerTrait;
/**
* Constructs a Raven log object.
*/
public function __construct(
protected ConfigFactoryInterface $configFactory,
protected LogMessageParserInterface $parser,
#[Autowire('%kernel.environment%')]
protected string $environment,
protected AccountInterface $currentUser,
protected RequestStack $requestStack,
protected Settings $settings,
#[Autowire('@event_dispatcher')]
protected EventDispatcherInterface $eventDispatcher,
#[Autowire('@raven.request_fetcher')]
protected RequestFetcherInterface $requestFetcher,
) {
// We cannot lazily initialize Sentry, because we want the scope to be
// immediately available for adding context, etc.
$this->getClient();
}
/**
* {@inheritdoc}
*/
public function getClient(bool $force_new = FALSE, bool $force_throw = FALSE): ?ClientInterface {
// Is the client already initialized?
if (!$force_new && ($client = SentrySdk::getCurrentHub()->getClient())) {
return $client;
}
$config = $this->configFactory->get('raven.settings');
$options = [
'default_integrations' => FALSE,
'dsn' => $config->get('client_key'),
'environment' => $config->get('environment') ?: $this->environment,
];
if (!\is_null($timeout = $config->get('timeout'))) {
$options['http_connect_timeout'] = $options['http_timeout'] = $timeout;
}
if (!\is_null($http_compression = $config->get('http_compression'))) {
$options['http_compression'] = $http_compression;
}
if ($config->get('stack')) {
$options['attach_stacktrace'] = TRUE;
}
if ($config->get('fatal_error_handler')) {
$options['integrations'][] = new FatalErrorListenerIntegration();
}
$options['integrations'][] = new RequestIntegration($this->requestFetcher);
$options['integrations'][] = new TransactionIntegration();
$options['integrations'][] = new FrameContextifierIntegration();
$options['integrations'][] = new EnvironmentIntegration();
$options['integrations'][] = new SanitizeIntegration();
if (!$config->get('trace')) {
$options['integrations'][] = new RemoveExceptionFrameVarsIntegration();
}
if ($config->get('modules')) {
$options['integrations'][] = new ModulesIntegration();
}
if ($release = $config->get('release')) {
$options['release'] = $release;
}
if (!$config->get('send_request_body')) {
$options['max_request_body_size'] = 'never';
}
if (!\is_null($traces = $config->get('traces_sample_rate'))) {
$options['traces_sample_rate'] = $traces;
}
if ($trace_propagation_targets = $config->get('trace_propagation_targets_backend')) {
$options['trace_propagation_targets'] = $trace_propagation_targets;
}
$options['profiles_sample_rate'] = $config->get('profiles_sample_rate');
if (PHP_SAPI === 'cli') {
$options['enable_logs'] = $config->get('cli_enable_logs') ?? FALSE;
}
else {
$options['enable_logs'] = $this->currentUser->hasPermission('send logs to sentry');
}
// Proxy configuration (DSN is null before install).
$parsed_dsn = parse_url(\is_string($options['dsn']) ? $options['dsn'] : '');
if (!empty($parsed_dsn['host']) && !empty($parsed_dsn['scheme'])) {
$http_client_config = $this->settings->get('http_client_config', []);
if (\is_array($http_client_config) && isset($http_client_config['proxy']) && \is_array($http_client_config['proxy']) && !empty($http_client_config['proxy'][$parsed_dsn['scheme']])) {
$no_proxy = $http_client_config['proxy']['no'] ?? [];
// No need to configure proxy if Sentry host is on proxy bypass list.
if (\is_array($no_proxy) && !\in_array($parsed_dsn['host'], $no_proxy, TRUE)) {
$options['http_proxy'] = $http_client_config['proxy'][$parsed_dsn['scheme']];
}
}
}
// If we're in Drush debug mode, attach Drush logger to Sentry client.
if (\function_exists('drush_main') && Drush::debug()) {
$options['logger'] = Drush::logger();
}
$this->eventDispatcher->dispatch(new OptionsAlter($options), OptionsAlter::class);
try {
// @phpstan-ignore argument.type
\Sentry\init($options);
}
catch (\InvalidArgumentException $e) {
if ($force_throw) {
throw $e;
}
return NULL;
}
// Set default user context.
\Sentry\configureScope(function (Scope $scope): void {
$user = ['id' => $this->currentUser->id()];
$config = $this->configFactory->get('raven.settings');
if ($config->get('capture_user_ip') && ($request = $this->requestStack->getCurrentRequest())) {
$user['ip_address'] = $request->getClientIp();
}
if ($config->get('send_user_data')) {
$user['email'] = $this->currentUser->getEmail();
$user['username'] = $this->currentUser->getAccountName();
}
$scope->setUser($user);
});
// Try to flush logs after a fatal error.
drupal_register_shutdown_function(static fn () => \Sentry\logger()->flush());
return SentrySdk::getCurrentHub()->getClient();
}
/**
* {@inheritdoc}
*/
public function log(mixed $level, string|\Stringable $message, array $context = []): void {
global $base_root;
static $counter = 0;
$client = $this->getClient();
if (!$client) {
return;
}
$log_level = LogLevel::fromLevel($level);
$config = $this->configFactory->get('raven.settings');
$log_levels = $config->get('log_levels');
if (!\is_array($log_levels)) {
$log_levels = [];
}
$ignored_channels = $config->get('ignored_channels');
if (!\is_array($ignored_channels)) {
$ignored_channels = [];
}
// Preserve the original $message argument for debugging purposes.
$unformatted_message = $message;
// Remove backtrace string from the message, as it is redundant with Sentry
// stack traces, and could leak function calling arguments to Sentry
// (depending on the configuration of zend.exception_ignore_args and
// zend.exception_string_param_max_len).
if (isset($context['@backtrace_string'])) {
$unformatted_message = str_replace(' @backtrace_string', '', $unformatted_message);
unset($context['@backtrace_string']);
}
$message_placeholders = $this->parser->parseMessagePlaceholders($unformatted_message, $context);
$formatted_message = empty($message_placeholders) ? $unformatted_message : strtr($unformatted_message, $message_placeholders);
$ignored_messages = $config->get('ignored_messages');
if (!\is_array($ignored_messages)) {
$ignored_messages = [];
}
if ($log_level->isEnabled($log_levels) && !\in_array($context['channel'], $ignored_channels) && !\in_array($unformatted_message, $ignored_messages)) {
$event = Event::createEvent()
->setLevel($log_level->getSeverity())
->setMessage($unformatted_message, $message_placeholders, $formatted_message)
->setTimestamp($context['timestamp'])
->setLogger($context['channel']);
$extra = ['request_uri' => $context['request_uri']];
if ($context['referer']) {
$extra['referer'] = $context['referer'];
}
if ($context['link']) {
$extra['link'] = MailFormatHelper::htmlToText($context['link']);
}
$event->setExtra($extra);
$user = UserDataBag::createFromUserIdentifier($context['uid']);
if ($config->get('capture_user_ip')) {
$user->setIpAddress($context['ip'] ?: NULL);
}
if ($this->currentUser->id() == $context['uid'] && $config->get('send_user_data')) {
$user->setEmail($this->currentUser->getEmail())
->setUsername($this->currentUser->getAccountName());
}
$event->setUser($user);
if ($client->getOptions()->shouldAttachStacktrace()) {
if (isset($context['backtrace'])) {
$backtrace = $context['backtrace'];
if (!$config->get('trace')) {
foreach ($backtrace as &$frame) {
unset($frame['args']);
}
}
}
else {
$backtrace = debug_backtrace($config->get('trace') ? 0 : DEBUG_BACKTRACE_IGNORE_ARGS);
// Remove any logger stack frames.
$finder = new ClassFinder();
$class_file = $finder->findFile(LoggerChannel::class);
if ($class_file && isset($backtrace[0]['file']) && $backtrace[0]['file'] === realpath($class_file)) {
array_shift($backtrace);
$class_file = $finder->findFile(LoggerTrait::class);
if ($class_file && isset($backtrace[0]['file']) && $backtrace[0]['file'] === realpath($class_file)) {
array_shift($backtrace);
}
}
}
$stacktrace = $client->getStacktraceBuilder()->buildFromBacktrace($backtrace, '', 0);
$stacktrace->removeFrame(\count($stacktrace->getFrames()) - 1);
$event->setStacktrace($stacktrace);
$eventHint['stacktrace'] = $stacktrace;
}
$eventHint['extra'] = [
'level' => $level,
'message' => $unformatted_message,
'context' => $context,
];
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
$eventHint['exception'] = $context['exception'];
// Capture "critical" uncaught exceptions logged by
// ExceptionLoggingSubscriber and "fatal" errors logged by
// _drupal_log_error() as "unhandled" exceptions.
if (!$context['exception'] instanceof HttpExceptionInterface || $context['exception']->getStatusCode() >= 500) {
$backtrace = debug_backtrace(0, 3);
if ((isset($backtrace[2]['class']) && $backtrace[2]['class'] === ExceptionLoggingSubscriber::class && $backtrace[2]['function'] === 'onError') || (!isset($backtrace[2]['class']) && isset($backtrace[2]['function']) && $backtrace[2]['function'] === '_drupal_log_error' && !empty($backtrace[2]['args'][1]))) {
$eventHint['mechanism'] = new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, FALSE);
}
}
}
$start = microtime(TRUE);
$rateLimit = $config->get('rate_limit');
if (!$rateLimit || $counter < $rateLimit) {
\Sentry\captureEvent($event, EventHint::fromArray($eventHint));
}
elseif ($counter == $rateLimit) {
\Sentry\captureException(new RateLimitException('Log event discarded due to rate limit exceeded; future log events will not be captured by Sentry.'));
}
$counter++;
$parent = SentrySdk::getCurrentHub()->getSpan();
if ($parent && $parent->getSampled()) {
$span = SpanContext::make()
->setOrigin('auto.app')
->setOp('sentry.capture')
->setDescription($context['channel'] . ': ' . $formatted_message)
->setStartTimestamp($start)
->setEndTimestamp(microtime(TRUE));
$parent->startChild($span);
}
}
if ($client->getOptions()->getEnableLogs()) {
$logs_log_levels = $config->get('logs_log_levels');
if (!\is_array($logs_log_levels)) {
$logs_log_levels = [];
}
if ($log_level->isEnabled($logs_log_levels)) {
$attributes['channel'] = $context['channel'];
if (!empty($context['link'])) {
foreach (Html::load($context['link'])->getElementsByTagName('a') as $link) {
$attributes['link'] = $base_root . $link->getAttribute('href');
}
}
if (!empty($context['referer'])) {
$attributes['referer'] = $context['referer'];
}
$attributes['request_uri'] = $context['request_uri'];
if ($message_placeholders) {
$attributes['sentry.message.template'] = $unformatted_message;
foreach ($message_placeholders as $key => $value) {
$attributes["sentry.message.parameter.$key"] = $value;
}
}
$attributes['user.id'] = $context['uid'];
if ($config->get('capture_user_ip')) {
$attributes['user.ip_address'] = $context['ip'];
}
$this->eventDispatcher->dispatch(new AttributesAlter($attributes, $context), AttributesAlter::class);
\Sentry\logger()->aggregator()->add(
$log_level->getLogsLogLevel(),
$formatted_message,
[],
$attributes,
);
}
}
// Record a breadcrumb.
$breadcrumb = [
'category' => $context['channel'],
'message' => (string) $formatted_message,
'level' => $log_level->getBreadcrumbLevel(),
];
foreach (['%line', '%file', '%type', '%function'] as $key) {
if (isset($context[$key])) {
$breadcrumb['data'][substr($key, 1)] = $context[$key];
}
}
\Sentry\addBreadcrumb(Breadcrumb::fromArray($breadcrumb));
}
/**
* Sends all unsent events.
*
* Call this method periodically if you have a long-running script or are
* processing a large set of data which may generate errors.
*/
public function flush(): void {
if ($client = $this->getClient()) {
$client->flush();
\Sentry\logger()->flush();
}
}
}
