docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Interface für Channel-spezifisches Logging
*
* Ermöglicht Logging in spezifische Channels mit allen Standard-Log-Levels.
* Verwendet vom Haupt-Logger für die channel-spezifischen Properties.
*/
interface ChannelLogger
{
/**
* Log DEBUG level message
*/
public function debug(string $message, ?LogContext $context = null): void;
/**
* Log INFO level message
*/
public function info(string $message, ?LogContext $context = null): void;
/**
* Log NOTICE level message
*/
public function notice(string $message, ?LogContext $context = null): void;
/**
* Log WARNING level message
*/
public function warning(string $message, ?LogContext $context = null): void;
/**
* Log ERROR level message
*/
public function error(string $message, ?LogContext $context = null): void;
/**
* Log CRITICAL level message
*/
public function critical(string $message, ?LogContext $context = null): void;
/**
* Log ALERT level message
*/
public function alert(string $message, ?LogContext $context = null): void;
/**
* Log EMERGENCY level message
*/
public function emergency(string $message, ?LogContext $context = null): void;
/**
* Log with arbitrary level
*/
public function log(LogLevel $level, string $message, ?LogContext $context = null): void;
/**
* Gibt den Channel zurück, in den dieser Logger schreibt
*/
public function getChannel(): LogChannel;
}

View File

@@ -14,7 +14,7 @@ use App\Framework\Logging\LogRotator;
final readonly class RotateLogsCommand
{
#[ConsoleCommand(name: "logs:rotate", description: "Rotiert Log-Dateien basierend auf Größe und Anzahl")]
public function execute(ConsoleInput $input, ConsoleOutput $output): int
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine('<info>Starting log rotation...</info>');
@@ -61,7 +61,7 @@ final readonly class RotateLogsCommand
$output->writeLine(" Files rotated: {$rotatedCount}");
$output->writeLine(" Total log size: " . $totalSize->toHumanReadable());
return ExitCode::SUCCESS->value;
return ExitCode::SUCCESS;
}
private function getLogPaths(): array

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Standard-Implementation für Channel-spezifisches Logging
*
* Delegiert alle Log-Aufrufe an den übergeordneten Logger mit dem spezifischen Channel.
* Ermöglicht die elegante API: $logger->security->error('message')
*/
final readonly class DefaultChannelLogger implements ChannelLogger
{
public function __construct(
private Logger $parentLogger,
private LogChannel $channel
) {
}
public function debug(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::DEBUG, $message, $context);
}
public function info(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::INFO, $message, $context);
}
public function notice(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::NOTICE, $message, $context);
}
public function warning(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::WARNING, $message, $context);
}
public function error(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::ERROR, $message, $context);
}
public function critical(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::CRITICAL, $message, $context);
}
public function alert(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::ALERT, $message, $context);
}
public function emergency(string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, LogLevel::EMERGENCY, $message, $context);
}
public function log(LogLevel $level, string $message, ?LogContext $context = null): void
{
$this->parentLogger->logToChannel($this->channel, $level, $message, $context);
}
public function getChannel(): LogChannel
{
return $this->channel;
}
}

View File

