refactor: improve logging system and add deployment fixes

- Enhance logging handlers (Console, DockerJson, File, JsonFile, MultiFile)
- Improve exception and line formatters
- Update logger initialization and processor management
- Add Ansible playbooks for staging 502 error troubleshooting
- Update deployment documentation
- Fix serializer and queue components
- Update error kernel and queued log handler
This commit is contained in:
2025-11-02 01:37:49 +01:00
parent 2defdf2baf
commit cf0ad6e905
23 changed files with 612 additions and 556 deletions

22
DEPLOYMENT_FIX.md Normal file
View File

@@ -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.

View File

@@ -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 }}"

View File

@@ -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)"

View File

@@ -158,9 +158,40 @@ ansible-playbook -i deployment/ansible/inventory/production.yml \
## Verhindern in Zukunft ## Verhindern in Zukunft
1. **Entrypoint-Script:** Das Entrypoint-Script behebt das Problem automatisch beim Container-Start ### Nach jedem Deployment
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` **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 ## Siehe auch

View File

@@ -16,6 +16,7 @@ use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Http\ResponseEmitter; use App\Framework\Http\ResponseEmitter;
use App\Framework\Logging\DefaultLogger; use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Logger; use App\Framework\Logging\Logger;
use App\Framework\Logging\LoggerInitializer;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface; use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\Reflection\CachedReflectionProvider; use App\Framework\Reflection\CachedReflectionProvider;
@@ -135,12 +136,22 @@ final readonly class ContainerBootstrapper
PerformanceCollectorInterface $collector PerformanceCollectorInterface $collector
): void { ): void {
// Core services that need runtime data // 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(PerformanceCollectorInterface::class, $collector);
$container->instance(Cache::class, new CacheInitializer($collector, $container)()); $container->instance(Cache::class, new CacheInitializer($collector, $container)());
$container->instance(PathProvider::class, new PathProvider($basePath));
$container->instance(ResponseEmitter::class, new ResponseEmitter()); $container->instance(ResponseEmitter::class, new ResponseEmitter());
$container->instance(Clock::class, new SystemClock());
// TEMPORARY FIX: Manual RequestFactory binding until Discovery issue is resolved // TEMPORARY FIX: Manual RequestFactory binding until Discovery issue is resolved
$container->singleton(\App\Framework\Http\Request::class, function ($container) { $container->singleton(\App\Framework\Http\Request::class, function ($container) {
@@ -220,9 +231,15 @@ final readonly class ContainerBootstrapper
$isMcpMode = getenv('MCP_SERVER_MODE') === '1'; $isMcpMode = getenv('MCP_SERVER_MODE') === '1';
$handlers = $isMcpMode $handlers = $isMcpMode
? [new \App\Framework\Logging\Handlers\NullHandler()] ? [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( $logger = new \App\Framework\Logging\DefaultLogger(
clock: $clock,
minLevel: \App\Framework\Logging\LogLevel::DEBUG, minLevel: \App\Framework\Logging\LogLevel::DEBUG,
handlers: $handlers, handlers: $handlers,
processorManager: new \App\Framework\Logging\ProcessorManager(), processorManager: new \App\Framework\Logging\ProcessorManager(),

View File

@@ -25,8 +25,11 @@ final readonly class MethodInvoker
/** /**
* Führt eine Methode auf einer Klasse aus und löst alle Parameter automatisch auf * 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()); $instance = $this->container->get($className->getFullyQualified());
return $this->invokeOn($instance, $methodName, $overrides); return $this->invokeOn($instance, $methodName, $overrides);

View File

@@ -17,6 +17,8 @@ final readonly class ErrorKernel
$log = new LogReporter(); $log = new LogReporter();
$log->report($e->getMessage()); $log->report($e->getMessage());
var_dump((string)$e);
$this->rendererFactory->getRenderer()->render(); $this->rendererFactory->getRenderer()->render();
return null; return null;

View File

@@ -5,10 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Logging; namespace App\Framework\Logging;
use App\Framework\Attributes\Singleton; use App\Framework\Attributes\Singleton;
use App\Framework\DateTime\Clock;
use App\Framework\Logging\ValueObjects\LogContext; use App\Framework\Logging\ValueObjects\LogContext;
use DateMalformedStringException; use DateMalformedStringException;
use DateTimeImmutable;
use DateTimeZone;
/** /**
* Einfacher Logger für das Framework. * Einfacher Logger für das Framework.
@@ -19,12 +18,14 @@ final readonly class DefaultLogger implements Logger, SupportsChannels
private ChannelLoggerRegistry $channelRegistry; private ChannelLoggerRegistry $channelRegistry;
/** /**
* @param Clock $clock Clock für Timestamp-Generierung
* @param LogLevel $minLevel Minimales Level, das geloggt werden soll * @param LogLevel $minLevel Minimales Level, das geloggt werden soll
* @param array<LogHandler> $handlers Array von Log-Handlern * @param array<LogHandler> $handlers Array von Log-Handlern
* @param ProcessorManager $processorManager Processor Manager für die Verarbeitung * @param ProcessorManager $processorManager Processor Manager für die Verarbeitung
* @param LogContextManager|null $contextManager Optional: Context Manager für automatische Kontext-Anreicherung * @param LogContextManager|null $contextManager Optional: Context Manager für automatische Kontext-Anreicherung
*/ */
public function __construct( public function __construct(
private Clock $clock,
private LogLevel $minLevel = LogLevel::DEBUG, private LogLevel $minLevel = LogLevel::DEBUG,
/** @var LogHandler[] */ /** @var LogHandler[] */
private array $handlers = [], private array $handlers = [],
@@ -84,6 +85,30 @@ final readonly class DefaultLogger implements Logger, SupportsChannels
* @throws DateMalformedStringException * @throws DateMalformedStringException
*/ */
public function log(LogLevel $level, string $message, ?LogContext $context = null): void 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 // Wenn kein Context übergeben, leeren Context erstellen
if ($context === null) { if ($context === null) {
@@ -103,47 +128,8 @@ final readonly class DefaultLogger implements Logger, SupportsChannels
message: $message, message: $message,
context: $finalContext, context: $finalContext,
level: $level, level: $level,
timestamp: new DateTimeImmutable(timezone: new DateTimeZone('Europe/Berlin')), timestamp: $this->clock->now(),
); channel: $channel
// 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
); );
// Record durch alle Processors verarbeiten // Record durch alle Processors verarbeiten
@@ -173,75 +159,6 @@ final readonly class DefaultLogger implements Logger, SupportsChannels
return $currentContext->merge($context); 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 * Holt einen ChannelLogger für einen spezifischen Channel

View File

@@ -13,11 +13,11 @@ use App\Framework\Logging\ValueObjects\StackFrame;
* Formatiert ExceptionContext zu lesbarem String-Format * Formatiert ExceptionContext zu lesbarem String-Format
* ähnlich wie PHP's natürliche Exception-Ausgabe. * ähnlich wie PHP's natürliche Exception-Ausgabe.
*/ */
final class ExceptionFormatter final readonly class ExceptionFormatter
{ {
public function __construct( public function __construct(
private readonly int $maxStackFrames = 10, private int $maxStackFrames = 10,
private readonly bool $includeArgs = false private bool $includeArgs = false
) { ) {
} }

View File

@@ -23,12 +23,24 @@ final readonly class LineFormatter implements LogFormatter
$allData = array_merge($context, $extras); $allData = array_merge($context, $extras);
$contextString = ! empty($allData) ? json_encode($allData, JSON_UNESCAPED_SLASHES) : ''; $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 = [ $replacements = [
'{timestamp}' => $record->getFormattedTimestamp($this->timestampFormat), '{timestamp}' => $record->getFormattedTimestamp($this->timestampFormat),
'{channel}' => $record->channel ?? 'app', '{channel}' => $record->channel ?? 'app',
'{level}' => $record->level->getName(), '{level}' => $record->level->getName(),
'{level_name}' => $record->level->getName(),
'{message}' => $record->message, '{message}' => $record->message,
'{context}' => $contextString, '{context}' => $contextString,
'{request_id}' => $requestId,
]; ];
return strtr($this->format, $replacements); return strtr($this->format, $replacements);

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers; namespace App\Framework\Logging\Handlers;
use App\Framework\Console\ConsoleColor;
use App\Framework\Logging\LogHandler; use App\Framework\Logging\LogHandler;
use App\Framework\Logging\Formatter\LogFormatter;
use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord; use App\Framework\Logging\LogRecord;
@@ -25,26 +25,26 @@ class ConsoleHandler implements LogHandler
private bool $debugOnly; 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 * 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 LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird
* @param bool $debugOnly Ob der Handler nur im Debug-Modus aktiv ist * @param bool $debugOnly Ob der Handler nur im Debug-Modus aktiv ist
* @param string $outputFormat Format für die Ausgabe
*/ */
public function __construct( public function __construct(
LogFormatter $formatter,
LogLevel|int $minLevel = LogLevel::DEBUG, LogLevel|int $minLevel = LogLevel::DEBUG,
bool $debugOnly = true, bool $debugOnly = true,
string $outputFormat = '{color}[{level_name}]{reset} {timestamp} {request_id}{message}{structured}',
private readonly LogLevel $stderrLevel = LogLevel::WARNING, private readonly LogLevel $stderrLevel = LogLevel::WARNING,
) { ) {
$this->formatter = $formatter;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); $this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->debugOnly = $debugOnly; $this->debugOnly = $debugOnly;
$this->outputFormat = $outputFormat;
} }
/** /**
@@ -71,32 +71,18 @@ class ConsoleHandler implements LogHandler
*/ */
public function handle(LogRecord $record): void public function handle(LogRecord $record): void
{ {
$logLevel = $record->getLevel(); // Formatter verwenden für Formatierung
$color = $logLevel->getConsoleColor()->value; $formatted = ($this->formatter)($record);
$reset = ConsoleColor::RESET->value;
// Request-ID-Teil erstellen, falls vorhanden // Formatter gibt immer string zurück für Console
$requestId = $record->hasExtra('request_id') $output = is_string($formatted)
? "[{$record->getExtra('request_id')}] " ? $formatted
: ''; : json_encode($formatted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Structured Logging Extras formatieren // Stelle sicher, dass ein Newline vorhanden ist
$structuredInfo = $this->formatStructuredExtras($record); if (!str_ends_with($output, PHP_EOL)) {
$output .= PHP_EOL;
// 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;
// Fehler und Warnungen auf stderr, alles andere auf stdout // Fehler und Warnungen auf stderr, alles andere auf stdout
if ($record->getLevel()->value >= $this->stderrLevel->value) { 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 * Minimales Log-Level setzen
*/ */
@@ -117,101 +111,4 @@ class ConsoleHandler implements LogHandler
return $this; 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);
}
} }

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers; namespace App\Framework\Logging\Handlers;
use App\Framework\Config\Environment; use App\Framework\Config\Environment;
use App\Framework\Logging\FormattableHandler;
use App\Framework\Logging\Formatter\JsonFormatter; 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\LogLevel;
use App\Framework\Logging\LogRecord; use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Security\SensitiveDataRedactor; use App\Framework\Logging\Security\SensitiveDataRedactor;
@@ -33,7 +34,7 @@ use App\Framework\Logging\Security\SensitiveDataRedactor;
* - docker logs <container> 2>&1 | jq 'select(.level == "ERROR")' * - docker logs <container> 2>&1 | jq 'select(.level == "ERROR")'
* - docker logs <container> 2>&1 | jq -r '[.timestamp, .level, .message] | @tsv' * - docker logs <container> 2>&1 | jq -r '[.timestamp, .level, .message] | @tsv'
*/ */
final readonly class DockerJsonHandler implements LogHandler final readonly class DockerJsonHandler implements FormattableHandler
{ {
private JsonFormatter $formatter; private JsonFormatter $formatter;
private LogLevel $minLevel; private LogLevel $minLevel;
@@ -79,6 +80,14 @@ final readonly class DockerJsonHandler implements LogHandler
echo $json . PHP_EOL; echo $json . PHP_EOL;
} }
/**
* Gibt den Formatter zurück
*/
public function getFormatter(): LogFormatter
{
return $this->formatter;
}
/** /**
* Setzt minimales Log-Level * Setzt minimales Log-Level
*/ */

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers; namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider; use App\Framework\Core\PathProvider;
use App\Framework\Logging\Formatter\LogFormatter;
use App\Framework\Logging\LogHandler; use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord; use App\Framework\Logging\LogRecord;
@@ -26,9 +27,9 @@ class FileHandler implements LogHandler
private string $logFile; 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 * @var int Datei-Modi für die Log-Datei
@@ -48,22 +49,23 @@ class FileHandler implements LogHandler
/** /**
* Erstellt einen neuen FileHandler * Erstellt einen neuen FileHandler
* *
* @param LogFormatter $formatter Formatter für die Log-Ausgabe
* @param string $logFile Pfad zur Log-Datei * @param string $logFile Pfad zur Log-Datei
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird * @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 int $fileMode Datei-Modi für die Log-Datei
* @param LogRotator|null $rotator Optional: Log-Rotator für automatische Rotation * @param LogRotator|null $rotator Optional: Log-Rotator für automatische Rotation
* @param PathProvider|null $pathProvider Optional: PathProvider für die Auflösung von Pfaden * @param PathProvider|null $pathProvider Optional: PathProvider für die Auflösung von Pfaden
*/ */
public function __construct( public function __construct(
LogFormatter $formatter,
string $logFile, string $logFile,
LogLevel|int $minLevel = LogLevel::DEBUG, LogLevel|int $minLevel = LogLevel::DEBUG,
string $outputFormat = '[{timestamp}] [{level_name}] {request_id}{channel}{message}',
int $fileMode = 0644, int $fileMode = 0644,
?LogRotator $rotator = null, ?LogRotator $rotator = null,
?PathProvider $pathProvider = null ?PathProvider $pathProvider = null
) { ) {
$this->pathProvider = $pathProvider; $this->pathProvider = $pathProvider;
$this->formatter = $formatter;
// Pfad auflösen, falls PathProvider vorhanden // Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) { if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
@@ -72,7 +74,6 @@ class FileHandler implements LogHandler
$this->logFile = $logFile; $this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); $this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->outputFormat = $outputFormat;
$this->fileMode = $fileMode; $this->fileMode = $fileMode;
$this->rotator = $rotator; $this->rotator = $rotator;
@@ -93,27 +94,13 @@ class FileHandler implements LogHandler
*/ */
public function handle(LogRecord $record): void public function handle(LogRecord $record): void
{ {
// Request-ID-Teil erstellen, falls vorhanden // Formatter verwenden für Formatierung
$requestId = $record->hasExtra('request_id') $formatted = ($this->formatter)($record);
? "[{$record->getExtra('request_id')}] "
: '';
// Channel-Teil erstellen, falls vorhanden // Formatter kann string oder array zurückgeben
$channel = $record->getChannel() $output = is_string($formatted)
? "[{$record->getChannel()}] " ? $formatted . PHP_EOL
: ''; : json_encode($formatted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL;
// 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;
// Prüfe Rotation vor dem Schreiben // Prüfe Rotation vor dem Schreiben
if ($this->rotator !== null) { 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 * Schreibt einen String in die Log-Datei
*/ */
@@ -167,15 +162,6 @@ class FileHandler implements LogHandler
return $this; return $this;
} }
/**
* Ausgabeformat setzen
*/
public function setOutputFormat(string $format): self
{
$this->outputFormat = $format;
return $this;
}
/** /**
* Log-Datei setzen * Log-Datei setzen

View File

@@ -6,8 +6,9 @@ namespace App\Framework\Logging\Handlers;
use App\Framework\Config\Environment; use App\Framework\Config\Environment;
use App\Framework\Core\PathProvider; use App\Framework\Core\PathProvider;
use App\Framework\Filesystem\Serializers\JsonSerializer;
use App\Framework\Logging\LogHandler; 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\LogLevel;
use App\Framework\Logging\LogRecord; 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. * Handler für die Ausgabe von Log-Einträgen als JSON in Dateien.
* Besonders nützlich für maschinelle Verarbeitung und Log-Aggregatoren. * 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: * Standard-Felder für Log-Aggregatoren:
* - @timestamp: Elasticsearch-konformes Zeitstempelfeld * - @timestamp: Elasticsearch-konformes Zeitstempelfeld
@@ -37,62 +38,31 @@ class JsonFileHandler implements LogHandler
private string $logFile; 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 * @var PathProvider|null PathProvider für die Auflösung von Pfaden
*/ */
private ?PathProvider $pathProvider = null; 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 * Erstellt einen neuen JsonFileHandler
* *
* @param JsonFormatter $formatter Formatter für die JSON-Ausgabe
* @param string $logFile Pfad zur Log-Datei * @param string $logFile Pfad zur Log-Datei
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird * @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 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( public function __construct(
JsonFormatter $formatter,
string $logFile, string $logFile,
LogLevel|int $minLevel = LogLevel::INFO, LogLevel|int $minLevel = LogLevel::INFO,
?array $includedFields = null, ?PathProvider $pathProvider = null
?PathProvider $pathProvider = null,
bool $flattenContext = true,
?Environment $env = null,
?string $serviceName = null
) { ) {
$this->pathProvider = $pathProvider; $this->pathProvider = $pathProvider;
$this->flattenContext = $flattenContext; $this->formatter = $formatter;
// Pfad auflösen, falls PathProvider vorhanden // Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) { if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
@@ -102,34 +72,6 @@ class JsonFileHandler implements LogHandler
$this->logFile = $logFile; $this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel); $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 // Stelle sicher, dass das Verzeichnis existiert
$this->ensureDirectoryExists(dirname($logFile)); $this->ensureDirectoryExists(dirname($logFile));
} }
@@ -147,81 +89,26 @@ class JsonFileHandler implements LogHandler
*/ */
public function handle(LogRecord $record): void public function handle(LogRecord $record): void
{ {
// Formatiere Record zu einheitlichem Array (konsistent mit JsonFormatter) // Formatter verwenden für JSON-Formatierung
$data = $this->formatRecord($record); $json = ($this->formatter)($record);
// Als JSON formatieren mit JsonSerializer und in die Datei schreiben // JsonFormatter gibt immer string zurück
$json = $this->serializer->serialize($data) . PHP_EOL; $output = is_string($json) ? $json : json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->write($json);
// 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 * Gibt den Formatter zurück
* (Gleiche Logik wie JsonFormatter für Konsistenz)
*
* @return array<string, mixed>
*/ */
private function formatRecord(LogRecord $record): array public function getFormatter(): LogFormatter
{ {
$timestamp = $record->timestamp->format('c'); // ISO 8601 return $this->formatter;
$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<string, mixed> $data
* @return array<string, mixed>
*/
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;
} }
/** /**
@@ -252,15 +139,6 @@ class JsonFileHandler implements LogHandler
return $this; 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 * Log-Datei setzen

View File

@@ -5,9 +5,10 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers; namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider; 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\LogChannel;
use App\Framework\Logging\LogConfig; use App\Framework\Logging\LogConfig;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel; use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord; use App\Framework\Logging\LogRecord;
@@ -24,13 +25,19 @@ final class MultiFileHandler implements LogHandler
*/ */
private array $fileHandles = []; private array $fileHandles = [];
/**
* @var LogFormatter Formatter für die Log-Ausgabe
*/
private LogFormatter $formatter;
public function __construct( public function __construct(
private readonly mixed $logConfig, private readonly LogConfig $logConfig,
private readonly mixed $pathProvider, private readonly PathProvider $pathProvider,
LogFormatter $formatter,
private readonly LogLevel $minLevel = LogLevel::DEBUG, private readonly LogLevel $minLevel = LogLevel::DEBUG,
private readonly string $outputFormat = '[{timestamp}] [{level_name}] [{channel}] {message}',
private readonly int $fileMode = 0644 private readonly int $fileMode = 0644
) { ) {
$this->formatter = $formatter;
} }
/** /**
@@ -56,13 +63,31 @@ final class MultiFileHandler implements LogHandler
// Entsprechende Log-Datei ermitteln // Entsprechende Log-Datei ermitteln
$logFile = $this->getLogFileForChannel($channel); $logFile = $this->getLogFileForChannel($channel);
// Log-Nachricht formatieren // Formatter verwenden für Formatierung
$formattedMessage = $this->formatMessage($record); $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 // In Datei schreiben
$this->writeToFile($logFile, $formattedMessage); $this->writeToFile($logFile, $formattedMessage);
} }
/**
* Gibt den Formatter zurück
*/
public function getFormatter(): LogFormatter
{
return $this->formatter;
}
/** /**
* Ermittelt die Log-Datei für einen Channel * Ermittelt die Log-Datei für einen Channel
*/ */
@@ -81,29 +106,6 @@ final class MultiFileHandler implements LogHandler
return $this->logConfig->getLogPath($logPathKey); 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 * Schreibt die formatierte Nachricht in eine Datei
*/ */

View File

@@ -26,8 +26,9 @@ final readonly class QueuedLogHandler implements LogHandler
public function handle(LogRecord $record): void public function handle(LogRecord $record): void
{ {
$job = new ProcessLogCommand($record); var_dump('whould be queued');
$payload = JobPayload::immediate($job); #$job = new ProcessLogCommand($record);
$this->queue->push($payload); #$payload = JobPayload::immediate($job);
#$this->queue->push($payload);
} }
} }

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Logging; namespace App\Framework\Logging;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
/** /**
* Factory für Logger-Instanzen. * 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 final class LoggerFactory
{ {
@@ -13,17 +19,23 @@ final class LoggerFactory
/** /**
* Erzeugt einen neuen Logger mit optionalen Einstellungen. * Erzeugt einen neuen Logger mit optionalen Einstellungen.
*
* @deprecated Verwende stattdessen Dependency Injection. Der Logger wird automatisch über LoggerInitializer erstellt.
*/ */
public static function create( public static function create(
?Clock $clock = null,
LogLevel|int $minLevel = LogLevel::DEBUG, LogLevel|int $minLevel = LogLevel::DEBUG,
bool $enabled = true,
array $handlers = [] array $handlers = []
): DefaultLogger { ): 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. * 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 public static function getDefaultLogger(): DefaultLogger
{ {
@@ -31,7 +43,7 @@ final class LoggerFactory
$debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN);
$minLevel = $debug ? LogLevel::DEBUG : LogLevel::INFO; $minLevel = $debug ? LogLevel::DEBUG : LogLevel::INFO;
self::$defaultLogger = self::create($minLevel); self::$defaultLogger = self::create(null, $minLevel);
} }
return self::$defaultLogger; return self::$defaultLogger;
@@ -39,6 +51,8 @@ final class LoggerFactory
/** /**
* Setzt einen benutzerdefinierten Logger als Standard-Logger. * Setzt einen benutzerdefinierten Logger als Standard-Logger.
*
* @deprecated Verwende stattdessen Dependency Injection.
*/ */
public static function setDefaultLogger(DefaultLogger $logger): void public static function setDefaultLogger(DefaultLogger $logger): void
{ {

View File

@@ -8,17 +8,19 @@ use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey; use App\Framework\Config\EnvKey;
use App\Framework\Config\TypedConfiguration; use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\PathProvider; use App\Framework\Core\PathProvider;
use App\Framework\DateTime\Clock;
use App\Framework\DI\Container; use App\Framework\DI\Container;
use App\Framework\DI\Initializer; 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\ConsoleHandler;
use App\Framework\Logging\Handlers\DockerJsonHandler; use App\Framework\Logging\Handlers\DockerJsonHandler;
use App\Framework\Logging\Handlers\FileHandler; use App\Framework\Logging\Handlers\FileHandler;
use App\Framework\Logging\Handlers\JsonFileHandler;
use App\Framework\Logging\Handlers\MultiFileHandler; use App\Framework\Logging\Handlers\MultiFileHandler;
use App\Framework\Logging\Handlers\NullHandler; use App\Framework\Logging\Handlers\NullHandler;
use App\Framework\Logging\Handlers\QueuedLogHandler; use App\Framework\Logging\Handlers\QueuedLogHandler;
use App\Framework\Logging\Handlers\WebHandler; 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\Queue;
use App\Framework\Queue\RedisQueue; use App\Framework\Queue\RedisQueue;
use App\Framework\Redis\RedisConfig; use App\Framework\Redis\RedisConfig;
@@ -36,10 +38,40 @@ final readonly class LoggerInitializer
// MCP Server Mode: Use NullHandler to suppress all output // MCP Server Mode: Use NullHandler to suppress all output
// This prevents log interference with JSON-RPC communication // This prevents log interference with JSON-RPC communication
if ($env->get(EnvKey::MCP_SERVER_MODE) === '1') { if ($env->get(EnvKey::MCP_SERVER_MODE) === '1') {
$contextManager = new LogContextManager(); return $this->createMcpLogger($container);
}
// LogContextManager als Singleton im Container registrieren
$this->initializeLogContextManager($config, $container);
$processorManager = new ProcessorManager(); $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( 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, minLevel: LogLevel::DEBUG,
handlers: [new NullHandler()], handlers: [new NullHandler()],
processorManager: $processorManager, processorManager: $processorManager,
@@ -47,7 +79,11 @@ final readonly class LoggerInitializer
); );
} }
// LogContextManager als Singleton im Container registrieren /**
* Initialisiert den LogContextManager im Container
*/
private function initializeLogContextManager(TypedConfiguration $config, Container $container): void
{
if (! $container->has(LogContextManager::class)) { if (! $container->has(LogContextManager::class)) {
$contextManager = new LogContextManager(); $contextManager = new LogContextManager();
$container->singleton(LogContextManager::class, $contextManager); $container->singleton(LogContextManager::class, $contextManager);
@@ -57,46 +93,123 @@ final readonly class LoggerInitializer
$contextManager->addGlobalData('environment', $config->app->environment->value); $contextManager->addGlobalData('environment', $config->app->environment->value);
$contextManager->addGlobalTags('application', 'framework'); $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::DEBUG
: LogLevel::INFO; : 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); $logConfig = new LogConfig($pathProvider);
// Stelle sicher, dass alle Logverzeichnisse existieren
$logConfig->ensureLogDirectoriesExist(); $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); $redisConfig = new RedisConfig(host: $env->getString(EnvKey::REDIS_HOST, 'redis'), database: 2);
$redisConnection = new RedisConnection($redisConfig, 'queue'); $redisConnection = new RedisConnection($redisConfig, 'queue');
$queue = new RedisQueue($redisConnection, 'commands');
return new RedisQueue($redisConnection, 'commands');
// Alternativ: FileQueue mit aufgelöstem Pfad // Alternativ: FileQueue mit aufgelöstem Pfad
// $queuePath = $pathProvider->resolvePath('storage/queue'); // $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<LogHandler>
*/
private function createHandlers(
TypedConfiguration $config,
Environment $env,
LogConfig $logConfig,
PathProvider $pathProvider,
LogLevel $minLevel,
Queue $queue
): array {
$handlers = []; $handlers = [];
// Docker/Console Logging Handler // Docker/Console Logging Handler
if (PHP_SAPI === 'cli') { if (PHP_SAPI === 'cli') {
$handlers[] = $this->createCliHandler($config, $env, $minLevel);
}
//$handlers[] = new QueuedLogHandler($queue);
$handlers[] = new WebHandler();
// MultiFileHandler für automatisches Channel-Routing
$multiFileFormatter = new LineFormatter();
$handlers[] = new MultiFileHandler(
$logConfig,
$pathProvider,
$multiFileFormatter,
$minLevel,
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,
0644,
null,
$pathProvider
);
return $handlers;
}
/**
* 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) // Prüfe ob wir in Docker laufen (für strukturierte JSON-Logs)
$inDocker = file_exists('/.dockerenv') || getenv('DOCKER_CONTAINER') === 'true'; $inDocker = file_exists('/.dockerenv') || getenv('DOCKER_CONTAINER') === 'true';
if ($inDocker) { if ($inDocker) {
if ($config->app->isProduction()) { if ($config->app->isProduction()) {
// Production Docker: Compact JSON für Log-Aggregatoren mit Redaction // Production Docker: Compact JSON für Log-Aggregatoren mit Redaction
$handlers[] = new DockerJsonHandler( return new DockerJsonHandler(
env: $env, env: $env,
minLevel: $minLevel, minLevel: $minLevel,
redactSensitiveData: true // Auto-redact in Production redactSensitiveData: true // Auto-redact in Production
); );
} else { } else {
// Development Docker: Pretty JSON für bessere Lesbarkeit // Development Docker: Pretty JSON für bessere Lesbarkeit
$handlers[] = new DockerJsonHandler( return new DockerJsonHandler(
env: $env, env: $env,
serviceName: $config->app->name ?? 'app', serviceName: $config->app->name ?? 'app',
minLevel: $minLevel, minLevel: $minLevel,
@@ -106,40 +219,11 @@ final readonly class LoggerInitializer
} }
} else { } else {
// Lokale Entwicklung: Farbige Console-Logs // Lokale Entwicklung: Farbige Console-Logs
$handlers[] = new ConsoleHandler(); $consoleFormatter = new DevelopmentFormatter(
} includeStackTrace: true,
} colorOutput: true
$handlers[] = new QueuedLogHandler($queue);
$handlers[] = new WebHandler();
// MultiFileHandler für automatisches Channel-Routing
$handlers[] = new MultiFileHandler(
$logConfig,
$pathProvider,
$minLevel,
'[{timestamp}] [{level_name}] [{channel}] {message}',
0644
);
// Fallback FileHandler für Kompatibilität (nur für 'app' Channel ohne Channel-Info)
$handlers[] = new FileHandler(
$logConfig->getLogPath('app'),
$minLevel,
'[{timestamp}] [{level_name}] {request_id}{channel}{message}',
0644,
null,
$pathProvider
);
// LogContextManager aus Container holen
$contextManager = $container->get(LogContextManager::class);
return new DefaultLogger(
minLevel: $minLevel,
handlers: $handlers,
processorManager: $processorManager,
contextManager: $contextManager,
); );
return new ConsoleHandler($consoleFormatter, $minLevel);
}
} }
} }

View File

@@ -37,20 +37,6 @@ final readonly class ProcessorManager
return $record; return $record;
} }
/**
* Sortiert die Processors nach Priorität (höhere Priorität = frühere Ausführung)
*/
private function sortProcessors(): array
{
$processors = $this->processors;
usort($processors, function (LogProcessor $a, LogProcessor $b) {
return $b->getPriority() <=> $a->getPriority(); // Absteigend sortieren
});
return $processors;
}
/** /**
* Sortiert eine Liste von Processors * Sortiert eine Liste von Processors
*/ */

View File

@@ -28,16 +28,91 @@ final readonly class StackFrame implements \JsonSerializable
*/ */
public static function fromArray(array $frame): self 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( return new self(
file: $frame['file'] ?? 'unknown', file: $frame['file'] ?? 'unknown',
line: $frame['line'] ?? 0, line: $frame['line'] ?? 0,
function: $frame['function'] ?? null, function: $frame['function'] ?? null,
class: $frame['class'] ?? null, class: $frame['class'] ?? null,
type: $frame['type'] ?? null, type: $frame['type'] ?? null,
args: $frame['args'] ?? [] args: $args
); );
} }
/**
* Bereinigt Args, um nicht-serialisierbare Objekte (wie ReflectionClass) zu entfernen
*
* @param array<int, mixed> $args
* @return array<int, mixed>
*/
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) * Gibt Kurzform des File-Pfads zurück (relativ zum Project Root wenn möglich)
*/ */
@@ -132,23 +207,25 @@ final readonly class StackFrame implements \JsonSerializable
/** /**
* Serialisiert Arguments für Log-Ausgabe * Serialisiert Arguments für Log-Ausgabe
* *
* Note: Args sind bereits beim Erstellen bereinigt, aber für toArray()
* formatieren wir sie nochmal kompakter.
*
* @return array<int, mixed> * @return array<int, mixed>
*/ */
private function serializeArgs(): array private function serializeArgs(): array
{ {
return array_map( return array_map(
fn($arg) => $this->serializeValue($arg), fn($arg) => $this->formatValueForOutput($arg),
$this->args $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) { return match (true) {
is_object($value) => get_class($value),
is_array($value) => sprintf('array(%d)', count($value)), is_array($value) => sprintf('array(%d)', count($value)),
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)), is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
is_string($value) && strlen($value) > 100 => substr($value, 0, 100) . '...', is_string($value) && strlen($value) > 100 => substr($value, 0, 100) . '...',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Queue; namespace App\Framework\Queue;
use App\Framework\DateTime\SystemClock;
use App\Framework\Filesystem\Directory; use App\Framework\Filesystem\Directory;
use App\Framework\Filesystem\FileStorage; use App\Framework\Filesystem\FileStorage;
use App\Framework\Logging\DefaultLogger; use App\Framework\Logging\DefaultLogger;
@@ -61,6 +62,7 @@ final readonly class FileQueue implements Queue
$this->delayedDirectory = new Directory($delayedPath, $this->storage); $this->delayedDirectory = new Directory($delayedPath, $this->storage);
$this->logger = $logger ?? new DefaultLogger( $this->logger = $logger ?? new DefaultLogger(
clock: new SystemClock(),
minLevel: LogLevel::WARNING, minLevel: LogLevel::WARNING,
handlers: [], handlers: [],
processorManager: new ProcessorManager() processorManager: new ProcessorManager()

View File

@@ -36,13 +36,14 @@ final readonly class PhpSerializer implements Serializer
{ {
// Check for resources in the data // Check for resources in the data
if ($this->containsResource($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 { try {
return serialize($data); return serialize($data);
} catch (\Exception $e) { } 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 // Special case: if unserialize returns false, we need to check if the original data
// was actually serialized false, or if there was an error // was actually serialized false, or if there was an error
if ($result === false && $data !== serialize(false)) { if ($result === false && $data !== serialize(false)) {
throw new DeserializeException('Failed to unserialize data'); throw DeserializeException::simple('Failed to unserialize data');
} }
restore_error_handler(); restore_error_handler();
@@ -122,7 +123,7 @@ final readonly class PhpSerializer implements Serializer
} catch (\Throwable $e) { } catch (\Throwable $e) {
restore_error_handler(); restore_error_handler();
throw new DeserializeException('Failed to unserialize data: ' . $e->getMessage(), 0, $e); throw DeserializeException::simple('Failed to unserialize data: ' . $e->getMessage(), $e);
} }
} }

View File

@@ -23,7 +23,7 @@ use RuntimeException;
*/ */
// Enhanced handler that supports formatters // Enhanced handler that supports formatters
class FormattableHandler implements LogHandler class LogHandler implements LogHandler
{ {
public array $handledOutputs = []; public array $handledOutputs = [];
@@ -68,10 +68,10 @@ it('demonstrates different formatters with same log data', function () {
])->addTags('error', 'order_processing'); ])->addTags('error', 'order_processing');
// Create handlers with different formatters // Create handlers with different formatters
$lineHandler = new FormattableHandler(new LineFormatter()); $lineHandler = new LogHandler(new LineFormatter());
$jsonHandler = new FormattableHandler(new JsonFormatter()); $jsonHandler = new LogHandler(new JsonFormatter());
$devHandler = new FormattableHandler(new DevelopmentFormatter(colorOutput: false)); $devHandler = new LogHandler(new DevelopmentFormatter(colorOutput: false));
$structuredHandler = new FormattableHandler(new StructuredFormatter()); $structuredHandler = new LogHandler(new StructuredFormatter());
$logger = new DefaultLogger( $logger = new DefaultLogger(
handlers: [$lineHandler, $jsonHandler, $devHandler, $structuredHandler], handlers: [$lineHandler, $jsonHandler, $devHandler, $structuredHandler],
@@ -124,9 +124,9 @@ it('demonstrates formatter customization options', function () {
$kvFormatter = new StructuredFormatter(format: 'kv'); $kvFormatter = new StructuredFormatter(format: 'kv');
$handlers = [ $handlers = [
'custom_line' => new FormattableHandler($customLineFormatter), 'custom_line' => new LogHandler($customLineFormatter),
'pretty_json' => new FormattableHandler($prettyJsonFormatter), 'pretty_json' => new LogHandler($prettyJsonFormatter),
'key_value' => new FormattableHandler($kvFormatter), 'key_value' => new LogHandler($kvFormatter),
]; ];
$logger = new DefaultLogger(handlers: array_values($handlers)); $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 () { it('demonstrates formatter performance with batch logging', function () {
$jsonFormatter = new JsonFormatter(); $jsonFormatter = new JsonFormatter();
$handler = new FormattableHandler($jsonFormatter); $handler = new LogHandler($jsonFormatter);
$logger = new DefaultLogger(handlers: [$handler]); $logger = new DefaultLogger(handlers: [$handler]);
// Batch log multiple entries // Batch log multiple entries