Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Logging\LogRotator;
final readonly class RotateLogsCommand
{
#[ConsoleCommand(name: "logs:rotate", description: "Rotiert Log-Dateien basierend auf Größe und Anzahl")]
public function execute(ConsoleInput $input, ConsoleOutput $output): int
{
$output->writeLine('<info>Starting log rotation...</info>');
// Standard Log-Pfade
$logPaths = $this->getLogPaths();
$rotator = LogRotator::production();
$rotatedCount = 0;
$totalSize = Byte::zero();
foreach ($logPaths as $name => $path) {
if (! file_exists($path)) {
$output->writeLine("<comment>Skipping {$name}: File not found ({$path})</comment>");
continue;
}
$beforeSize = Byte::fromBytes(filesize($path));
$totalSize = $totalSize->add($beforeSize);
if ($rotator->shouldRotate($path)) {
$output->writeLine("<info>Rotating {$name} ({$path})</info>");
if ($rotator->rotateLog($path)) {
$rotatedCount++;
$output->writeLine("<success> ✓ Successfully rotated {$name}</success>");
// Zeige Rotation-Info
$info = $rotator->getRotationInfo($path);
$output->writeLine(" Current size: {$info['current_size']}");
$output->writeLine(" Rotated files: " . count($info['rotated_files']));
} else {
$output->writeLine("<error> ✗ Failed to rotate {$name}</error>");
}
} else {
$output->writeLine("<comment> → {$name} doesn't need rotation</comment>");
$info = $rotator->getRotationInfo($path);
$output->writeLine(" Current size: {$info['current_size']} / {$info['max_size']}");
}
}
$output->writeLine('');
$output->writeLine("<info>Rotation completed:</info>");
$output->writeLine(" Files rotated: {$rotatedCount}");
$output->writeLine(" Total log size: " . $totalSize->toHumanReadable());
return ExitCode::SUCCESS->value;
}
private function getLogPaths(): array
{
$basePath = $_SERVER['DOCUMENT_ROOT'] ?? '/var/www/html';
return [
'app' => $basePath . '/public/logs/app.log',
'mcp_debug' => $basePath . '/mcp_debug.log',
'framework' => '/tmp/framework.log',
];
}
}

View File

@@ -1,10 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Attributes\Singleton;
use App\Framework\Logging\Handlers\ConsoleHandler;
use DateTimeZone;
/**
@@ -23,7 +23,8 @@ final readonly class DefaultLogger implements Logger
/** @var LogHandler[] */
private array $handlers = [],
private ProcessorManager $processorManager = new ProcessorManager(),
) {}
) {
}
public function debug(string $message, array $context = []): void
{
@@ -100,7 +101,7 @@ final readonly class DefaultLogger implements Logger
{
return [
'minLevel' => $this->minLevel->value,
'handlers' => array_map(fn(LogHandler $h) => get_class($h), $this->handlers),
'handlers' => array_map(fn (LogHandler $h) => get_class($h), $this->handlers),
'processors' => $this->processorManager->getProcessorList(),
];
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
@@ -57,7 +58,7 @@ class ConsoleHandler implements LogHandler
}
// Optional: Debug-Modus-Check nur in CLI
if ($this->debugOnly && !filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
if ($this->debugOnly && ! filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
return false;
}
@@ -109,6 +110,7 @@ class ConsoleHandler implements LogHandler
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
@@ -118,6 +120,7 @@ class ConsoleHandler implements LogHandler
public function setOutputFormat(string $format): self
{
$this->outputFormat = $format;
return $this;
}
}

View File

@@ -1,11 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\LogRotator;
/**
* Handler für die Ausgabe von Log-Einträgen in Dateien.
@@ -32,6 +35,16 @@ class FileHandler implements LogHandler
*/
private int $fileMode;
/**
* @var LogRotator|null Log-Rotator für automatische Rotation
*/
private ?LogRotator $rotator = null;
/**
* @var PathProvider|null PathProvider für die Auflösung von Pfaden
*/
private ?PathProvider $pathProvider = null;
/**
* Erstellt einen neuen FileHandler
*
@@ -39,17 +52,29 @@ class FileHandler implements LogHandler
* @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
* @param LogRotator|null $rotator Optional: Log-Rotator für automatische Rotation
* @param PathProvider|null $pathProvider Optional: PathProvider für die Auflösung von Pfaden
*/
public function __construct(
string $logFile,
LogLevel|int $minLevel = LogLevel::DEBUG,
string $outputFormat = '[{timestamp}] [{level_name}] {request_id}{channel}{message}',
int $fileMode = 0644
int $fileMode = 0644,
?LogRotator $rotator = null,
?PathProvider $pathProvider = null
) {
$this->pathProvider = $pathProvider;
// Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
$logFile = $this->pathProvider->resolvePath($logFile);
}
$this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->outputFormat = $outputFormat;
$this->fileMode = $fileMode;
$this->rotator = $rotator;
// Stelle sicher, dass das Verzeichnis existiert
$this->ensureDirectoryExists(dirname($logFile));
@@ -90,6 +115,11 @@ class FileHandler implements LogHandler
// Formatierte Ausgabe erstellen
$output = strtr($this->outputFormat, $values) . PHP_EOL;
// Prüfe Rotation vor dem Schreiben
if ($this->rotator !== null) {
$this->rotator->rotateIfNeeded($this->logFile);
}
// In die Datei schreiben
$this->write($output);
@@ -122,7 +152,7 @@ class FileHandler implements LogHandler
*/
private function ensureDirectoryExists(string $dir): void
{
if (!file_exists($dir)) {
if (! file_exists($dir)) {
mkdir($dir, 0777, true);
}
}
@@ -133,6 +163,7 @@ class FileHandler implements LogHandler
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
@@ -142,6 +173,7 @@ class FileHandler implements LogHandler
public function setOutputFormat(string $format): self
{
$this->outputFormat = $format;
return $this;
}
@@ -150,8 +182,14 @@ class FileHandler implements LogHandler
*/
public function setLogFile(string $logFile): self
{
// Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
$logFile = $this->pathProvider->resolvePath($logFile);
}
$this->logFile = $logFile;
$this->ensureDirectoryExists(dirname($logFile));
return $this;
}
}

