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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user