feat(sentry): Implement Sentry tracing (#50)

This commit is contained in:
2026-01-23 23:42:54 +03:00
parent 172c56de7d
commit 91c5d56903
8 changed files with 230 additions and 46 deletions

View File

@@ -6,3 +6,7 @@ TELECART_CACHE_DRIVER=redis
#TELECART_REDIS_HOST=redis
#TELECART_REDIS_PORT=6379
#TELECART_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -5,3 +5,7 @@ TELECART_CACHE_DRIVER=mysql
TELECART_REDIS_HOST=redis
TELECART_REDIS_PORT=6379
TELECART_REDIS_DATABASE=0
SENTRY_ENABLED=false
SENTRY_DSN=
SENTRY_ENABLE_LOGS=false

View File

@@ -36,7 +36,8 @@
"ramsey/uuid": "^4.2",
"symfony/http-foundation": "^5.4",
"symfony/console": "^5.4",
"dragonmantank/cron-expression": "^3.5"
"dragonmantank/cron-expression": "^3.5",
"sentry/sentry": "^4.19"
},
"require-dev": {
"doctrine/sql-formatter": "^1.3",

View File

@@ -12,7 +12,7 @@ use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksService;
use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksServiceProvider;
use Openguru\OpenCartFramework\Migrations\MigrationsServiceProvider;
use Openguru\OpenCartFramework\Router\Router;
use Openguru\OpenCartFramework\Support\ExecutionTimeProfiler;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
@@ -27,11 +27,6 @@ class Application extends Container implements LoggerAwareInterface
private LoggerInterface $logger;
/**
* @var ExecutionTimeProfiler
*/
private ExecutionTimeProfiler $profiler;
public static function getInstance(): Application
{
return static::$instance;
@@ -39,13 +34,6 @@ class Application extends Container implements LoggerAwareInterface
private function bootKernelServices(): void
{
$this->singleton(ExecutionTimeProfiler::class, function () {
return new ExecutionTimeProfiler();
});
$this->profiler = $this->get(ExecutionTimeProfiler::class);
$this->profiler->start();
$this->singleton(Container::class, function (Container $container) {
return $container;
});
@@ -56,8 +44,6 @@ class Application extends Container implements LoggerAwareInterface
return new Settings($container->getConfigValue());
});
$this->loadEnvironmentVariables();
$errorHandler = new ErrorHandler(
$this->get(LoggerInterface::class),
$this,
@@ -68,16 +54,23 @@ class Application extends Container implements LoggerAwareInterface
public function boot(): Application
{
$this->loadEnvironmentVariables();
$action = $_GET['api_action'] ?? null;
SentryService::init($action);
SentryService::measure('boot_kernel_services', 'app.boot', function () {
$this->bootKernelServices();
});
SentryService::measure('dependency_registration', 'app.boot', function () {
$this->initializeEventDispatcher(static::$events);
DependencyRegistration::register($this, $this->serviceProviders);
});
static::$instance = $this;
$this->profiler->addCheckpoint('Bootstrap Application');
return $this;
}
@@ -97,18 +90,25 @@ class Application extends Container implements LoggerAwareInterface
throw new InvalidArgumentException('Invalid action: ' . $controller . '->' . $method);
}
$this->profiler->addCheckpoint('Handle Middlewares.');
$span = SentryService::startSpan('handle_middlewares', 'http.handle');
try {
$next = fn($req) => $this->call($controller, $method);
foreach (array_reverse($this->middlewareStack) as $class) {
$instance = $this->get($class);
$next = static fn($req) => $instance->handle($req, $next);
}
} finally {
SentryService::endSpan($span);
}
$span = SentryService::startSpan('handle_controller', 'http.handle');
try {
$response = $next($request);
$this->profiler->addCheckpoint('Handle HTTP request.');
} finally {
SentryService::endSpan($span);
}
return $response;
}
@@ -117,9 +117,11 @@ class Application extends Container implements LoggerAwareInterface
{
$this->boot();
SentryService::measure('handle_request', 'http.handle', function () {
$request = Request::createFromGlobals();
$response = $this->handleRequest($request);
$response->send();
});
}
public function withServiceProviders(array $serviceProviders): Application
@@ -179,9 +181,10 @@ class Application extends Container implements LoggerAwareInterface
*/
private function loadEnvironmentVariables(): void
{
if (!defined('BP_PHAR_BASE_PATH') || !defined('BP_REAL_BASE_PATH')) {
if (! defined('BP_PHAR_BASE_PATH') || ! defined('BP_REAL_BASE_PATH')) {
$dotenv = Dotenv::createMutable(__DIR__ . '/../');
$dotenv->load();
return;
}

View File

@@ -8,6 +8,7 @@ use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
use Openguru\OpenCartFramework\Exceptions\HttpNotFoundException;
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Psr\Log\LoggerInterface;
@@ -110,6 +111,8 @@ class ErrorHandler
public function handleShutdown(): void
{
SentryService::finishTransaction();
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) {

View File

@@ -2,9 +2,11 @@
namespace Openguru\OpenCartFramework\QueryBuilder\Connections;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Openguru\OpenCartFramework\Support\Utils;
use PDO;
use RuntimeException;
use Sentry\Tracing\SpanContext;
class MySqlConnection implements ConnectionInterface
{
@@ -25,10 +27,23 @@ class MySqlConnection implements ConnectionInterface
public function select(string $sql, array $bindings = []): array
{
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
$context->setData([
'db.system' => 'mysql',
'db.statement' => $sql,
]);
});
try {
$statement = $this->pdo->prepare($sql);
$statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC);
$results = $statement->fetchAll(PDO::FETCH_ASSOC);
} finally {
SentryService::endSpan($span);
}
return $results;
}
public function escape($value): string
@@ -88,10 +103,19 @@ class MySqlConnection implements ConnectionInterface
public function statement(string $sql, array $bindings = []): bool
{
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
$context->setData([
'db.system' => 'mysql',
'db.statement' => $sql,
]);
});
try {
$statement = $this->pdo->prepare($sql);
if (! $statement) {
$this->lastError = $this->pdo->errorInfo();
return false;
}
@@ -99,8 +123,12 @@ class MySqlConnection implements ConnectionInterface
if (! $success) {
$this->lastError = $statement->errorInfo();
return false;
}
} finally {
SentryService::endSpan($span);
}
return $success;
}
@@ -147,7 +175,7 @@ class MySqlConnection implements ConnectionInterface
public function insert(string $table, array $data): bool
{
$placeholders = implode(',', array_fill(0, count($data), '?'));
$columns = implode(',', array_map(static fn ($key) => "`${key}`", array_keys($data)));
$columns = implode(',', array_map(static fn($key) => "`${key}`", array_keys($data)));
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $columns, $placeholders);
return $this->statement($sql, array_values($data));

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Openguru\OpenCartFramework\Sentry;
use Closure;
use Sentry\SentrySdk;
use Sentry\Tracing\Span;
use Sentry\Tracing\SpanContext;
use Sentry\Tracing\Transaction;
use Sentry\Tracing\TransactionContext;
use SplObjectStorage;
use function Sentry\init;
use function Sentry\startTransaction;
class SentryService
{
private static ?string $currentAction = null;
public static ?Transaction $currentTransaction = null;
/**
* Stores parent span for each created child span to correctly restore nesting.
*
* @var SplObjectStorage<Span, Span|null>|null
*/
private static ?SplObjectStorage $spanParents = null;
public static array $excludeActions = [
'health',
];
private static function resolveOptionsFromEnv(): array
{
return [
'dsn' => env('SENTRY_DSN'),
'send_default_pii' => true,
'enable_logs' => (bool) filter_var(env('SENTRY_ENABLE_LOGS', false), FILTER_VALIDATE_BOOLEAN),
'traces_sample_rate' => (float) env('SENTRY_TRACES_SAMPLE_RATE', 1.0),
];
}
private static function isSentryEnabled(): bool
{
return self::$currentAction !== null
&& ! in_array(self::$currentAction, self::$excludeActions, true)
&& env('SENTRY_ENABLED', false);
}
public static function init(?string $action): void
{
$options = self::resolveOptionsFromEnv();
self::$currentAction = $action;
if (! self::isSentryEnabled()) {
return;
}
init($options);
$transactionContext = TransactionContext::make()
->setName(self::$currentAction)
->setOp('http.server');
self::$currentTransaction = startTransaction($transactionContext);
SentrySdk::getCurrentHub()->setSpan(self::$currentTransaction);
}
public static function startSpan(string $description, string $op, ?Closure $closure = null): ?Span
{
$parent = self::resolveParent();
if (! self::isSentryEnabled() || ! $parent) {
return null;
}
$context = SpanContext::make()
->setOp($op)
->setDescription($description);
if ($closure) {
$closure($context);
}
$span = $parent->startChild($context);
if (self::$spanParents === null) {
self::$spanParents = new SplObjectStorage();
}
self::$spanParents[$span] = $parent;
SentrySdk::getCurrentHub()->setSpan($span);
return $span;
}
public static function endSpan(?Span $span = null): void
{
if (! $span || ! self::isSentryEnabled()) {
return;
}
$parent = null;
if (self::$spanParents !== null && self::$spanParents->contains($span)) {
$parent = self::$spanParents[$span] ?? null;
self::$spanParents->detach($span);
}
$span->finish();
SentrySdk::getCurrentHub()->setSpan($parent ?? self::$currentTransaction);
}
public static function resolveParent(): ?Span
{
return SentrySdk::getCurrentHub()->getSpan() ?? self::$currentTransaction;
}
public static function measure(string $description, string $op, Closure $closure, ?Closure $params = null): void
{
$span = self::startSpan($description, $op, $params);
try {
$closure();
} finally {
self::endSpan($span);
}
}
public static function finishTransaction(): void
{
if (self::$currentTransaction) {
self::$currentTransaction->finish();
self::$currentTransaction = null;
}
}
}

View File

@@ -16,6 +16,7 @@ use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table;
use Openguru\OpenCartFramework\Sentry\SentryService;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Support\PaginationHelper;
use Openguru\OpenCartFramework\Support\Str;
@@ -157,6 +158,7 @@ class ProductsService
->get();
}
$span = SentryService::startSpan('crop_images', 'image.process');
$productsImagesMap = [];
foreach ($productsImages as $item) {
$productId = $item['product_id'];
@@ -174,6 +176,8 @@ class ProductsService
}
}
SentryService::endSpan($span);
$debug = [];
if (env('APP_DEBUG')) {
$debug = [