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
+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);
}
}