<?php
namespace Sentry\Laravel\Tracing;
use Closure;
use Illuminate\Contracts\Foundation\Application as LaravelApplication;
use Illuminate\Http\Request;
use Laravel\Lumen\Application as LumenApplication;
use Sentry\SentrySdk;
use Sentry\State\HubInterface;
use Sentry\Tracing\Span;
use Sentry\Tracing\SpanContext;
use Sentry\Tracing\TransactionSource;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use function Sentry\continueTrace;
/**
* @internal
*/
class Middleware
{
/**
* The current active transaction.
*
* @var \Sentry\Tracing\Transaction|null
*/
protected $transaction;
/**
* The span for the `app.handle` part of the application.
*
* @var \Sentry\Tracing\Span|null
*/
protected $appSpan;
/**
* The timestamp of application bootstrap completion.
*
* @var float|null
*/
private $bootedTimestamp;
/**
* The Laravel or Lumen application instance.
*
* @var LaravelApplication|LumenApplication
*/
private $app;
/**
* Whether we should continue tracing after the response has been sent to the client.
*
* @var bool
*/
private $continueAfterResponse;
/**
* Whether the terminating callback has been registered.
*
* @var bool
*/
private $registeredTerminatingCallback = false;
/**
* Whether a defined route was matched in the application.
*
* @var bool
*/
private $didRouteMatch = false;
/**
* Construct the Sentry tracing middleware.
*
* @param LaravelApplication|LumenApplication $app
*/
public function __construct($app, bool $continueAfterResponse = true)
{
$this->app = $app;
$this->continueAfterResponse = $continueAfterResponse;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($this->app->bound(HubInterface::class)) {
$this->startTransaction($request, $this->app->make(HubInterface::class));
}
return $next($request);
}
/**
* Handle the application termination.
*
* @param \Illuminate\Http\Request $request
* @param mixed $response
*
* @return void
*/
public function terminate(Request $request, $response): void
{
// If there is no transaction or the HubInterface is not bound in the container there is nothing for us to do
if ($this->transaction === null || !$this->app->bound(HubInterface::class)) {
return;
}
if ($this->shouldRouteBeIgnored($request)) {
return;
}
if ($this->appSpan !== null) {
$this->appSpan->finish();
$this->appSpan = null;
}
if ($response instanceof SymfonyResponse) {
$this->hydrateResponseData($response);
}
if ($this->continueAfterResponse) {
// Ensure we do not register the terminating callback multiple times since there is no point in doing so
if ($this->registeredTerminatingCallback) {
return;
}
// We need to finish the transaction after the response has been sent to the client
// so we register a terminating callback to do so, this allows us to also capture
// spans that are created during the termination of the application like queue
// dispatched using dispatch(...)->afterResponse(). This middleware is called
// before the terminating callbacks so we are 99.9% sure to be the last one
// to run except if another terminating callback is registered after ours.
$this->app->terminating(function () {
$this->finishTransaction();
});
$this->registeredTerminatingCallback = true;
} else {
$this->finishTransaction();
}
}
/**
* Set the timestamp of application bootstrap completion.
*
* @param float|null $timestamp The unix timestamp of the booted event, default to `microtime(true)` if not `null`.
*
* @return void
*
* @internal This method should only be invoked right after the application has finished "booting".
*/
public function setBootedTimestamp(?float $timestamp = null): void
{
$this->bootedTimestamp = $timestamp ?? microtime(true);
}
private function startTransaction(Request $request, HubInterface $sentry): void
{
// Reset our internal state in case we are handling multiple requests (e.g. in Octane)
$this->didRouteMatch = false;
// Try $_SERVER['REQUEST_TIME_FLOAT'] then LARAVEL_START and fallback to microtime(true) if neither are defined
$requestStartTime = $request->server(
'REQUEST_TIME_FLOAT',
defined('LARAVEL_START')
? LARAVEL_START
: microtime(true)
);
$context = continueTrace(
$request->header('sentry-trace') ?? $request->header('traceparent', ''),
$request->header('baggage', '')
);
$requestPath = '/' . ltrim($request->path(), '/');
$context->setOp('http.server');
$context->setName($requestPath);
$context->setSource(TransactionSource::url());
$context->setStartTimestamp($requestStartTime);
$context->setData([
'url' => $requestPath,
'http.request.method' => strtoupper($request->method()),
]);
$transaction = $sentry->startTransaction($context);
// If this transaction is not sampled, we can stop here to prevent doing work for nothing
if (!$transaction->getSampled()) {
return;
}
$this->transaction = $transaction;
SentrySdk::getCurrentHub()->setSpan($this->transaction);
$bootstrapSpan = $this->addAppBootstrapSpan();
$appContextStart = new SpanContext;
$appContextStart->setOp('middleware.handle');
$appContextStart->setStartTimestamp($bootstrapSpan ? $bootstrapSpan->getEndTimestamp() : microtime(true));
$this->appSpan = $this->transaction->startChild($appContextStart);
SentrySdk::getCurrentHub()->setSpan($this->appSpan);
}
private function addAppBootstrapSpan(): ?Span
{
if ($this->bootedTimestamp === null) {
return null;
}
$spanContextStart = new SpanContext;
$spanContextStart->setOp('app.bootstrap');
$spanContextStart->setStartTimestamp($this->transaction->getStartTimestamp());
$spanContextStart->setEndTimestamp($this->bootedTimestamp);
$span = $this->transaction->startChild($spanContextStart);
// Consume the booted timestamp, because we don't want to report the bootstrap span more than once
$this->bootedTimestamp = null;
// Add more information about the bootstrap section if possible
$this->addBootDetailTimeSpans($span);
return $span;
}
private function addBootDetailTimeSpans(Span $bootstrap): void
{
// This constant should be defined right after the composer `autoload.php` require statement in `public/index.php`
// define('SENTRY_AUTOLOAD', microtime(true));
if (!defined('SENTRY_AUTOLOAD') || !SENTRY_AUTOLOAD) {
return;
}
$autoload = new SpanContext;
$autoload->setOp('app.php.autoload');
$autoload->setStartTimestamp($this->transaction->getStartTimestamp());
$autoload->setEndTimestamp(SENTRY_AUTOLOAD);
$bootstrap->startChild($autoload);
}
private function hydrateResponseData(SymfonyResponse $response): void
{
$this->transaction->setHttpStatus($response->getStatusCode());
}
private function finishTransaction(): void
{
// We could end up multiple times here since we register a terminating callback so
// double check if we have a transaction before trying to finish it since it could
// have already been finished in between being registered and being executed again
if ($this->transaction === null) {
return;
}
// Make sure we set the transaction and not have a child span in the Sentry SDK
// If the transaction is not on the scope during finish, the trace.context is wrong
SentrySdk::getCurrentHub()->setSpan($this->transaction);
$this->transaction->finish();
$this->transaction = null;
}
private function internalSignalRouteWasMatched(): void
{
$this->didRouteMatch = true;
}
/**
* Indicates if the route should be ignored and the transaction discarded.
*/
private function shouldRouteBeIgnored(Request $request): bool
{
// Laravel Lumen doesn't use `illuminate/routing`.
// Instead we use the route available on the request to detect if a route was matched.
if ($this->app instanceof LumenApplication) {
return $request->route() === null && config('sentry.tracing.missing_routes', false) === false;
}
// If a route has not been matched we ignore unless we are configured to trace missing routes
return !$this->didRouteMatch && config('sentry.tracing.missing_routes', false) === false;
}
public static function signalRouteWasMatched(): void
{
if (!app()->bound(self::class)) {
return;
}
app(self::class)->internalSignalRouteWasMatched();
}
}