chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Attributes\Singleton;
use App\Framework\Logging\Handlers\ConsoleHandler;
use DateTimeZone;
/**
* Einfacher Logger für das Framework.
*/
#[Singleton]
final readonly class DefaultLogger implements Logger
{
/**
* @param LogLevel $minLevel Minimales Level, das geloggt werden soll
* @param array<LogHandler> $handlers Array von Log-Handlern
* @param ProcessorManager $processorManager Optional: Processor Manager für die Verarbeitung
*/
public function __construct(
private LogLevel $minLevel = LogLevel::DEBUG,
/** @var LogHandler[] */
private array $handlers = [],
private ProcessorManager $processorManager = new ProcessorManager(),
) {}
public function debug(string $message, array $context = []): void
{
$this->log(LogLevel::DEBUG, $message, $context);
}
public function info(string $message, array $context = []): void
{
$this->log(LogLevel::INFO, $message, $context);
}
public function notice(string $message, array $context = []): void
{
$this->log(LogLevel::NOTICE, $message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->log(LogLevel::WARNING, $message, $context);
}
public function error(string $message, array $context = []): void
{
$this->log(LogLevel::ERROR, $message, $context);
}
public function critical(string $message, array $context = []): void
{
$this->log(LogLevel::CRITICAL, $message, $context);
}
public function alert(string $message, array $context = []): void
{
$this->log(LogLevel::ALERT, $message, $context);
}
public function emergency(string $message, array $context = []): void
{
$this->log(LogLevel::EMERGENCY, $message, $context);
}
/**
* Log-Nachricht mit beliebigem Level erstellen
*
* @param LogLevel $level Log-Level
* @param string $message Log-Nachricht
* @param array $context Kontext-Daten für Platzhalter-Ersetzung
*/
public function log(LogLevel $level, string $message, array $context = []): void
{
// Log-Record erstellen
$record = new LogRecord(
message: $message,
context: $context,
level: $level,
timestamp: new \DateTimeImmutable(timezone: new DateTimeZone('Europe/Berlin')),
);
// Record durch alle Processors verarbeiten
$processedRecord = $this->processorManager->processRecord($record);
// Alle Handler durchlaufen
foreach ($this->handlers as $handler) {
if ($handler->isHandling($processedRecord)) {
$handler->handle($processedRecord);
}
}
}
/**
* Gibt die aktuelle Konfiguration des Loggers zurück
*/
public function getConfiguration(): array
{
return [
'minLevel' => $this->minLevel->value,
'handlers' => array_map(fn(LogHandler $h) => get_class($h), $this->handlers),
'processors' => $this->processorManager->getProcessorList(),
];
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Console\ConsoleColor;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Handler für die Ausgabe von Log-Einträgen in der Konsole.
*/
class ConsoleHandler implements LogHandler
{
/**
* @var LogLevel Minimales Level, ab dem dieser Handler aktiv wird
*/
private LogLevel $minLevel;
/**
* @var bool Ob der Handler nur im Debug-Modus aktiv ist
*/
private bool $debugOnly;
/**
* @var string Format für die Ausgabe
*/
private string $outputFormat;
/**
* Erstellt einen neuen ConsoleHandler
*
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird
* @param bool $debugOnly Ob der Handler nur im Debug-Modus aktiv ist
* @param string $outputFormat Format für die Ausgabe
*/
public function __construct(
LogLevel|int $minLevel = LogLevel::DEBUG,
bool $debugOnly = true,
string $outputFormat = '{color}[{level_name}]{reset} {timestamp} {request_id}{message}',
private LogLevel $stderrLevel = LogLevel::WARNING,
) {
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->debugOnly = $debugOnly;
$this->outputFormat = $outputFormat;
}
/**
* Überprüft, ob dieser Handler den Log-Eintrag verarbeiten soll
*/
public function isHandling(LogRecord $record): bool
{
// Nur im CLI-Modus aktiv - NIE bei Web-Requests!
if (PHP_SAPI !== 'cli') {
return false;
}
// Optional: Debug-Modus-Check nur in CLI
if ($this->debugOnly && !filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
return false;
}
return $record->getLevel()->value >= $this->minLevel->value;
}
/**
* Verarbeitet einen Log-Eintrag
*/
public function handle(LogRecord $record): void
{
$logLevel = $record->getLevel();
$color = $logLevel->getConsoleColor()->value;
$reset = ConsoleColor::RESET->value;
// Request-ID-Teil erstellen, falls vorhanden
$requestId = $record->hasExtra('request_id')
? "[{$record->getExtra('request_id')}] "
: '';
// Werte für Platzhalter im Format
$values = [
'{color}' => $color,
'{reset}' => $reset,
'{level_name}' => $record->getLevel()->getName(),
'{timestamp}' => $record->getFormattedTimestamp(),
'{request_id}' => $requestId,
'{message}' => $record->getMessage(),
'{channel}' => $record->getChannel() ? "[{$record->getChannel()}] " : '',
];
// Formatierte Ausgabe erstellen
$output = strtr($this->outputFormat, $values) . PHP_EOL;
// Fehler und Warnungen auf stderr, alles andere auf stdout
if ($record->getLevel()->value >= $this->stderrLevel->value) {
// WARNING, ERROR, CRITICAL, ALERT, EMERGENCY -> stderr
file_put_contents('php://stderr', $output);
} else {
// DEBUG, INFO, NOTICE -> stdout
echo $output;
}
}
/**
* Minimales Log-Level setzen
*/
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
/**
* Ausgabeformat setzen
*/
public function setOutputFormat(string $format): self
{
$this->outputFormat = $format;
return $this;
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Handler für die Ausgabe von Log-Einträgen in Dateien.
*/
class FileHandler implements LogHandler
{
/**
* @var LogLevel Minimales Level, ab dem dieser Handler aktiv wird
*/
private LogLevel $minLevel;
/**
* @var string Pfad zur Log-Datei
*/
private string $logFile;
/**
* @var string Format für die Ausgabe
*/
private string $outputFormat;
/**
* @var int Datei-Modi für die Log-Datei
*/
private int $fileMode;
/**
* Erstellt einen neuen FileHandler
*
* @param string $logFile Pfad zur Log-Datei
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird
* @param string $outputFormat Format für die Ausgabe
* @param int $fileMode Datei-Modi für die Log-Datei
*/
public function __construct(
string $logFile,
LogLevel|int $minLevel = LogLevel::DEBUG,
string $outputFormat = '[{timestamp}] [{level_name}] {request_id}{channel}{message}',
int $fileMode = 0644
) {
$this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->outputFormat = $outputFormat;
$this->fileMode = $fileMode;
// Stelle sicher, dass das Verzeichnis existiert
$this->ensureDirectoryExists(dirname($logFile));
}
/**
* Überprüft, ob dieser Handler den Log-Eintrag verarbeiten soll
*/
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->value >= $this->minLevel->value;
}
/**
* Verarbeitet einen Log-Eintrag
*/
public function handle(LogRecord $record): void
{
// Request-ID-Teil erstellen, falls vorhanden
$requestId = $record->hasExtra('request_id')
? "[{$record->getExtra('request_id')}] "
: '';
// Channel-Teil erstellen, falls vorhanden
$channel = $record->getChannel()
? "[{$record->getChannel()}] "
: '';
// Werte für Platzhalter im Format
$values = [
'{level_name}' => $record->getLevel()->getName(),
'{timestamp}' => $record->getFormattedTimestamp(),
'{request_id}' => $requestId,
'{channel}' => $channel,
'{message}' => $record->getMessage(),
];
// Formatierte Ausgabe erstellen
$output = strtr($this->outputFormat, $values) . PHP_EOL;
// In die Datei schreiben
$this->write($output);
// Bei kritischen Fehlern Cache leeren, um sicherzustellen, dass der Eintrag geschrieben wird
if ($record->getLevel()->value >= LogLevel::CRITICAL->value) {
$this->flush();
}
}
/**
* Schreibt einen String in die Log-Datei
*/
protected function write(string $output): void
{
file_put_contents($this->logFile, $output, FILE_APPEND);
}
/**
* Leert Cache und stellt sicher, dass alles geschrieben wurde
*/
protected function flush(): void
{
if (function_exists('opcache_reset')) {
opcache_reset();
}
}
/**
* Stellt sicher, dass ein Verzeichnis existiert
*/
private function ensureDirectoryExists(string $dir): void
{
if (!file_exists($dir)) {
mkdir($dir, 0777, true);
}
}
/**
* Minimales Log-Level setzen
*/
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
/**
* Ausgabeformat setzen
*/
public function setOutputFormat(string $format): self
{
$this->outputFormat = $format;
return $this;
}
/**
* Log-Datei setzen
*/
public function setLogFile(string $logFile): self
{
$this->logFile = $logFile;
$this->ensureDirectoryExists(dirname($logFile));
return $this;
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Handler für die Ausgabe von Log-Einträgen als JSON in Dateien.
* Besonders nützlich für maschinelle Verarbeitung und Log-Aggregatoren.
*/
class JsonFileHandler implements LogHandler
{
/**
* @var LogLevel Minimales Level, ab dem dieser Handler aktiv wird
*/
private LogLevel $minLevel;
/**
* @var string Pfad zur Log-Datei
*/
private string $logFile;
/**
* @var array Liste der Felder, die in der JSON-Ausgabe enthalten sein sollen
*/
private array $includedFields;
/**
* Erstellt einen neuen JsonFileHandler
*
* @param string $logFile Pfad zur Log-Datei
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird
* @param array|null $includedFields Liste der Felder, die in der JSON-Ausgabe enthalten sein sollen (null = alle)
*/
public function __construct(
string $logFile,
LogLevel|int $minLevel = LogLevel::INFO,
?array $includedFields = null
) {
$this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
// Standardfelder, falls nicht anders angegeben
$this->includedFields = $includedFields ?? [
'timestamp',
'level_name',
'message',
'context',
'extra',
'channel',
];
// Stelle sicher, dass das Verzeichnis existiert
$this->ensureDirectoryExists(dirname($logFile));
}
/**
* Überprüft, ob dieser Handler den Log-Eintrag verarbeiten soll
*/
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->value >= $this->minLevel->value;
}
/**
* Verarbeitet einen Log-Eintrag
*/
public function handle(LogRecord $record): void
{
// Alle Daten des Records als Array holen
$data = $record->toArray();
// Nur die gewünschten Felder behalten
if (!empty($this->includedFields)) {
$data = array_intersect_key($data, array_flip($this->includedFields));
}
// Zeitstempel als ISO 8601 formatieren für bessere Interoperabilität
if (isset($data['datetime']) && $data['datetime'] instanceof \DateTimeInterface) {
$data['timestamp_iso'] = $data['datetime']->format(\DateTimeInterface::ATOM);
unset($data['datetime']); // DateTime-Objekt entfernen
}
// Als JSON formatieren und in die Datei schreiben
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR) . PHP_EOL;
$this->write($json);
}
/**
* Schreibt einen String in die Log-Datei
*/
protected function write(string $output): void
{
file_put_contents($this->logFile, $output, FILE_APPEND);
}
/**
* Stellt sicher, dass ein Verzeichnis existiert
*/
private function ensureDirectoryExists(string $dir): void
{
if (!file_exists($dir)) {
mkdir($dir, 0777, true);
}
}
/**
* Minimales Log-Level setzen
*/
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
/**
* Setzt die Liste der Felder, die in der JSON-Ausgabe enthalten sein sollen
*/
public function setIncludedFields(array $fields): self
{
$this->includedFields = $fields;
return $this;
}
/**
* Log-Datei setzen
*/
public function setLogFile(string $logFile): self
{
$this->logFile = $logFile;
$this->ensureDirectoryExists(dirname($logFile));
return $this;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Framework\Logging\Handlers;
use App\Framework\Database\DatabaseManager;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ProcessLogCommand;
use App\Framework\Queue\Queue;
final readonly class QueuedLogHandler implements LogHandler
{
public function __construct(
private Queue $queue,
#private LogLevel $minLevel = LogLevel::INFO,
){}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$job = new ProcessLogCommand($record);
$this->queue->push($job);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Handler für Syslog-basiertes Logging
*/
final class SyslogHandler implements LogHandler
{
private bool $isOpen = false;
public function __construct(
private readonly string $ident = 'php-app',
private readonly int $facility = LOG_USER,
private readonly LogLevel $minLevel = LogLevel::DEBUG
) {}
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->value >= $this->minLevel->value;
}
public function handle(LogRecord $record): void
{
$this->openSyslog();
$priority = $this->mapLogLevelToSyslogPriority($record->getLevel());
$message = $this->formatMessage($record);
syslog($priority, $message);
}
private function openSyslog(): void
{
if (!$this->isOpen) {
openlog($this->ident, LOG_PID | LOG_PERROR, $this->facility);
$this->isOpen = true;
}
}
private function formatMessage(LogRecord $record): string
{
$parts = [];
// Request-ID falls vorhanden
if ($record->hasExtra('request_id')) {
$parts[] = "[{$record->getExtra('request_id')}]";
}
// Channel falls vorhanden
if ($record->getChannel()) {
$parts[] = "[{$record->getChannel()}]";
}
// Hauptnachricht
$parts[] = $record->getMessage();
// Context-Daten falls vorhanden
$context = $record->getContext();
if (!empty($context)) {
$parts[] = 'Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);
}
return implode(' ', $parts);
}
/**
* Mappt Framework LogLevel auf Syslog-Prioritäten
*/
private function mapLogLevelToSyslogPriority(LogLevel $level): int
{
return match ($level) {
LogLevel::EMERGENCY => LOG_EMERG,
LogLevel::ALERT => LOG_ALERT,
LogLevel::CRITICAL => LOG_CRIT,
LogLevel::ERROR => LOG_ERR,
LogLevel::WARNING => LOG_WARNING,
LogLevel::NOTICE => LOG_NOTICE,
LogLevel::INFO => LOG_INFO,
LogLevel::DEBUG => LOG_DEBUG,
};
}
public function __destruct()
{
if ($this->isOpen) {
closelog();
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
final class WebHandler implements LogHandler
{
public function __construct(
private readonly LogLevel $minLevel = LogLevel::DEBUG,
private readonly bool $debugOnly = true
) {}
public function isHandling(LogRecord $record): bool
{
// Nur bei Web-Requests (nicht CLI)
if (PHP_SAPI === 'cli') {
return false;
}
// Debug-Modus-Check
if ($this->debugOnly && !filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
return false;
}
return $record->getLevel()->value >= $this->minLevel->value;
}
public function handle(LogRecord $record): void
{
$timestamp = $record->getFormattedTimestamp();
$level = $record->getLevel()->getName();
$message = $record->getMessage();
$channel = $record->getChannel();
// Request-ID falls vorhanden
$requestId = $record->hasExtra('request_id')
? "[{$record->getExtra('request_id')}] "
: '';
// Channel falls vorhanden
$channelPrefix = $channel ? "[$channel] " : '';
$logLine = sprintf(
'[%s] %s[%s] %s%s',
$timestamp,
$requestId,
$level,
$channelPrefix,
$message
);
// Context-Daten falls vorhanden
$context = $record->getContext();
if (!empty($context)) {
$logLine .= ' | Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);
}
// In error_log schreiben
error_log($logLine);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Interface für Log-Handler, die Log-Einträge verarbeiten.
*/
interface LogHandler
{
/**
* Überprüft, ob dieser Handler den Log-Eintrag verarbeiten soll
*/
public function isHandling(LogRecord $record): bool;
/**
* Verarbeitet einen Log-Eintrag
*/
public function handle(LogRecord $record): void;
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Console\ConsoleColor;
/**
* Enum für Log-Level mit zugehörigen Werten und Namen.
*/
enum LogLevel: int
{
case DEBUG = 100;
case INFO = 200;
case NOTICE = 250;
case WARNING = 300;
case ERROR = 400;
case CRITICAL = 500;
case ALERT = 550;
case EMERGENCY = 600;
/**
* Gibt den Namen des Log-Levels zurück.
*/
public function getName(): string
{
return match($this) {
self::DEBUG => 'DEBUG',
self::INFO => 'INFO',
self::NOTICE => 'NOTICE',
self::WARNING => 'WARNING',
self::ERROR => 'ERROR',
self::CRITICAL => 'CRITICAL',
self::ALERT => 'ALERT',
self::EMERGENCY => 'EMERGENCY',
};
}
/**
* Gibt die Farbe für die Konsole zurück.
*/
public function getConsoleColor(): ConsoleColor
{
return match($this) {
self::DEBUG => ConsoleColor::GRAY,
self::INFO => ConsoleColor::GREEN,
self::NOTICE => ConsoleColor::CYAN,
self::WARNING => ConsoleColor::YELLOW,
self::ERROR => ConsoleColor::RED,
self::CRITICAL => ConsoleColor::MAGENTA,
self::ALERT => ConsoleColor::WHITE_ON_RED,
self::EMERGENCY => ConsoleColor::BLACK_ON_YELLOW,
};
}
/**
* Erstellt ein LogLevel aus einem Integer-Wert.
* Gibt das nächstniedrigere Level zurück, wenn der exakte Wert nicht existiert.
*/
public static function fromValue(int $value): self
{
return match(true) {
$value >= self::EMERGENCY->value => self::EMERGENCY,
$value >= self::ALERT->value => self::ALERT,
$value >= self::CRITICAL->value => self::CRITICAL,
$value >= self::ERROR->value => self::ERROR,
$value >= self::WARNING->value => self::WARNING,
$value >= self::NOTICE->value => self::NOTICE,
$value >= self::INFO->value => self::INFO,
default => self::DEBUG,
};
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Interface für Log-Processors, die Log-Einträge vor dem Handling verarbeiten.
*/
interface LogProcessor
{
/**
* Verarbeitet einen Log-Record und gibt den modifizierten Record zurück
*/
public function processRecord(LogRecord $record): LogRecord;
/**
* Gibt die Priorität des Processors zurück (höhere Werte = frühere Ausführung)
*/
public function getPriority(): int;
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string;
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Repräsentiert einen einzelnen Log-Eintrag mit allen relevanten Informationen.
*/
final class LogRecord
{
/**
* @var array<string, mixed> Zusätzliche Daten, die dynamisch hinzugefügt werden können
*/
private array $extra = [];
/**
* Erstellt einen neuen Log-Eintrag
*/
public function __construct(
private string $message,
private array $context,
private LogLevel $level,
private \DateTimeImmutable $timestamp,
private ?string $channel = null
) {}
/**
* Gibt die Log-Nachricht zurück
*/
public function getMessage(): string
{
return $this->message;
}
/**
* Setzt die Log-Nachricht
*/
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
/**
* Gibt den Kontext zurück
*/
public function getContext(): array
{
return $this->context;
}
/**
* Setzt oder erweitert den Kontext
*/
public function withContext(array $context): self
{
$this->context = array_merge($this->context, $context);
return $this;
}
/**
* Gibt das Log-Level zurück
*/
public function getLevel(): LogLevel
{
return $this->level;
}
/**
* Gibt den Zeitstempel zurück
*/
public function getTimestamp(): \DateTimeImmutable
{
return $this->timestamp;
}
/**
* Gibt den formattierten Zeitstempel zurück
*/
public function getFormattedTimestamp(string $format = 'Y-m-d H:i:s'): string
{
return $this->timestamp->format($format);
}
/**
* Gibt den Kanal zurück, falls gesetzt
*/
public function getChannel(): ?string
{
return $this->channel;
}
/**
* Setzt den Kanal
*/
public function setChannel(string $channel): self
{
$this->channel = $channel;
return $this;
}
/**
* Fügt einen Wert zu den Extra-Daten hinzu
*/
public function addExtra(string $key, mixed $value): self
{
$this->extra[$key] = $value;
return $this;
}
/**
* Fügt mehrere Extra-Daten hinzu
*/
public function addExtras(array $extras): self
{
foreach ($extras as $key => $value) {
$this->extra[$key] = $value;
}
return $this;
}
/**
* Prüft, ob ein bestimmter Extra-Wert existiert
*/
public function hasExtra(string $key): bool
{
return array_key_exists($key, $this->extra);
}
/**
* Gibt einen Extra-Wert zurück
*/
public function getExtra(string $key, mixed $default = null): mixed
{
return $this->extra[$key] ?? $default;
}
/**
* Gibt alle Extra-Daten zurück
*/
public function getExtras(): array
{
return $this->extra;
}
/**
* Erstellt ein Array mit allen Log-Daten
*/
public function toArray(): array
{
return [
'message' => $this->message,
'context' => $this->context,
'level' => $this->level->value,
'level_name' => $this->level->getName(),
'timestamp' => $this->getFormattedTimestamp(),
'datetime' => $this->timestamp,
'channel' => $this->channel,
'extra' => $this->extra,
];
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Framework\Logging;
interface Logger
{
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Factory für Logger-Instanzen.
*/
final class LoggerFactory
{
private static ?DefaultLogger $defaultLogger = null;
/**
* Erzeugt einen neuen Logger mit optionalen Einstellungen.
*/
public static function create(
LogLevel|int $minLevel = LogLevel::DEBUG,
bool $enabled = true,
array $handlers = []
): DefaultLogger {
return new DefaultLogger($minLevel, $enabled, $handlers);
}
/**
* Gibt den Standard-Logger zurück oder erstellt ihn, falls er noch nicht existiert.
*/
public static function getDefaultLogger(): DefaultLogger
{
if (self::$defaultLogger === null) {
$debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN);
$minLevel = $debug ? LogLevel::DEBUG : LogLevel::INFO;
self::$defaultLogger = self::create($minLevel);
}
return self::$defaultLogger;
}
/**
* Setzt einen benutzerdefinierten Logger als Standard-Logger.
*/
public static function setDefaultLogger(DefaultLogger $logger): void
{
self::$defaultLogger = $logger;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Framework\Logging;
use App\Framework\Core\PathProvider;
use App\Framework\Database\DatabaseManager;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\Handlers\FileHandler;
use App\Framework\Logging\Handlers\QueuedLogHandler;
use App\Framework\Logging\Handlers\WebHandler;
use App\Framework\Queue\FileQueue;
use App\Framework\Queue\Queue;
use App\Framework\Queue\RedisQueue;
;
final readonly class LoggerInitializer
{
public function __construct(
#private DatabaseManager $db,
#private PathProvider $pathProvider,
){}
#[Initializer]
public function __invoke():Logger
{
$processorManager = new ProcessorManager();
$queue = new RedisQueue('commands', 'redis');
#$path = $this->pathProvider->resolvePath('/src/Framework/CommandBus/storage/queue');
#$queue = new FileQueue($path);
return new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [
new QueuedLogHandler($queue),
new ConsoleHandler(),
new WebHandler(),
new FileHandler('logs/app.log')],
processorManager: $processorManager,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Framework\Logging;
use App\Framework\Database\DatabaseManager;
final readonly class ProcessLogCommand
{
public function __construct(
public LogRecord $logData,
) {}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Framework\Logging;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\Database\DatabaseManager;
final readonly class ProcessLogCommandHandler
{
public function __construct(
private DatabaseManager $db,
){}
#[CommandHandler]
public function __invoke(ProcessLogCommand $commandData): void
{
$data = $commandData->logData;
$contextJson = json_encode($data->getContext());
$contextHash = hash('sha256', $contextJson);
// context_hash zur Eindeutigkeit in den Primärschlüssel aufgenommen
$this->db->getConnection()->execute("CREATE TABLE IF NOT EXISTS logs (timestamp TIMESTAMP, level VARCHAR(10), message VARCHAR(255), context JSON, context_hash VARCHAR(64), PRIMARY KEY (timestamp, level, message, context_hash))");
$this->db->getConnection()->execute(
"INSERT INTO logs (timestamp, level, message, context, context_hash) VALUES (?, ?, ?, ?, ?)",
[
$data->getFormattedTimestamp(),
$data->getLevel()->getName(),
$data->getMessage(),
$contextJson,
$contextHash,
]
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Verwaltet die Log-Processors und wendet sie auf Log-Records an.
*/
final readonly class ProcessorManager
{
/** @var array<LogProcessor> Liste der Processors, sortiert nach Priorität */
private array $processors;
public function __construct(LogProcessor ...$processors)
{
$this->processors = $processors;
}
public function addProcessor(LogProcessor $processor): self
{
$processors = $this->sortProcessors($processor, ...$this->processors);
return new self(...$processors);
}
/**
* Wendet alle Processors auf einen Log-Record an
*/
public function processRecord(LogRecord $record): LogRecord
{
foreach ($this->processors as $processor) {
$record = $processor->processRecord($record);
}
return $record;
}
/**
* Sortiert die Processors nach Priorität (höhere Priorität = frühere Ausführung)
*/
private function sortProcessors(): array
{
$processors = $this->processors;
usort($processors, function (LogProcessor $a, LogProcessor $b) {
return $b->getPriority() <=> $a->getPriority(); // Absteigend sortieren
});
return $processors;
}
/**
* Gibt alle registrierten Processors zurück
*
* @return array<string, int> Array mit Processor-Namen als Schlüssel und Prioritäten als Werte
*/
public function getProcessorList(): array
{
$result = [];
foreach ($this->processors as $processor) {
$result[$processor->getName()] = $processor->getPriority();
}
return $result;
}
public function hasProcessor(string $name): bool
{
return array_any($this->processors, fn($processor) => $processor->getName() === $name);
}
public function getProcessor(string $name): ?LogProcessor
{
return array_find($this->processors, fn($processor) => $processor->getName() === $name);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Verarbeitet Exceptions im Kontext und fügt detaillierte Informationen hinzu.
*/
final class ExceptionProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (15 = früh, nach Interpolation)
*/
private const int PRIORITY = 15;
/**
* @var bool Ob Stack-Traces hinzugefügt werden sollen
*/
private bool $includeStackTraces;
/**
* @var int Maximale Tiefe für Stack-Traces
*/
private int $traceDepth;
/**
* Erstellt einen neuen ExceptionProcessor
*/
public function __construct(
bool $includeStackTraces = true,
int $traceDepth = 10
) {
$this->includeStackTraces = $includeStackTraces;
$this->traceDepth = $traceDepth;
}
/**
* Verarbeitet einen Log-Record und extrahiert Exception-Informationen
*/
public function processRecord(LogRecord $record): LogRecord
{
$context = $record->getContext();
// Prüfen, ob eine Exception im Kontext vorhanden ist
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
$exception = $context['exception'];
$exceptionData = $this->formatException($exception);
// Exception-Daten zu Extra-Daten hinzufügen
$record->addExtra('exception', $exceptionData);
}
return $record;
}
/**
* Formatiert eine Exception für das Logging
*/
private function formatException(\Throwable $exception): array
{
$result = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];
// Stack-Trace hinzufügen, wenn aktiviert
if ($this->includeStackTraces) {
$result['trace'] = $this->formatTrace($exception);
}
// Bei verschachtelten Exceptions auch die vorherige Exception formatieren
if ($exception->getPrevious()) {
$result['previous'] = $this->formatException($exception->getPrevious());
}
return $result;
}
/**
* Formatiert den Stack-Trace einer Exception
*/
private function formatTrace(\Throwable $exception): array
{
$trace = $exception->getTrace();
$result = [];
// Tiefe begrenzen
$trace = array_slice($trace, 0, $this->traceDepth);
foreach ($trace as $frame) {
$entry = [
'file' => $frame['file'] ?? 'unknown',
'line' => $frame['line'] ?? 0,
];
if (isset($frame['function'])) {
$function = '';
if (isset($frame['class'])) {
$function .= $frame['class'] . $frame['type'];
}
$function .= $frame['function'];
$entry['function'] = $function;
}
$result[] = $entry;
}
return $result;
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'exception';
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Ersetzt Platzhalter in Log-Nachrichten durch Kontext-Werte.
*/
final class InterpolationProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (höher = früher, 20 = sehr früh)
*/
private const int PRIORITY = 20;
/**
* Verarbeitet einen Log-Record und ersetzt Platzhalter in der Nachricht
*/
public function processRecord(LogRecord $record): LogRecord
{
$message = $record->getMessage();
$context = $record->getContext();
// Platzhalter ersetzen, falls vorhanden
if (str_contains($message, '{')) {
$message = $this->interpolate($message, $context);
$record->setMessage($message);
}
return $record;
}
/**
* Platzhalter im Log-Text ersetzen
*/
private function interpolate(string $message, array $context): string
{
// Platzhalter ersetzen (Format: {key})
$replace = [];
foreach ($context as $key => $val) {
if ($val === null || is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) {
$replace['{' . $key . '}'] = $val;
} elseif ($val instanceof \Throwable) {
$replace['{' . $key . '}'] = $val->getMessage() . ' in ' . $val->getFile() . ':' . $val->getLine();
} elseif (is_object($val)) {
$replace['{' . $key . '}'] = '[object ' . get_class($val) . ']';
} elseif (is_array($val)) {
$replace['{' . $key . '}'] = 'array' . json_encode($val,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
} else {
$replace['{' . $key . '}'] = '[' . gettype($val) . ']';
}
}
return strtr($message, $replace);
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'interpolation';
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Fügt Code-Ortsinformationen (Datei, Zeile, Funktion) zu Log-Einträgen hinzu.
*/
final class IntrospectionProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors
*/
private const int PRIORITY = 7;
/**
* @var string[] Liste der Klassen, die bei der Introspection ignoriert werden sollen
*/
private array $skipClassesPartials;
/**
* @var int Anzahl der Frames, die übersprungen werden sollen (Logger usw.)
*/
private int $skipStackFrames;
/**
* Erstellt einen neuen IntrospectionProcessor
*
* @param string[] $skipClassesPartials Liste der Klassen, die ignoriert werden sollen
* @param int $skipStackFrames Anzahl der Stack-Frames, die übersprungen werden sollen
*/
public function __construct(
array $skipClassesPartials = ['App\\Framework\\Logging\\'],
int $skipStackFrames = 0
) {
$this->skipClassesPartials = $skipClassesPartials;
$this->skipStackFrames = $skipStackFrames;
}
/**
* Verarbeitet einen Log-Record und fügt Introspection-Daten hinzu
*/
public function processRecord(LogRecord $record): LogRecord
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
// Überspringe Stack-Frames, die zur Logger-Infrastruktur gehören
$i = $this->skipStackFrames;
$framesCount = count($trace);
// Suche nach dem ersten Frame, der nicht zu ignorieren ist
while ($i < $framesCount) {
if (isset($trace[$i]['class'])) {
foreach ($this->skipClassesPartials as $partial) {
if (str_starts_with($trace[$i]['class'], $partial)) {
$i++;
continue 2; // Zum nächsten Frame wechseln
}
}
}
break; // Frame gefunden, der nicht ignoriert werden soll
}
// Sicherstellen, dass wir einen gültigen Frame haben
if ($i >= $framesCount) {
return $record; // Kein passender Frame gefunden
}
// Introspection-Daten extrahieren
$frame = $trace[$i];
$introspection = [
'file' => $frame['file'] ?? 'unknown',
'line' => $frame['line'] ?? 0,
'class' => $frame['class'] ?? '',
'function' => $frame['function'] ?? '',
];
// Zu den Extra-Daten hinzufügen
return $record->addExtra('introspection', $introspection);
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'introspection';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Fügt die Request-ID zu Log-Einträgen hinzu.
*/
final readonly class RequestIdProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (10 = recht früh)
*/
private const int PRIORITY = 10;
public function __construct(
private RequestIdGenerator $requestIdGenerator
) {}
/**
* Verarbeitet einen Log-Record und fügt die Request-ID hinzu
*/
public function processRecord(LogRecord $record): LogRecord
{
$currentId = $this->requestIdGenerator->getCurrentId();
if ($currentId !== null) {
// Nur die ID ohne Signatur als Extra-Feld hinzufügen
return $record->addExtra('request_id', $currentId->getId());
}
return $record;
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'request_id';
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Fügt Web-Informationen wie IP, URL, Methode und User-Agent zu Log-Einträgen hinzu.
*/
final class WebInfoProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (5 = früh, aber nach Request-ID)
*/
private const int PRIORITY = 5;
/**
* @var array Konfiguration, welche Informationen gesammelt werden sollen
*/
private array $config;
/**
* Erstellt einen neuen WebInfoProcessor
*
* @param array $config Konfiguration, welche Informationen gesammelt werden sollen
*/
public function __construct(array $config = [])
{
$this->config = array_merge(
[
'url' => true,
'ip' => true,
'http_method' => true,
'user_agent' => true,
'referrer' => false,
],
$config
);
}
/**
* Verarbeitet einen Log-Record und fügt Web-Informationen hinzu
*/
public function processRecord(LogRecord $record): LogRecord
{
// Nur im Web-Kontext ausführen
if (PHP_SAPI === 'cli') {
return $record;
}
// Webinfos als Extra-Felder hinzufügen
$extras = $this->collectWebInfo();
if (!empty($extras)) {
$record->addExtras($extras);
}
return $record;
}
/**
* Sammelt Informationen aus dem Web-Kontext
*/
private function collectWebInfo(): array
{
$info = [];
if ($this->config['url'] && isset($_SERVER['REQUEST_URI'])) {
$info['url'] = $this->getFullUrl();
}
if ($this->config['ip'] && isset($_SERVER['REMOTE_ADDR'])) {
$info['ip'] = $this->getClientIp();
}
if ($this->config['http_method'] && isset($_SERVER['REQUEST_METHOD'])) {
$info['http_method'] = $_SERVER['REQUEST_METHOD'];
}
if ($this->config['user_agent'] && isset($_SERVER['HTTP_USER_AGENT'])) {
$info['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
}
if ($this->config['referrer'] && isset($_SERVER['HTTP_REFERER'])) {
$info['referrer'] = $_SERVER['HTTP_REFERER'];
}
return $info;
}
/**
* Ermittelt die vollständige URL des Requests
*/
private function getFullUrl(): string
{
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
return $protocol . '://' . $host . $uri;
}
/**
* Ermittelt die IP-Adresse des Clients unter Berücksichtigung von Proxies
*/
private function getClientIp(): string
{
$keys = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR', // Standard Proxy/Load Balancer
'HTTP_CLIENT_IP', // Manche Proxies
'REMOTE_ADDR', // Fallback direkte Verbindung
];
foreach ($keys as $key) {
if (isset($_SERVER[$key])) {
// Bei X-Forwarded-For kann eine Komma-separierte Liste vorliegen
if ($key === 'HTTP_X_FORWARDED_FOR') {
$ips = explode(',', $_SERVER[$key]);
return trim($ips[0]); // Erste IP = ursprünglicher Client
}
return $_SERVER[$key];
}
}
return 'unknown';
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'web_info';
}
}