feat(logging): Implement database logging with app_log table

- Created migration to set up app_log table for storing application logs.
- Added AppLog entity and repository for ORM handling of logs.
- Developed DbLogger service to persist logs of level WARNING and above to the database while maintaining existing logging behavior.
- Implemented tests for admin log retrieval and DbLogger functionality to ensure proper logging behavior.
- Enhanced logging context sanitization for better error tracking.
This commit is contained in:
hamed
2026-06-29 20:01:03 +03:30
parent 830f7e8d0c
commit 803196108c
38 changed files with 3284 additions and 618 deletions
@@ -18,6 +18,7 @@ use App\Representation\Entity\Representation;
use App\Secretary\Entity\DoctorSecretary;
use App\Settlement\Entity\FinancialBreakdown;
use App\Settlement\Entity\Settlement;
use App\Shared\Logging\AppLog;
use App\Sms\Entity\SmsLog;
use App\Sms\Entity\SmsTemplate;
use App\Shared\Controller\BaseController;
@@ -1929,4 +1930,63 @@ class AdminApiController extends BaseController
'period' => ['from' => $from, 'to' => $to],
]);
}
// ── Application logs ────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/logs',
summary: 'List persisted application logs (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 25)),
new OA\Parameter(name: 'level', in: 'query', required: false, description: 'PSR level filter (warning/error/critical/...)', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'search', in: 'query', required: false, description: 'substring match on message', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'from', in: 'query', required: false, description: 'unix timestamp lower bound', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'to', in: 'query', required: false, description: 'unix timestamp upper bound', schema: new OA\Schema(type: 'integer')),
],
responses: [new OA\Response(response: 200, description: 'Paginated list of logs')]
)]
#[Route('/api/v1/admin/logs', methods: ['GET'])]
public function logs(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
$level = trim((string) $request->query->get('level', ''));
$search = trim((string) $request->query->get('search', ''));
$from = trim((string) $request->query->get('from', ''));
$to = trim((string) $request->query->get('to', ''));
$qb = $this->em->createQueryBuilder()
->select('l.id, l.level, l.message, l.context, l.channel, l.path, l.createdAt')
->from(AppLog::class, 'l');
if ($level !== '') {
$qb->andWhere('l.level = :level')->setParameter('level', $level);
}
if ($search !== '') {
$qb->andWhere('l.message LIKE :s')->setParameter('s', '%' . $search . '%');
}
if ($from !== '') {
$qb->andWhere('l.createdAt >= :from')->setParameter('from', (int) $from);
}
if ($to !== '') {
$qb->andWhere('l.createdAt <= :to')->setParameter('to', (int) $to);
}
$qb->orderBy('l.id', 'DESC');
$total = (clone $qb)->select('COUNT(l.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
return $this->paginated(array_map(fn(array $l) => [
'id' => (int) $l['id'],
'level' => $l['level'],
'message' => $l['message'],
'context' => $l['context'],
'channel' => $l['channel'],
'path' => $l['path'],
'created_at' => (int) $l['createdAt'],
], $rows), (int) $total, $page, $limit);
}
}
+4
View File
@@ -3,6 +3,7 @@
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MellatGateway implements PaymentGatewayInterface
@@ -12,6 +13,7 @@ class MellatGateway implements PaymentGatewayInterface
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly LoggerInterface $logger,
private readonly string $terminalId = '',
private readonly string $username = '',
private readonly string $password = '',
@@ -46,6 +48,7 @@ class MellatGateway implements PaymentGatewayInterface
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment initiate failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]);
return new PaymentInitResult(false, errorMessage: $e->getMessage());
}
}
@@ -79,6 +82,7 @@ class MellatGateway implements PaymentGatewayInterface
return new PaymentVerifyResult(true, referenceId: $refId);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'refId' => $refId]);
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
}
}
+4
View File
@@ -3,6 +3,7 @@
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SepGateway implements PaymentGatewayInterface
@@ -13,6 +14,7 @@ class SepGateway implements PaymentGatewayInterface
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly LoggerInterface $logger,
private readonly string $terminalId = '',
) {}
@@ -47,6 +49,7 @@ class SepGateway implements PaymentGatewayInterface
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $token);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment initiate failed (sep): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]);
return new PaymentInitResult(false, errorMessage: $e->getMessage());
}
}
@@ -84,6 +87,7 @@ class SepGateway implements PaymentGatewayInterface
amountRials: (int) $data['TransactionDetail']['AffectiveAmount']
);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment verify failed (sep): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'refNum' => $refNum]);
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Shared\Logging;
use App\Shared\Logging\AppLogRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AppLogRepository::class)]
#[ORM\Table(name: 'app_log')]
#[ORM\Index(columns: ['level', 'created_at'], name: 'idx_app_log_level')]
#[ORM\Index(columns: ['created_at'], name: 'idx_app_log_created')]
class AppLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 16)]
private string $level;
#[ORM\Column(type: 'text')]
private string $message;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $context = null;
#[ORM\Column(type: 'string', length: 32, nullable: true)]
private ?string $channel = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $path = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(string $level, string $message, ?string $context = null, ?string $channel = null, ?string $path = null)
{
$this->level = $level;
$this->message = $message;
$this->context = $context;
$this->channel = $channel;
$this->path = $path;
$this->createdAt = time();
}
public function toArray(): array
{
return [
'id' => $this->id,
'level' => $this->level,
'message' => $this->message,
'context' => $this->context,
'channel' => $this->channel,
'path' => $this->path,
'created_at' => $this->createdAt,
];
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Shared\Logging;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class AppLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, AppLog::class); }
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Shared\Logging;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Decorates Symfony's minimal `logger` service: every existing LoggerInterface
* injection keeps writing to stderr (so logs surface in `liara logs`) AND is
* persisted to the app_log table for `warning` and above, making them queryable
* from the admin panel. Logging must never slow down or break the request, so the
* DB write uses a raw DBAL INSERT (independent of the request's ORM transaction)
* wrapped in a catch-all.
*/
final class DbLogger implements LoggerInterface
{
private const PERSIST = [
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
LogLevel::ALERT,
LogLevel::EMERGENCY,
];
public function __construct(
private readonly LoggerInterface $inner,
private readonly Connection $conn,
private readonly RequestStack $requestStack,
) {}
public function log($level, \Stringable|string $message, array $context = []): void
{
$this->inner->log($level, $message, $context);
if (!in_array((string) $level, self::PERSIST, true)) {
return;
}
try {
$this->conn->insert('app_log', [
'level' => (string) $level,
'message' => (string) $message,
'context' => $context ? json_encode($this->sanitize($context), JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR) : null,
'channel' => 'app',
'path' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
'created_at' => time(),
]);
} catch (\Throwable) {
// Logging must never break the request; a failed persist stays on stderr only.
}
}
public function emergency(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
/**
* Replace a raw Throwable in context with a compact string — the full object
* is huge and not JSON-friendly. Keeps the rest of the context as-is.
*/
private function sanitize(array $context): array
{
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
$e = $context['exception'];
$context['exception'] = sprintf('%s: %s @ %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine());
}
return $context;
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ class ApiIrService
} catch (AppException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->error('api.ir inquiry failed', ['path' => $path, 'error' => $e->getMessage()]);
$this->logger->error(sprintf('api.ir inquiry failed: %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'path' => $path]);
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
}
}
+6 -2
View File
@@ -3,6 +3,7 @@
namespace App\Sms\Provider;
use App\Config\Repository\SiteConfigRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KavehNegarProvider implements SmsProviderInterface
@@ -12,6 +13,7 @@ class KavehNegarProvider implements SmsProviderInterface
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly LoggerInterface $logger,
// Nullable: env `%env(default::KAVENEGAR_API_KEY)%` resolves to null when unset
// (the real key normally comes from DB site config below, not env).
private readonly ?string $apiKey = null,
@@ -38,7 +40,8 @@ class KavehNegarProvider implements SmsProviderInterface
);
$data = $resp->toArray();
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
return false;
}
}
@@ -58,7 +61,8 @@ class KavehNegarProvider implements SmsProviderInterface
);
$data = $resp->toArray();
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
return false;
}
}
+6 -2
View File
@@ -2,6 +2,7 @@
namespace App\Sms\Provider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class RanginehProvider implements SmsProviderInterface
@@ -10,6 +11,7 @@ class RanginehProvider implements SmsProviderInterface
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
// Nullable: env `%env(default::RANGINEH_API_KEY)%` resolves to null when unset,
// which would crash construction (TypeError) before the provider is ever used.
private readonly ?string $apiKey = null,
@@ -27,7 +29,8 @@ class RanginehProvider implements SmsProviderInterface
'timeout' => 10,
]);
return $resp->getStatusCode() === 200;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS send failed (rangineh): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
return false;
}
}
@@ -45,7 +48,8 @@ class RanginehProvider implements SmsProviderInterface
'timeout' => 10,
]);
return $resp->getStatusCode() === 200;
} catch (\Throwable) {
} catch (\Throwable $e) {
$this->logger->error(sprintf('SMS sendTemplate failed (rangineh): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
return false;
}
}