<?php
namespace Sq\Service\EventSubscriber;
use Sq\Entity\Schema\ORM as Entity;
use Sq\Event\Queue\CronTaskFinishedEvent;
use Sq\Event\Queue\CronTaskStartedEvent;
use Sq\Event\Queue\WorkerJobFinishedEvent;
use Sq\Event\Queue\WorkerJobStartedEvent;
use Sq\Exception\Controller\AdminAccessDeniedException;
use Sq\Exception\Controller\MemberAccessDeniedException;
use Sq\Exception\Controller\MFARequiredException;
use Sq\Exception\Controller\MFAVerificationException;
use Sq\Service\Config\SqConfig;
use Sq\Service\Log\LogBuilder;
use Sq\Service\Log\LogBuilderInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
class TidewaysProfiler implements EventSubscriberInterface
{
protected array $ignoredExceptionClasses = [
MFARequiredException::class,
MFAVerificationException::class,
AdminAccessDeniedException::class,
MemberAccessDeniedException::class,
NotFoundHttpException::class,
];
protected array $trackedErrors = [];
private bool $ignoreTransaction = false;
private bool $staticAttributesAdded = false;
public function __construct(
private readonly SqConfig $config,
private readonly ParameterBagInterface $parameterBag,
private readonly LogBuilderInterface $logBuilder,
private readonly TokenStorageInterface $tokenStorage,
) {
}
public static function getSubscribedEvents()
{
return [
KernelEvents::CONTROLLER => ['onController', -64],
KernelEvents::EXCEPTION => ['onKernelException'],
KernelEvents::TERMINATE => ['onTerminate'],
CronTaskStartedEvent::class => ['onCronTaskStarted'],
CronTaskFinishedEvent::class => ['onCronTaskFinished'],
WorkerJobStartedEvent::class => ['onWorkerJobStarted'],
WorkerJobFinishedEvent::class => ['onWorkerJobFinished'],
];
}
public function onController(ControllerEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
if (!$event->isMainRequest())
{
return;
}
$request = $event->getRequest();
$this->ignoreTransaction = !$this->shouldTrackWebRequest($request);
$this->startTransaction();
$this->addAttribute('request_method', $request->getMethod());
$requestId = $request->headers->get('X-Request-Id');
if (is_string($requestId))
{
$this->addAttribute('http_request_id', $requestId);
}
if (is_graphql_request($request))
{
$operationName = $request->attributes->get('_gql_operation_name');
if (is_string($operationName))
{
$this->nameTransaction('gql:' . $operationName);
if (in_array($operationName, $this->getTraceDisabledGqlOperations()))
{
$this->disableCurrentTrace();
}
}
}
$memberId = $this->getMemberId();
if ($memberId !== null)
{
$this->addAttribute('m_id', $memberId);
}
$userToken = $this->tokenStorage->getToken();
$user = $userToken?->getUser();
if ($user instanceof Entity\User)
{
$this->addAttribute('u_id', (string) $user->getId());
}
if ($userToken instanceof SwitchUserToken)
{
$impersonator = $userToken->getOriginalToken()->getUser();
if ($impersonator instanceof Entity\User)
{
$this->addAttribute('impersonator_u_id', (string) $impersonator->getId());
}
}
}
public function onKernelException(ExceptionEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
$this->trackThrowable($event->getThrowable());
}
public function onTerminate(TerminateEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
if ($this->ignoreTransaction)
{
$this->ignoreCurrentTransaction();
}
}
public function onCronTaskStarted(CronTaskStartedEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
$this->ignoreTransaction = !$this->shouldTrackCron();
$this->startTransaction();
register_shutdown_function([$this, 'stopTransaction']);
$this->nameTransaction('cron:' . $event->getName());
}
public function onCronTaskFinished(CronTaskFinishedEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
if ($this->ignoreTransaction)
{
$this->ignoreCurrentTransaction();
return;
}
$throwable = $event->getThrowable();
if ($throwable !== null)
{
$this->trackThrowable($throwable);
}
}
public function onWorkerJobStarted(WorkerJobStartedEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
$this->ignoreTransaction = !$this->shouldTrackWorkerJob();
$this->startTransaction();
register_shutdown_function([$this, 'stopTransaction']);
$this->nameTransaction('job:' . $event->getName());
}
public function onWorkerJobFinished(WorkerJobFinishedEvent $event): void
{
if (!$this->isExtensionEnabled())
{
return;
}
if ($this->ignoreTransaction)
{
$this->ignoreCurrentTransaction();
return;
}
$throwable = $event->getThrowable();
if ($throwable !== null)
{
$this->trackThrowable($throwable);
}
$this->stopTransaction();
}
private function trackThrowable(\Throwable $throwable): void
{
if (in_array(get_class($throwable), $this->ignoredExceptionClasses, true))
{
return;
}
$hash = spl_object_hash($throwable);
if (isset($this->trackedErrors[$hash]))
{
return;
}
$this->trackedErrors[$hash] = true;
\Tideways\Profiler::logException($throwable);
}
private function addStaticAttributes(): void
{
if ($this->staticAttributesAdded)
{
return;
}
$this->staticAttributesAdded = true;
$this->logBuilder instanceof LogBuilder && $this->addAttribute('process_id', $this->logBuilder->getProcessId());
$this->addAttribute('instance_id', (string) $this->parameterBag->get('instance.identifier'));
$this->addAttribute('app_name', (string) $this->parameterBag->get('kernel.app_name'));
$this->addAttribute('app_type', (string) $this->parameterBag->get('kernel.app_type'));
$this->addAttribute('environment', (string) $this->parameterBag->get('kernel.environment'));
$this->addAttribute('run_mode', is_cli() ? 'cli' : 'web');
}
private function addAttribute(string $name, ?string $value): void
{
if ($value === null || $value === '')
{
return;
}
\Tideways\Profiler::setCustomVariable($name, $value);
}
private function startTransaction(): void
{
if (!$this->isProfilerStarted())
{
\Tideways\Profiler::start([]);
}
$this->addStaticAttributes();
}
private function stopTransaction(): void
{
if ($this->isProfilerStarted())
{
\Tideways\Profiler::stop();
}
}
private function nameTransaction(string $name): void
{
if ($name !== '')
{
\Tideways\Profiler::setTransactionName($name);
}
}
private function disableCurrentTrace(): void
{
\Tideways\Profiler::disableCallgraphProfiler();
\Tideways\Profiler::disableTracingProfiler();
}
private function ignoreCurrentTransaction(): void
{
\Tideways\Profiler::ignoreTransaction();
}
private function isExtensionEnabled(): bool
{
return extension_loaded('tideways') && class_exists(\Tideways\Profiler::class);
}
private function shouldTrackWebRequest(Request $request): bool
{
if (!($this->getConfig()['web']['enabled'] ?? true))
{
return false;
}
if (!empty($this->getConfig()['web']['force_cookie_name']) && $request->cookies->has($this->getConfig()['web']['force_cookie_name']))
{
return true;
}
$samplePercent = $this->getConfig()['web']['sample_rate_pct'] ?? 10;
if (mt_rand(1, 100) > $samplePercent)
{
return false;
}
return true;
}
private function shouldTrackWorkerJob(): bool
{
if (!($this->getConfig()['worker']['enabled'] ?? true))
{
return false;
}
$samplePercent = $this->getConfig()['worker']['sample_rate_pct'] ?? 10;
if (mt_rand(1, 100) > $samplePercent)
{
return false;
}
return true;
}
private function shouldTrackCron(): bool
{
if (!($this->getConfig()['cron']['enabled'] ?? true))
{
return false;
}
$samplePercent = $this->getConfig()['cron']['sample_rate_pct'] ?? 10;
if (mt_rand(1, 100) > $samplePercent)
{
return false;
}
return true;
}
private function getConfig(): array
{
$config = $this->config->getConfig()['tideways_profiler'] ?? null;
return is_array($config) ? $config : [];
}
private function getMemberId(): ?string
{
$memberId = $GLOBALS['m_id'] ?? null;
if ($memberId === null || $memberId === '')
{
return null;
}
return (string) $memberId;
}
private function isProfilerStarted(): bool
{
return \Tideways\Profiler::isStarted();
}
private function getTraceDisabledGqlOperations(): array
{
return $this->getConfig()['web']['trace_disabled_gql_operations'] ?? [];
}
}