@@ -5,6 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Attributes\Singleton;
use App\Framework\Logging\ValueObjects\LogContext;
use DateMalformedStringException;
use DateTimeImmutable;
use DateTimeZone;
/**
@@ -13,55 +16,73 @@ use DateTimeZone;
#[Singleton]
final readonly class DefaultLogger implements Logger
{
public ChannelLogger $security;
public ChannelLogger $cache;
public ChannelLogger $database;
public ChannelLogger $framework;
public ChannelLogger $error;
/**
* @param LogLevel $minLevel Minimales Level, das geloggt werden soll
* @param array<LogHandler> $handlers Array von Log-Handlern
* @param ProcessorManager $processorManager Optional: Processor Manager für die Verarbeitung
* @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 LogLevel $minLevel = LogLevel::DEBUG,
private LogLevel $minLevel = LogLevel::DEBUG,
/** @var LogHandler[] */
private array $handlers = [],
private ProcessorManager $processorManager = new ProcessorManager(),
private array $handlers = [],
private ProcessorManager $processorManager = new ProcessorManager(),
private ?LogContextManager $contextManager = null,
) {
// Channel-Logger initialisieren
$this->security = new DefaultChannelLogger($this, LogChannel::SECURITY);
$this->cache = new DefaultChannelLogger($this, LogChannel::CACHE);
$this->database = new DefaultChannelLogger($this, LogChannel::DATABASE);
$this->framework = new DefaultChannelLogger($this, LogChannel::FRAMEWORK);
$this->error = new DefaultChannelLogger($this, LogChannel::ERROR);
}
public function debug(string $message, array $context = []): void
public function debug(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::DEBUG, $message, $context);
}
public function info(string $message, array $context = []): void
public function info(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::INFO, $message, $context);
}
public function notice(string $message, array $context = []): void
public function notice(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::NOTICE, $message, $context);
}
public function warning(string $message, array $context = []): void
public function warning(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::WARNING, $message, $context);
}
public function error(string $message, array $context = []): void
public function error(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::ERROR, $message, $context);
}
public function critical(string $message, array $context = []): void
public function critical(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::CRITICAL, $message, $context);
}
public function alert(string $message, array $context = []): void
public function alert(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::ALERT, $message, $context);
}
public function emergency(string $message, array $context = []): void
public function emergency(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::EMERGENCY, $message, $context);
}
@@ -71,16 +92,30 @@ final readonly class DefaultLogger implements Logger
*
* @param LogLevel $level Log-Level
* @param string $message Log-Nachricht
* @param array $context Kontext-Daten für Platzhalter-Ersetzung
* @param LogContext|null $context Strukturierter LogContext
* @throws DateMalformedStringException
*/
public function log(LogLevel $level, string $message, array $context = []): void
public function log(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
$record = new LogRecord(
message: $message,
context: $context,
context: $finalContext,
level: $level,
timestamp: new \DateTimeImmutable(timezone: new DateTimeZone('Europe/Berlin')),
timestamp: new DateTimeImmutable(timezone: new DateTimeZone('Europe/Berlin')),
);
// Record durch alle Processors verarbeiten
@@ -94,6 +129,132 @@ final readonly class DefaultLogger implements Logger
}
}
/**
* 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
$processedRecord = $this->processorManager->processRecord($record);
// Alle Handler durchlaufen
foreach ($this->handlers as $handler) {
if ($handler->isHandling($processedRecord)) {
$handler->handle($processedRecord);
}
}
}
/**
* Reichert den übergebenen Context mit dem aktuellen Context vom LogContextManager an
*/
private function enrichWithCurrentContext(LogContext $context): LogContext
{
// Wenn kein ContextManager verfügbar ist, Context unverändert zurückgeben
if ($this->contextManager === null) {
return $context;
}
$currentContext = $this->contextManager->getCurrentContext();
// Wenn der übergebene Context ein LogContext ist, mit aktuellem Context mergen
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;
}
/**
* Gibt die aktuelle Konfiguration des Loggers zurück
*/

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Formatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Development Formatter für human-readable Debug Output
*/
final readonly class DevelopmentFormatter implements LogFormatter
{
public function __construct(
private bool $includeStackTrace = true,
private bool $colorOutput = true
) {
}
public function __invoke(LogRecord $record): string
{
$level = $record->getLevel();
$timestamp = $record->getFormattedTimestamp('H:i:s.u');
$channel = $record->getChannel() ?? 'app';
$message = $record->getMessage();
// Color coding for levels
$levelString = $this->colorOutput ? $this->colorizeLevel($level) : $level->getName();
$output = sprintf(
"%s [%s] %s.%s: %s\n",
$timestamp,
$levelString,
$channel,
$level->getName(),
$message
);
// Add context if present
$context = $record->getContext();
if (! empty($context)) {
$output .= $this->formatContext($context);
}
// Add extras (like exception details)
$extras = $record->getExtras();
if (! empty($extras)) {
$output .= $this->formatExtras($extras);
}
return $output;
}
private function colorizeLevel(LogLevel $level): string
{
if (! $this->colorOutput) {
return $level->getName();
}
return match($level) {
LogLevel::DEBUG => "\033[36m" . $level->getName() . "\033[0m", // Cyan
LogLevel::INFO => "\033[32m" . $level->getName() . "\033[0m", // Green
LogLevel::NOTICE => "\033[34m" . $level->getName() . "\033[0m", // Blue
LogLevel::WARNING => "\033[33m" . $level->getName() . "\033[0m", // Yellow
LogLevel::ERROR => "\033[31m" . $level->getName() . "\033[0m", // Red
LogLevel::CRITICAL => "\033[35m" . $level->getName() . "\033[0m", // Magenta
LogLevel::ALERT => "\033[41m" . $level->getName() . "\033[0m", // Red background
LogLevel::EMERGENCY => "\033[41;37m" . $level->getName() . "\033[0m", // Red bg, white text
};
}
private function formatContext(array $context): string
{
$output = " Context:\n";
foreach ($context as $key => $value) {
if (str_starts_with($key, '_')) {
continue; // Skip internal context keys for cleaner output
}
$formattedValue = $this->formatValue($value);
$output .= " {$key}: {$formattedValue}\n";
}
return $output;
}
private function formatExtras(array $extras): string
{
$output = "";
// Special handling for exception details
if (isset($extras['exception_class'])) {
$output .= $this->formatException($extras);
}
// Other extras
$otherExtras = array_diff_key($extras, [
'exception_class' => true,
'exception_message' => true,
'exception_file' => true,
'exception_line' => true,
'stack_trace' => true,
'stack_trace_short' => true,
'previous_exceptions' => true,
'exception_hash' => true,
'exception_severity' => true,
]);
if (! empty($otherExtras)) {
$output .= " Extra:\n";
foreach ($otherExtras as $key => $value) {
$formattedValue = $this->formatValue($value);
$output .= " {$key}: {$formattedValue}\n";
}
}
return $output;
}
private function formatException(array $extras): string
{
$output = " Exception:\n";
$output .= " Class: {$extras['exception_class']}\n";
$output .= " File: {$extras['exception_file']}:{$extras['exception_line']}\n";
if (isset($extras['exception_hash'])) {
$output .= " Hash: {$extras['exception_hash']} (severity: {$extras['exception_severity']})\n";
}
if ($this->includeStackTrace && isset($extras['stack_trace_short'])) {
$output .= " Trace: {$extras['stack_trace_short']}\n";
}
if (isset($extras['previous_exceptions']) && ! empty($extras['previous_exceptions'])) {
$output .= " Previous: " . count($extras['previous_exceptions']) . " exception(s)\n";
}
return $output;
}
private function formatValue(mixed $value): string
{
return match(true) {
is_string($value) => '"' . $value . '"',
is_array($value) => 'Array[' . count($value) . ']',
is_object($value) => get_class($value) . '{}',
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
default => (string) $value
};
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Formatter;
use App\Framework\Logging\LogRecord;
/**
* JSON Formatter für strukturierte Logs
*/
final readonly class JsonFormatter implements LogFormatter
{
public function __construct(
private bool $prettyPrint = false,
private bool $includeExtras = true
) {
}
public function __invoke(LogRecord $record): string
{
$data = [
'timestamp' => $record->getTimestamp()->format('c'), // ISO 8601
'level' => $record->getLevel()->getName(),
'level_value' => $record->getLevel()->value,
'channel' => $record->getChannel(),
'message' => $record->getMessage(),
'context' => $record->getContext(),
];
// Add extras if enabled
if ($this->includeExtras && ! empty($record->getExtras())) {
$data['extra'] = $record->getExtras();
}
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
if ($this->prettyPrint) {
$flags |= JSON_PRETTY_PRINT;
}
return json_encode($data, $flags);
}
}

View File

@@ -8,8 +8,29 @@ use App\Framework\Logging\LogRecord;
final readonly class LineFormatter implements LogFormatter
{
public function __construct(
private string $format = "[{timestamp}] {channel}.{level}: {message} {context}",
private string $timestampFormat = 'Y-m-d H:i:s'
) {
}
public function __invoke(LogRecord $record): string
{
return 'LogFormatter: ' . $record->getMessage();
$context = $record->getContext();
$extras = $record->getExtras();
// Combine context and extras for display
$allData = array_merge($context, $extras);
$contextString = ! empty($allData) ? json_encode($allData, JSON_UNESCAPED_SLASHES) : '';
$replacements = [
'{timestamp}' => $record->getFormattedTimestamp($this->timestampFormat),
'{channel}' => $record->getChannel() ?? 'app',
'{level}' => $record->getLevel()->getName(),
'{message}' => $record->getMessage(),
'{context}' => $contextString,
];
return strtr($this->format, $replacements);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Formatter;
use App\Framework\Logging\LogRecord;
/**
* Structured Formatter für Log-Aggregation und Analysis
*/
final readonly class StructuredFormatter implements LogFormatter
{
public function __construct(
private string $format = 'logfmt', // 'logfmt' or 'kv'
private bool $includeTimestamp = true
) {
}
public function __invoke(LogRecord $record): string|array
{
$data = $this->extractStructuredData($record);
return match($this->format) {
'logfmt' => $this->formatAsLogfmt($data),
'kv' => $this->formatAsKeyValue($data),
'array' => $data, // Return raw array for further processing
default => $this->formatAsLogfmt($data)
};
}
private function extractStructuredData(LogRecord $record): array
{
$data = [];
if ($this->includeTimestamp) {
$data['ts'] = $record->getTimestamp()->format('c');
}
$data['level'] = $record->getLevel()->getName();
$data['msg'] = $record->getMessage();
if ($record->getChannel()) {
$data['channel'] = $record->getChannel();
}
// Flatten context
$context = $record->getContext();
foreach ($context as $key => $value) {
$data[$this->sanitizeKey($key)] = $this->sanitizeValue($value);
}
// Add extras
$extras = $record->getExtras();
foreach ($extras as $key => $value) {
$data[$this->sanitizeKey($key)] = $this->sanitizeValue($value);
}
return $data;
}
private function formatAsLogfmt(array $data): string
{
$pairs = [];
foreach ($data as $key => $value) {
if (is_string($value) && (str_contains($value, ' ') || str_contains($value, '='))) {
$value = '"' . addslashes($value) . '"';
}
$pairs[] = "{$key}={$value}";
}
return implode(' ', $pairs);
}
private function formatAsKeyValue(array $data): string
{
$pairs = [];
foreach ($data as $key => $value) {
$pairs[] = "{$key}: {$value}";
}
return implode(', ', $pairs);
}
private function sanitizeKey(string $key): string
{
// Remove internal prefixes and make key aggregation-friendly
$key = ltrim($key, '_');
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
}
private function sanitizeValue(mixed $value): string
{
return match(true) {
is_string($value) => $value,
is_numeric($value) => (string) $value,
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
is_array($value) => json_encode($value, JSON_UNESCAPED_SLASHES),
is_object($value) => get_class($value),
default => 'unknown'
};
}
}

View File

@@ -39,7 +39,7 @@ class ConsoleHandler implements LogHandler
public function __construct(
LogLevel|int $minLevel = LogLevel::DEBUG,
bool $debugOnly = true,
string $outputFormat = '{color}[{level_name}]{reset} {timestamp} {request_id}{message}',
string $outputFormat = '{color}[{level_name}]{reset} {timestamp} {request_id}{message}{structured}',
private readonly LogLevel $stderrLevel = LogLevel::WARNING,
) {
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
@@ -80,6 +80,9 @@ class ConsoleHandler implements LogHandler
? "[{$record->getExtra('request_id')}] "
: '';
// Structured Logging Extras formatieren
$structuredInfo = $this->formatStructuredExtras($record);
// Werte für Platzhalter im Format
$values = [
'{color}' => $color,
@@ -89,6 +92,7 @@ class ConsoleHandler implements LogHandler
'{request_id}' => $requestId,
'{message}' => $record->getMessage(),
'{channel}' => $record->getChannel() ? "[{$record->getChannel()}] " : '',
'{structured}' => $structuredInfo,
];
// Formatierte Ausgabe erstellen
@@ -123,4 +127,91 @@ class ConsoleHandler implements LogHandler
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

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider;
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;
/**
* Handler für automatisches Routing von Log-Nachrichten in verschiedene Dateien
*
* Schreibt Logs basierend auf dem Channel in verschiedene Dateien.
* Verwendet LogConfig für die Pfad-Auflösung und PathProvider für die Pfad-Normalisierung.
*/
final class MultiFileHandler implements LogHandler
{
/**
* @var array<string, resource> Cache für geöffnete Datei-Handles
*/
private array $fileHandles = [];
public function __construct(
private readonly mixed $logConfig,
private readonly mixed $pathProvider,
private readonly LogLevel $minLevel = LogLevel::DEBUG,
private readonly string $outputFormat = '[{timestamp}] [{level_name}] [{channel}] {message}',
private readonly int $fileMode = 0644
) {
}
/**
* Überprüft, ob dieser Handler den Log-Eintrag verarbeiten soll
*/
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->isHigherThan($this->minLevel) || $record->getLevel()->isEqual($this->minLevel);
}
/**
* Verarbeitet den Log-Eintrag und schreibt ihn in die entsprechende Datei
*/
public function handle(LogRecord $record): void
{
if (! $this->isHandling($record)) {
return;
}
// Channel aus Record extrahieren, Fallback zu 'app'
$channel = $record->getChannel() ?? LogChannel::APP->value;
// Entsprechende Log-Datei ermitteln
$logFile = $this->getLogFileForChannel($channel);
// Log-Nachricht formatieren
$formattedMessage = $this->formatMessage($record);
// In Datei schreiben
$this->writeToFile($logFile, $formattedMessage);
}
/**
* Ermittelt die Log-Datei für einen Channel
*/
private function getLogFileForChannel(string $channel): string
{
// Channel zu LogConfig-Key mappen
$logPathKey = match ($channel) {
LogChannel::SECURITY->value => 'security',
LogChannel::CACHE->value => 'cache',
LogChannel::DATABASE->value => 'database',
LogChannel::FRAMEWORK->value => 'framework',
LogChannel::ERROR->value => 'error',
default => 'app'
};
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
*/
private function writeToFile(string $logFile, string $message): void
{
// Verzeichnis erstellen, falls es nicht existiert
$directory = dirname($logFile);
if (! is_dir($directory)) {
if (! mkdir($directory, 0755, true) && ! is_dir($directory)) {
// Fehler beim Erstellen des Verzeichnisses - verwende Fallback
error_log("Failed to create log directory: $directory");
return;
}
}
// Datei-Handle aus Cache holen oder erstellen
$cacheKey = $logFile;
if (! isset($this->fileHandles[$cacheKey])) {
$handle = fopen($logFile, 'a');
if ($handle === false) {
error_log("Failed to open log file: $logFile");
return;
}
// Datei-Berechtigungen setzen
chmod($logFile, $this->fileMode);
$this->fileHandles[$cacheKey] = $handle;
}
// In Datei schreiben
fwrite($this->fileHandles[$cacheKey], $message);
fflush($this->fileHandles[$cacheKey]);
}
/**
* Schließt alle geöffneten Datei-Handles dieser Instanz
*/
public function closeAllHandles(): void
{
foreach ($this->fileHandles as $handle) {
if (is_resource($handle)) {
fclose($handle);
}
}
$this->fileHandles = [];
}
/**
* Destruktor - schließt alle Datei-Handles
*/
public function __destruct()
{
$this->closeAllHandles();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
/**
* Enum für verfügbare Log-Channels
*
* Definiert alle verfügbaren Channels für das Logging-System.
* Jeder Channel entspricht einer eigenen Log-Datei im storage/logs Verzeichnis.
*/
enum LogChannel: string
{
case APP = 'app';
case SECURITY = 'security';
case CACHE = 'cache';
case DATABASE = 'database';
case FRAMEWORK = 'framework';
case ERROR = 'error';
/**
* Gibt den entsprechenden Log-Pfad-Key für LogConfig zurück
*/
public function getLogPathKey(): string
{
return $this->value;
}
/**
* Gibt eine menschenlesbare Beschreibung des Channels zurück
*/
public function getDescription(): string
{
return match ($this) {
self::APP => 'Application logs',
self::SECURITY => 'Security events and authentication logs',
self::CACHE => 'Cache operations and debugging',
self::DATABASE => 'Database queries and operations',
self::FRAMEWORK => 'Framework internals and debugging',
self::ERROR => 'Error-specific logs'
};
}
/**
* Gibt alle verfügbaren Channels zurück
*
* @return array<string, string>
*/
public static function getAllChannels(): array
{
$channels = [];
foreach (self::cases() as $channel) {
$channels[$channel->value] = $channel->getDescription();
}
return $channels;
}
}

View File

@@ -28,8 +28,8 @@ final class LogConfig
{
$this->pathProvider = $pathProvider;
// Basis-Logverzeichnis
$logBasePath = $_ENV['LOG_BASE_PATH'] ?? 'logs';
// Basis-Logverzeichnis (storage/logs für moderne PHP Framework Convention)
$logBasePath = 'storage/logs';
// Standard-Logpfade definieren
$this->logPaths = [
@@ -38,8 +38,8 @@ final class LogConfig
'error' => $this->resolvePath($logBasePath . '/app/error.log'),
// Systemlogs
'nginx_access' => $_ENV['NGINX_ACCESS_LOG'] ?? '/var/log/nginx/access.log',
'nginx_error' => $_ENV['NGINX_ERROR_LOG'] ?? '/var/log/nginx/error.log',
'nginx_access' => '/var/log/nginx/access.log',
'nginx_error' => '/var/log/nginx/error.log',
// Sicherheitslogs
'security' => $this->resolvePath($logBasePath . '/security/security.log'),
@@ -50,14 +50,8 @@ final class LogConfig
'database' => $this->resolvePath($logBasePath . '/debug/database.log'),
];
// Umgebungsvariablen für Logpfade berücksichtigen
if (isset($_ENV['LOG_PATH'])) {
$this->logPaths['app'] = $_ENV['LOG_PATH'];
}
if (isset($_ENV['PHP_ERROR_LOG'])) {
$this->logPaths['error'] = $_ENV['PHP_ERROR_LOG'];
}
// Hinweis: Umgebungsvariablen werden nicht mehr direkt verwendet
// Konfiguration erfolgt über das Framework-Konfigurationssystem
}
/**
@@ -102,7 +96,14 @@ final class LogConfig
foreach ($this->logPaths as $path) {
$dir = dirname($path);
if (! file_exists($dir)) {
mkdir($dir, 0777, true);
try {
if (is_writable(dirname($dir))) {
mkdir($dir, 0777, true);
}
} catch (\Exception $e) {
// Silently ignore if we can't create directories
// This allows the log viewer to work even if some log directories can't be created
}
}
}
}

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Context\ExecutionContext;
use App\Framework\Http\Session\Session;
use App\Framework\Http\StateKey;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\RequestContext;
use App\Framework\Logging\ValueObjects\UserContext;
use App\Framework\Tracing\TraceContext;
/**
* Log Context Manager für Request-basierte Kontext-Propagation.
*
* Verwaltet Request-lokale Kontexte für automatische Anreicherung
* von Log-Nachrichten über Dependency Injection.
*/
final class LogContextManager
{
private ?LogContext $globalContext = null;
private ?LogContext $requestContext = null;
public function __construct(
private readonly ?ExecutionContext $executionContext = null
) {
}
/**
* Setzt globalen Kontext für alle Logs
*/
public function setGlobalContext(LogContext $context): void
{
$this->globalContext = $context;
}
/**
* Setzt Request-spezifischen Kontext
*/
public function setRequestContext(LogContext $context): void
{
$this->requestContext = $context;
}
/**
* Fügt Daten zum globalen Kontext hinzu
*/
public function addGlobalData(string $key, mixed $value): void
{
$this->globalContext = $this->globalContext?->addData($key, $value)
?? LogContext::withData([$key => $value]);
}
/**
* Fügt Tags zum globalen Kontext hinzu
*/
public function addGlobalTags(string ...$tags): void
{
$this->globalContext = $this->globalContext?->addTags(...$tags)
?? LogContext::withTags(...$tags);
}
/**
* Fügt Daten zum Request-Kontext hinzu
*/
public function addRequestData(string $key, mixed $value): void
{
$this->requestContext = $this->requestContext?->addData($key, $value)
?? LogContext::withData([$key => $value]);
}
/**
* Fügt Tags zum Request-Kontext hinzu
*/
public function addRequestTags(string ...$tags): void
{
$this->requestContext = $this->requestContext?->addTags(...$tags)
?? LogContext::withTags(...$tags);
}
/**
* Gibt aktuellen kombinierten Kontext zurück
*/
public function getCurrentContext(): LogContext
{
$context = LogContext::empty();
// Globalen Kontext hinzufügen
if ($this->globalContext !== null) {
$context = $context->merge($this->globalContext);
}
// Request-Kontext hinzufügen
if ($this->requestContext !== null) {
$context = $context->merge($this->requestContext);
}
// Automatische ExecutionContext-Integration
if ($this->executionContext !== null) {
$context = $this->enrichFromExecutionContext($context);
}
// Aktuellen Trace-Kontext hinzufügen
if (TraceContext::current() !== null) {
$context = $context->withTrace(TraceContext::current());
}
return $context;
}
/**
* Erstellt automatisch Request-Kontext aus aktueller Umgebung
*/
public function initializeRequestContext(?Session $session = null): LogContext
{
$context = LogContext::empty()
->withRequest(RequestContext::fromGlobals());
// Aktuellen Trace-Kontext hinzufügen falls vorhanden
if (TraceContext::current() !== null) {
$context = $context->withTrace(TraceContext::current());
}
// User-Kontext aus Session hinzufügen
if ($session !== null) {
$userContext = $this->createUserContextFromSession($session);
if ($userContext !== null) {
$context = $context->withUser($userContext);
}
}
// Request-Kontext setzen
$this->setRequestContext($context);
return $context;
}
/**
* Erweitert aktuellen Kontext mit User-Informationen
*/
public function withUser(UserContext $user): void
{
// FIXED: Nicht getCurrentContext() verwenden - das verursacht Memory Leak!
// Nur den aktuellen requestContext erweitern, nicht den gesamten merged context
$this->requestContext = ($this->requestContext ?? LogContext::empty())->withUser($user);
}
/**
* Erweitert aktuellen Kontext mit Request-Informationen
*/
public function withRequest(RequestContext $request): void
{
// FIXED: Nicht getCurrentContext() verwenden - das verursacht Memory Leak!
$this->requestContext = ($this->requestContext ?? LogContext::empty())->withRequest($request);
}
/**
* Erweitert aktuellen Kontext mit Trace-Informationen
*/
public function withTrace(TraceContext $trace): void
{
// FIXED: Nicht getCurrentContext() verwenden - das verursacht Memory Leak!
$this->requestContext = ($this->requestContext ?? LogContext::empty())->withTrace($trace);
}
/**
* Löscht gesamten Kontext (z.B. am Ende eines Requests)
*/
public function clearContext(): void
{
$this->requestContext = null;
}
/**
* Löscht nur Request-Kontext (behält globalen Kontext)
*/
public function clearRequestContext(): void
{
$this->requestContext = null;
}
/**
* Löscht nur globalen Kontext
*/
public function clearGlobalContext(): void
{
$this->globalContext = null;
}
/**
* Führt Callback mit temporärem Kontext aus
*/
public function withTemporaryContext(LogContext $context, callable $callback): mixed
{
$originalContext = $this->requestContext;
try {
// FIXED: Nicht getCurrentContext() verwenden - das verursacht Memory Leak!
$this->requestContext = ($this->requestContext ?? LogContext::empty())->merge($context);
return $callback();
} finally {
$this->requestContext = $originalContext;
}
}
/**
* Führt Callback mit temporären Tags aus
*/
public function withTemporaryTags(array $tags, callable $callback): mixed
{
return $this->withTemporaryContext(
LogContext::withTags(...$tags),
$callback
);
}
/**
* Führt Callback mit temporären Daten aus
*/
public function withTemporaryData(array $data, callable $callback): mixed
{
return $this->withTemporaryContext(
LogContext::withData($data),
$callback
);
}
/**
* Erstellt User-Kontext aus Session
*/
private function createUserContextFromSession(Session $session): ?UserContext
{
if (! $session->isStarted()) {
return null;
}
$sessionData = $session->all();
// Keine User-Daten in Session
if (empty($sessionData)) {
return UserContext::anonymous($session->getId());
}
// User-Kontext aus Session-Daten erstellen
return UserContext::fromSession($sessionData);
}
/**
* Debug-Informationen über aktuellen Kontext
*/
public function getDebugInfo(): array
{
return [
'has_global_context' => $this->globalContext !== null,
'has_request_context' => $this->requestContext !== null,
'global_context' => $this->globalContext?->toArray(),
'request_context' => $this->requestContext?->toArray(),
'current_trace' => TraceContext::current()?->toArray(),
'combined_context' => $this->getCurrentContext()->toArray(),
];
}
/**
* Prüft, ob Kontext Trace-Informationen hat
*/
public function hasTraceContext(): bool
{
return TraceContext::current() !== null;
}
/**
* Prüft, ob Kontext User-Informationen hat
*/
public function hasUserContext(): bool
{
$current = $this->getCurrentContext();
return $current->user !== null;
}
/**
* Prüft, ob Kontext Request-Informationen hat
*/
public function hasRequestContext(): bool
{
$current = $this->getCurrentContext();
return $current->request !== null;
}
/**
* Reichert Context automatisch aus ExecutionContext an
*/
private function enrichFromExecutionContext(LogContext $context): LogContext
{
// Request aus ExecutionContext
$request = $this->executionContext->get(StateKey::REQUEST);
if ($request !== null && $context->request === null) {
$requestContext = RequestContext::fromHttpRequest($request);
$context = $context->withRequest($requestContext);
}
// User aus ExecutionContext
$user = $this->executionContext->get(StateKey::USER);
if ($user !== null && $context->user === null) {
$userContext = UserContext::fromUserEntity($user);
$context = $context->withUser($userContext);
}
// Trace aus ExecutionContext
$trace = $this->executionContext->get(StateKey::TRACE);
if ($trace !== null && $context->trace === null) {
$context = $context->withTrace($trace);
}
return $context;
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
/**
* Repräsentiert einen einzelnen Log-Eintrag mit allen relevanten Informationen.
*/
@@ -18,11 +21,11 @@ final class LogRecord
* Erstellt einen neuen Log-Eintrag
*/
public function __construct(
private string $message,
private array $context,
private LogLevel $level,
private \DateTimeImmutable $timestamp,
private ?string $channel = null
private string $message,
private readonly LogContext $context,
private readonly LogLevel $level,
private readonly DateTimeImmutable $timestamp,
private ?string $channel = null
) {
}
@@ -45,11 +48,36 @@ final class LogRecord
}
/**
* Gibt den Kontext zurück
* Gibt den Kontext zurück als flaches Array
*/
public function getContext(): array
{
return $this->context;
$result = $this->context->structured;
// Tags hinzufügen
if ($this->context->hasTags()) {
$result['_tags'] = $this->context->tags;
}
// Trace-Informationen hinzufügen
if ($this->context->trace !== null) {
$result['_trace_id'] = $this->context->trace->getTraceId();
if ($activeSpan = $this->context->trace->getActiveSpan()) {
$result['_span_id'] = $activeSpan->spanId;
}
}
// User-Kontext hinzufügen
if ($this->context->user !== null) {
$result['_user_id'] = $this->context->user->userId ?? null;
}
// Request-Kontext hinzufügen
if ($this->context->request !== null) {
$result['_request_id'] = $this->context->request->requestId ?? null;
}
return array_merge($result, $this->context->metadata);
}
/**
@@ -57,7 +85,7 @@ final class LogRecord
*/
public function withContext(array $context): self
{
$this->context = array_merge($this->context, $context);
#$this->context = array_merge($this->context->toArray(), $context);
return $this;
}
@@ -73,7 +101,7 @@ final class LogRecord
/**
* Gibt den Zeitstempel zurück
*/
public function getTimestamp(): \DateTimeImmutable
public function getTimestamp(): DateTimeImmutable
{
return $this->timestamp;
}

View File

@@ -24,20 +24,39 @@ final class LogViewer
{
$logs = [];
foreach ($this->logPaths as $name => $path) {
if (file_exists($path)) {
$fileSize = filesize($path);
$byteSize = Byte::fromBytes($fileSize);
// Use storage/logs directory (where Logger actually writes)
$storageLogsPath = '/var/www/html/storage/logs';
$logs[$name] = [
'name' => $name,
'path' => $path,
'size' => $fileSize,
'size_human' => $byteSize->toHumanReadable(),
'modified' => filemtime($path),
'modified_human' => date('Y-m-d H:i:s', filemtime($path)),
'readable' => is_readable($path),
];
if (is_dir($storageLogsPath)) {
// Simple approach: scan each subdirectory for .log files
$subdirectories = ['app', 'debug', 'security'];
foreach ($subdirectories as $subdir) {
$subdirPath = $storageLogsPath . '/' . $subdir;
if (is_dir($subdirPath)) {
$files = glob($subdirPath . '/*.log');
foreach ($files as $filePath) {
if (is_file($filePath) && is_readable($filePath)) {
$filename = basename($filePath);
$name = $subdir . '_' . str_replace('.log', '', $filename);
$fileSize = filesize($filePath);
$byteSize = Byte::fromBytes($fileSize);
$logs[$name] = [
'name' => $name,
'path' => $filePath,
'size' => $fileSize,
'size_human' => $byteSize->toHumanReadable(),
'modified' => filemtime($filePath),
'modified_human' => date('Y-m-d H:i:s', filemtime($filePath)),
'readable' => is_readable($filePath),
];
}
}
}
}
}
@@ -48,11 +67,14 @@ final class LogViewer
{
$limit = $limit ?? $this->defaultLimit;
if (! isset($this->logPaths[$logName])) {
// Get available logs to find the path
$availableLogs = $this->getAvailableLogs();
if (! isset($availableLogs[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
$path = $availableLogs[$logName]['path'];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");
@@ -86,11 +108,14 @@ final class LogViewer
public function tailLog(string $logName, int $lines = 50): array
{
if (! isset($this->logPaths[$logName])) {
// Get available logs to find the path
$availableLogs = $this->getAvailableLogs();
if (! isset($availableLogs[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
$path = $availableLogs[$logName]['path'];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");
@@ -122,11 +147,12 @@ final class LogViewer
public function searchLogs(string $query, ?array $logNames = null, ?string $level = null): array
{
$logNames = $logNames ?? array_keys($this->logPaths);
$availableLogs = $this->getAvailableLogs();
$logNames = $logNames ?? array_keys($availableLogs);
$results = [];
foreach ($logNames as $logName) {
if (! isset($this->logPaths[$logName])) {
if (! isset($availableLogs[$logName])) {
continue;
}
@@ -157,11 +183,14 @@ final class LogViewer
*/
public function streamLog(string $logName, ?string $level = null, ?string $search = null, int $batchSize = 10): \Generator
{
if (! isset($this->logPaths[$logName])) {
// Get available logs to find the path
$availableLogs = $this->getAvailableLogs();
if (! isset($availableLogs[$logName])) {
throw new \InvalidArgumentException("Log '{$logName}' not found");
}
$path = $this->logPaths[$logName];
$path = $availableLogs[$logName]['path'];
if (! file_exists($path) || ! is_readable($path)) {
throw new \InvalidArgumentException("Log file '{$path}' is not readable");

View File

@@ -15,10 +15,8 @@ final readonly class LogViewerInitializer
// Erstelle LogConfig für zentrale Pfadverwaltung
$logConfig = new LogConfig($pathProvider);
// Stelle sicher, dass alle Logverzeichnisse existieren
$logConfig->ensureLogDirectoriesExist();
// Verwende die konfigurierten Logpfade aus LogConfig
// Note: Directory creation is handled gracefully by LogViewer when needed
$logPaths = $logConfig->getAllLogPaths();
return new LogViewer($logPaths);

View File

@@ -4,21 +4,65 @@ declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Logging\ValueObjects\LogContext;
interface Logger
{
public function debug(string $message, array $context = []): void;
public function debug(string $message, ?LogContext $context = null): void;
public function info(string $message, array $context = []): void;
public function info(string $message, ?LogContext $context = null): void;
public function notice(string $message, array $context = []): void;
public function notice(string $message, ?LogContext $context = null): void;
public function warning(string $message, array $context = []): void;
public function warning(string $message, ?LogContext $context = null): void;
public function error(string $message, array $context = []): void;
public function error(string $message, ?LogContext $context = null): void;
public function critical(string $message, array $context = []): void;
public function critical(string $message, ?LogContext $context = null): void;
public function alert(string $message, array $context = []): void;
public function alert(string $message, ?LogContext $context = null): void;
public function emergency(string $message, array $context = []): void;
public function emergency(string $message, ?LogContext $context = null): void;
/**
* Log mit beliebigem Level
*/
public function log(LogLevel $level, string $message, ?LogContext $context = null): void;
/**
* Log in einen spezifischen Channel
*
* @internal Wird von ChannelLogger verwendet
*/
public function logToChannel(LogChannel $channel, LogLevel $level, string $message, ?LogContext $context = null): void;
/**
* Security Channel Logger
* für Sicherheitsereignisse und Authentifizierung
*/
public ChannelLogger $security {get;}
/**
* Cache Channel Logger
* für Cache-Operationen und Debugging
*/
public ChannelLogger $cache {get;}
/**
* Database Channel Logger
* für Datenbankabfragen und -operationen
*/
public ChannelLogger $database {get;}
/**
* Framework Channel Logger
* für Framework-Interna und Debugging
*/
public ChannelLogger $framework {get;}
/**
* Error Channel Logger
* für Error-spezifische Logs
*/
public ChannelLogger $error {get;}
}

View File

@@ -6,9 +6,11 @@ namespace App\Framework\Logging;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\Handlers\FileHandler;
use App\Framework\Logging\Handlers\MultiFileHandler;
use App\Framework\Logging\Handlers\QueuedLogHandler;
use App\Framework\Logging\Handlers\WebHandler;
use App\Framework\Queue\FileQueue;
@@ -20,8 +22,18 @@ use App\Framework\Redis\RedisConnection;
final readonly class LoggerInitializer
{
#[Initializer]
public function __invoke(TypedConfiguration $config, PathProvider $pathProvider): Logger
public function __invoke(TypedConfiguration $config, PathProvider $pathProvider, Container $container): Logger
{
// LogContextManager als Singleton im Container registrieren
if (! $container->has(LogContextManager::class)) {
$contextManager = new LogContextManager();
$container->singleton(LogContextManager::class, $contextManager);
// Globalen Kontext mit App-Informationen initialisieren
$contextManager->addGlobalData('app_version', $config->app->version);
$contextManager->addGlobalData('environment', $config->app->environment->value);
$contextManager->addGlobalTags('application', 'framework');
}
$processorManager = new ProcessorManager();
// Set log level based on environment
@@ -52,6 +64,17 @@ final readonly class LoggerInitializer
$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,
@@ -61,10 +84,14 @@ final readonly class LoggerInitializer
$pathProvider
);
// LogContextManager aus Container holen
$contextManager = $container->get(LogContextManager::class);
return new DefaultLogger(
minLevel: $minLevel,
handlers: $handlers,
processorManager: $processorManager,
contextManager: $contextManager,
);
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Attributes\Singleton;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Interceptor für PHP's native error_log() - Elegant und transparent
*/
#[Singleton]
final class PhpErrorLogInterceptor
{
private $interceptStream = null;
private ?string $originalErrorLog = null;
private bool $isInstalled = false;
public function __construct(
private Logger $logger
) {
}
/**
* Installiert den Interceptor - fängt alle error_log() Aufrufe ab
*/
public function install(): void
{
if ($this->isInstalled) {
return;
}
// Aktuelles error_log Setting speichern
$this->originalErrorLog = ini_get('error_log') ?: null;
// Temporären Stream für Interception erstellen
$this->interceptStream = fopen('php://temp', 'r+');
if ($this->interceptStream === false) {
throw new \RuntimeException('Could not create intercept stream');
}
// PHP error_log auf unseren Stream umleiten
$streamPath = stream_get_meta_data($this->interceptStream)['uri'];
ini_set('error_log', $streamPath);
// Stream-Monitoring in separatem Process starten
$this->startStreamMonitoring();
$this->isInstalled = true;
}
/**
* Deinstalliert den Interceptor und stellt original error_log wieder her
*/
public function uninstall(): void
{
if (! $this->isInstalled) {
return;
}
// Original error_log Setting wiederherstellen
if ($this->originalErrorLog !== null) {
ini_set('error_log', $this->originalErrorLog);
} else {
ini_restore('error_log');
}
// Stream schließen
if ($this->interceptStream !== null) {
fclose($this->interceptStream);
$this->interceptStream = null;
}
$this->isInstalled = false;
}
/**
* Startet das Stream-Monitoring für error_log Interception
*/
private function startStreamMonitoring(): void
{
if ($this->interceptStream === null) {
return;
}
// Nicht-blockierender Stream
stream_set_blocking($this->interceptStream, false);
// Register shutdown function um letzten Content zu lesen
register_shutdown_function(function () {
$this->readAndForwardLogs();
});
// Für CLI: Monitoring-Loop in Background
if (php_sapi_name() === 'cli') {
$this->startCliMonitoring();
} else {
// Für Web: Output Buffer Hook
$this->startWebMonitoring();
}
}
/**
* CLI Monitoring - Background Stream Reading
*/
private function startCliMonitoring(): void
{
// Registriere Tick-Handler für periodisches Lesen
declare(ticks=1);
register_tick_function(function () {
$this->readAndForwardLogs();
});
}
/**
* Web Monitoring - Output Buffer Hook
*/
private function startWebMonitoring(): void
{
// Output buffer callback um am Ende der Request zu lesen
ob_start(function (string $buffer) {
$this->readAndForwardLogs();
return $buffer;
});
}
/**
* Liest Logs aus dem Stream und leitet sie an Framework Logger weiter
*/
private function readAndForwardLogs(): void
{
if ($this->interceptStream === null) {
return;
}
// Stream position zurücksetzen
rewind($this->interceptStream);
// Alles aus dem Stream lesen
$content = stream_get_contents($this->interceptStream);
if ($content === false || $content === '') {
return;
}
// Stream für nächste Writes leeren
ftruncate($this->interceptStream, 0);
rewind($this->interceptStream);
// Content in einzelne Log-Zeilen aufteilen
$lines = array_filter(explode("\n", trim($content)));
foreach ($lines as $line) {
$this->forwardLogLine(trim($line));
}
}
/**
* Leitet eine einzelne Log-Zeile an Framework Logger weiter
*/
private function forwardLogLine(string $line): void
{
if (empty($line)) {
return;
}
// Parse PHP error_log Format: [timestamp] message
$context = $this->parseErrorLogLine($line);
// Bestimme Log Level basierend auf Content
$level = $this->determineLogLevel($line);
// An Framework Logger weiterleiten
$this->logger->error->log($level, $line, $context);
// Optional: Auch an original error_log weiterleiten
if ($this->originalErrorLog !== null && $this->originalErrorLog !== '') {
$this->forwardToOriginalErrorLog($line);
}
}
/**
* Parst eine error_log Zeile und extrahiert Context
*/
private function parseErrorLogLine(string $line): LogContext
{
$caller = $this->extractCaller();
$context = LogContext::empty()
->withData([
'source' => 'php_error_log',
'original_line' => $line,
'caller' => $caller,
'intercepted_at' => date('Y-m-d H:i:s'),
]);
// Parse PHP Error Format wenn möglich
if (preg_match('/^\[([^\]]+)\]\s+(.+)$/', $line, $matches)) {
$context = $context->withData([
'original_timestamp' => $matches[1],
'parsed_message' => $matches[2],
]);
}
return $context;
}
/**
* Bestimmt das passende Log Level für eine Nachricht
*/
private function determineLogLevel(string $line): LogLevel
{
$lowerLine = strtolower($line);
return match (true) {
str_contains($lowerLine, 'fatal') || str_contains($lowerLine, 'emergency') => LogLevel::EMERGENCY,
str_contains($lowerLine, 'critical') || str_contains($lowerLine, 'alert') => LogLevel::CRITICAL,
str_contains($lowerLine, 'error') => LogLevel::ERROR,
str_contains($lowerLine, 'warning') || str_contains($lowerLine, 'warn') => LogLevel::WARNING,
str_contains($lowerLine, 'notice') => LogLevel::NOTICE,
str_contains($lowerLine, 'info') => LogLevel::INFO,
str_contains($lowerLine, 'debug') => LogLevel::DEBUG,
default => LogLevel::ERROR, // Default für error_log
};
}
/**
* Extrahiert Aufrufer-Informationen aus Backtrace
*/
private function extractCaller(): array
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
// Suche nach dem ersten Aufruf außerhalb des Interceptors
foreach ($trace as $frame) {
if (
isset($frame['file']) &&
! str_contains($frame['file'], 'PhpErrorLogInterceptor.php') &&
($frame['function'] ?? '') !== 'error_log'
) {
return [
'file' => $frame['file'],
'line' => $frame['line'] ?? 0,
'function' => $frame['function'] ?? 'unknown',
'class' => $frame['class'] ?? null,
];
}
}
return ['file' => 'unknown', 'line' => 0, 'function' => 'unknown', 'class' => null];
}
/**
* Leitet Log an originales error_log weiter (optional)
*/
private function forwardToOriginalErrorLog(string $line): void
{
if ($this->originalErrorLog === null) {
return;
}
// Temporär original error_log wiederherstellen
$current = ini_get('error_log');
ini_set('error_log', $this->originalErrorLog);
// An original error_log senden
\error_log($line);
// Wieder auf unseren Stream umstellen
ini_set('error_log', $current);
}
/**
* Prüft ob der Interceptor installiert ist
*/
public function isInstalled(): bool
{
return $this->isInstalled;
}
/**
* Destruktor - stellt sicher dass alles sauber aufgeräumt wird
*/
public function __destruct()
{
$this->readAndForwardLogs(); // Letzte Logs lesen
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Attributes\Initializer;
use App\Framework\DI\Container;
/**
* Initializer für PHP Error Log Interceptor
*/
final readonly class PhpErrorLogInterceptorInitializer
{
#[Initializer]
public function initialize(Container $container): void
{
// Interceptor aus Container holen und installieren
$interceptor = $container->get(PhpErrorLogInterceptor::class);
$interceptor->install();
// Interceptor für spätere Nutzung im Container registrieren
$container->singleton(PhpErrorLogInterceptor::class, $interceptor);
}
}

View File

@@ -19,9 +19,10 @@ final readonly class ProcessorManager
public function addProcessor(LogProcessor $processor): self
{
$processors = $this->sortProcessors($processor, ...$this->processors);
$allProcessors = [...$this->processors, $processor];
$sorted = $this->sortProcessorsList($allProcessors);
return new self(...$processors);
return new self(...$sorted);
}
/**
@@ -50,6 +51,18 @@ final readonly class ProcessorManager
return $processors;
}
/**
* Sortiert eine Liste von Processors
*/
private function sortProcessorsList(array $processors): array
{
usort($processors, function (LogProcessor $a, LogProcessor $b) {
return $b->getPriority() <=> $a->getPriority(); // Absteigend sortieren
});
return $processors;
}
/**
* Gibt alle registrierten Processors zurück
*

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
use Throwable;
/**
* Enriches log records with detailed exception information
*/
final readonly class ExceptionEnrichmentProcessor implements LogProcessor
{
public function __construct(
private int $maxStackTraceDepth = 20,
private bool $includeArgs = false
) {
}
public function processRecord(LogRecord $record): LogRecord
{
$context = $record->getContext();
// Check for exception in context
$exception = $this->findExceptionInContext($context);
if ($exception === null) {
return $record;
}
// Enrich record with exception details
return $record->addExtras([
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
'stack_trace' => $this->formatStackTrace($exception),
'stack_trace_short' => $this->getShortStackTrace($exception),
'previous_exceptions' => $this->getPreviousExceptions($exception),
'exception_hash' => $this->generateExceptionHash($exception),
'exception_severity' => $this->categorizeExceptionSeverity($exception),
]);
}
public function getPriority(): int
{
return 100; // High priority - run early
}
public function getName(): string
{
return 'exception_enrichment';
}
/**
* Find exception in context data
*/
private function findExceptionInContext(array $context): ?Throwable
{
// Direct exception key
if (isset($context['exception']) && $context['exception'] instanceof Throwable) {
return $context['exception'];
}
// Search in nested context
foreach ($context as $value) {
if ($value instanceof Throwable) {
return $value;
}
}
return null;
}
/**
* Format complete stack trace
*/
private function formatStackTrace(Throwable $exception): array
{
$trace = $exception->getTrace();
$formatted = [];
foreach (array_slice($trace, 0, $this->maxStackTraceDepth) as $index => $frame) {
$formatted[] = [
'index' => $index,
'file' => $frame['file'] ?? '[internal]',
'line' => $frame['line'] ?? null,
'function' => $this->formatFunction($frame),
'class' => $frame['class'] ?? null,
'type' => $frame['type'] ?? null,
'args' => $this->includeArgs ? $this->formatArgs($frame['args'] ?? []) : null,
];
}
return $formatted;
}
/**
* Get short stack trace (top 5 frames)
*/
private function getShortStackTrace(Throwable $exception): string
{
$trace = $exception->getTrace();
$short = [];
foreach (array_slice($trace, 0, 5) as $frame) {
$file = basename($frame['file'] ?? '[internal]');
$line = $frame['line'] ?? '?';
$function = $this->formatFunction($frame);
$short[] = "{$file}:{$line} {$function}()";
}
return implode(' → ', $short);
}
/**
* Get all previous exceptions in chain
*/
private function getPreviousExceptions(Throwable $exception): array
{
$previous = [];
$current = $exception->getPrevious();
while ($current !== null) {
$previous[] = [
'class' => get_class($current),
'message' => $current->getMessage(),
'code' => $current->getCode(),
'file' => $current->getFile(),
'line' => $current->getLine(),
];
$current = $current->getPrevious();
// Prevent infinite loops
if (count($previous) > 10) {
break;
}
}
return $previous;
}
/**
* Generate unique hash for exception pattern
*/
private function generateExceptionHash(Throwable $exception): string
{
$signature = get_class($exception) .
$exception->getFile() .
$exception->getLine() .
$exception->getMessage();
return substr(md5($signature), 0, 8);
}
/**
* Categorize exception severity
*/
private function categorizeExceptionSeverity(Throwable $exception): string
{
return match(true) {
$exception instanceof \Error => 'fatal',
$exception instanceof \ParseError => 'fatal',
$exception instanceof \TypeError => 'high',
$exception instanceof \InvalidArgumentException => 'medium',
$exception instanceof \LogicException => 'high',
$exception instanceof \RuntimeException => 'medium',
default => 'unknown'
};
}
/**
* Format function name with class context
*/
private function formatFunction(array $frame): string
{
if (isset($frame['class'])) {
$class = $this->shortenClassName($frame['class']);
$type = $frame['type'] ?? '::';
$function = $frame['function'] ?? 'unknown';
return "{$class}{$type}{$function}";
}
return $frame['function'] ?? 'unknown';
}
/**
* Shorten class name for readability
*/
private function shortenClassName(string $className): string
{
$parts = explode('\\', $className);
if (count($parts) <= 2) {
return $className;
}
// Keep first and last part: App\...\Controller
return $parts[0] . '\\...\\' . end($parts);
}
/**
* Format function arguments safely
*/
private function formatArgs(array $args): array
{
return array_map(function ($arg) {
return match(true) {
is_object($arg) => get_class($arg) . '{}',
is_array($arg) => 'Array[' . count($arg) . ']',
is_string($arg) => '"' . substr($arg, 0, 50) . (strlen($arg) > 50 ? '...' : '') . '"',
is_numeric($arg) => (string) $arg,
is_bool($arg) => $arg ? 'true' : 'false',
is_null($arg) => 'null',
default => gettype($arg)
};
}, $args);
}
}

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Tracing\TraceContext;
/**
* Strukturierte Logging-Kontext Value Object für typsichere Structured Logging.
*
* Ermöglicht strukturierte, maschinenlesbare Log-Daten mit Korrelations-IDs,
* Tags und verschiedenen Kontext-Typen für moderne Observability.
*
* Nutzt das bestehende Framework Tracing-System für Request-Korrelation.
*/
final readonly class LogContext
{
/**
* @param array<string, mixed> $structured Strukturierte Daten (key-value pairs)
* @param string[] $tags Tags für Kategorisierung und Filterung
* @param TraceContext|null $trace Framework Tracing-Kontext für Request-Korrelation
* @param UserContext|null $user Benutzer-Kontext für Audit-Logs
* @param RequestContext|null $request HTTP-Request-Kontext
* @param array<string, mixed> $metadata Zusätzliche Metadaten
*/
public function __construct(
public array $structured = [],
public array $tags = [],
public ?TraceContext $trace = null,
public ?UserContext $user = null,
public ?RequestContext $request = null,
public array $metadata = []
) {
}
/**
* Erstellt leeren LogContext
*/
public static function empty(): self
{
return new self();
}
/**
* Erstellt LogContext mit strukturierten Daten
*/
public static function withData(array $data): self
{
return new self(structured: $data);
}
/**
* Erstellt LogContext mit Tags
*/
public static function withTags(string ...$tags): self
{
return new self(tags: $tags);
}
/**
* Fügt strukturierte Daten hinzu
*/
public function addData(string $key, mixed $value): self
{
return new self(
structured: array_merge($this->structured, [$key => $value]),
tags: $this->tags,
trace: $this->trace,
user: $this->user,
request: $this->request,
metadata: $this->metadata
);
}
/**
* Fügt mehrere strukturierte Daten hinzu
*/
public function mergeData(array $data): self
{
return new self(
structured: array_merge($this->structured, $data),
tags: $this->tags,
trace: $this->trace,
user: $this->user,
request: $this->request,
metadata: $this->metadata
);
}
/**
* Fügt Tags hinzu
*/
public function addTags(string ...$tags): self
{
return new self(
structured: $this->structured,
tags: array_values(array_unique(array_merge($this->tags, $tags))),
trace: $this->trace,
user: $this->user,
request: $this->request,
metadata: $this->metadata
);
}
/**
* Erstellt LogContext mit aktuellem Tracing-Kontext
*/
public static function withCurrentTrace(): self
{
return new self(trace: TraceContext::current());
}
/**
* Setzt Trace-Kontext
*/
public function withTrace(TraceContext $trace): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
trace: $trace,
user: $this->user,
request: $this->request,
metadata: $this->metadata
);
}
/**
* Setzt User-Kontext
*/
public function withUser(UserContext $user): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
trace: $this->trace,
user: $user,
request: $this->request,
metadata: $this->metadata
);
}
/**
* Setzt Request-Kontext
*/
public function withRequest(RequestContext $request): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
trace: $this->trace,
user: $this->user,
request: $request,
metadata: $this->metadata
);
}
/**
* Fügt Metadaten hinzu
*/
public function addMetadata(string $key, mixed $value): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
trace: $this->trace,
user: $this->user,
request: $this->request,
metadata: array_merge($this->metadata, [$key => $value])
);
}
/**
* Kombiniert zwei LogContexts
*/
public function merge(self $other): self
{
return new self(
structured: array_merge($this->structured, $other->structured),
tags: array_unique(array_merge($this->tags, $other->tags)),
trace: $other->trace ?? $this->trace,
user: $other->user ?? $this->user,
request: $other->request ?? $this->request,
metadata: array_merge($this->metadata, $other->metadata)
);
}
/**
* Prüft, ob Context strukturierte Daten hat
*/
public function hasStructuredData(): bool
{
return ! empty($this->structured);
}
/**
* Prüft, ob Context Tags hat
*/
public function hasTags(): bool
{
return ! empty($this->tags);
}
/**
* Prüft, ob bestimmter Tag vorhanden ist
*/
public function hasTag(string $tag): bool
{
return in_array($tag, $this->tags, true);
}
/**
* Konvertiert zu Array für Serialisierung
*/
public function toArray(): array
{
$result = [];
if (! empty($this->structured)) {
$result['structured'] = $this->structured;
}
if (! empty($this->tags)) {
$result['tags'] = $this->tags;
}
if ($this->trace !== null) {
$result['trace'] = [
'trace_id' => $this->trace->getTraceId(),
'active_span' => $this->trace->getActiveSpan()?->toArray(),
];
}
if ($this->user !== null) {
$result['user'] = $this->user->toArray();
}
if ($this->request !== null) {
$result['request'] = $this->request->toArray();
}
if (! empty($this->metadata)) {
$result['metadata'] = $this->metadata;
}
return $result;
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Exception\RequestContext as FrameworkRequestContext;
use App\Framework\Http\RequestId;
use App\Framework\UserAgent\UserAgent;
/**
* Request-Kontext Value Object für Structured Logging.
*
* Erweitert den bestehenden Framework RequestContext um
* Logging-spezifische Funktionalität.
*/
final readonly class RequestContext
{
public function __construct(
private FrameworkRequestContext $requestContext
) {
}
/**
* Erstellt von Framework RequestContext
*/
public static function fromFrameworkContext(FrameworkRequestContext $context): self
{
return new self($context);
}
/**
* Erstellt aus Global-Variablen
*/
public static function fromGlobals(): self
{
return new self(FrameworkRequestContext::fromGlobals());
}
/**
* Erstellt leeren Kontext
*/
public static function empty(): self
{
return new self(FrameworkRequestContext::empty());
}
/**
* Erstellt mit spezifischen Daten
*/
public static function create(
?string $clientIp = null,
?UserAgent $userAgent = null,
?string $requestMethod = null,
?string $requestUri = null,
?string $hostIp = null,
?string $hostname = null,
?string $protocol = null,
?string $port = null,
?RequestId $requestId = null,
array $headers = []
): self {
return new self(FrameworkRequestContext::create(
clientIp: $clientIp,
userAgent: $userAgent,
requestMethod: $requestMethod,
requestUri: $requestUri,
hostIp: $hostIp,
hostname: $hostname,
protocol: $protocol,
port: $port,
requestId: $requestId,
headers: $headers
));
}
/**
* Konvertiert zu Array für Logging
*/
public function toArray(): array
{
return $this->requestContext->toArray();
}
/**
* Gibt Request-ID zurück
*/
public function getRequestId(): ?string
{
return $this->requestContext->requestId?->toString();
}
/**
* Gibt Client-IP zurück
*/
public function getClientIp(): ?string
{
return $this->requestContext->clientIp;
}
/**
* Gibt User-Agent zurück
*/
public function getUserAgent(): ?string
{
return $this->requestContext->userAgent?->value;
}
/**
* Gibt Request-Method zurück
*/
public function getMethod(): ?string
{
return $this->requestContext->requestMethod;
}
/**
* Gibt Request-URI zurück
*/
public function getUri(): ?string
{
return $this->requestContext->requestUri;
}
/**
* Gibt Host zurück
*/
public function getHost(): ?string
{
return $this->requestContext->hostname;
}
/**
* Gibt Protocol zurück
*/
public function getProtocol(): ?string
{
return $this->requestContext->protocol;
}
/**
* Prüft, ob HTTPS verwendet wird
*/
public function isSecure(): bool
{
return $this->requestContext->protocol === 'https';
}
/**
* Gibt Headers zurück
*/
public function getHeaders(): array
{
return $this->requestContext->headers;
}
/**
* Prüft, ob Header existiert
*/
public function hasHeader(string $name): bool
{
return array_key_exists($name, $this->requestContext->headers);
}
/**
* Gibt spezifischen Header zurück
*/
public function getHeader(string $name): ?string
{
return $this->requestContext->headers[$name] ?? null;
}
/**
* Konvertiert zu Privacy-sicherem Array (für öffentliche Logs)
*/
public function toPrivacySafeArray(): array
{
return [
'method' => $this->getMethod(),
'uri_path' => $this->getUriPath(),
'protocol' => $this->getProtocol(),
'is_secure' => $this->isSecure(),
'user_agent_family' => $this->getUserAgentFamily(),
'request_id' => $this->getRequestId(),
];
}
/**
* Gibt nur URI-Path zurück (ohne Query-Parameter für Privacy)
*/
public function getUriPath(): ?string
{
$uri = $this->getUri();
if ($uri === null) {
return null;
}
return parse_url($uri, PHP_URL_PATH) ?: $uri;
}
/**
* Gibt User-Agent-Familie zurück (ohne Details für Privacy)
*/
public function getUserAgentFamily(): ?string
{
$userAgent = $this->getUserAgent();
if ($userAgent === null) {
return null;
}
// Einfache User-Agent-Familie-Erkennung
if (str_contains($userAgent, 'Chrome')) {
return 'Chrome';
}
if (str_contains($userAgent, 'Firefox')) {
return 'Firefox';
}
if (str_contains($userAgent, 'Safari')) {
return 'Safari';
}
if (str_contains($userAgent, 'Edge')) {
return 'Edge';
}
return 'Unknown';
}
/**
* Zugriff auf den ursprünglichen Framework RequestContext
*/
public function getFrameworkContext(): FrameworkRequestContext
{
return $this->requestContext;
}
}

View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
/**
* User-Kontext Value Object für Structured Logging.
*
* Sammelt Benutzer-bezogene Informationen für Audit-Logs und
* Benutzer-spezifische Log-Analyse.
*/
final readonly class UserContext
{
public function __construct(
public ?string $userId = null,
public ?string $username = null,
public ?string $email = null,
public ?string $sessionId = null,
public array $roles = [],
public array $permissions = [],
public ?string $authMethod = null,
public bool $isAuthenticated = false,
public array $metadata = []
) {
}
/**
* Erstellt UserContext für authentifizierten Benutzer
*/
public static function authenticated(
string $userId,
?string $username = null,
?string $email = null,
?string $sessionId = null,
array $roles = [],
array $permissions = [],
string $authMethod = 'session'
): self {
return new self(
userId: $userId,
username: $username,
email: $email,
sessionId: $sessionId,
roles: $roles,
permissions: $permissions,
authMethod: $authMethod,
isAuthenticated: true
);
}
/**
* Erstellt UserContext für anonymen Benutzer
*/
public static function anonymous(?string $sessionId = null): self
{
return new self(
sessionId: $sessionId,
isAuthenticated: false
);
}
/**
* Erstellt UserContext aus Session-Daten
*/
public static function fromSession(array $sessionData): self
{
$userId = self::extractUserId($sessionData);
$isAuthenticated = $userId !== null;
return new self(
userId: $userId,
username: $sessionData['username'] ?? $sessionData['name'] ?? null,
email: $sessionData['email'] ?? null,
sessionId: $sessionData['session_id'] ?? null,
roles: $sessionData['roles'] ?? [],
permissions: $sessionData['permissions'] ?? [],
authMethod: $sessionData['auth_method'] ?? 'session',
isAuthenticated: $isAuthenticated,
metadata: array_diff_key($sessionData, array_flip([
'user_id', 'id', 'username', 'name', 'email', 'session_id',
'roles', 'permissions', 'auth_method',
]))
);
}
/**
* Fügt Rolle hinzu
*/
public function withRole(string $role): self
{
return new self(
userId: $this->userId,
username: $this->username,
email: $this->email,
sessionId: $this->sessionId,
roles: array_unique([...$this->roles, $role]),
permissions: $this->permissions,
authMethod: $this->authMethod,
isAuthenticated: $this->isAuthenticated,
metadata: $this->metadata
);
}
/**
* Fügt Permission hinzu
*/
public function withPermission(string $permission): self
{
return new self(
userId: $this->userId,
username: $this->username,
email: $this->email,
sessionId: $this->sessionId,
roles: $this->roles,
permissions: array_unique([...$this->permissions, $permission]),
authMethod: $this->authMethod,
isAuthenticated: $this->isAuthenticated,
metadata: $this->metadata
);
}
/**
* Fügt Metadaten hinzu
*/
public function withMetadata(string $key, mixed $value): self
{
return new self(
userId: $this->userId,
username: $this->username,
email: $this->email,
sessionId: $this->sessionId,
roles: $this->roles,
permissions: $this->permissions,
authMethod: $this->authMethod,
isAuthenticated: $this->isAuthenticated,
metadata: array_merge($this->metadata, [$key => $value])
);
}
/**
* Prüft, ob Benutzer bestimmte Rolle hat
*/
public function hasRole(string $role): bool
{
return in_array($role, $this->roles, true);
}
/**
* Prüft, ob Benutzer bestimmte Permission hat
*/
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions, true);
}
/**
* Gibt anonymisierten User-Identifier zurück (für Privacy)
*/
public function getAnonymizedId(): ?string
{
if ($this->userId === null) {
return null;
}
// Hash für anonyme Logs
return substr(hash('sha256', $this->userId), 0, 8);
}
/**
* Maskiert Email für Logs
*/
public function getMaskedEmail(): ?string
{
if ($this->email === null) {
return null;
}
$parts = explode('@', $this->email);
if (count($parts) !== 2) {
return '***@***';
}
$local = $parts[0];
$domain = $parts[1];
// Maskiere lokalen Teil
$maskedLocal = strlen($local) > 2
? substr($local, 0, 1) . str_repeat('*', strlen($local) - 2) . substr($local, -1)
: str_repeat('*', strlen($local));
return $maskedLocal . '@' . $domain;
}
/**
* Konvertiert zu Array für Serialisierung
*/
public function toArray(): array
{
$result = [
'user_id' => $this->userId,
'is_authenticated' => $this->isAuthenticated,
];
if ($this->username !== null) {
$result['username'] = $this->username;
}
if ($this->email !== null) {
$result['email_masked'] = $this->getMaskedEmail();
}
if ($this->sessionId !== null) {
$result['session_id'] = $this->sessionId;
}
if (! empty($this->roles)) {
$result['roles'] = $this->roles;
}
if (! empty($this->permissions)) {
$result['permissions'] = $this->permissions;
}
if ($this->authMethod !== null) {
$result['auth_method'] = $this->authMethod;
}
if (! empty($this->metadata)) {
$result['metadata'] = $this->metadata;
}
return $result;
}
/**
* Konvertiert zu Array für Privacy-sichere Logs
*/
public function toPrivacySafeArray(): array
{
return [
'user_id_anonymized' => $this->getAnonymizedId(),
'is_authenticated' => $this->isAuthenticated,
'roles_count' => count($this->roles),
'permissions_count' => count($this->permissions),
'auth_method' => $this->authMethod,
'has_session' => $this->sessionId !== null,
];
}
/**
* Extrahiert User-ID aus verschiedenen Session-Formaten
*/
private static function extractUserId(array $sessionData): ?string
{
$userIdKeys = ['user_id', 'id', 'user', 'auth_user_id', 'logged_in_user'];
foreach ($userIdKeys as $key) {
if (! isset($sessionData[$key])) {
continue;
}
$userId = $sessionData[$key];
// Handle user objects
if (is_object($userId) && isset($userId->id)) {
return (string) $userId->id;
}
// Handle arrays
if (is_array($userId) && isset($userId['id'])) {
return (string) $userId['id'];
}
// Handle simple values
if (is_scalar($userId)) {
return (string) $userId;
}
}
return null;
}
}