diff --git a/DEPLOYMENT_FIX.md b/DEPLOYMENT_FIX.md new file mode 100644 index 00000000..21d3819e --- /dev/null +++ b/DEPLOYMENT_FIX.md @@ -0,0 +1,22 @@ +# Deployment Fix f?r Staging 502 Error + +Das Problem: Nach jedem Deployment tritt der 502-Fehler wieder auf. + +## L?sung: Deployment-Script erweitern + +F?ge nach Zeile 991 in `.gitea/workflows/build-image.yml` folgenden Code ein: + +```yaml + # Fix nginx upstream configuration - critical fix for 502 errors + # sites-available/default uses 127.0.0.1:9000 but PHP-FPM runs in staging-app container + echo \"?? Fixing nginx PHP-FPM upstream configuration (post-deploy fix)...\" + sleep 5 + docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo \"?? Upstream fix (127.0.0.1) failed\" + docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo \"?? Upstream fix (localhost) failed\" + docker compose exec -T staging-nginx nginx -t && docker compose restart staging-nginx || echo \"?? Nginx config test or restart failed\" + echo \"? Nginx configuration fixed and reloaded\" +``` + +**Position:** Nach Zeile 991 (`docker compose restart staging-app || echo \"?? Failed to restart staging-app\"`) und vor Zeile 993 (`echo \"? Waiting for services to stabilize...\"`) + +**Grund:** Die Container werden mit `--force-recreate` neu erstellt, wodurch die Datei `/etc/nginx/sites-available/default` wieder aus dem Docker-Image kommt und `127.0.0.1:9000` verwendet. Dieser Fix muss nach jedem Deployment ausgef?hrt werden. diff --git a/deployment/ansible/playbooks/check-entrypoint-execution.yml b/deployment/ansible/playbooks/check-entrypoint-execution.yml new file mode 100644 index 00000000..c0d43737 --- /dev/null +++ b/deployment/ansible/playbooks/check-entrypoint-execution.yml @@ -0,0 +1,63 @@ +--- +- name: Check Entrypoint Script Execution + hosts: production + gather_facts: yes + become: no + + tasks: + - name: Check when nginx container started + shell: | + cd ~/deployment/stacks/staging + docker compose ps staging-nginx --format "{{.Status}}" || echo "Container not running" + args: + executable: /bin/bash + register: container_status + ignore_errors: yes + failed_when: false + + - name: Display container status + debug: + msg: "{{ container_status.stdout }}" + + - name: Check entrypoint logs + shell: | + cd ~/deployment/stacks/staging + echo "=== Entrypoint logs (startup) ===" + docker compose logs staging-nginx 2>&1 | grep -E "(??|Fixing|PHP-FPM|upstream)" | head -20 + args: + executable: /bin/bash + register: entrypoint_logs + ignore_errors: yes + failed_when: false + + - name: Display entrypoint logs + debug: + msg: "{{ entrypoint_logs.stdout_lines }}" + + - name: Check if sites-available/default is a volume mount + shell: | + cd ~/deployment/stacks/staging + docker inspect staging-nginx 2>&1 | grep -A 20 "Mounts" | grep "sites-available\|sites-enabled" || echo "No volume mounts for sites-available" + args: + executable: /bin/bash + register: volume_check + ignore_errors: yes + failed_when: false + + - name: Display volume check + debug: + msg: "{{ volume_check.stdout_lines }}" + + - name: Check when sites-available/default was last modified + shell: | + cd ~/deployment/stacks/staging + docker compose exec -T staging-nginx stat -c "%y" /etc/nginx/sites-available/default 2>&1 || echo "Could not get file stat" + args: + executable: /bin/bash + register: file_stat + ignore_errors: yes + failed_when: false + + - name: Display file modification time + debug: + msg: "{{ file_stat.stdout_lines }}" diff --git a/deployment/ansible/playbooks/fix-staging-502-quick.yml b/deployment/ansible/playbooks/fix-staging-502-quick.yml new file mode 100644 index 00000000..558940f1 --- /dev/null +++ b/deployment/ansible/playbooks/fix-staging-502-quick.yml @@ -0,0 +1,52 @@ +--- +- name: Quick Fix Staging 502 Bad Gateway + hosts: production + gather_facts: yes + become: no + + tasks: + - name: Fix php-upstream in sites-available/default + shell: | + cd ~/deployment/stacks/staging + echo "=== Fixing nginx upstream configuration ===" + docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "Fix 127.0.0.1 failed" + docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default || echo "Fix localhost failed" + echo "=== Verifying fix ===" + docker compose exec -T staging-nginx grep -A 3 "upstream php-upstream" /etc/nginx/sites-available/default + args: + executable: /bin/bash + register: fix_result + ignore_errors: yes + failed_when: false + + - name: Display fix result + debug: + msg: "{{ fix_result.stdout_lines }}" + + - name: Reload nginx + shell: | + cd ~/deployment/stacks/staging + docker compose exec -T staging-nginx nginx -t && docker compose restart staging-nginx + args: + executable: /bin/bash + register: reload_result + ignore_errors: yes + failed_when: false + + - name: Display reload result + debug: + msg: "{{ reload_result.stdout_lines }}" + + - name: Test if fix worked + shell: | + sleep 3 + curl -H "User-Agent: Mozilla/5.0" -s -o /dev/null -w "%{http_code}" https://staging.michaelschiemer.de/ || echo "502" + args: + executable: /bin/bash + register: test_result + ignore_errors: yes + failed_when: false + + - name: Display test result + debug: + msg: "HTTP Status: {{ test_result.stdout }} (200 = OK, 502 = Still broken)" diff --git a/deployment/docs/troubleshooting/staging-502-nginx-phpfpm.md b/deployment/docs/troubleshooting/staging-502-nginx-phpfpm.md index 34282178..a4362ebc 100644 --- a/deployment/docs/troubleshooting/staging-502-nginx-phpfpm.md +++ b/deployment/docs/troubleshooting/staging-502-nginx-phpfpm.md @@ -158,9 +158,40 @@ ansible-playbook -i deployment/ansible/inventory/production.yml \ ## Verhindern in Zukunft -1. **Entrypoint-Script:** Das Entrypoint-Script behebt das Problem automatisch beim Container-Start -2. **Image-Build:** Idealerweise sollte die `sites-available/default` Datei im Docker-Image bereits korrekt konfiguriert sein -3. **Alternativ:** Entferne `sites-available/default` komplett und verwende nur `conf.d/default.conf` +### Nach jedem Deployment + +**WICHTIG:** Nach jedem Deployment (Push zu staging-Branch) muss dieser Fix ausgef?hrt werden, da die Container neu erstellt werden. + +```bash +# Mit Ansible (empfohlen) +ansible-playbook -i deployment/ansible/inventory/production.yml \ + deployment/ansible/playbooks/fix-staging-502-quick.yml + +# Oder manuell +cd ~/deployment/stacks/staging +docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default +docker compose restart staging-nginx +``` + +### Langfristige L?sungen + +1. **Entrypoint-Script:** Das Entrypoint-Script behebt das Problem automatisch beim Container-Start (implementiert, aber nicht 100% zuverl?ssig) +2. **Deployment-Script:** Erweitere `.gitea/workflows/build-image.yml` um einen Post-Deployment-Fix (siehe TODO unten) +3. **Image-Build:** Idealerweise sollte die `sites-available/default` Datei im Docker-Image bereits korrekt konfiguriert sein +4. **Alternativ:** Entferne `sites-available/default` komplett und verwende nur `conf.d/default.conf` + +### TODO: Deployment-Script erweitern + +Das Deployment-Script in `.gitea/workflows/build-image.yml` sollte nach Zeile 991 erweitert werden: + +```bash +# Fix nginx upstream configuration - critical fix for 502 errors +echo "?? Fixing nginx PHP-FPM upstream configuration (post-deploy fix)..." +sleep 5 +docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default +docker compose exec -T staging-nginx sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server staging-app:9000;|g' /etc/nginx/sites-available/default +docker compose exec -T staging-nginx nginx -t && docker compose restart staging-nginx +``` ## Siehe auch diff --git a/src/Framework/Core/ContainerBootstrapper.php b/src/Framework/Core/ContainerBootstrapper.php index 0d8b7a9a..0cba988f 100644 --- a/src/Framework/Core/ContainerBootstrapper.php +++ b/src/Framework/Core/ContainerBootstrapper.php @@ -16,6 +16,7 @@ use App\Framework\Discovery\DiscoveryServiceBootstrapper; use App\Framework\Http\ResponseEmitter; use App\Framework\Logging\DefaultLogger; use App\Framework\Logging\Logger; +use App\Framework\Logging\LoggerInitializer; use App\Framework\Performance\Contracts\PerformanceCollectorInterface; use App\Framework\Reflection\CachedReflectionProvider; @@ -135,12 +136,22 @@ final readonly class ContainerBootstrapper PerformanceCollectorInterface $collector ): void { // Core services that need runtime data - $container->instance(Logger::class, new DefaultLogger()); + // Clock must be registered first as it's required by Logger + // Only create if not already registered (e.g. by ClockInitializer) + if (! $container->has(Clock::class)) { + $container->instance(Clock::class, new SystemClock()); + } + $clock = $container->get(Clock::class); + $container->instance(PathProvider::class, new PathProvider($basePath)); + $init = $container->get(LoggerInitializer::class); + + $logger = $container->invoker->invoke(LoggerInitializer::class, '__invoke'); + + $container->instance(Logger::class, $logger); $container->instance(PerformanceCollectorInterface::class, $collector); $container->instance(Cache::class, new CacheInitializer($collector, $container)()); - $container->instance(PathProvider::class, new PathProvider($basePath)); + $container->instance(ResponseEmitter::class, new ResponseEmitter()); - $container->instance(Clock::class, new SystemClock()); // TEMPORARY FIX: Manual RequestFactory binding until Discovery issue is resolved $container->singleton(\App\Framework\Http\Request::class, function ($container) { @@ -220,9 +231,15 @@ final readonly class ContainerBootstrapper $isMcpMode = getenv('MCP_SERVER_MODE') === '1'; $handlers = $isMcpMode ? [new \App\Framework\Logging\Handlers\NullHandler()] - : [new \App\Framework\Logging\Handlers\ConsoleHandler()]; + : [ + new \App\Framework\Logging\Handlers\ConsoleHandler( + new \App\Framework\Logging\Formatter\DevelopmentFormatter(), + \App\Framework\Logging\LogLevel::DEBUG + ) + ]; $logger = new \App\Framework\Logging\DefaultLogger( + clock: $clock, minLevel: \App\Framework\Logging\LogLevel::DEBUG, handlers: $handlers, processorManager: new \App\Framework\Logging\ProcessorManager(), diff --git a/src/Framework/DI/MethodInvoker.php b/src/Framework/DI/MethodInvoker.php index 049db129..b38d5399 100644 --- a/src/Framework/DI/MethodInvoker.php +++ b/src/Framework/DI/MethodInvoker.php @@ -25,8 +25,11 @@ final readonly class MethodInvoker /** * Führt eine Methode auf einer Klasse aus und löst alle Parameter automatisch auf */ - public function invoke(ClassName $className, string $methodName, array $overrides = []): mixed + public function invoke(ClassName|string $className, string $methodName, array $overrides = []): mixed { + if(is_string($className)) { + $className = ClassName::create($className); + } $instance = $this->container->get($className->getFullyQualified()); return $this->invokeOn($instance, $methodName, $overrides); diff --git a/src/Framework/ExceptionHandling/ErrorKernel.php b/src/Framework/ExceptionHandling/ErrorKernel.php index 5b5b369c..504ae4ad 100644 --- a/src/Framework/ExceptionHandling/ErrorKernel.php +++ b/src/Framework/ExceptionHandling/ErrorKernel.php @@ -17,6 +17,8 @@ final readonly class ErrorKernel $log = new LogReporter(); $log->report($e->getMessage()); + var_dump((string)$e); + $this->rendererFactory->getRenderer()->render(); return null; diff --git a/src/Framework/Logging/DefaultLogger.php b/src/Framework/Logging/DefaultLogger.php index 6ac55251..445bdd09 100644 --- a/src/Framework/Logging/DefaultLogger.php +++ b/src/Framework/Logging/DefaultLogger.php @@ -5,10 +5,9 @@ declare(strict_types=1); namespace App\Framework\Logging; use App\Framework\Attributes\Singleton; +use App\Framework\DateTime\Clock; use App\Framework\Logging\ValueObjects\LogContext; use DateMalformedStringException; -use DateTimeImmutable; -use DateTimeZone; /** * Einfacher Logger für das Framework. @@ -19,12 +18,14 @@ final readonly class DefaultLogger implements Logger, SupportsChannels private ChannelLoggerRegistry $channelRegistry; /** + * @param Clock $clock Clock für Timestamp-Generierung * @param LogLevel $minLevel Minimales Level, das geloggt werden soll * @param array $handlers Array von Log-Handlern * @param ProcessorManager $processorManager Processor Manager für die Verarbeitung * @param LogContextManager|null $contextManager Optional: Context Manager für automatische Kontext-Anreicherung */ public function __construct( + private Clock $clock, private LogLevel $minLevel = LogLevel::DEBUG, /** @var LogHandler[] */ private array $handlers = [], @@ -84,6 +85,30 @@ final readonly class DefaultLogger implements Logger, SupportsChannels * @throws DateMalformedStringException */ public function log(LogLevel $level, string $message, ?LogContext $context = null): void + { + $this->createAndProcessRecord($level, $message, $context); + } + + /** + * Loggt in einen spezifischen Channel + * + * @internal Wird von ChannelLogger verwendet + */ + public function logToChannel(LogChannel $channel, LogLevel $level, string $message, ?LogContext $context = null): void + { + $this->createAndProcessRecord($level, $message, $context, $channel->value); + } + + /** + * Erstellt und verarbeitet einen Log-Record + * + * @param LogLevel $level Log-Level + * @param string $message Log-Nachricht + * @param LogContext|null $context Strukturierter LogContext + * @param string|null $channel Optional: Channel-Name + * @throws DateMalformedStringException + */ + private function createAndProcessRecord(LogLevel $level, string $message, ?LogContext $context = null, ?string $channel = null): void { // Wenn kein Context übergeben, leeren Context erstellen if ($context === null) { @@ -103,47 +128,8 @@ final readonly class DefaultLogger implements Logger, SupportsChannels message: $message, context: $finalContext, 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); - } - } - } - - /** - * Loggt in einen spezifischen Channel - * - * @internal Wird von ChannelLogger verwendet - */ - public function logToChannel(LogChannel $channel, LogLevel $level, string $message, ?LogContext $context = null): void - { - // Wenn kein Context übergeben, leeren Context erstellen - if ($context === null) { - $context = LogContext::empty(); - } - - // Prüfen, ob Level hoch genug ist - if ($level->isLowerThan($this->minLevel)) { - return; - } - - // LogContext automatisch mit aktuellem Context anreichern - $finalContext = $this->enrichWithCurrentContext($context); - - // Log-Record erstellen mit Channel - $record = new LogRecord( - message: $message, - context: $finalContext, - level: $level, - timestamp: new DateTimeImmutable(timezone: new DateTimeZone('Europe/Berlin')), - channel: $channel->value + timestamp: $this->clock->now(), + channel: $channel ); // Record durch alle Processors verarbeiten @@ -173,75 +159,6 @@ final readonly class DefaultLogger implements Logger, SupportsChannels return $currentContext->merge($context); } - /** - * Konvertiert LogContext zu Array für Legacy-Kompatibilität - * @deprecated - */ - private function convertLogContextToArray(LogContext $logContext): array - { - $context = $logContext->structured; - - // Tags hinzufügen - if ($logContext->hasTags()) { - $context['_tags'] = $logContext->tags; - } - - // Trace-Informationen hinzufügen - if ($logContext->trace !== null) { - $context['_trace_id'] = $logContext->trace->getTraceId(); - if ($activeSpan = $logContext->trace->getActiveSpan()) { - $context['_span_id'] = $activeSpan->spanId; - } - } - - // User-Kontext hinzufügen - if ($logContext->user !== null) { - $context['_user_id'] = $logContext->user->userId ?? null; - } - - // Request-Kontext hinzufügen - if ($logContext->request !== null) { - $context['_request_id'] = $logContext->request->requestId ?? null; - } - - return array_merge($context, $logContext->metadata); - } - - /** - * Reichert LogRecord mit strukturierten Daten aus LogContext an - */ - private function enrichRecordWithLogContext(LogRecord $record, LogContext $logContext): LogRecord - { - // Tags als Extra hinzufügen - if ($logContext->hasTags()) { - $record->addExtra('structured_tags', $logContext->tags); - } - - // Trace-Kontext als Extra hinzufügen - if ($logContext->trace !== null) { - $record->addExtra('trace_context', [ - 'trace_id' => $logContext->trace->getTraceId(), - 'active_span' => $logContext->trace->getActiveSpan()?->toArray(), - ]); - } - - // User-Kontext als Extra hinzufügen - if ($logContext->user !== null) { - $record->addExtra('user_context', $logContext->user->toArray()); - } - - // Request-Kontext als Extra hinzufügen - if ($logContext->request !== null) { - $record->addExtra('request_context', $logContext->request->toArray()); - } - - // Metadaten als Extra hinzufügen - if (! empty($logContext->metadata)) { - $record->addExtras($logContext->metadata); - } - - return $record; - } /** * Holt einen ChannelLogger für einen spezifischen Channel diff --git a/src/Framework/Logging/Formatter/ExceptionFormatter.php b/src/Framework/Logging/Formatter/ExceptionFormatter.php index fd0d0e15..eaa13b28 100644 --- a/src/Framework/Logging/Formatter/ExceptionFormatter.php +++ b/src/Framework/Logging/Formatter/ExceptionFormatter.php @@ -13,11 +13,11 @@ use App\Framework\Logging\ValueObjects\StackFrame; * Formatiert ExceptionContext zu lesbarem String-Format * ähnlich wie PHP's natürliche Exception-Ausgabe. */ -final class ExceptionFormatter +final readonly class ExceptionFormatter { public function __construct( - private readonly int $maxStackFrames = 10, - private readonly bool $includeArgs = false + private int $maxStackFrames = 10, + private bool $includeArgs = false ) { } diff --git a/src/Framework/Logging/Formatter/LineFormatter.php b/src/Framework/Logging/Formatter/LineFormatter.php index de89fb1c..aeb8a0f9 100644 --- a/src/Framework/Logging/Formatter/LineFormatter.php +++ b/src/Framework/Logging/Formatter/LineFormatter.php @@ -23,12 +23,24 @@ final readonly class LineFormatter implements LogFormatter $allData = array_merge($context, $extras); $contextString = ! empty($allData) ? json_encode($allData, JSON_UNESCAPED_SLASHES) : ''; + // 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()}] " + : ''; + $replacements = [ '{timestamp}' => $record->getFormattedTimestamp($this->timestampFormat), '{channel}' => $record->channel ?? 'app', '{level}' => $record->level->getName(), + '{level_name}' => $record->level->getName(), '{message}' => $record->message, '{context}' => $contextString, + '{request_id}' => $requestId, ]; return strtr($this->format, $replacements); diff --git a/src/Framework/Logging/Handlers/ConsoleHandler.php b/src/Framework/Logging/Handlers/ConsoleHandler.php index d542bb2a..86368d14 100644 --- a/src/Framework/Logging/Handlers/ConsoleHandler.php +++ b/src/Framework/Logging/Handlers/ConsoleHandler.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Framework\Logging\Handlers; -use App\Framework\Console\ConsoleColor; use App\Framework\Logging\LogHandler; +use App\Framework\Logging\Formatter\LogFormatter; use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogRecord; @@ -25,26 +25,26 @@ class ConsoleHandler implements LogHandler private bool $debugOnly; /** - * @var string Format für die Ausgabe + * @var LogFormatter Formatter für die Log-Ausgabe */ - private string $outputFormat; + private LogFormatter $formatter; /** * Erstellt einen neuen ConsoleHandler * + * @param LogFormatter $formatter Formatter für die Log-Ausgabe * @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}{structured}', + LogFormatter $formatter, + LogLevel|int $minLevel = LogLevel::DEBUG, + bool $debugOnly = true, private readonly LogLevel $stderrLevel = LogLevel::WARNING, ) { + $this->formatter = $formatter; $this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); $this->debugOnly = $debugOnly; - $this->outputFormat = $outputFormat; } /** @@ -71,32 +71,18 @@ class ConsoleHandler implements LogHandler */ public function handle(LogRecord $record): void { - $logLevel = $record->getLevel(); - $color = $logLevel->getConsoleColor()->value; - $reset = ConsoleColor::RESET->value; + // Formatter verwenden für Formatierung + $formatted = ($this->formatter)($record); - // Request-ID-Teil erstellen, falls vorhanden - $requestId = $record->hasExtra('request_id') - ? "[{$record->getExtra('request_id')}] " - : ''; + // Formatter gibt immer string zurück für Console + $output = is_string($formatted) + ? $formatted + : json_encode($formatted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - // Structured Logging Extras formatieren - $structuredInfo = $this->formatStructuredExtras($record); - - // 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()}] " : '', - '{structured}' => $structuredInfo, - ]; - - // Formatierte Ausgabe erstellen - $output = strtr($this->outputFormat, $values) . PHP_EOL; + // Stelle sicher, dass ein Newline vorhanden ist + if (!str_ends_with($output, PHP_EOL)) { + $output .= PHP_EOL; + } // Fehler und Warnungen auf stderr, alles andere auf stdout if ($record->getLevel()->value >= $this->stderrLevel->value) { @@ -108,6 +94,14 @@ class ConsoleHandler implements LogHandler } } + /** + * Gibt den Formatter zurück + */ + public function getFormatter(): LogFormatter + { + return $this->formatter; + } + /** * Minimales Log-Level setzen */ @@ -117,101 +111,4 @@ class ConsoleHandler implements LogHandler return $this; } - - /** - * Ausgabeformat setzen - */ - public function setOutputFormat(string $format): self - { - $this->outputFormat = $format; - - return $this; - } - - /** - * Formatiert Structured Logging Extras für Console-Ausgabe mit Farben - */ - private function formatStructuredExtras(LogRecord $record): string - { - $parts = []; - $reset = ConsoleColor::RESET->toAnsi(); - - // Tags anzeigen (Cyan mit Tag-Symbol) - if ($record->hasExtra('structured_tags')) { - $tags = $record->getExtra('structured_tags'); - if (! empty($tags)) { - $cyan = ConsoleColor::CYAN->toAnsi(); - $tagString = implode(',', $tags); - $parts[] = "{$cyan}🏷 [{$tagString}]{$reset}"; - } - } - - // Trace-Kontext anzeigen (Blau mit Trace-Symbol) - if ($record->hasExtra('trace_context')) { - $traceContext = $record->getExtra('trace_context'); - $blue = ConsoleColor::BLUE->toAnsi(); - - if (isset($traceContext['trace_id'])) { - $traceId = substr($traceContext['trace_id'], 0, 8); - $parts[] = "{$blue}🔍 {$traceId}{$reset}"; - } - if (isset($traceContext['active_span']['spanId'])) { - $spanId = substr($traceContext['active_span']['spanId'], 0, 8); - $parts[] = "{$blue}↳ {$spanId}{$reset}"; - } - } - - // User-Kontext anzeigen (Grün mit User-Symbol) - if ($record->hasExtra('user_context')) { - $userContext = $record->getExtra('user_context'); - $green = ConsoleColor::GREEN->toAnsi(); - - if (isset($userContext['user_id'])) { - // Anonymisierte User-ID für Privacy - $userId = substr(md5($userContext['user_id']), 0, 8); - $parts[] = "{$green}👤 {$userId}{$reset}"; - } elseif (isset($userContext['is_authenticated']) && ! $userContext['is_authenticated']) { - $parts[] = "{$green}👤 anon{$reset}"; - } - } - - // Request-Kontext anzeigen (Gelb mit HTTP-Symbol) - if ($record->hasExtra('request_context')) { - $requestContext = $record->getExtra('request_context'); - if (isset($requestContext['request_method'], $requestContext['request_uri'])) { - $yellow = ConsoleColor::YELLOW->toAnsi(); - $method = $requestContext['request_method']; - $uri = $requestContext['request_uri']; - - // Kompakte URI-Darstellung - if (strlen($uri) > 25) { - $uri = substr($uri, 0, 22) . '...'; - } - - $parts[] = "{$yellow}🌐 {$method} {$uri}{$reset}"; - } - } - - // Context-Data anzeigen (Grau mit Data-Symbol) - $context = $record->getContext(); - if (! empty($context)) { - $contextKeys = array_keys($context); - // Interne Keys ausfiltern - $contextKeys = array_filter($contextKeys, fn ($key) => ! str_starts_with($key, '_')); - - if (! empty($contextKeys)) { - $gray = ConsoleColor::GRAY->toAnsi(); - $keyCount = count($contextKeys); - - if ($keyCount <= 3) { - $keyString = implode('·', $contextKeys); - } else { - $keyString = implode('·', array_slice($contextKeys, 0, 2)) . "·+{$keyCount}"; - } - $parts[] = "{$gray}📊 {$keyString}{$reset}"; - } - } - - return empty($parts) ? '' : "\n " . implode(' ', $parts); - } } diff --git a/src/Framework/Logging/Handlers/DockerJsonHandler.php b/src/Framework/Logging/Handlers/DockerJsonHandler.php index 324c7805..1b7b27f7 100644 --- a/src/Framework/Logging/Handlers/DockerJsonHandler.php +++ b/src/Framework/Logging/Handlers/DockerJsonHandler.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace App\Framework\Logging\Handlers; use App\Framework\Config\Environment; +use App\Framework\Logging\FormattableHandler; use App\Framework\Logging\Formatter\JsonFormatter; -use App\Framework\Logging\LogHandler; +use App\Framework\Logging\Formatter\LogFormatter; use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogRecord; use App\Framework\Logging\Security\SensitiveDataRedactor; @@ -33,7 +34,7 @@ use App\Framework\Logging\Security\SensitiveDataRedactor; * - docker logs 2>&1 | jq 'select(.level == "ERROR")' * - docker logs 2>&1 | jq -r '[.timestamp, .level, .message] | @tsv' */ -final readonly class DockerJsonHandler implements LogHandler +final readonly class DockerJsonHandler implements FormattableHandler { private JsonFormatter $formatter; private LogLevel $minLevel; @@ -79,6 +80,14 @@ final readonly class DockerJsonHandler implements LogHandler echo $json . PHP_EOL; } + /** + * Gibt den Formatter zurück + */ + public function getFormatter(): LogFormatter + { + return $this->formatter; + } + /** * Setzt minimales Log-Level */ diff --git a/src/Framework/Logging/Handlers/FileHandler.php b/src/Framework/Logging/Handlers/FileHandler.php index 1a214f09..f5fc4353 100644 --- a/src/Framework/Logging/Handlers/FileHandler.php +++ b/src/Framework/Logging/Handlers/FileHandler.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Framework\Logging\Handlers; use App\Framework\Core\PathProvider; +use App\Framework\Logging\Formatter\LogFormatter; use App\Framework\Logging\LogHandler; use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogRecord; @@ -26,9 +27,9 @@ class FileHandler implements LogHandler private string $logFile; /** - * @var string Format für die Ausgabe + * @var LogFormatter Formatter für die Log-Ausgabe */ - private string $outputFormat; + private LogFormatter $formatter; /** * @var int Datei-Modi für die Log-Datei @@ -48,22 +49,23 @@ class FileHandler implements LogHandler /** * Erstellt einen neuen FileHandler * + * @param LogFormatter $formatter Formatter für die Log-Ausgabe * @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 * @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( + LogFormatter $formatter, string $logFile, LogLevel|int $minLevel = LogLevel::DEBUG, - string $outputFormat = '[{timestamp}] [{level_name}] {request_id}{channel}{message}', int $fileMode = 0644, ?LogRotator $rotator = null, ?PathProvider $pathProvider = null ) { $this->pathProvider = $pathProvider; + $this->formatter = $formatter; // Pfad auflösen, falls PathProvider vorhanden if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) { @@ -72,7 +74,6 @@ class FileHandler implements LogHandler $this->logFile = $logFile; $this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); - $this->outputFormat = $outputFormat; $this->fileMode = $fileMode; $this->rotator = $rotator; @@ -93,27 +94,13 @@ class FileHandler implements LogHandler */ public function handle(LogRecord $record): void { - // Request-ID-Teil erstellen, falls vorhanden - $requestId = $record->hasExtra('request_id') - ? "[{$record->getExtra('request_id')}] " - : ''; + // Formatter verwenden für Formatierung + $formatted = ($this->formatter)($record); - // 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; + // Formatter kann string oder array zurückgeben + $output = is_string($formatted) + ? $formatted . PHP_EOL + : json_encode($formatted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL; // Prüfe Rotation vor dem Schreiben if ($this->rotator !== null) { @@ -129,6 +116,14 @@ class FileHandler implements LogHandler } } + /** + * Gibt den Formatter zurück + */ + public function getFormatter(): LogFormatter + { + return $this->formatter; + } + /** * Schreibt einen String in die Log-Datei */ @@ -167,15 +162,6 @@ class FileHandler implements LogHandler return $this; } - /** - * Ausgabeformat setzen - */ - public function setOutputFormat(string $format): self - { - $this->outputFormat = $format; - - return $this; - } /** * Log-Datei setzen diff --git a/src/Framework/Logging/Handlers/JsonFileHandler.php b/src/Framework/Logging/Handlers/JsonFileHandler.php index 96107d3e..95ae28e1 100644 --- a/src/Framework/Logging/Handlers/JsonFileHandler.php +++ b/src/Framework/Logging/Handlers/JsonFileHandler.php @@ -6,8 +6,9 @@ namespace App\Framework\Logging\Handlers; use App\Framework\Config\Environment; use App\Framework\Core\PathProvider; -use App\Framework\Filesystem\Serializers\JsonSerializer; use App\Framework\Logging\LogHandler; +use App\Framework\Logging\Formatter\JsonFormatter; +use App\Framework\Logging\Formatter\LogFormatter; use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogRecord; @@ -15,7 +16,7 @@ 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. * - * Nutzt JsonSerializer für einheitliche JSON-Ausgabe konsistent mit JsonFormatter. + * Nutzt JsonFormatter für einheitliche JSON-Ausgabe. * * Standard-Felder für Log-Aggregatoren: * - @timestamp: Elasticsearch-konformes Zeitstempelfeld @@ -37,62 +38,31 @@ class JsonFileHandler implements LogHandler private string $logFile; /** - * @var array Liste der Felder, die in der JSON-Ausgabe enthalten sein sollen + * @var JsonFormatter Formatter für die JSON-Ausgabe */ - private array $includedFields; + private JsonFormatter $formatter; /** * @var PathProvider|null PathProvider für die Auflösung von Pfaden */ private ?PathProvider $pathProvider = null; - /** - * @var JsonSerializer JSON Serializer für konsistente Ausgabe - */ - private JsonSerializer $serializer; - - /** - * @var bool Flatten LogContext für bessere Aggregator-Kompatibilität - */ - private bool $flattenContext; - - /** - * @var string Deployment-Umgebung (production, staging, development) - */ - private string $environment; - - /** - * @var string Server-Hostname - */ - private string $host; - - /** - * @var string Service-/Anwendungsname - */ - private string $serviceName; - /** * Erstellt einen neuen JsonFileHandler * + * @param JsonFormatter $formatter Formatter für die JSON-Ausgabe * @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 - * @param bool $flattenContext Flatten LogContext structured data (default: true) - * @param Environment|null $env Optional: Environment für Konfiguration - * @param string|null $serviceName Optional: Service-/Anwendungsname */ public function __construct( + JsonFormatter $formatter, string $logFile, LogLevel|int $minLevel = LogLevel::INFO, - ?array $includedFields = null, - ?PathProvider $pathProvider = null, - bool $flattenContext = true, - ?Environment $env = null, - ?string $serviceName = null + ?PathProvider $pathProvider = null ) { $this->pathProvider = $pathProvider; - $this->flattenContext = $flattenContext; + $this->formatter = $formatter; // Pfad auflösen, falls PathProvider vorhanden if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) { @@ -102,34 +72,6 @@ class JsonFileHandler implements LogHandler $this->logFile = $logFile; $this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); - // Standardfelder, falls nicht anders angegeben (konsistent mit JsonFormatter) - $this->includedFields = $includedFields ?? [ - 'timestamp', - '@timestamp', - 'level', - 'level_value', - 'severity', - 'channel', - 'message', - 'environment', - 'host', - 'service', - 'context', - 'extra', - ]; - - // Compact JSON für Datei-Ausgabe (eine Zeile pro Log) - $this->serializer = JsonSerializer::compact(); - - // Environment Detection (production, staging, development) - $this->environment = $env?->getString('APP_ENV', 'production') ?? 'production'; - - // Host Detection - $this->host = gethostname() ?: 'unknown'; - - // Service Name (default: app name from env) - $this->serviceName = $serviceName ?? $env?->getString('APP_NAME', 'app') ?? 'app'; - // Stelle sicher, dass das Verzeichnis existiert $this->ensureDirectoryExists(dirname($logFile)); } @@ -147,81 +89,26 @@ class JsonFileHandler implements LogHandler */ public function handle(LogRecord $record): void { - // Formatiere Record zu einheitlichem Array (konsistent mit JsonFormatter) - $data = $this->formatRecord($record); + // Formatter verwenden für JSON-Formatierung + $json = ($this->formatter)($record); - // Als JSON formatieren mit JsonSerializer und in die Datei schreiben - $json = $this->serializer->serialize($data) . PHP_EOL; - $this->write($json); + // JsonFormatter gibt immer string zurück + $output = is_string($json) ? $json : json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // Stelle sicher, dass ein Newline vorhanden ist + if (!str_ends_with($output, PHP_EOL)) { + $output .= PHP_EOL; + } + + $this->write($output); } /** - * Formatiert LogRecord zu einheitlichem Array für JSON-Serialisierung - * (Gleiche Logik wie JsonFormatter für Konsistenz) - * - * @return array + * Gibt den Formatter zurück */ - private function formatRecord(LogRecord $record): array + public function getFormatter(): LogFormatter { - $timestamp = $record->timestamp->format('c'); // ISO 8601 - - $data = [ - // Standard Log Fields - 'timestamp' => $timestamp, - '@timestamp' => $timestamp, // Elasticsearch convention - 'level' => $record->level->getName(), - 'level_value' => $record->level->value, - 'severity' => $record->level->toRFC5424(), // RFC 5424 (0-7) - 'channel' => $record->channel, - 'message' => $record->message, - - // Infrastructure Fields (for log aggregators) - 'environment' => $this->environment, - 'host' => $this->host, - 'service' => $this->serviceName, - ]; - - // Context hinzufügen mit optionalem Flatten - $context = $record->context->toArray(); - - if ($this->flattenContext && isset($context['structured'])) { - // Flatten: Nur strukturierte Daten für bessere Aggregator-Kompatibilität - $data['context'] = $context['structured']; - } else { - // Raw: Gesamtes LogContext-Array - $data['context'] = $context; - } - - // Extras hinzufügen (wenn vorhanden) - if (!empty($record->extra)) { - $data['extra'] = $record->extra; - } - - // Nur gewünschte Felder behalten, in Reihenfolge von $includedFields - return $this->filterFields($data); - } - - /** - * Filtert Array nach includedFields - * - * @param array $data - * @return array - */ - private function filterFields(array $data): array - { - if (empty($this->includedFields)) { - return $data; - } - - $filtered = []; - - foreach ($this->includedFields as $field) { - if (array_key_exists($field, $data)) { - $filtered[$field] = $data[$field]; - } - } - - return $filtered; + return $this->formatter; } /** @@ -252,15 +139,6 @@ class JsonFileHandler implements LogHandler 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 diff --git a/src/Framework/Logging/Handlers/MultiFileHandler.php b/src/Framework/Logging/Handlers/MultiFileHandler.php index a7e942a0..5e58bd69 100644 --- a/src/Framework/Logging/Handlers/MultiFileHandler.php +++ b/src/Framework/Logging/Handlers/MultiFileHandler.php @@ -5,9 +5,10 @@ declare(strict_types=1); namespace App\Framework\Logging\Handlers; use App\Framework\Core\PathProvider; +use App\Framework\Logging\LogHandler; +use App\Framework\Logging\Formatter\LogFormatter; use App\Framework\Logging\LogChannel; use App\Framework\Logging\LogConfig; -use App\Framework\Logging\LogHandler; use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogRecord; @@ -24,13 +25,19 @@ final class MultiFileHandler implements LogHandler */ private array $fileHandles = []; + /** + * @var LogFormatter Formatter für die Log-Ausgabe + */ + private LogFormatter $formatter; + public function __construct( - private readonly mixed $logConfig, - private readonly mixed $pathProvider, + private readonly LogConfig $logConfig, + private readonly PathProvider $pathProvider, + LogFormatter $formatter, private readonly LogLevel $minLevel = LogLevel::DEBUG, - private readonly string $outputFormat = '[{timestamp}] [{level_name}] [{channel}] {message}', private readonly int $fileMode = 0644 ) { + $this->formatter = $formatter; } /** @@ -56,13 +63,31 @@ final class MultiFileHandler implements LogHandler // Entsprechende Log-Datei ermitteln $logFile = $this->getLogFileForChannel($channel); - // Log-Nachricht formatieren - $formattedMessage = $this->formatMessage($record); + // Formatter verwenden für Formatierung + $formatted = ($this->formatter)($record); + + // Formatter kann string oder array zurückgeben + $formattedMessage = is_string($formatted) + ? $formatted + : json_encode($formatted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // Stelle sicher, dass ein Newline vorhanden ist + if (!str_ends_with($formattedMessage, PHP_EOL)) { + $formattedMessage .= PHP_EOL; + } // In Datei schreiben $this->writeToFile($logFile, $formattedMessage); } + /** + * Gibt den Formatter zurück + */ + public function getFormatter(): LogFormatter + { + return $this->formatter; + } + /** * Ermittelt die Log-Datei für einen Channel */ @@ -81,29 +106,6 @@ final class MultiFileHandler implements LogHandler return $this->logConfig->getLogPath($logPathKey); } - /** - * Formatiert die Log-Nachricht - */ - private function formatMessage(LogRecord $record): string - { - $replacements = [ - '{timestamp}' => $record->getFormattedTimestamp('Y-m-d H:i:s'), - '{level_name}' => $record->getLevel()->getName(), - '{channel}' => $record->getChannel() ?? 'app', - '{message}' => $record->getMessage(), - ]; - - $formatted = str_replace(array_keys($replacements), array_values($replacements), $this->outputFormat); - - // Context hinzufügen, falls vorhanden - $context = $record->getContext(); - if (! empty($context)) { - $formatted .= ' ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } - - return $formatted . PHP_EOL; - } - /** * Schreibt die formatierte Nachricht in eine Datei */ diff --git a/src/Framework/Logging/Handlers/QueuedLogHandler.php b/src/Framework/Logging/Handlers/QueuedLogHandler.php index 61a50c08..8d5ed00b 100644 --- a/src/Framework/Logging/Handlers/QueuedLogHandler.php +++ b/src/Framework/Logging/Handlers/QueuedLogHandler.php @@ -26,8 +26,9 @@ final readonly class QueuedLogHandler implements LogHandler public function handle(LogRecord $record): void { - $job = new ProcessLogCommand($record); - $payload = JobPayload::immediate($job); - $this->queue->push($payload); + var_dump('whould be queued'); + #$job = new ProcessLogCommand($record); + #$payload = JobPayload::immediate($job); + #$this->queue->push($payload); } } diff --git a/src/Framework/Logging/LoggerFactory.php b/src/Framework/Logging/LoggerFactory.php index 03bfb7d0..affa559f 100644 --- a/src/Framework/Logging/LoggerFactory.php +++ b/src/Framework/Logging/LoggerFactory.php @@ -4,8 +4,14 @@ declare(strict_types=1); namespace App\Framework\Logging; +use App\Framework\DateTime\Clock; +use App\Framework\DateTime\SystemClock; + /** * Factory für Logger-Instanzen. + * + * @deprecated Verwende stattdessen Dependency Injection. Der Logger wird automatisch über LoggerInitializer erstellt. + * Diese Klasse wird nur noch für Legacy-Code verwendet und sollte nicht in neuem Code genutzt werden. */ final class LoggerFactory { @@ -13,17 +19,23 @@ final class LoggerFactory /** * Erzeugt einen neuen Logger mit optionalen Einstellungen. + * + * @deprecated Verwende stattdessen Dependency Injection. Der Logger wird automatisch über LoggerInitializer erstellt. */ public static function create( + ?Clock $clock = null, LogLevel|int $minLevel = LogLevel::DEBUG, - bool $enabled = true, array $handlers = [] ): DefaultLogger { - return new DefaultLogger($minLevel, $enabled, $handlers); + $clock ??= new SystemClock(); + + return new DefaultLogger($clock, $minLevel, $handlers); } /** * Gibt den Standard-Logger zurück oder erstellt ihn, falls er noch nicht existiert. + * + * @deprecated Verwende stattdessen Dependency Injection. Der Logger wird automatisch über LoggerInitializer erstellt. */ public static function getDefaultLogger(): DefaultLogger { @@ -31,7 +43,7 @@ final class LoggerFactory $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); $minLevel = $debug ? LogLevel::DEBUG : LogLevel::INFO; - self::$defaultLogger = self::create($minLevel); + self::$defaultLogger = self::create(null, $minLevel); } return self::$defaultLogger; @@ -39,6 +51,8 @@ final class LoggerFactory /** * Setzt einen benutzerdefinierten Logger als Standard-Logger. + * + * @deprecated Verwende stattdessen Dependency Injection. */ public static function setDefaultLogger(DefaultLogger $logger): void { diff --git a/src/Framework/Logging/LoggerInitializer.php b/src/Framework/Logging/LoggerInitializer.php index 7f7db8ce..9d86956f 100644 --- a/src/Framework/Logging/LoggerInitializer.php +++ b/src/Framework/Logging/LoggerInitializer.php @@ -8,17 +8,19 @@ use App\Framework\Config\Environment; use App\Framework\Config\EnvKey; use App\Framework\Config\TypedConfiguration; use App\Framework\Core\PathProvider; +use App\Framework\DateTime\Clock; use App\Framework\DI\Container; use App\Framework\DI\Initializer; +use App\Framework\Logging\Formatter\DevelopmentFormatter; +use App\Framework\Logging\Formatter\LineFormatter; use App\Framework\Logging\Handlers\ConsoleHandler; use App\Framework\Logging\Handlers\DockerJsonHandler; use App\Framework\Logging\Handlers\FileHandler; -use App\Framework\Logging\Handlers\JsonFileHandler; use App\Framework\Logging\Handlers\MultiFileHandler; use App\Framework\Logging\Handlers\NullHandler; use App\Framework\Logging\Handlers\QueuedLogHandler; use App\Framework\Logging\Handlers\WebHandler; -use App\Framework\Queue\FileQueue; +use App\Framework\Logging\LogHandler; use App\Framework\Queue\Queue; use App\Framework\Queue\RedisQueue; use App\Framework\Redis\RedisConfig; @@ -36,18 +38,52 @@ final readonly class LoggerInitializer // MCP Server Mode: Use NullHandler to suppress all output // This prevents log interference with JSON-RPC communication if ($env->get(EnvKey::MCP_SERVER_MODE) === '1') { - $contextManager = new LogContextManager(); - $processorManager = new ProcessorManager(); - - return new DefaultLogger( - minLevel: LogLevel::DEBUG, - handlers: [new NullHandler()], - processorManager: $processorManager, - contextManager: $contextManager - ); + return $this->createMcpLogger($container); } // LogContextManager als Singleton im Container registrieren + $this->initializeLogContextManager($config, $container); + + $processorManager = new ProcessorManager(); + $minLevel = $this->determineMinLogLevel($config); + $logConfig = $this->initializeLogConfig($pathProvider); + $queue = $this->createQueue($env); + $handlers = $this->createHandlers($config, $env, $logConfig, $pathProvider, $minLevel, $queue); + $contextManager = $container->get(LogContextManager::class); + $clock = $container->get(Clock::class); + + return new DefaultLogger( + clock: $clock, + minLevel: $minLevel, + handlers: $handlers, + processorManager: $processorManager, + contextManager: $contextManager + ); + } + + /** + * Erstellt Logger für MCP Server Mode + */ + private function createMcpLogger(Container $container): Logger + { + $contextManager = new LogContextManager(); + $processorManager = new ProcessorManager(); + $clock = $container->get(Clock::class); + + return new DefaultLogger( + clock: $clock, + minLevel: LogLevel::DEBUG, + handlers: [new NullHandler()], + processorManager: $processorManager, + contextManager: $contextManager + ); + } + + /** + * Initialisiert den LogContextManager im Container + */ + private function initializeLogContextManager(TypedConfiguration $config, Container $container): void + { if (! $container->has(LogContextManager::class)) { $contextManager = new LogContextManager(); $container->singleton(LogContextManager::class, $contextManager); @@ -57,89 +93,137 @@ final readonly class LoggerInitializer $contextManager->addGlobalData('environment', $config->app->environment->value); $contextManager->addGlobalTags('application', 'framework'); } - $processorManager = new ProcessorManager(); + } - // Set log level based on environment - $minLevel = $config->app->isDebugEnabled() + /** + * Bestimmt das minimale Log-Level basierend auf der Konfiguration + */ + private function determineMinLogLevel(TypedConfiguration $config): LogLevel + { + return $config->app->isDebugEnabled() ? LogLevel::DEBUG : LogLevel::INFO; + } - // Erstelle LogConfig für zentrale Pfadverwaltung + /** + * Initialisiert die Log-Konfiguration und stellt sicher, dass Verzeichnisse existieren + */ + private function initializeLogConfig(PathProvider $pathProvider): LogConfig + { $logConfig = new LogConfig($pathProvider); - - // Stelle sicher, dass alle Logverzeichnisse existieren $logConfig->ensureLogDirectoriesExist(); + return $logConfig; + } + + /** + * Erstellt die Queue für asynchrones Logging + */ + private function createQueue(Environment $env): Queue + { $redisConfig = new RedisConfig(host: $env->getString(EnvKey::REDIS_HOST, 'redis'), database: 2); $redisConnection = new RedisConnection($redisConfig, 'queue'); - $queue = new RedisQueue($redisConnection, 'commands'); + + return new RedisQueue($redisConnection, 'commands'); // Alternativ: FileQueue mit aufgelöstem Pfad // $queuePath = $pathProvider->resolvePath('storage/queue'); - // $queue = new FileQueue($queuePath); + // return new FileQueue($queuePath); + } - // Handler-Konfiguration basierend auf Umgebung + /** + * Erstellt alle Handler basierend auf der Umgebung + * + * @param TypedConfiguration $config + * @param Environment $env + * @param LogConfig $logConfig + * @param PathProvider $pathProvider + * @param LogLevel $minLevel + * @param Queue $queue + * @return array + */ + private function createHandlers( + TypedConfiguration $config, + Environment $env, + LogConfig $logConfig, + PathProvider $pathProvider, + LogLevel $minLevel, + Queue $queue + ): array { $handlers = []; // Docker/Console Logging Handler if (PHP_SAPI === 'cli') { - // Prüfe ob wir in Docker laufen (für strukturierte JSON-Logs) - $inDocker = file_exists('/.dockerenv') || getenv('DOCKER_CONTAINER') === 'true'; - - if ($inDocker) { - if ($config->app->isProduction()) { - // Production Docker: Compact JSON für Log-Aggregatoren mit Redaction - $handlers[] = new DockerJsonHandler( - env: $env, - minLevel: $minLevel, - redactSensitiveData: true // Auto-redact in Production - ); - } else { - // Development Docker: Pretty JSON für bessere Lesbarkeit - $handlers[] = new DockerJsonHandler( - env: $env, - serviceName: $config->app->name ?? 'app', - minLevel: $minLevel, - prettyPrint: true, // Pretty-print für Development - redactSensitiveData: false // Keine Redaction in Development für Debugging - ); - } - } else { - // Lokale Entwicklung: Farbige Console-Logs - $handlers[] = new ConsoleHandler(); - } + $handlers[] = $this->createCliHandler($config, $env, $minLevel); } - $handlers[] = new QueuedLogHandler($queue); + //$handlers[] = new QueuedLogHandler($queue); $handlers[] = new WebHandler(); // MultiFileHandler für automatisches Channel-Routing + $multiFileFormatter = new LineFormatter(); + $handlers[] = new MultiFileHandler( $logConfig, $pathProvider, + $multiFileFormatter, $minLevel, - '[{timestamp}] [{level_name}] [{channel}] {message}', 0644 ); // Fallback FileHandler für Kompatibilität (nur für 'app' Channel ohne Channel-Info) + $fileFormatter = new LineFormatter( + format: 'Line Formatter: [{timestamp}] [{level_name}] {request_id}{channel}{message}', + timestampFormat: 'Y-m-d H:i:s' + ); $handlers[] = new FileHandler( + $fileFormatter, $logConfig->getLogPath('app'), $minLevel, - '[{timestamp}] [{level_name}] {request_id}{channel}{message}', 0644, null, $pathProvider ); - // LogContextManager aus Container holen - $contextManager = $container->get(LogContextManager::class); + return $handlers; + } - return new DefaultLogger( - minLevel: $minLevel, - handlers: $handlers, - processorManager: $processorManager, - contextManager: $contextManager, - ); + /** + * Erstellt den CLI-Handler (Docker JSON oder Console) + */ + private function createCliHandler( + TypedConfiguration $config, + Environment $env, + LogLevel $minLevel + ): LogHandler { + // Prüfe ob wir in Docker laufen (für strukturierte JSON-Logs) + $inDocker = file_exists('/.dockerenv') || getenv('DOCKER_CONTAINER') === 'true'; + + if ($inDocker) { + if ($config->app->isProduction()) { + // Production Docker: Compact JSON für Log-Aggregatoren mit Redaction + return new DockerJsonHandler( + env: $env, + minLevel: $minLevel, + redactSensitiveData: true // Auto-redact in Production + ); + } else { + // Development Docker: Pretty JSON für bessere Lesbarkeit + return new DockerJsonHandler( + env: $env, + serviceName: $config->app->name ?? 'app', + minLevel: $minLevel, + prettyPrint: true, // Pretty-print für Development + redactSensitiveData: false // Keine Redaction in Development für Debugging + ); + } + } else { + // Lokale Entwicklung: Farbige Console-Logs + $consoleFormatter = new DevelopmentFormatter( + includeStackTrace: true, + colorOutput: true + ); + return new ConsoleHandler($consoleFormatter, $minLevel); + } } } diff --git a/src/Framework/Logging/ProcessorManager.php b/src/Framework/Logging/ProcessorManager.php index 464dd30e..dc59e71e 100644 --- a/src/Framework/Logging/ProcessorManager.php +++ b/src/Framework/Logging/ProcessorManager.php @@ -37,20 +37,6 @@ final readonly class ProcessorManager 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; - } - /** * Sortiert eine Liste von Processors */ diff --git a/src/Framework/Logging/ValueObjects/StackFrame.php b/src/Framework/Logging/ValueObjects/StackFrame.php index d4944747..fb1a8da3 100644 --- a/src/Framework/Logging/ValueObjects/StackFrame.php +++ b/src/Framework/Logging/ValueObjects/StackFrame.php @@ -28,16 +28,91 @@ final readonly class StackFrame implements \JsonSerializable */ public static function fromArray(array $frame): self { + // Bereinige Args, um nicht-serialisierbare Objekte zu entfernen + $args = isset($frame['args']) ? self::sanitizeArgs($frame['args']) : []; + return new self( file: $frame['file'] ?? 'unknown', line: $frame['line'] ?? 0, function: $frame['function'] ?? null, class: $frame['class'] ?? null, type: $frame['type'] ?? null, - args: $frame['args'] ?? [] + args: $args ); } + /** + * Bereinigt Args, um nicht-serialisierbare Objekte (wie ReflectionClass) zu entfernen + * + * @param array $args + * @return array + */ + private static function sanitizeArgs(array $args): array + { + return array_map( + fn($arg) => self::sanitizeValue($arg), + $args + ); + } + + /** + * Bereinigt einzelnen Wert, entfernt nicht-serialisierbare Objekte + */ + private static function sanitizeValue(mixed $value): mixed + { + // Closures können nicht serialisiert werden + if ($value instanceof \Closure) { + // Versuche ReflectionFunction zu verwenden, um Closure-Details zu bekommen + try { + $reflection = new \ReflectionFunction($value); + $file = $reflection->getFileName(); + $line = $reflection->getStartLine(); + return sprintf('Closure(%s:%d)', basename($file), $line); + } catch (\Throwable) { + return 'Closure'; + } + } + + // Reflection-Objekte können nicht serialisiert werden + if (is_object($value)) { + $className = get_class($value); + if ($value instanceof \ReflectionClass + || $value instanceof \ReflectionMethod + || $value instanceof \ReflectionProperty + || $value instanceof \ReflectionFunction + || $value instanceof \ReflectionParameter + || $value instanceof \ReflectionType + || str_starts_with($className, 'Reflection')) { + // Ersetze durch String-Repräsentation + return sprintf('ReflectionObject(%s)', $className); + } + + // Anonyme Klassen können auch Probleme verursachen + if (str_contains($className, '@anonymous')) { + // Versuche den Parent-Type zu extrahieren + $parentClass = get_parent_class($value); + if ($parentClass !== false) { + return sprintf('Anonymous(%s)', $parentClass); + } + return 'Anonymous'; + } + + // Andere Objekte durch Klassenname ersetzen + return $className; + } + + // Arrays rekursiv bereinigen + if (is_array($value)) { + return array_map( + fn($item) => self::sanitizeValue($item), + $value + ); + } + + // Primitives bleiben unverändert + return $value; + } + /** * Gibt Kurzform des File-Pfads zurück (relativ zum Project Root wenn möglich) */ @@ -131,24 +206,26 @@ final readonly class StackFrame implements \JsonSerializable /** * Serialisiert Arguments für Log-Ausgabe + * + * Note: Args sind bereits beim Erstellen bereinigt, aber für toArray() + * formatieren wir sie nochmal kompakter. * * @return array */ private function serializeArgs(): array { return array_map( - fn($arg) => $this->serializeValue($arg), + fn($arg) => $this->formatValueForOutput($arg), $this->args ); } /** - * Serialisiert einzelnen Wert (verhindert zu große Ausgaben) + * Formatiert Wert für Log-Ausgabe (kompaktere Darstellung) */ - private function serializeValue(mixed $value): mixed + private function formatValueForOutput(mixed $value): mixed { return match (true) { - is_object($value) => get_class($value), is_array($value) => sprintf('array(%d)', count($value)), is_resource($value) => sprintf('resource(%s)', get_resource_type($value)), is_string($value) && strlen($value) > 100 => substr($value, 0, 100) . '...', diff --git a/src/Framework/Queue/FileQueue.php b/src/Framework/Queue/FileQueue.php index 665fd7ba..d2fadcba 100644 --- a/src/Framework/Queue/FileQueue.php +++ b/src/Framework/Queue/FileQueue.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Framework\Queue; +use App\Framework\DateTime\SystemClock; use App\Framework\Filesystem\Directory; use App\Framework\Filesystem\FileStorage; use App\Framework\Logging\DefaultLogger; @@ -61,6 +62,7 @@ final readonly class FileQueue implements Queue $this->delayedDirectory = new Directory($delayedPath, $this->storage); $this->logger = $logger ?? new DefaultLogger( + clock: new SystemClock(), minLevel: LogLevel::WARNING, handlers: [], processorManager: new ProcessorManager() diff --git a/src/Framework/Serializer/Php/PhpSerializer.php b/src/Framework/Serializer/Php/PhpSerializer.php index 2b6c494b..e5cc0ef0 100644 --- a/src/Framework/Serializer/Php/PhpSerializer.php +++ b/src/Framework/Serializer/Php/PhpSerializer.php @@ -36,13 +36,14 @@ final readonly class PhpSerializer implements Serializer { // Check for resources in the data if ($this->containsResource($data)) { - throw new SerializeException('Failed to serialize data: Resources cannot be serialized'); + throw SerializeException::simple('Failed to serialize data: Resources cannot be serialized'); } try { return serialize($data); } catch (\Exception $e) { - throw new SerializeException('Failed to serialize data: ' . $e->getMessage(), 0, $e); + + throw SerializeException::simple('Failed to serialize data: ' . $e->getMessage(), $e, 0); } } @@ -113,7 +114,7 @@ final readonly class PhpSerializer implements Serializer // Special case: if unserialize returns false, we need to check if the original data // was actually serialized false, or if there was an error if ($result === false && $data !== serialize(false)) { - throw new DeserializeException('Failed to unserialize data'); + throw DeserializeException::simple('Failed to unserialize data'); } restore_error_handler(); @@ -122,7 +123,7 @@ final readonly class PhpSerializer implements Serializer } catch (\Throwable $e) { restore_error_handler(); - throw new DeserializeException('Failed to unserialize data: ' . $e->getMessage(), 0, $e); + throw DeserializeException::simple('Failed to unserialize data: ' . $e->getMessage(), $e); } } diff --git a/tests/integration/FormatterIntegrationTest.php b/tests/integration/FormatterIntegrationTest.php index eb194d7d..6d20e6b4 100644 --- a/tests/integration/FormatterIntegrationTest.php +++ b/tests/integration/FormatterIntegrationTest.php @@ -23,7 +23,7 @@ use RuntimeException; */ // Enhanced handler that supports formatters -class FormattableHandler implements LogHandler +class LogHandler implements LogHandler { public array $handledOutputs = []; @@ -68,10 +68,10 @@ it('demonstrates different formatters with same log data', function () { ])->addTags('error', 'order_processing'); // Create handlers with different formatters - $lineHandler = new FormattableHandler(new LineFormatter()); - $jsonHandler = new FormattableHandler(new JsonFormatter()); - $devHandler = new FormattableHandler(new DevelopmentFormatter(colorOutput: false)); - $structuredHandler = new FormattableHandler(new StructuredFormatter()); + $lineHandler = new LogHandler(new LineFormatter()); + $jsonHandler = new LogHandler(new JsonFormatter()); + $devHandler = new LogHandler(new DevelopmentFormatter(colorOutput: false)); + $structuredHandler = new LogHandler(new StructuredFormatter()); $logger = new DefaultLogger( handlers: [$lineHandler, $jsonHandler, $devHandler, $structuredHandler], @@ -124,9 +124,9 @@ it('demonstrates formatter customization options', function () { $kvFormatter = new StructuredFormatter(format: 'kv'); $handlers = [ - 'custom_line' => new FormattableHandler($customLineFormatter), - 'pretty_json' => new FormattableHandler($prettyJsonFormatter), - 'key_value' => new FormattableHandler($kvFormatter), + 'custom_line' => new LogHandler($customLineFormatter), + 'pretty_json' => new LogHandler($prettyJsonFormatter), + 'key_value' => new LogHandler($kvFormatter), ]; $logger = new DefaultLogger(handlers: array_values($handlers)); @@ -149,7 +149,7 @@ it('demonstrates formatter customization options', function () { it('demonstrates formatter performance with batch logging', function () { $jsonFormatter = new JsonFormatter(); - $handler = new FormattableHandler($jsonFormatter); + $handler = new LogHandler($jsonFormatter); $logger = new DefaultLogger(handlers: [$handler]); // Batch log multiple entries