diff --git a/Helper/Data.php b/Helper/Data.php index 01d2e58..ed301b2 100644 --- a/Helper/Data.php +++ b/Helper/Data.php @@ -48,6 +48,9 @@ class Data extends AbstractHelper 'js_sdk_version', 'tracing_enabled', 'tracing_sample_rate', + 'performance_tracking_enabled', + 'performance_tracking_excluded_areas', + 'profiles_sample_rate', 'ignore_js_errors', ]; @@ -93,6 +96,11 @@ public function getTracingSampleRate(): float return (float) $this->config['tracing_sample_rate'] ?? 0.2; } + public function getPhpProfileSampleRate(): float + { + return (float) ($this->config['profiles_sample_rate'] ?? 0); + } + /** * @return array|null */ @@ -272,10 +280,20 @@ public function isPhpTrackingEnabled(): bool return $this->scopeConfig->isSetFlag(static::XML_PATH_SRS.'enable_php_tracking'); } + public function isPerformanceTrackingEnabled(): bool + { + return $this->isTracingEnabled() && $this->config['performance_tracking_enabled'] ?? false; + } + + public function getPerformanceTrackingExcludedAreas(): array + { + return $this->config['performance_tracking_excluded_areas'] ?? ['adminhtml', 'crontab']; + } + /** * @return bool */ - public function useScriptTag(): bool + public function useScriptTag() { return $this->scopeConfig->isSetFlag(static::XML_PATH_SRS.'enable_script_tag'); } diff --git a/Model/PerformanceTracingDto.php b/Model/PerformanceTracingDto.php new file mode 100644 index 0000000..ce31dd8 --- /dev/null +++ b/Model/PerformanceTracingDto.php @@ -0,0 +1,33 @@ +scope; + } + + public function getParentSpan(): ?Span + { + return $this->parentSpan; + } + + public function getSpan(): ?Span + { + return $this->span; + } +} diff --git a/Model/SentryInteraction.php b/Model/SentryInteraction.php index ea484f4..5aec00f 100644 --- a/Model/SentryInteraction.php +++ b/Model/SentryInteraction.php @@ -6,20 +6,36 @@ // phpcs:disable Magento2.Functions.DiscouragedFunction +use JustBetter\Sentry\Helper\Data; +use Throwable; + use function Sentry\captureException; use function Sentry\init; class SentryInteraction { - public function initialize($config) + public function __construct( + private Data $sentryHelper + ) { + } + + public function initialize($config): void { init($config); } - public function captureException(\Throwable $ex) + public function captureException(Throwable $ex): void { + if (!$this->sentryHelper->shouldCaptureException($ex)) { + return; + } + ob_start(); - captureException($ex); + + try { + captureException($ex); + } catch (Throwable) { + } ob_end_clean(); } } diff --git a/Model/SentryPerformance.php b/Model/SentryPerformance.php new file mode 100644 index 0000000..5de1d25 --- /dev/null +++ b/Model/SentryPerformance.php @@ -0,0 +1,153 @@ +request->getServer('REQUEST_TIME_FLOAT', microtime(true)); + + $context = TransactionContext::fromHeaders( + $this->request->getHeader('sentry-trace') ?: '', + $this->request->getHeader('baggage') ?: '' + ); + + $requestPath = '/'.ltrim($this->request->getRequestUri(), '/'); + + $context->setName($requestPath); + $context->setSource(TransactionSource::url()); + $context->setStartTimestamp($requestStartTime); + + $context->setData([ + 'url' => $requestPath, + 'method' => strtoupper($this->request->getMethod()), + ]); + + // Start the transaction + $transaction = startTransaction($context); + + // If this transaction is not sampled, don't set it either and stop doing work from this point on + if (!$transaction->getSampled()) { + return; + } + + $this->transaction = $transaction; + SentrySdk::getCurrentHub()->setSpan($transaction); + } + + public function finishTransaction(ResponseInterface|int $statusCode): void + { + if ($this->transaction === null) { + return; + } + + try { + $state = $this->objectManager->get(State::class); + $areaCode = $state->getAreaCode(); + } catch (LocalizedException) { + // we wont track transaction without an area + return; + } + + if (in_array($areaCode, $this->helper->getPerformanceTrackingExcludedAreas())) { + return; + } + + if ($statusCode instanceof Response) { + $statusCode = (int) $statusCode->getStatusCode(); + } + + if (is_numeric($statusCode)) { + $this->transaction->setHttpStatus($statusCode); + } + + if (in_array($state->getAreaCode(), ['frontend', 'webapi_rest', 'adminhtml'])) { + if (!empty($this->request->getFullActionName())) { + $this->transaction->setName(strtoupper($this->request->getMethod()). ' ' .$this->request->getFullActionName()); + } + + $this->transaction->setOp('http'); + + $this->transaction->setData(array_merge( + $this->transaction->getData(), + $this->request->__debugInfo(), + [ + 'module' => $this->request->getModuleName(), + 'action' => $this->request->getFullActionName(), + ] + )); + } elseif ($state->getAreaCode() === 'graphql') { + $this->transaction->setOp('graphql'); + } else { + $this->transaction->setOp($state->getAreaCode()); + } + + try { + // Finish the transaction, this submits the transaction and it's span to Sentry + $this->transaction->finish(); + } catch (Throwable) { + } + + $this->transaction = null; + } + + public static function traceStart(SpanContext $context): PerformanceTracingDto + { + $scope = SentrySdk::getCurrentHub()->pushScope(); + $span = null; + + $parentSpan = $scope->getSpan(); + if ($parentSpan !== null && $parentSpan->getSampled()) { + $span = $parentSpan->startChild($context); + $scope->setSpan($span); + } + + return new PerformanceTracingDto($scope, $parentSpan, $span); + } + + public static function traceEnd(PerformanceTracingDto $context): void + { + if ($context->getSpan()) { + $context->getSpan()->finish(); + $context->getScope()->setSpan($context->getParentSpan()); + } + SentrySdk::getCurrentHub()->popScope(); + } +} diff --git a/Plugin/GlobalExceptionCatcher.php b/Plugin/GlobalExceptionCatcher.php index fc236a5..9d35c19 100755 --- a/Plugin/GlobalExceptionCatcher.php +++ b/Plugin/GlobalExceptionCatcher.php @@ -4,30 +4,25 @@ // phpcs:disable Magento2.CodeAnalysis.EmptyBlock -use JustBetter\Sentry\Helper\Data as SenteryHelper; +use JustBetter\Sentry\Helper\Data as SentryHelper; use JustBetter\Sentry\Model\ReleaseIdentifier; use JustBetter\Sentry\Model\SentryInteraction; +use JustBetter\Sentry\Model\SentryPerformance; use Magento\Framework\AppInterface; +use Magento\Framework\DataObject; use Magento\Framework\DataObjectFactory; use Magento\Framework\Event\ManagerInterface as EventManagerInterface; +use Throwable; class GlobalExceptionCatcher { - /** - * ExceptionCatcher constructor. - * - * @param SenteryHelper $sentryHelper - * @param ReleaseIdentifier $releaseIdentifier - * @param SentryInteraction $sentryInteraction - * @param EventManagerInterface $eventManager - * @param DataObjectFactory $dataObjectFactory - */ public function __construct( - protected SenteryHelper $sentryHelper, + private SentryHelper $sentryHelper, private ReleaseIdentifier $releaseIdentifier, private SentryInteraction $sentryInteraction, private EventManagerInterface $eventManager, - private DataObjectFactory $dataObjectFactory + private DataObjectFactory $dataObjectFactory, + private SentryPerformance $sentryPerformance ) { } @@ -37,6 +32,7 @@ public function aroundLaunch(AppInterface $subject, callable $proceed) return $proceed(); } + /** @var DataObject $config */ $config = $this->dataObjectFactory->create(); $config->setDsn($this->sentryHelper->getDSN()); @@ -59,24 +55,29 @@ public function aroundLaunch(AppInterface $subject, callable $proceed) return $data->getEvent(); }); + if ($this->sentryHelper->isPerformanceTrackingEnabled()) { + $config->setTracesSampleRate($this->sentryHelper->getTracingSampleRate()); + } + + if ($rate = $this->sentryHelper->getPhpProfileSampleRate()) { + $config->setData('profiles_sample_rate', $rate); + } + $this->eventManager->dispatch('sentry_before_init', [ 'config' => $config, ]); $this->sentryInteraction->initialize($config->getData()); + $this->sentryPerformance->startTransaction($subject); try { - return $proceed(); - } catch (\Throwable $ex) { - try { - if ($this->sentryHelper->shouldCaptureException($ex)) { - $this->sentryInteraction->captureException($ex); - } - } catch (\Throwable $bigProblem) { - // do nothing if sentry fails - } - - throw $ex; + return $response = $proceed(); + } catch (Throwable $exception) { + $this->sentryInteraction->captureException($exception); + + throw $exception; + } finally { + $this->sentryPerformance->finishTransaction($response ?? 500); } } } diff --git a/Plugin/Profiling/DbQueryLoggerPlugin.php b/Plugin/Profiling/DbQueryLoggerPlugin.php new file mode 100644 index 0000000..fae99d1 --- /dev/null +++ b/Plugin/Profiling/DbQueryLoggerPlugin.php @@ -0,0 +1,35 @@ +tracingDto = SentryPerformance::traceStart( + SpanContext::make() + ->setOp('db.sql.query') + ->setStartTimestamp(microtime(true)) + ); + } + + public function beforeLogStats(LoggerInterface $subject, $type, $sql, $bind = [], $result = null): void + { + if ($this->tracingDto === null) { + return; + } + + $this->tracingDto->getSpan()?->setDescription($sql); + SentryPerformance::traceEnd($this->tracingDto); + $this->tracingDto = null; + } +} diff --git a/Plugin/Profiling/EventManagerPlugin.php b/Plugin/Profiling/EventManagerPlugin.php new file mode 100644 index 0000000..b97159e --- /dev/null +++ b/Plugin/Profiling/EventManagerPlugin.php @@ -0,0 +1,68 @@ +excludePatterns = array_merge([ + '^model_load_', + '_load_before$', + '_load_after$', + '_$', + '^view_block_abstract_', + '^core_layout_render_e', + ], $excludePatterns); + } + + private function _canTrace(string|null $eventName): bool + { + if ($eventName === null) { + return false; + } + + foreach ($this->excludePatterns as $excludePattern) { + if (preg_match('/'.$excludePattern.'/i', $eventName)) { + return false; + } + } + + if ($this->config->getObservers(mb_strtolower($eventName)) === []) { + return false; + } + + return true; + } + + public function aroundDispatch(ManagerInterface $subject, callable $callable, string $eventName, array $data = []): mixed + { + if (!$this->_canTrace($eventName)) { + return $callable($eventName, $data); + } + + $context = SpanContext::make() + ->setOp('event') + ->setDescription($eventName) + ->setData([ + 'event.name' => $eventName, + ]); + + $tracingDto = SentryPerformance::traceStart($context); + + try { + return $callable($eventName, $data); + } finally { + SentryPerformance::traceEnd($tracingDto); + } + } +} diff --git a/Plugin/Profiling/TemplatePlugin.php b/Plugin/Profiling/TemplatePlugin.php new file mode 100644 index 0000000..b3b9a25 --- /dev/null +++ b/Plugin/Profiling/TemplatePlugin.php @@ -0,0 +1,39 @@ +getModuleName())) { + $tags['magento.module'] = $subject->getModuleName(); + } + + $context = SpanContext::make() + ->setOp('template.render') + ->setDescription($subject->getNameInLayout() ?: $fileName) + ->setTags($tags) + ->setData([ + 'block_name' => $subject->getNameInLayout(), + 'block_class' => get_class($subject), + 'module' => $subject->getModuleName(), + 'template' => $fileName, + ]); + + $tracingDto = SentryPerformance::traceStart($context); + + try { + return $callable($fileName); + } finally { + SentryPerformance::traceEnd($tracingDto); + } + } +} diff --git a/Plugin/SampleRequest.php b/Plugin/SampleRequest.php new file mode 100644 index 0000000..5f30bff --- /dev/null +++ b/Plugin/SampleRequest.php @@ -0,0 +1,35 @@ +sentryPerformance = $sentryPerformance; + $this->request = $request; + } + + /** + * Add our toolbar to the response. + * + * @param ResponseInterface $response + */ + public function beforeSendResponse(ResponseInterface $response) + { + $this->sentryPerformance->finishTransaction($response); + } +} diff --git a/README.md b/README.md index eece21b..275302c 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,11 @@ This module uses the [Magento Deployment Configuration](https://devdocs.magento. 'ignore_exceptions' => [], 'mage_mode_development' => false, 'js_sdk_version' => \JustBetter\Sentry\Block\SentryScript::CURRENT_VERSION, - 'tracing_enabled' => true, + 'tracing_enabled' => true, 'tracing_sample_rate' => 0.5, + 'performance_tracking_enabled' => true, + 'performance_tracking_excluded_areas' => ['adminhtml', 'crontab'], + 'profiles_sample_rate' => 0.5, 'ignore_js_errors' => [] ] ``` @@ -39,9 +42,12 @@ Next to that there are some configuration options under Stores > Configuration > * `errorexception_reporting`: If the Exception being thrown is an instance of [ErrorException](https://www.php.net/manual/en/class.errorexception.php) send the error to sentry if it matches the error reporting. This uses the same syntax as [Error Reporting](https://www.php.net/manual/en/function.error-reporting.php) eg. `E_ERROR | E_WARNING` to only log Errors and Warnings. * `ignore_exceptions`: If the class being thrown matches any in this list do not send it to Sentry e.g. `[\Magento\Framework\Exception\NoSuchEntityException::class]` * `mage_mode_development`: If this option is set to true you will receive issues in Sentry even if you're Magento is running in develop mode. -* `js_sdk_version`: if this option is set, it will load the explicit version of the javascript SDK of Sentry. +* `js_sdk_version`: if this option is set, it will load the explicit version of the javascript SDK of Sentry. * `tracing_enabled` if this option is set to true, tracing got enabled (bundle file got loaded automatically). Default: `false` * `tracing_sample_rate` if tracing is enabled, you should also set the sample rate. Default: `0.2` +* `performance_tracking_enabled` if performance tracking is enabled, a performance report got generated for the request. Default: `false` +* `performance_tracking_excluded_areas`: if `performance_tracking_enabled` is enabled, we recommend to exclude the `adminhtml` & `crontab` area. Default `['adminhtml', 'crontab']` +* `profiles_sample_rate` if this option is larger than 0 (zero), the module will create a profile of the request. Please note that you have to install [Excimer](https://www.mediawiki.org/wiki/Excimer) on your server to use profiling. [Sentry documentation](https://docs.sentry.io/platforms/php/profiling/). You have to enable tracing too. Default `0` (disabled) * `ignore_js_errors` array of javascript error messages, which should be not send to Sentry. (see also `ignoreErrors` in [Sentry documentation](https://docs.sentry.io/clients/javascript/config/)) ### Configuration for Adobe Cloud @@ -59,11 +65,15 @@ using the "Variables" in Adobe Commerce using the following variables: * `CONFIG__SENTRY__ENVIRONMENT__JS_SDK_VERSION`: string * `CONFIG__SENTRY__ENVIRONMENT__TRACING_ENABLED`: boolean * `CONFIG__SENTRY__ENVIRONMENT__TRACING_SAMPLE_RATE`: float +* `CONFIG__SENTRY__ENVIRONMENT__TRACING_PERFORMANCE_TRACKING_ENABLED`: boolean +* `CONFIG__SENTRY__ENVIRONMENT__TRACING_PERFORMANCE_TRACKING_EXCLUDED_AREAS`: boolean * `CONFIG__SENTRY__ENVIRONMENT__IGNORE_JS_ERRORS`: A JSON encoded array of error messages The following configuration settings can be overridden in the Magento admin. This is limited to ensure that changes to particular config settings can only be done on server level and can't be broken by changes in the admin. +Please note, that it is not possible to use profiling within the Adobe Cloud. + ## Optional error page configuration - Optional you can configure custom error pages in pub/errors. You can use the sentry feedback form and insert here the sentry log ID. The Sentry Log Id is captured in de customer session and can be retrieved in `processor.php`. diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 02a14ec..8544eb8 100755 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -19,6 +19,10 @@ Magento\Config\Model\Config\Source\Yesno + + + Magento\Config\Model\Config\Source\Yesno + Magento\Config\Model\Config\Source\Yesno diff --git a/etc/config.xml b/etc/config.xml index d315ab3..088648c 100755 --- a/etc/config.xml +++ b/etc/config.xml @@ -4,6 +4,7 @@ 1 + 0 0 before.body.end 0 diff --git a/etc/di.xml b/etc/di.xml index 3e4c720..c25afec 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -34,4 +34,15 @@ + + + + + + + + + + + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml new file mode 100644 index 0000000..fbcf889 --- /dev/null +++ b/etc/frontend/di.xml @@ -0,0 +1,11 @@ + + + + + + + +