View File

@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
@@ -28,18 +30,32 @@ class JsonFileHandler implements LogHandler
*/
private array $includedFields;
/**
* @var PathProvider|null PathProvider für die Auflösung von Pfaden
*/
private ?PathProvider $pathProvider = null;
/**
* 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)
* @param PathProvider|null $pathProvider Optional: PathProvider für die Auflösung von Pfaden
*/
public function __construct(
string $logFile,
LogLevel|int $minLevel = LogLevel::INFO,
?array $includedFields = null
?array $includedFields = null,
?PathProvider $pathProvider = null
) {
$this->pathProvider = $pathProvider;
// Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
$logFile = $this->pathProvider->resolvePath($logFile);
}
$this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
@@ -74,7 +90,7 @@ class JsonFileHandler implements LogHandler
$data = $record->toArray();
// Nur die gewünschten Felder behalten
if (!empty($this->includedFields)) {
if (! empty($this->includedFields)) {
$data = array_intersect_key($data, array_flip($this->includedFields));
}
@@ -102,7 +118,7 @@ class JsonFileHandler implements LogHandler
*/
private function ensureDirectoryExists(string $dir): void
{
if (!file_exists($dir)) {
if (! file_exists($dir)) {
mkdir($dir, 0777, true);
}
}
@@ -113,6 +129,7 @@ class JsonFileHandler implements LogHandler
public function setMinLevel(LogLevel|int $level): self
{
$this->minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return $this;
}
@@ -122,6 +139,7 @@ class JsonFileHandler implements LogHandler
public function setIncludedFields(array $fields): self
{
$this->includedFields = $fields;
return $this;
}
@@ -130,8 +148,14 @@ class JsonFileHandler implements LogHandler
*/
public function setLogFile(string $logFile): self
{
// Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
$logFile = $this->pathProvider->resolvePath($logFile);
}
$this->logFile = $logFile;
$this->ensureDirectoryExists(dirname($logFile));
return $this;
}
}

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
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;
@@ -14,7 +15,8 @@ final readonly class QueuedLogHandler implements LogHandler
public function __construct(
private Queue $queue,
#private LogLevel $minLevel = LogLevel::INFO,
){}
) {
}
public function isHandling(LogRecord $record): bool
{

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
@@ -18,7 +19,8 @@ final class SyslogHandler implements LogHandler
private readonly string $ident = 'php-app',
private readonly int $facility = LOG_USER,
private readonly LogLevel $minLevel = LogLevel::DEBUG
) {}
) {
}
public function isHandling(LogRecord $record): bool
{
@@ -37,7 +39,7 @@ final class SyslogHandler implements LogHandler
private function openSyslog(): void
{
if (!$this->isOpen) {
if (! $this->isOpen) {
openlog($this->ident, LOG_PID | LOG_PERROR, $this->facility);
$this->isOpen = true;
}
@@ -62,7 +64,7 @@ final class SyslogHandler implements LogHandler
// Context-Daten falls vorhanden
$context = $record->getContext();
if (!empty($context)) {
if (! empty($context)) {
$parts[] = 'Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
@@ -12,7 +13,8 @@ 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
{
@@ -22,7 +24,7 @@ final class WebHandler implements LogHandler
}
// Debug-Modus-Check
if ($this->debugOnly && !filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
if ($this->debugOnly && ! filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN)) {
return false;
}
@@ -55,12 +57,11 @@ final class WebHandler implements LogHandler
// Context-Daten falls vorhanden
$context = $record->getContext();
if (!empty($context)) {
if (! empty($context)) {
$logLine .= ' | Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);
}
// In error_log schreiben
error_log($logLine);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Core\PathProvider;
/**
* Zentrale Konfiguration für Logpfade
*/
final class LogConfig
{
/**
* @var array<string, string> Konfigurierte Logpfade
*/
private array $logPaths;
/**
* @var PathProvider PathProvider für die Auflösung von Pfaden
*/
private PathProvider $pathProvider;
/**
* Erstellt eine neue LogConfig-Instanz
*/
public function __construct(PathProvider $pathProvider)
{
$this->pathProvider = $pathProvider;
// Basis-Logverzeichnis
$logBasePath = $_ENV['LOG_BASE_PATH'] ?? 'logs';
// Standard-Logpfade definieren
$this->logPaths = [
// Anwendungslogs
'app' => $this->resolvePath($logBasePath . '/app/app.log'),
'error' => $this->resolvePath($logBasePath . '/app/error.log'),
// Systemlogs
'nginx_access' => $_ENV['NGINX_ACCESS_LOG'] ?? '/var/log/nginx/access.log',
'nginx_error' => $_ENV['NGINX_ERROR_LOG'] ?? '/var/log/nginx/error.log',
// Sicherheitslogs
'security' => $this->resolvePath($logBasePath . '/security/security.log'),
// Debug-Logs
'framework' => $this->resolvePath($logBasePath . '/debug/framework.log'),
'cache' => $this->resolvePath($logBasePath . '/debug/cache.log'),
'database' => $this->resolvePath($logBasePath . '/debug/database.log'),
];
// Umgebungsvariablen für Logpfade berücksichtigen
if (isset($_ENV['LOG_PATH'])) {
$this->logPaths['app'] = $_ENV['LOG_PATH'];
}
if (isset($_ENV['PHP_ERROR_LOG'])) {
$this->logPaths['error'] = $_ENV['PHP_ERROR_LOG'];
}
}
/**
* Gibt den Pfad für einen bestimmten Log-Typ zurück
*/
public function getLogPath(string $type): string
{
return $this->logPaths[$type] ?? $this->logPaths['app'];
}
/**
* Gibt alle konfigurierten Logpfade zurück
*
* @return array<string, string>
*/
public function getAllLogPaths(): array
{
return $this->logPaths;
}
/**
* Löst einen relativen Pfad auf
*/
private function resolvePath(string $path): string
{
return $this->pathProvider->resolvePath($path);
}
/**
* Gibt den Basis-Logpfad zurück
*/
public function getBaseLogPath(): string
{
return dirname($this->logPaths['app']);
}
/**
* Stellt sicher, dass alle Logverzeichnisse existieren
*/
public function ensureLogDirectoriesExist(): void
{
foreach ($this->logPaths as $path) {
$dir = dirname($path);
if (! file_exists($dir)) {
mkdir($dir, 0777, true);
}
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
@@ -22,7 +23,8 @@ final class LogRecord
private LogLevel $level,
private \DateTimeImmutable $timestamp,
private ?string $channel = null
) {}
) {
}
/**
* Gibt die Log-Nachricht zurück
@@ -38,6 +40,7 @@ final class LogRecord
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
@@ -55,6 +58,7 @@ final class LogRecord
public function withContext(array $context): self
{
$this->context = array_merge($this->context, $context);
return $this;
}
@@ -96,6 +100,7 @@ final class LogRecord
public function setChannel(string $channel): self
{
$this->channel = $channel;
return $this;
}
@@ -105,6 +110,7 @@ final class LogRecord
public function addExtra(string $key, mixed $value): self
{
$this->extra[$key] = $value;
return $this;
}
@@ -116,6 +122,7 @@ final class LogRecord
foreach ($extras as $key => $value) {
$this->extra[$key] = $value;
}
return $this;
}

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Core\ValueObjects\Byte;
final class LogRotator
{
public function __construct(
private readonly Byte $maxFileSize = new Byte(10 * 1024 * 1024), // 10MB default
private readonly int $maxFiles = 5,
private readonly bool $compress = true
) {
}
/**
* Rotiert eine Log-Datei wenn sie zu groß ist
*/
public function rotateIfNeeded(string $logFile): bool
{
if (! file_exists($logFile)) {
return false;
}
$currentSize = Byte::fromBytes(filesize($logFile));
if ($currentSize->lessThan($this->maxFileSize)) {
return false; // Rotation nicht nötig
}
return $this->rotateLog($logFile);
}
/**
* Führt die Log-Rotation durch
*/
public function rotateLog(string $logFile): bool
{
if (! file_exists($logFile)) {
return false;
}
try {
// Lösche älteste Log-Datei wenn nötig
$this->cleanupOldLogs($logFile);
// Rotiere bestehende Log-Dateien
$this->rotateLogs($logFile);
// Erstelle neue leere Log-Datei
file_put_contents($logFile, '');
return true;
} catch (\Throwable $e) {
error_log("Log rotation failed for {$logFile}: " . $e->getMessage());
return false;
}
}
/**
* Rotiert bestehende Log-Dateien (.1 -> .2, .2 -> .3, etc.)
*/
private function rotateLogs(string $logFile): void
{
// Rotiere von der höchsten zur niedrigsten Nummer
for ($i = $this->maxFiles - 1; $i >= 1; $i--) {
$currentFile = $logFile . '.' . $i;
$nextFile = $logFile . '.' . ($i + 1);
if (file_exists($currentFile)) {
if ($i + 1 <= $this->maxFiles) {
// Komprimiere wenn aktiviert
if ($this->compress && $i + 1 == $this->maxFiles) {
$this->compressFile($currentFile, $nextFile . '.gz');
} else {
rename($currentFile, $nextFile);
}
}
}
}
// Bewege aktuelle Log-Datei zu .1
if (file_exists($logFile)) {
rename($logFile, $logFile . '.1');
}
}
/**
* Löscht alte Log-Dateien die das Limit überschreiten
*/
private function cleanupOldLogs(string $logFile): void
{
$baseDir = dirname($logFile);
$baseName = basename($logFile);
// Finde alle rotierten Log-Dateien
$pattern = $baseDir . '/' . $baseName . '.*';
$files = glob($pattern);
if (empty($files)) {
return;
}
// Sortiere nach Nummer (höchste zuerst)
usort($files, function ($a, $b) use ($baseName) {
$aNum = $this->extractRotationNumber($a, $baseName);
$bNum = $this->extractRotationNumber($b, $baseName);
return $bNum <=> $aNum;
});
// Lösche Files die das Limit überschreiten
$filesToDelete = array_slice($files, $this->maxFiles - 1);
foreach ($filesToDelete as $file) {
if (file_exists($file)) {
unlink($file);
}
}
}
/**
* Extrahiert die Rotations-Nummer aus einem Dateinamen
*/
private function extractRotationNumber(string $fileName, string $baseName): int
{
$suffix = str_replace(dirname($fileName) . '/' . $baseName . '.', '', $fileName);
$suffix = str_replace('.gz', '', $suffix);
return is_numeric($suffix) ? (int) $suffix : 0;
}
/**
* Komprimiert eine Datei mit gzip
*/
private function compressFile(string $source, string $destination): bool
{
if (! function_exists('gzopen')) {
// Fallback: normale Datei verschieben
return rename($source, str_replace('.gz', '', $destination));
}
$sourceHandle = fopen($source, 'rb');
$destHandle = gzopen($destination, 'wb9');
if (! $sourceHandle || ! $destHandle) {
return false;
}
while (! feof($sourceHandle)) {
$data = fread($sourceHandle, 8192);
gzwrite($destHandle, $data);
}
fclose($sourceHandle);
gzclose($destHandle);
// Lösche Original nach erfolgreicher Komprimierung
unlink($source);
return true;
}
/**
* Gibt Information über rotierte Log-Dateien zurück
*/
public function getRotationInfo(string $logFile): array
{
$baseDir = dirname($logFile);
$baseName = basename($logFile);
$pattern = $baseDir . '/' . $baseName . '.*';
$files = glob($pattern);
$rotatedFiles = [];
foreach ($files as $file) {
if (file_exists($file)) {
$size = Byte::fromBytes(filesize($file));
$rotatedFiles[] = [
'file' => $file,
'size' => $size->toHumanReadable(),
'size_bytes' => $size->toBytes(),
'modified' => date('Y-m-d H:i:s', filemtime($file)),
'compressed' => str_ends_with($file, '.gz'),
];
}
}
// Sortiere nach Rotation-Nummer
usort($rotatedFiles, function ($a, $b) use ($baseName) {
$aNum = $this->extractRotationNumber($a['file'], $baseName);
$bNum = $this->extractRotationNumber($b['file'], $baseName);
return $aNum <=> $bNum;
});
return [
'current_file' => $logFile,
'current_size' => file_exists($logFile) ? Byte::fromBytes(filesize($logFile))->toHumanReadable() : '0 B',
'max_size' => $this->maxFileSize->toHumanReadable(),
'max_files' => $this->maxFiles,
'compress_enabled' => $this->compress,
'rotated_files' => $rotatedFiles,
'total_files' => count($rotatedFiles) + (file_exists($logFile) ? 1 : 0),
];
}
/**
* Prüft ob eine Rotation empfohlen wird
*/
public function shouldRotate(string $logFile): bool
{
if (! file_exists($logFile)) {
return false;
}
$currentSize = Byte::fromBytes(filesize($logFile));
return $currentSize->greaterThan($this->maxFileSize) || $currentSize->equals($this->maxFileSize);
}
/**
* Factory-Methoden für gängige Konfigurationen
*/
public static function daily(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(50), // 50MB
maxFiles: 7, // Eine Woche
compress: true
);
}
public static function weekly(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(100), // 100MB
maxFiles: 4, // Ein Monat
compress: true
);
}
public static function production(): self
{
return new self(
maxFileSize: Byte::fromMegabytes(25), // 25MB
maxFiles: 10,
compress: true
);
}
}

View File

@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Core\ValueObjects\Byte;
final class LogViewer
{
private array $logPaths = [];
private int $defaultLimit = 100;
private array $logLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
public function __construct(
array $logPaths = []
) {
$this->logPaths = $logPaths ?: $this->getDefaultLogPaths();
}
public function getAvailableLogs(): array
{
$logs = [];
foreach ($this->logPaths as $name => $path) {
if (file_exists($path)) {
$fileSize = filesize($path);
$byteSize = Byte::fromBytes($fileSize);
$logs[$name] = [
'name' => $name,
'path' => $path,
'size' => $fileSize,
'size_human' => $byteSize->toHumanReadable(),
'modified' => filemtime($path),
'modified_human' => date('Y-m-d H:i:s', filemtime($path)),
'readable' => is_readable($path),
];
}
}
return $logs;
}
public function readLog(string $logName, ?int $limit = null, ?string $level = null, ?string $search = null): array
{
$limit = $limit ?? $this->defaultLimit;
if (! isset($this->logPaths[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");
}
// Use generator for memory efficiency
$entries = [];
$count = 0;
foreach ($this->readLogEntriesGenerator($path, $level, $search) as $entry) {
$entries[] = $entry;
$count++;
if ($count >= $limit) {
break;
}
}
return [
'log_name' => $logName,
'path' => $path,
'total_entries' => count($entries),
'filters' => [
'level' => $level,
'search' => $search,
'limit' => $limit,
],
'entries' => $entries,
];
}
public function tailLog(string $logName, int $lines = 50): array
{
if (! isset($this->logPaths[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");
}
// Use generator for memory efficiency
$entries = [];
$count = 0;
foreach ($this->readLogLinesGenerator($path, $lines) as $line) {
$entry = $this->parseLogLine($line);
if ($entry) {
$entries[] = $entry;
$count++;
}
}
// Reverse to show most recent first
$entries = array_reverse($entries);
return [
'log_name' => $logName,
'path' => $path,
'lines_requested' => $lines,
'entries_found' => count($entries),
'entries' => $entries,
];
}
public function searchLogs(string $query, ?array $logNames = null, ?string $level = null): array
{
$logNames = $logNames ?? array_keys($this->logPaths);
$results = [];
foreach ($logNames as $logName) {
if (! isset($this->logPaths[$logName])) {
continue;
}
try {
$logData = $this->readLog($logName, 500, $level, $query);
if (! empty($logData['entries'])) {
$results[$logName] = $logData;
}
} catch (\Exception $e) {
$results[$logName] = [
'error' => $e->getMessage(),
];
}
}
return [
'query' => $query,
'level_filter' => $level,
'searched_logs' => $logNames,
'results' => $results,
'total_matches' => array_sum(array_column($results, 'total_entries')),
];
}
/**
* Generator für Streaming-API: liefert Log-Einträge einzeln
* Ideal für Server-Sent Events oder Ajax-Streaming
*/
public function streamLog(string $logName, ?string $level = null, ?string $search = null, int $batchSize = 10): \Generator
{
if (! isset($this->logPaths[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");
}
$batch = [];
$count = 0;
foreach ($this->readLogEntriesGenerator($path, $level, $search) as $entry) {
$batch[] = $entry;
$count++;
// Sende Batch wenn voll
if (count($batch) >= $batchSize) {
yield [
'batch' => $batch,
'batch_number' => intval($count / $batchSize),
'entries_in_batch' => count($batch),
'total_processed' => $count,
];
$batch = [];
}
}
// Sende letzten Batch falls nicht leer
if (! empty($batch)) {
yield [
'batch' => $batch,
'batch_number' => intval($count / $batchSize) + 1,
'entries_in_batch' => count($batch),
'total_processed' => $count,
'is_final' => true,
];
}
}
private function getDefaultLogPaths(): array
{
$basePath = $_SERVER['DOCUMENT_ROOT'] ?? '/var/www/html';
return [
'app' => $basePath . '/public/logs/app.log',
'app_dist' => $basePath . '/dist/logs/app.log',
'mcp_debug' => $basePath . '/mcp_debug.log',
'php_error' => ini_get('error_log') ?: '/var/log/php_errors.log',
'framework' => '/tmp/framework.log',
];
}
/**
* Generator für effizientes Lesen der letzten N Zeilen einer Log-Datei
* Verwendet Memory-Mapping für bessere Performance bei großen Dateien
*/
private function readLogLinesGenerator(string $path, int $limit): \Generator
{
$file = fopen($path, 'r');
if (! $file) {
return;
}
$fileSize = filesize($path);
if ($fileSize === 0) {
fclose($file);
return;
}
// Für kleine Dateien: einfach von hinten lesen
if ($fileSize < 1024 * 1024) { // < 1MB
yield from $this->readSmallFileLines($file, $limit);
fclose($file);
return;
}
// Für große Dateien: chunk-weise von hinten lesen
yield from $this->readLargeFileLines($file, $fileSize, $limit);
fclose($file);
}
/**
* Liest Zeilen von kleinen Dateien (< 1MB)
*/
private function readSmallFileLines($file, int $limit): \Generator
{
$lines = [];
$content = stream_get_contents($file);
$allLines = array_filter(explode("\n", $content));
// Nimm die letzten $limit Zeilen
$lastLines = array_slice($allLines, -$limit);
foreach ($lastLines as $line) {
yield trim($line);
}
}
/**
* Liest Zeilen von großen Dateien chunk-weise von hinten
*/
private function readLargeFileLines($file, int $fileSize, int $limit): \Generator
{
$chunkSize = 8192; // 8KB chunks
$lines = [];
$buffer = '';
$position = $fileSize;
while ($position > 0 && count($lines) < $limit) {
// Berechne Chunk-Größe
$readSize = min($chunkSize, $position);
$position -= $readSize;
// Lese Chunk
fseek($file, $position);
$chunk = fread($file, $readSize);
// Füge zu Buffer hinzu
$buffer = $chunk . $buffer;
// Extrahiere vollständige Zeilen
$newLines = explode("\n", $buffer);
// Die erste "Zeile" ist unvollständig (außer am Dateianfang)
if ($position > 0) {
$buffer = array_shift($newLines);
} else {
$buffer = '';
}
// Füge neue Zeilen hinzu (rückwärts)
foreach (array_reverse($newLines) as $line) {
if (trim($line) !== '') {
array_unshift($lines, trim($line));
if (count($lines) >= $limit) {
break 2;
}
}
}
}
// Gib Zeilen aus
foreach ($lines as $line) {
yield $line;
}
}
/**
* Generator für gefilterte Log-Einträge mit Memory-Effizienz
*/
private function readLogEntriesGenerator(string $path, ?string $level = null, ?string $search = null): \Generator
{
foreach ($this->readLogLinesGenerator($path, PHP_INT_MAX) as $line) {
$entry = $this->parseLogLine($line);
if (! $entry) {
continue;
}
// Level-Filter anwenden
if ($level && strtoupper($entry['level']) !== strtoupper($level)) {
continue;
}
// Such-Filter anwenden
if ($search &&
stripos($entry['message'], $search) === false &&
stripos($entry['context'], $search) === false) {
continue;
}
yield $entry;
}
}
/**
* Legacy-Wrapper für Rückwärtskompatibilität
*/
private function readLogLines(string $path, int $limit): array
{
return iterator_to_array($this->readLogLinesGenerator($path, $limit));
}
private function parseLogEntries(array $lines): array
{
$entries = [];
foreach ($lines as $line) {
$entry = $this->parseLogLine($line);
if ($entry) {
$entries[] = $entry;
}
}
return array_reverse($entries); // Most recent first
}
private function parseLogLine(string $line): ?array
{
// Try to parse common log formats
// Standard PHP/Laravel format: [2023-12-25 10:30:45] local.ERROR: Message {"context":"data"}
if (preg_match('/\[([^\]]+)\]\s+\w+\.(\w+):\s+(.+)/', $line, $matches)) {
return [
'timestamp' => $matches[1],
'level' => strtoupper($matches[2]),
'message' => $this->extractMessage($matches[3]),
'context' => $this->extractContext($matches[3]),
'raw' => $line,
'parsed' => true,
];
}
// Simple format: 2023-12-25 10:30:45 ERROR: Message
if (preg_match('/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\w+):\s+(.+)/', $line, $matches)) {
return [
'timestamp' => $matches[1],
'level' => strtoupper($matches[2]),
'message' => $matches[3],
'context' => '',
'raw' => $line,
'parsed' => true,
];
}
// Fallback - treat as unstructured log
return [
'timestamp' => date('Y-m-d H:i:s'),
'level' => 'INFO',
'message' => $line,
'context' => '',
'raw' => $line,
'parsed' => false,
];
}
private function extractMessage(string $text): string
{
// Extract message before JSON context
$pos = strpos($text, '{');
if ($pos !== false) {
return trim(substr($text, 0, $pos));
}
return $text;
}
private function extractContext(string $text): string
{
// Extract JSON context
$pos = strpos($text, '{');
if ($pos !== false) {
return substr($text, $pos);
}
return '';
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Initializer;
final readonly class LogViewerInitializer
{
#[Initializer]
public function __invoke(PathProvider $pathProvider): LogViewer
{
// Erstelle LogConfig für zentrale Pfadverwaltung
$logConfig = new LogConfig($pathProvider);
// Stelle sicher, dass alle Logverzeichnisse existieren
$logConfig->ensureLogDirectoriesExist();
// Verwende die konfigurierten Logpfade aus LogConfig
$logPaths = $logConfig->getAllLogPaths();
return new LogViewer($logPaths);
}
}

View File

@@ -1,8 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
interface Logger
{
public function debug(string $message, array $context = []): void;
public function info(string $message, array $context = []): void;
public function notice(string $message, array $context = []): void;
public function warning(string $message, array $context = []): void;
public function error(string $message, array $context = []): void;
public function critical(string $message, array $context = []): void;
public function alert(string $message, array $context = []): void;
public function emergency(string $message, array $context = []): void;
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;

View File

@@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Config\TypedConfiguration;
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;
@@ -12,33 +14,57 @@ use App\Framework\Logging\Handlers\WebHandler;
use App\Framework\Queue\FileQueue;
use App\Framework\Queue\Queue;
use App\Framework\Queue\RedisQueue;
;
use App\Framework\Redis\RedisConfig;
use App\Framework\Redis\RedisConnection;
final readonly class LoggerInitializer
{
public function __construct(
#private DatabaseManager $db,
#private PathProvider $pathProvider,
){}
#[Initializer]
public function __invoke():Logger
public function __invoke(TypedConfiguration $config, PathProvider $pathProvider): Logger
{
$processorManager = new ProcessorManager();
$queue = new RedisQueue('commands', 'redis');
#$path = $this->pathProvider->resolvePath('/src/Framework/CommandBus/storage/queue');
#$queue = new FileQueue($path);
// Set log level based on environment
$minLevel = $config->app->isDebugEnabled()
? LogLevel::DEBUG
: LogLevel::INFO;
// Erstelle LogConfig für zentrale Pfadverwaltung
$logConfig = new LogConfig($pathProvider);
// Stelle sicher, dass alle Logverzeichnisse existieren
$logConfig->ensureLogDirectoriesExist();
$redisConfig = new RedisConfig(host: 'redis', database: 2);
$redisConnection = new RedisConnection($redisConfig, 'queue');
$queue = new RedisQueue($redisConnection, 'commands');
// Alternativ: FileQueue mit aufgelöstem Pfad
// $queuePath = $pathProvider->resolvePath('storage/queue');
// $queue = new FileQueue($queuePath);
// In production, we might want to exclude console handler
$handlers = [];
if (! $config->app->isProduction()) {
$handlers[] = new ConsoleHandler();
}
$handlers[] = new QueuedLogHandler($queue);
$handlers[] = new WebHandler();
$handlers[] = new FileHandler(
$logConfig->getLogPath('app'),
$minLevel,
'[{timestamp}] [{level_name}] {request_id}{channel}{message}',
0644,
null,
$pathProvider
);
return new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [
new QueuedLogHandler($queue),
new ConsoleHandler(),
new WebHandler(),
new FileHandler('logs/app.log')],
processorManager: $processorManager,
minLevel: $minLevel,
handlers: $handlers,
processorManager: $processorManager,
);
}
}

View File

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

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\CommandBus\CommandHandler;
@@ -9,7 +11,8 @@ final readonly class ProcessLogCommandHandler
{
public function __construct(
private DatabaseManager $db,
){}
) {
}
#[CommandHandler]
public function __invoke(ProcessLogCommand $commandData): void

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
@@ -8,7 +9,7 @@ namespace App\Framework\Logging;
*/
final readonly class ProcessorManager
{
/** @var array<LogProcessor> Liste der Processors, sortiert nach Priorität */
/** @var array<LogProcessor> Liste der Processors, sortiert nach Priorität */
private array $processors;
public function __construct(LogProcessor ...$processors)
@@ -67,13 +68,13 @@ final readonly class ProcessorManager
public function hasProcessor(string $name): bool
{
return array_any($this->processors, fn($processor) => $processor->getName() === $name);
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);
return array_find($this->processors, fn ($processor) => $processor->getName() === $name);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
@@ -49,8 +50,10 @@ final class InterpolationProcessor implements LogProcessor
} 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);
$replace['{' . $key . '}'] = 'array' . json_encode(
$val,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR
);
} else {
$replace['{' . $key . '}'] = '[' . gettype($val) . ']';
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
@@ -57,10 +58,12 @@ final class IntrospectionProcessor implements LogProcessor
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
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
@@ -19,7 +20,8 @@ final readonly class RequestIdProcessor implements LogProcessor
public function __construct(
private RequestIdGenerator $requestIdGenerator
) {}
) {
}
/**
* Verarbeitet einen Log-Record und fügt die Request-ID hinzu

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
@@ -52,7 +53,7 @@ final class WebInfoProcessor implements LogProcessor
// Webinfos als Extra-Felder hinzufügen
$extras = $this->collectWebInfo();
if (!empty($extras)) {
if (! empty($extras)) {
$record->addExtras($extras);
}
@@ -94,7 +95,7 @@ final class WebInfoProcessor implements LogProcessor
*/
private function getFullUrl(): string
{
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
@@ -118,6 +119,7 @@ final class WebInfoProcessor implements LogProcessor
// 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
}