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