feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
100
src/Framework/Logging/Handlers/InMemoryHandler.php
Normal file
100
src/Framework/Logging/Handlers/InMemoryHandler.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Handlers;
|
||||
|
||||
use App\Framework\Logging\LogLevel;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use App\Framework\Logging\ValueObjects\LogEntry;
|
||||
|
||||
/**
|
||||
* In-memory log handler for testing
|
||||
*
|
||||
* Stores all log entries in memory for inspection and assertions in tests
|
||||
*/
|
||||
final class InMemoryHandler implements LogHandler
|
||||
{
|
||||
/** @var LogEntry[] */
|
||||
private array $entries = [];
|
||||
|
||||
public function handle(LogEntry $entry): void
|
||||
{
|
||||
$this->entries[] = $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logged entries
|
||||
*
|
||||
* @return LogEntry[]
|
||||
*/
|
||||
public function getEntries(): array
|
||||
{
|
||||
return $this->entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries by log level
|
||||
*
|
||||
* @return LogEntry[]
|
||||
*/
|
||||
public function getEntriesByLevel(LogLevel $level): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->entries,
|
||||
fn(LogEntry $entry) => $entry->level === $level
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message was logged
|
||||
*/
|
||||
public function hasMessage(string $message): bool
|
||||
{
|
||||
foreach ($this->entries as $entry) {
|
||||
if (str_contains($entry->message, $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message was logged at specific level
|
||||
*/
|
||||
public function hasMessageAtLevel(string $message, LogLevel $level): bool
|
||||
{
|
||||
foreach ($this->getEntriesByLevel($level) as $entry) {
|
||||
if (str_contains($entry->message, $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of logged entries
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of entries by level
|
||||
*/
|
||||
public function countByLevel(LogLevel $level): int
|
||||
{
|
||||
return count($this->getEntriesByLevel($level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logged entries
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->entries = [];
|
||||
}
|
||||
}
|
||||
30
src/Framework/Logging/Handlers/NullHandler.php
Normal file
30
src/Framework/Logging/Handlers/NullHandler.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Handlers;
|
||||
|
||||
use App\Framework\Logging\LogHandler;
|
||||
use App\Framework\Logging\LogRecord;
|
||||
|
||||
/**
|
||||
* NullHandler discards all log entries without any output
|
||||
*
|
||||
* Use Cases:
|
||||
* - MCP Server mode (prevents log interference with JSON-RPC)
|
||||
* - Testing environments where logging should be suppressed
|
||||
* - Performance-critical paths where logging overhead is unacceptable
|
||||
*/
|
||||
final readonly class NullHandler implements LogHandler
|
||||
{
|
||||
public function isHandling(LogRecord $record): bool
|
||||
{
|
||||
// Accept all levels but discard them
|
||||
return true;
|
||||
}
|
||||
|
||||
public function handle(LogRecord $record): void
|
||||
{
|
||||
// Discard all log entries - no output
|
||||
}
|
||||
}
|
||||
149
src/Framework/Logging/InMemoryLogger.php
Normal file
149
src/Framework/Logging/InMemoryLogger.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging;
|
||||
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* In-memory logger for testing
|
||||
*
|
||||
* Stores all log records in memory for inspection and assertions in tests.
|
||||
* Implements the Logger interface without any actual I/O operations.
|
||||
*/
|
||||
final class InMemoryLogger implements Logger
|
||||
{
|
||||
/** @var LogRecord[] */
|
||||
private array $records = [];
|
||||
|
||||
public function debug(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::DEBUG, $message, $context);
|
||||
}
|
||||
|
||||
public function info(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::INFO, $message, $context);
|
||||
}
|
||||
|
||||
public function notice(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::NOTICE, $message, $context);
|
||||
}
|
||||
|
||||
public function warning(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::WARNING, $message, $context);
|
||||
}
|
||||
|
||||
public function error(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::ERROR, $message, $context);
|
||||
}
|
||||
|
||||
public function critical(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::CRITICAL, $message, $context);
|
||||
}
|
||||
|
||||
public function alert(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::ALERT, $message, $context);
|
||||
}
|
||||
|
||||
public function emergency(string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$this->log(LogLevel::EMERGENCY, $message, $context);
|
||||
}
|
||||
|
||||
public function log(LogLevel $level, string $message, ?LogContext $context = null): void
|
||||
{
|
||||
$context = $context ?? LogContext::empty();
|
||||
|
||||
$record = new LogRecord(
|
||||
message: $message,
|
||||
context: $context,
|
||||
level: $level,
|
||||
timestamp: new DateTimeImmutable()
|
||||
);
|
||||
|
||||
$this->records[] = $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logged records
|
||||
*
|
||||
* @return LogRecord[]
|
||||
*/
|
||||
public function getRecords(): array
|
||||
{
|
||||
return $this->records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records by log level
|
||||
*
|
||||
* @return LogRecord[]
|
||||
*/
|
||||
public function getRecordsByLevel(LogLevel $level): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->records,
|
||||
fn(LogRecord $record) => $record->level === $level
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message was logged
|
||||
*/
|
||||
public function hasMessage(string $message): bool
|
||||
{
|
||||
foreach ($this->records as $record) {
|
||||
if (str_contains($record->message, $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message was logged at specific level
|
||||
*/
|
||||
public function hasMessageAtLevel(string $message, LogLevel $level): bool
|
||||
{
|
||||
foreach ($this->getRecordsByLevel($level) as $record) {
|
||||
if (str_contains($record->message, $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of logged records
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of records by level
|
||||
*/
|
||||
public function countByLevel(LogLevel $level): int
|
||||
{
|
||||
return count($this->getRecordsByLevel($level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logged records
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->records = [];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Framework\Logging\Handlers\DockerJsonHandler;
|
||||
use App\Framework\Logging\Handlers\FileHandler;
|
||||
use App\Framework\Logging\Handlers\JsonFileHandler;
|
||||
use App\Framework\Logging\Handlers\MultiFileHandler;
|
||||
use App\Framework\Logging\Handlers\NullHandler;
|
||||
use App\Framework\Logging\Handlers\QueuedLogHandler;
|
||||
use App\Framework\Logging\Handlers\WebHandler;
|
||||
use App\Framework\Queue\FileQueue;
|
||||
@@ -31,6 +32,20 @@ final readonly class LoggerInitializer
|
||||
Container $container,
|
||||
Environment $env
|
||||
): Logger {
|
||||
// MCP Server Mode: Use NullHandler to suppress all output
|
||||
// This prevents log interference with JSON-RPC communication
|
||||
if ($env->get(EnvKey::MCP_SERVER_MODE) === '1') {
|
||||
$contextManager = new LogContextManager();
|
||||
$processorManager = new ProcessorManager();
|
||||
|
||||
return new DefaultLogger(
|
||||
minLevel: LogLevel::DEBUG,
|
||||
handlers: [new NullHandler()],
|
||||
processorManager: $processorManager,
|
||||
contextManager: $contextManager
|
||||
);
|
||||
}
|
||||
|
||||
// LogContextManager als Singleton im Container registrieren
|
||||
if (! $container->has(LogContextManager::class)) {
|
||||
$contextManager = new LogContextManager();
|
||||
|
||||
@@ -6,10 +6,20 @@ namespace App\Framework\Logging\Processors;
|
||||
|
||||
use App\Framework\Logging\LogProcessor;
|
||||
use App\Framework\Logging\LogRecord;
|
||||
use App\Framework\Logging\ValueObjects\ExceptionContext;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Enriches log records with detailed exception information
|
||||
* Enriches log records with detailed exception information via ExceptionContext
|
||||
*
|
||||
* Nutzt das moderne ExceptionContext Value Object System für strukturierte
|
||||
* Exception-Daten statt Legacy-Array-Ansatz.
|
||||
*
|
||||
* Features:
|
||||
* - ExceptionContext mit StackFrame Value Objects
|
||||
* - Exception Hash für Pattern-Erkennung
|
||||
* - Exception Severity Categorization
|
||||
* - Short Stack Trace für Quick Overview
|
||||
*/
|
||||
final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
{
|
||||
@@ -21,27 +31,41 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
|
||||
public function processRecord(LogRecord $record): LogRecord
|
||||
{
|
||||
$context = $record->getContext();
|
||||
// Prüfe ob bereits ExceptionContext vorhanden
|
||||
if ($record->context->hasException()) {
|
||||
$exceptionContext = $record->context->getException();
|
||||
|
||||
// Check for exception in context
|
||||
$exception = $this->findExceptionInContext($context);
|
||||
// Füge Enrichment-Daten zu Extra hinzu
|
||||
return $record->addExtras([
|
||||
'exception_hash' => $this->generateExceptionHash($exceptionContext),
|
||||
'exception_severity' => $this->categorizeExceptionSeverity($exceptionContext->class),
|
||||
'stack_trace_short' => $this->getShortStackTrace($exceptionContext),
|
||||
'chain_length' => $exceptionContext->getChainLength(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Suche nach Exception in Context (für Legacy-Support)
|
||||
$exception = $this->findExceptionInContext($record->context->structured);
|
||||
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),
|
||||
]);
|
||||
// Erstelle ExceptionContext und füge zu Record hinzu
|
||||
$exceptionContext = ExceptionContext::fromThrowable($exception);
|
||||
|
||||
return new LogRecord(
|
||||
level: $record->level,
|
||||
message: $record->message,
|
||||
channel: $record->channel,
|
||||
context: $record->context->withExceptionContext($exceptionContext),
|
||||
timestamp: $record->timestamp,
|
||||
extra: array_merge($record->extra, [
|
||||
'exception_hash' => $this->generateExceptionHash($exceptionContext),
|
||||
'exception_severity' => $this->categorizeExceptionSeverity($exceptionContext->class),
|
||||
'stack_trace_short' => $this->getShortStackTrace($exceptionContext),
|
||||
'chain_length' => $exceptionContext->getChainLength(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
@@ -55,7 +79,7 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Find exception in context data
|
||||
* Find exception in context data (Legacy-Support)
|
||||
*/
|
||||
private function findExceptionInContext(array $context): ?Throwable
|
||||
{
|
||||
@@ -75,40 +99,22 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Format complete stack trace
|
||||
* Get short stack trace from ExceptionContext (top 5 frames)
|
||||
*/
|
||||
private function formatStackTrace(Throwable $exception): array
|
||||
private function getShortStackTrace(ExceptionContext $exceptionContext): string
|
||||
{
|
||||
$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();
|
||||
$frames = $exceptionContext->getTopFrames(5);
|
||||
$short = [];
|
||||
|
||||
foreach (array_slice($trace, 0, 5) as $frame) {
|
||||
$file = basename($frame['file'] ?? '[internal]');
|
||||
$line = $frame['line'] ?? '?';
|
||||
$function = $this->formatFunction($frame);
|
||||
foreach ($frames as $frame) {
|
||||
$file = basename($frame->file);
|
||||
$line = $frame->line;
|
||||
$function = $frame->function ?? 'unknown';
|
||||
|
||||
if ($frame->class !== null) {
|
||||
$class = $this->shortenClassName($frame->class);
|
||||
$function = "{$class}{$frame->type}{$function}";
|
||||
}
|
||||
|
||||
$short[] = "{$file}:{$line} {$function}()";
|
||||
}
|
||||
@@ -117,78 +123,34 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all previous exceptions in chain
|
||||
* Generate unique hash for exception pattern from ExceptionContext
|
||||
*/
|
||||
private function getPreviousExceptions(Throwable $exception): array
|
||||
private function generateExceptionHash(ExceptionContext $exceptionContext): string
|
||||
{
|
||||
$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();
|
||||
$signature = $exceptionContext->class .
|
||||
$exceptionContext->file .
|
||||
$exceptionContext->line .
|
||||
$exceptionContext->message;
|
||||
|
||||
return substr(md5($signature), 0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize exception severity
|
||||
* Categorize exception severity by class name
|
||||
*/
|
||||
private function categorizeExceptionSeverity(Throwable $exception): string
|
||||
private function categorizeExceptionSeverity(string $exceptionClass): 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',
|
||||
str_contains($exceptionClass, 'Error') && !str_contains($exceptionClass, 'Exception') => 'fatal',
|
||||
str_contains($exceptionClass, 'ParseError') => 'fatal',
|
||||
str_contains($exceptionClass, 'TypeError') => 'high',
|
||||
str_contains($exceptionClass, 'InvalidArgumentException') => 'medium',
|
||||
str_contains($exceptionClass, 'LogicException') => 'high',
|
||||
str_contains($exceptionClass, '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
|
||||
*/
|
||||
@@ -203,22 +165,4 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Processors;
|
||||
|
||||
use App\Framework\Logging\LogProcessor;
|
||||
use App\Framework\Logging\LogRecord;
|
||||
use App\Framework\Logging\ValueObjects\ExceptionContext;
|
||||
|
||||
/**
|
||||
* Exception Processor für automatisches Exception-Enrichment
|
||||
*
|
||||
* Erkennt Exceptions in strukturierten Daten und konvertiert sie
|
||||
* zu strukturiertem ExceptionContext.
|
||||
*
|
||||
* Features:
|
||||
* - Automatische Exception-Erkennung im Context
|
||||
* - Strukturierte Exception-Daten
|
||||
* - Stack Trace Parsing
|
||||
* - Previous Exception Chain
|
||||
*/
|
||||
final class ExceptionProcessor implements LogProcessor
|
||||
{
|
||||
/**
|
||||
* @var int Priorität des Processors (höher = früher)
|
||||
*/
|
||||
private const int PRIORITY = 15;
|
||||
|
||||
public function __construct(
|
||||
private readonly bool $includeStackTrace = true,
|
||||
private readonly bool $includePreviousExceptions = true
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(LogRecord $record): LogRecord
|
||||
{
|
||||
// Bereits ExceptionContext vorhanden - nichts zu tun
|
||||
if ($record->context->hasException()) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
// Suche nach Exception in strukturierten Daten
|
||||
$exception = $this->findException($record->context->structured);
|
||||
|
||||
if ($exception === null) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
// Konvertiere zu ExceptionContext und füge hinzu
|
||||
$exceptionContext = ExceptionContext::fromThrowable($exception);
|
||||
|
||||
return new LogRecord(
|
||||
level: $record->level,
|
||||
message: $record->message,
|
||||
channel: $record->channel,
|
||||
context: $record->context->withExceptionContext($exceptionContext),
|
||||
timestamp: $record->timestamp,
|
||||
extra: array_merge($record->extra, [
|
||||
'exception_class' => $exceptionContext->getShortClass(),
|
||||
'exception_message' => $exceptionContext->message,
|
||||
'exception_location' => sprintf(
|
||||
'%s:%d',
|
||||
$exceptionContext->getShortFile(),
|
||||
$exceptionContext->line
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht nach Throwable in strukturierten Daten
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function findException(array $data): ?\Throwable
|
||||
{
|
||||
// Suche nach 'exception' Key
|
||||
if (isset($data['exception']) && $data['exception'] instanceof \Throwable) {
|
||||
return $data['exception'];
|
||||
}
|
||||
|
||||
// Suche nach 'error' Key
|
||||
if (isset($data['error']) && $data['error'] instanceof \Throwable) {
|
||||
return $data['error'];
|
||||
}
|
||||
|
||||
// Suche nach 'throwable' Key
|
||||
if (isset($data['throwable']) && $data['throwable'] instanceof \Throwable) {
|
||||
return $data['throwable'];
|
||||
}
|
||||
|
||||
// Durchsuche alle Values
|
||||
foreach ($data as $value) {
|
||||
if ($value instanceof \Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return self::PRIORITY;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'exception';
|
||||
}
|
||||
}
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Processors;
|
||||
|
||||
use App\Framework\Logging\LogProcessor;
|
||||
use App\Framework\Logging\LogRecord;
|
||||
|
||||
/**
|
||||
* Verarbeitet Exceptions im Kontext und fügt detaillierte Informationen hinzu.
|
||||
*/
|
||||
final class ExceptionProcessor implements LogProcessor
|
||||
{
|
||||
/**
|
||||
* @var int Priorität des Processors (15 = früh, nach Interpolation)
|
||||
*/
|
||||
private const int PRIORITY = 15;
|
||||
|
||||
/**
|
||||
* @var bool Ob Stack-Traces hinzugefügt werden sollen
|
||||
*/
|
||||
private bool $includeStackTraces;
|
||||
|
||||
/**
|
||||
* @var int Maximale Tiefe für Stack-Traces
|
||||
*/
|
||||
private int $traceDepth;
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen ExceptionProcessor
|
||||
*/
|
||||
public function __construct(
|
||||
bool $includeStackTraces = true,
|
||||
int $traceDepth = 10
|
||||
) {
|
||||
$this->includeStackTraces = $includeStackTraces;
|
||||
$this->traceDepth = $traceDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet einen Log-Record und extrahiert Exception-Informationen
|
||||
*/
|
||||
public function processRecord(LogRecord $record): LogRecord
|
||||
{
|
||||
$context = $record->getContext();
|
||||
|
||||
// Prüfen, ob eine Exception im Kontext vorhanden ist
|
||||
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
|
||||
$exception = $context['exception'];
|
||||
$exceptionData = $this->formatException($exception);
|
||||
|
||||
// Exception-Daten zu Extra-Daten hinzufügen
|
||||
$record = $record->withExtra('exception', $exceptionData);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert eine Exception für das Logging
|
||||
*/
|
||||
private function formatException(\Throwable $exception): array
|
||||
{
|
||||
$result = [
|
||||
'class' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'code' => $exception->getCode(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
];
|
||||
|
||||
// Stack-Trace hinzufügen, wenn aktiviert
|
||||
if ($this->includeStackTraces) {
|
||||
$result['trace'] = $this->formatTrace($exception);
|
||||
}
|
||||
|
||||
// Bei verschachtelten Exceptions auch die vorherige Exception formatieren
|
||||
if ($exception->getPrevious()) {
|
||||
$result['previous'] = $this->formatException($exception->getPrevious());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert den Stack-Trace einer Exception
|
||||
*/
|
||||
private function formatTrace(\Throwable $exception): array
|
||||
{
|
||||
$trace = $exception->getTrace();
|
||||
$result = [];
|
||||
|
||||
// Tiefe begrenzen
|
||||
$trace = array_slice($trace, 0, $this->traceDepth);
|
||||
|
||||
foreach ($trace as $frame) {
|
||||
$entry = [
|
||||
'file' => $frame['file'] ?? 'unknown',
|
||||
'line' => $frame['line'] ?? 0,
|
||||
];
|
||||
|
||||
if (isset($frame['function'])) {
|
||||
$function = '';
|
||||
if (isset($frame['class'])) {
|
||||
$function .= $frame['class'] . $frame['type'];
|
||||
}
|
||||
$function .= $frame['function'];
|
||||
$entry['function'] = $function;
|
||||
}
|
||||
|
||||
$result[] = $entry;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Priorität des Processors zurück
|
||||
*/
|
||||
public function getPriority(): int
|
||||
{
|
||||
return self::PRIORITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt einen eindeutigen Namen für den Processor zurück
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return 'exception';
|
||||
}
|
||||
}
|
||||
109
src/Framework/Logging/Processors/SecurityEventProcessor.php
Normal file
109
src/Framework/Logging/Processors/SecurityEventProcessor.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\Processors;
|
||||
|
||||
use App\Framework\Logging\LogLevel;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
|
||||
/**
|
||||
* Security Event Processor für OWASP-konforme Security Event Logs
|
||||
*
|
||||
* Verarbeitet LogContext mit SecurityContext und generiert OWASP-format logs.
|
||||
*
|
||||
* OWASP Logging Cheat Sheet:
|
||||
* https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||
*/
|
||||
final readonly class SecurityEventProcessor
|
||||
{
|
||||
/**
|
||||
* Verarbeitet LogContext und generiert OWASP-format wenn SecurityContext vorhanden
|
||||
*
|
||||
* @param string $level Framework LogLevel
|
||||
* @param string $message Log message
|
||||
* @param LogContext $context LogContext mit potentiellem SecurityContext
|
||||
* @return array Enhanced log data mit OWASP format
|
||||
*/
|
||||
public function process(string $level, string $message, LogContext $context): array
|
||||
{
|
||||
// Wenn kein SecurityContext vorhanden, return original data
|
||||
if (!$context->hasSecurity()) {
|
||||
return $context->toArray();
|
||||
}
|
||||
|
||||
$security = $context->getSecurity();
|
||||
|
||||
// OWASP-Format generieren
|
||||
$owaspData = [
|
||||
'event_id' => $security->eventId,
|
||||
'event_category' => $security->category ?? 'general',
|
||||
'event_description' => $security->description,
|
||||
'security_level' => $security->level->value,
|
||||
'requires_alert' => $security->requiresAlert,
|
||||
];
|
||||
|
||||
// User-Informationen hinzufügen
|
||||
if ($security->userId !== null) {
|
||||
$owaspData['user_id'] = $security->userId;
|
||||
}
|
||||
|
||||
if ($security->username !== null) {
|
||||
$owaspData['username'] = $security->username;
|
||||
}
|
||||
|
||||
// Request-Informationen hinzufügen
|
||||
if ($security->sourceIp !== null) {
|
||||
$owaspData['source_ip'] = $security->sourceIp;
|
||||
}
|
||||
|
||||
if ($security->userAgent !== null) {
|
||||
$owaspData['user_agent'] = $security->userAgent;
|
||||
}
|
||||
|
||||
// Event-spezifische Daten hinzufügen
|
||||
if (!empty($security->eventData)) {
|
||||
$owaspData['event_data'] = $security->eventData;
|
||||
}
|
||||
|
||||
// Standard LogContext data mergen
|
||||
$contextData = $context->toArray();
|
||||
|
||||
// OWASP data als top-level field hinzufügen
|
||||
$contextData['owasp_security_event'] = $owaspData;
|
||||
|
||||
return $contextData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapped SecurityLogLevel zu Framework LogLevel
|
||||
*
|
||||
* @param \App\Framework\Exception\SecurityLogLevel $securityLevel
|
||||
* @return LogLevel
|
||||
*/
|
||||
public function mapSecurityLevelToLogLevel(\App\Framework\Exception\SecurityLogLevel $securityLevel): LogLevel
|
||||
{
|
||||
return match ($securityLevel) {
|
||||
\App\Framework\Exception\SecurityLogLevel::DEBUG => LogLevel::DEBUG,
|
||||
\App\Framework\Exception\SecurityLogLevel::INFO => LogLevel::INFO,
|
||||
\App\Framework\Exception\SecurityLogLevel::WARN => LogLevel::WARNING,
|
||||
\App\Framework\Exception\SecurityLogLevel::ERROR => LogLevel::ERROR,
|
||||
\App\Framework\Exception\SecurityLogLevel::FATAL => LogLevel::CRITICAL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Alert für dieses Security Event gesendet werden soll
|
||||
*
|
||||
* @param LogContext $context
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldAlert(LogContext $context): bool
|
||||
{
|
||||
if (!$context->hasSecurity()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $context->getSecurity()->requiresAlert;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ final readonly class LogContext
|
||||
* @param UserContext|null $user Benutzer-Kontext für Audit-Logs
|
||||
* @param RequestContext|null $request HTTP-Request-Kontext
|
||||
* @param ExceptionContext|null $exception Exception-Kontext für strukturiertes Exception-Logging
|
||||
* @param SecurityContext|null $security Security-Kontext für OWASP-konforme Security-Event-Logs
|
||||
* @param array<string, mixed> $metadata Zusätzliche Metadaten
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -34,6 +35,7 @@ final readonly class LogContext
|
||||
public ?UserContext $user = null,
|
||||
public ?RequestContext $request = null,
|
||||
public ?ExceptionContext $exception = null,
|
||||
public ?SecurityContext $security = null,
|
||||
public array $metadata = []
|
||||
) {
|
||||
}
|
||||
@@ -75,6 +77,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -92,6 +95,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -109,6 +113,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -195,6 +200,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -236,6 +242,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -269,6 +276,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -286,6 +294,7 @@ final readonly class LogContext
|
||||
user: $user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
@@ -303,10 +312,53 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Security-Kontext
|
||||
*/
|
||||
public function withSecurityContext(SecurityContext $security): self
|
||||
{
|
||||
return new self(
|
||||
structured: $this->structured,
|
||||
tags: $this->tags,
|
||||
correlationId: $this->correlationId,
|
||||
trace: $this->trace,
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $security,
|
||||
metadata: $this->metadata
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Security-Kontext (Alias für withSecurityContext)
|
||||
*/
|
||||
public function withSecurity(SecurityContext $security): self
|
||||
{
|
||||
return $this->withSecurityContext($security);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Security-Kontext
|
||||
*/
|
||||
public function getSecurity(): ?SecurityContext
|
||||
{
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob Security-Kontext vorhanden ist
|
||||
*/
|
||||
public function hasSecurity(): bool
|
||||
{
|
||||
return $this->security !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Metadaten hinzu
|
||||
*/
|
||||
@@ -320,6 +372,7 @@ final readonly class LogContext
|
||||
user: $this->user,
|
||||
request: $this->request,
|
||||
exception: $this->exception,
|
||||
security: $this->security,
|
||||
metadata: array_merge($this->metadata, [$key => $value])
|
||||
);
|
||||
}
|
||||
@@ -337,6 +390,7 @@ final readonly class LogContext
|
||||
user: $other->user ?? $this->user,
|
||||
request: $other->request ?? $this->request,
|
||||
exception: $other->exception ?? $this->exception,
|
||||
security: $other->security ?? $this->security,
|
||||
metadata: array_merge($this->metadata, $other->metadata)
|
||||
);
|
||||
}
|
||||
@@ -403,6 +457,10 @@ final readonly class LogContext
|
||||
$result['exception'] = $this->exception->toArray();
|
||||
}
|
||||
|
||||
if ($this->security !== null) {
|
||||
$result['security'] = $this->security->toArray();
|
||||
}
|
||||
|
||||
if (! empty($this->metadata)) {
|
||||
$result['metadata'] = $this->metadata;
|
||||
}
|
||||
|
||||
244
src/Framework/Logging/ValueObjects/SecurityContext.php
Normal file
244
src/Framework/Logging/ValueObjects/SecurityContext.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Logging\ValueObjects;
|
||||
|
||||
use App\Framework\Exception\SecurityLogLevel;
|
||||
|
||||
/**
|
||||
* Security Context Value Object für strukturiertes Security-Event-Logging
|
||||
*
|
||||
* Kapselt alle Security-relevanten Informationen für OWASP-konforme Logs.
|
||||
* Nutzt Framework's SecurityLogLevel aus Exception-Modul.
|
||||
*
|
||||
* OWASP Logging Cheat Sheet:
|
||||
* https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||
*/
|
||||
final readonly class SecurityContext
|
||||
{
|
||||
/**
|
||||
* @param string $eventId OWASP Event Identifier (z.B. "AUTHN_login_failure")
|
||||
* @param SecurityLogLevel $level Security-spezifisches Log Level
|
||||
* @param string $description Human-readable Event-Beschreibung
|
||||
* @param string|null $category Event-Kategorie (z.B. "authentication", "authorization")
|
||||
* @param bool $requiresAlert Ob Event ein Security-Alert triggern soll
|
||||
* @param string|null $userId Betroffene User-ID (falls zutreffend)
|
||||
* @param string|null $username Betroffener Username (falls zutreffend)
|
||||
* @param string|null $sourceIp Source IP-Adresse
|
||||
* @param string|null $userAgent User-Agent String
|
||||
* @param array<string, mixed> $eventData Zusätzliche Event-spezifische Daten
|
||||
*/
|
||||
public function __construct(
|
||||
public string $eventId,
|
||||
public SecurityLogLevel $level,
|
||||
public string $description,
|
||||
public ?string $category = null,
|
||||
public bool $requiresAlert = false,
|
||||
public ?string $userId = null,
|
||||
public ?string $username = null,
|
||||
public ?string $sourceIp = null,
|
||||
public ?string $userAgent = null,
|
||||
public array $eventData = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Erstellt SecurityContext für Authentication-Events
|
||||
*/
|
||||
public static function forAuthentication(
|
||||
string $eventId,
|
||||
string $description,
|
||||
SecurityLogLevel $level = SecurityLogLevel::WARN,
|
||||
bool $requiresAlert = false,
|
||||
array $eventData = []
|
||||
): self {
|
||||
return new self(
|
||||
eventId: $eventId,
|
||||
level: $level,
|
||||
description: $description,
|
||||
category: 'authentication',
|
||||
requiresAlert: $requiresAlert,
|
||||
eventData: $eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Erstellt SecurityContext für Authorization-Events
|
||||
*/
|
||||
public static function forAuthorization(
|
||||
string $eventId,
|
||||
string $description,
|
||||
SecurityLogLevel $level = SecurityLogLevel::WARN,
|
||||
bool $requiresAlert = false,
|
||||
array $eventData = []
|
||||
): self {
|
||||
return new self(
|
||||
eventId: $eventId,
|
||||
level: $level,
|
||||
description: $description,
|
||||
category: 'authorization',
|
||||
requiresAlert: $requiresAlert,
|
||||
eventData: $eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Erstellt SecurityContext für Input-Validation-Events
|
||||
*/
|
||||
public static function forInputValidation(
|
||||
string $eventId,
|
||||
string $description,
|
||||
SecurityLogLevel $level = SecurityLogLevel::WARN,
|
||||
bool $requiresAlert = false,
|
||||
array $eventData = []
|
||||
): self {
|
||||
return new self(
|
||||
eventId: $eventId,
|
||||
level: $level,
|
||||
description: $description,
|
||||
category: 'input_validation',
|
||||
requiresAlert: $requiresAlert,
|
||||
eventData: $eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Erstellt SecurityContext für Session-Events
|
||||
*/
|
||||
public static function forSession(
|
||||
string $eventId,
|
||||
string $description,
|
||||
SecurityLogLevel $level = SecurityLogLevel::INFO,
|
||||
bool $requiresAlert = false,
|
||||
array $eventData = []
|
||||
): self {
|
||||
return new self(
|
||||
eventId: $eventId,
|
||||
level: $level,
|
||||
description: $description,
|
||||
category: 'session',
|
||||
requiresAlert: $requiresAlert,
|
||||
eventData: $eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Erstellt SecurityContext für Intrusion-Detection-Events
|
||||
*/
|
||||
public static function forIntrusion(
|
||||
string $eventId,
|
||||
string $description,
|
||||
SecurityLogLevel $level = SecurityLogLevel::FATAL,
|
||||
bool $requiresAlert = true,
|
||||
array $eventData = []
|
||||
): self {
|
||||
return new self(
|
||||
eventId: $eventId,
|
||||
level: $level,
|
||||
description: $description,
|
||||
category: 'intrusion_detection',
|
||||
requiresAlert: $requiresAlert,
|
||||
eventData: $eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt User-Informationen
|
||||
*/
|
||||
public function withUser(?string $userId, ?string $username = null): self
|
||||
{
|
||||
return new self(
|
||||
eventId: $this->eventId,
|
||||
level: $this->level,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
requiresAlert: $this->requiresAlert,
|
||||
userId: $userId,
|
||||
username: $username,
|
||||
sourceIp: $this->sourceIp,
|
||||
userAgent: $this->userAgent,
|
||||
eventData: $this->eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Request-Informationen
|
||||
*/
|
||||
public function withRequestInfo(?string $sourceIp, ?string $userAgent = null): self
|
||||
{
|
||||
return new self(
|
||||
eventId: $this->eventId,
|
||||
level: $this->level,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
requiresAlert: $this->requiresAlert,
|
||||
userId: $this->userId,
|
||||
username: $this->username,
|
||||
sourceIp: $sourceIp,
|
||||
userAgent: $userAgent,
|
||||
eventData: $this->eventData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Event-Daten hinzu
|
||||
*/
|
||||
public function withEventData(array $eventData): self
|
||||
{
|
||||
return new self(
|
||||
eventId: $this->eventId,
|
||||
level: $this->level,
|
||||
description: $this->description,
|
||||
category: $this->category,
|
||||
requiresAlert: $this->requiresAlert,
|
||||
userId: $this->userId,
|
||||
username: $this->username,
|
||||
sourceIp: $this->sourceIp,
|
||||
userAgent: $this->userAgent,
|
||||
eventData: array_merge($this->eventData, $eventData)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Serialisierung
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [
|
||||
'event_id' => $this->eventId,
|
||||
'level' => $this->level->value,
|
||||
'description' => $this->description,
|
||||
];
|
||||
|
||||
if ($this->category !== null) {
|
||||
$result['category'] = $this->category;
|
||||
}
|
||||
|
||||
if ($this->requiresAlert) {
|
||||
$result['requires_alert'] = true;
|
||||
}
|
||||
|
||||
if ($this->userId !== null) {
|
||||
$result['user_id'] = $this->userId;
|
||||
}
|
||||
|
||||
if ($this->username !== null) {
|
||||
$result['username'] = $this->username;
|
||||
}
|
||||
|
||||
if ($this->sourceIp !== null) {
|
||||
$result['source_ip'] = $this->sourceIp;
|
||||
}
|
||||
|
||||
if ($this->userAgent !== null) {
|
||||
$result['user_agent'] = $this->userAgent;
|
||||
}
|
||||
|
||||
if (!empty($this->eventData)) {
|
||||
$result['event_data'] = $this->eventData;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user