diff --git a/composer.json b/composer.json index 33620d4d..f5033cf4 100644 --- a/composer.json +++ b/composer.json @@ -14,8 +14,10 @@ "require": { "php": "^7.1", "illuminate/support": "5.0 - 5.8 | ^6.0 | ^7.0", - "sentry/sdk": "^2.1" + "sentry/sdk": "3.x-dev" }, + "minimum-stability": "dev", + "prefer-stable": true, "require-dev": { "phpunit/phpunit": "^8.0", "laravel/framework": "^6.0", @@ -53,7 +55,8 @@ }, "laravel": { "providers": [ - "Sentry\\Laravel\\ServiceProvider" + "Sentry\\Laravel\\ServiceProvider", + "Sentry\\Laravel\\Tracing\\ServiceProvider" ], "aliases": { "Sentry": "Sentry\\Laravel\\Facade" diff --git a/config/sentry.php b/config/sentry.php index 8c91c3db..949de548 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -27,4 +27,5 @@ // @see: https://docs.sentry.io/error-reporting/configuration/?platform=php#send-default-pii 'send_default_pii' => false, + 'traces_sample_rate' => 0, ]; diff --git a/src/Sentry/Laravel/EventHandler.php b/src/Sentry/Laravel/EventHandler.php index 896259e7..326c506f 100644 --- a/src/Sentry/Laravel/EventHandler.php +++ b/src/Sentry/Laravel/EventHandler.php @@ -16,11 +16,12 @@ use Illuminate\Queue\QueueManager; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Routing\Route; -use Illuminate\Support\Str; use RuntimeException; use Sentry\Breadcrumb; use Sentry\SentrySdk; use Sentry\State\Scope; +use Sentry\Tracing\SpanContext; +use Sentry\Tracing\Transaction; class EventHandler { @@ -194,26 +195,16 @@ public function __call($method, $arguments) */ protected function routerMatchedHandler(Route $route) { - $routeName = null; + $routeName = Integration::extractNameForRoute($route) ?? ''; - if ($route->getName()) { - // someaction (route name/alias) - $routeName = $route->getName(); + $transaction = SentrySdk::getCurrentHub()->getTransaction(); - // Laravel 7 route caching generates a route names if the user didn't specify one - // theirselfs to optimize route matching. These route names are useless to the - // developer so if we encounter a generated route name we discard the value - if (Str::startsWith($routeName, 'generated::')) { - $routeName = null; - } - } - - if (empty($routeName) && $route->getActionName()) { - // SomeController@someAction (controller action) - $routeName = $route->getActionName(); - } elseif (empty($routeName) || $routeName === 'Closure') { - // /someaction // Fallback to the url - $routeName = $route->uri(); + if ($transaction instanceof Transaction) { + $transaction->setName($routeName); + $transaction->setData([ + 'action' => $route->getActionName(), + 'name' => $route->getName() + ]); } Integration::addBreadcrumb(new Breadcrumb( @@ -287,6 +278,16 @@ private function addQueryBreadcrumb($query, $bindings, $time, $connectionName) $data['bindings'] = $bindings; } + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + if (null !== $transaction) { + $context = new SpanContext(); + $context->op = 'sql.query'; + $context->description = $query; + $context->startTimestamp = microtime(true) - $time / 1000; + $context->endTimestamp = $context->startTimestamp + $time / 1000; + $transaction->startChild($context); + } + Integration::addBreadcrumb(new Breadcrumb( Breadcrumb::LEVEL_INFO, Breadcrumb::TYPE_DEFAULT, diff --git a/src/Sentry/Laravel/Integration.php b/src/Sentry/Laravel/Integration.php index 7c6eff68..01d215c9 100644 --- a/src/Sentry/Laravel/Integration.php +++ b/src/Sentry/Laravel/Integration.php @@ -2,8 +2,11 @@ namespace Sentry\Laravel; +use Illuminate\Routing\Route; +use Illuminate\Support\Str; use Sentry\FlushableClientInterface; use Sentry\SentrySdk; +use Sentry\Tracing\Span; use function Sentry\addBreadcrumb; use function Sentry\configureScope; use Sentry\Breadcrumb; @@ -30,7 +33,9 @@ public function setupOnce(): void return $event; } - $event->setTransaction($self->getTransaction()); + if (null === $event->getTransaction()) { + $event->setTransaction($self->getTransaction()); + } return $event; }); @@ -71,7 +76,7 @@ public static function configureScope(callable $callback): void /** * @return null|string */ - public static function getTransaction() + public static function getTransaction(): ?string { return self::$transaction; } @@ -98,4 +103,78 @@ public static function flushEvents(): void $client->flush(); } } + + /** + * Extract the readable name for a route. + * + * @param \Illuminate\Routing\Route $route + * + * @return string|null + */ + public static function extractNameForRoute(Route $route): ?string + { + $routeName = null; + + if (empty($routeName) && $route->getName()) { + // someaction (route name/alias) + $routeName = $route->getName(); + + // Laravel 7 route caching generates a route names if the user didn't specify one + // theirselfs to optimize route matching. These route names are useless to the + // developer so if we encounter a generated route name we discard the value + if (Str::startsWith($routeName, 'generated::')) { + $routeName = null; + } + + // If the route name ends with a `.` we assume an incomplete group name prefix + // we discard this value since it will most likely not mean anything to the + // developer and will be duplicated by other unnamed routes in the group + if (Str::endsWith($routeName, '.')) { + $routeName = null; + } + } + + if (empty($routeName) && $route->getActionName()) { + // SomeController@someAction (controller action) + $routeName = ltrim($route->getActionName(), '\\'); + } + + if (empty($routeName) || $routeName === 'Closure') { + // /someaction // Fallback to the url + $routeName = '/' . ltrim($route->uri(), '/'); + } + + return $routeName; + } + + /** + * Retrieve the meta tags with tracing information to link this request to front-end requests. + * + * @return string + */ + public static function sentryTracingMeta(): string + { + $span = self::currentTracingSpan(); + + if ($span === null) { + return ''; + } + + $content = sprintf('', $span->toTraceparent()); + // $content .= sprintf('', $span->getDescription()); + + return $content; + } + + /** + * Get the current active tracing span from the scope. + * + * @return \Sentry\Tracing\Span|null + * + * @internal This is used internally as an easy way to retrieve the current active tracing span. + */ + public static function currentTracingSpan(): ?Span + { + return SentrySdk::getCurrentHub()->getSpan(); + } } diff --git a/src/Sentry/Laravel/ServiceProvider.php b/src/Sentry/Laravel/ServiceProvider.php index 11f48488..7005bc6d 100644 --- a/src/Sentry/Laravel/ServiceProvider.php +++ b/src/Sentry/Laravel/ServiceProvider.php @@ -2,6 +2,7 @@ namespace Sentry\Laravel; +use Illuminate\Contracts\Http\Kernel as HttpKernelInterface; use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\ClientBuilder; diff --git a/src/Sentry/Laravel/Tracing/Middleware.php b/src/Sentry/Laravel/Tracing/Middleware.php new file mode 100644 index 00000000..074e7c04 --- /dev/null +++ b/src/Sentry/Laravel/Tracing/Middleware.php @@ -0,0 +1,117 @@ +bound('sentry')) { + $this->startTransaction($request, app('sentry')); + } + + return $next($request); + } + + /** + * Handle the application termination. + * + * @param \Illuminate\Http\Request $request + * @param \Illuminate\Http\Response $response + * + * @return void + */ + public function terminate($request, $response): void + { + if ($this->transaction !== null && app()->bound('sentry')) { + $this->transaction->finish(); + } + } + + private function startTransaction(Request $request, Hub $sentry): void + { + $path = '/' . ltrim($request->path(), '/'); + $fallbackTime = microtime(true); + $sentryTraceHeader = $request->header('sentry-trace'); + + $context = $sentryTraceHeader + ? TransactionContext::fromTraceparent($sentryTraceHeader) + : new TransactionContext; + + $context->op = 'http.server'; + $context->name = $path; + $context->data = [ + 'url' => $path, + 'method' => strtoupper($request->method()), + ]; + $context->startTimestamp = $request->server('REQUEST_TIME_FLOAT', $fallbackTime); + + $this->transaction = $sentry->startTransaction($context); + + // Setting the Transaction on the Hub + SentrySdk::getCurrentHub()->setSpan($this->transaction); + + if (!$this->addBootTimeSpans()) { + // @TODO: We might want to move this together with the `RouteMatches` listener to some central place and or do this from the `EventHandler` + app()->booted(function () use ($request, $fallbackTime): void { + $spanContextStart = new SpanContext(); + $spanContextStart->op = 'app.bootstrap'; + $spanContextStart->startTimestamp = defined('LARAVEL_START') ? LARAVEL_START : $request->server('REQUEST_TIME_FLOAT', $fallbackTime); + $spanContextStart->endTimestamp = microtime(true); + $this->transaction->startChild($spanContextStart); + }); + } + } + + private function addBootTimeSpans(): bool + { + if (!defined('LARAVEL_START') || !LARAVEL_START) { + return false; + } + + if (!defined('SENTRY_AUTOLOAD') || !SENTRY_AUTOLOAD) { + return false; + } + + if (!defined('SENTRY_BOOTSTRAP') || !SENTRY_BOOTSTRAP) { + return false; + } + + $spanContextStart = new SpanContext(); + $spanContextStart->op = 'autoload'; + $spanContextStart->startTimestamp = LARAVEL_START; + $spanContextStart->endTimestamp = SENTRY_AUTOLOAD; + $this->transaction->startChild($spanContextStart); + + $spanContextStart = new SpanContext(); + $spanContextStart->op = 'bootstrap'; + $spanContextStart->startTimestamp = SENTRY_AUTOLOAD; + $spanContextStart->endTimestamp = SENTRY_BOOTSTRAP; + $this->transaction->startChild($spanContextStart); + + return true; + } +} diff --git a/src/Sentry/Laravel/Tracing/ServiceProvider.php b/src/Sentry/Laravel/Tracing/ServiceProvider.php new file mode 100644 index 00000000..084ee835 --- /dev/null +++ b/src/Sentry/Laravel/Tracing/ServiceProvider.php @@ -0,0 +1,63 @@ +app->bound(HttpKernelInterface::class)) { + /** @var \Illuminate\Contracts\Http\Kernel $httpKernel */ + $httpKernel = $this->app->make(HttpKernelInterface::class); + + $httpKernel->prependMiddleware(Middleware::class); + } + } + + public function register(): void + { + $this->app->singleton(Middleware::class); + + $viewEngineWrapper = function (EngineResolver $engineResolver): void { + foreach (['file', 'php', 'blade'] as $engineName) { + try { + $realEngine = $engineResolver->resolve($engineName); + + $engineResolver->register($engineName, function () use ($realEngine) { + return $this->wrapViewEngine($realEngine); + }); + } catch (InvalidArgumentException $e) { + // The `file` engine was introduced in Laravel 5.4. On lower Laravel versions + // resolving that driver will throw an `InvalidArgumentException`. We can + // ignore this exception because we can't wrap drivers that don't exist + } + } + }; + + if ($this->app->resolved('view.engine.resolver')) { + $viewEngineWrapper($this->app->make('view.engine.resolver')); + } else { + $this->app->afterResolving('view.engine.resolver', $viewEngineWrapper); + } + } + + private function wrapViewEngine(Engine $realEngine): Engine + { + /** @var ViewFactory $viewFactory */ + $viewFactory = $this->app->make('view'); + + /** @noinspection UnusedFunctionResultInspection */ + $viewFactory->composer('*', static function (View $view) use ($viewFactory) : void { + $viewFactory->share(ViewEngineDecorator::SHARED_KEY, $view->name()); + }); + + return new ViewEngineDecorator($realEngine, $viewFactory); + } +} diff --git a/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php b/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php new file mode 100644 index 00000000..c059273c --- /dev/null +++ b/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php @@ -0,0 +1,59 @@ +engine = $engine; + $this->viewFactory = $viewFactory; + } + + /** + * {@inheritdoc} + */ + public function get($path, array $data = []): string + { + $parentSpan = Integration::currentTracingSpan(); + + if ($parentSpan === null) { + return $this->engine->get($path, $data); + } + + $context = new SpanContext(); + $context->op = 'view.render'; + $context->description = $this->viewFactory->shared(self::SHARED_KEY, basename($path)); + + $span = $parentSpan->startChild($context); + + $result = $this->engine->get($path, $data); + + $span->finish(); + + return $result; + } + + /** + * Laravel uses this function internally + */ + public function getCompiler(): CompilerInterface + { + return $this->engine->getCompiler(); + } +}