feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckInterface;
use App\Framework\Health\HealthCheckResult;
use App\Framework\Health\HealthStatus;
use App\Framework\Logging\Aggregation\AggregatedLogEntry;
use App\Framework\Logging\Aggregation\AggregationConfig;
use App\Framework\Logging\Aggregation\MessageFingerprint;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Aggregating Log Handler
*
* Gruppiert identische/ähnliche Logs und gibt sie als Summary aus.
* Reduziert Log-Volume massiv bei wiederkehrenden Messages.
*
* Features:
* - Message Normalisierung (entfernt IDs, UUIDs, Zahlen)
* - Time-Window basierte Aggregation
* - Automatischer Flush nach Intervall
* - Flood-Protection
* - Beispiel-Messages für Debugging
*/
final class AggregatingLogHandler implements LogHandler, HealthCheckInterface
{
/** @var array<string, AggregatedLogEntry> */
private array $aggregatedEntries = [];
private Timestamp $windowStart;
private int $totalProcessed = 0;
private int $totalAggregated = 0;
public function __construct(
private readonly LogHandler $handler,
private readonly AggregationConfig $config = new AggregationConfig()
) {
$this->windowStart = Timestamp::now();
}
public function handle(LogRecord $record): void
{
$this->totalProcessed++;
// Prüfe ob Window abgelaufen ist
$this->maybeFlush();
// Prüfe ob Level aggregiert werden soll
if (!$this->config->shouldAggregate($record->level)) {
$this->handler->handle($record);
return;
}
// Erstelle Fingerprint
$fingerprint = MessageFingerprint::fromLogRecord($record);
$hash = $fingerprint->getHash();
// Füge zu existierendem Eintrag hinzu oder erstelle neuen
if (isset($this->aggregatedEntries[$hash])) {
$this->aggregatedEntries[$hash]->add($record);
$this->totalAggregated++;
} else {
$this->aggregatedEntries[$hash] = new AggregatedLogEntry($fingerprint, $record);
}
// Schutz vor Memory-Overflow
if (count($this->aggregatedEntries) > $this->config->maxEntriesPerWindow) {
$this->flush();
}
}
private function maybeFlush(): void
{
$elapsed = Timestamp::now()->diffInSeconds($this->windowStart);
if ($elapsed >= $this->config->flushIntervalSeconds) {
$this->flush();
}
}
public function flush(): void
{
if (empty($this->aggregatedEntries)) {
return;
}
// Sortiere nach Häufigkeit (meiste zuerst)
uasort($this->aggregatedEntries, fn($a, $b) => $b->getCount() <=> $a->getCount());
// Calculate aggregation metrics
$originalCount = 0;
$aggregatedCount = 0;
foreach ($this->aggregatedEntries as $entry) {
$originalCount += $entry->getCount();
$aggregatedCount++;
}
// Report aggregation metrics
if (class_exists(\App\Framework\Logging\Metrics\LogMetricsCollector::class)) {
\App\Framework\Logging\Metrics\LogMetricsCollector::getInstance()
->recordAggregation($originalCount, $aggregatedCount);
}
foreach ($this->aggregatedEntries as $entry) {
// Nur aggregierte Logs ausgeben (mindestens X Vorkommen)
if ($entry->getCount() >= $this->config->minOccurrencesForAggregation) {
try {
$this->handler->handle($entry->toLogRecord());
} catch (\Throwable $e) {
// Silent fail - Aggregation darf nicht crashen
error_log('Aggregation flush failed: ' . $e->getMessage());
}
} else {
// Einzelne Logs normal ausgeben
try {
$this->handler->handle($entry->getFirstRecord());
} catch (\Throwable) {
// Silent fail
}
}
}
// Reset
$this->aggregatedEntries = [];
$this->windowStart = Timestamp::now();
}
public function __destruct()
{
try {
$this->flush();
} catch (\Throwable) {
// Silent fail im Destruktor
}
}
public function getAggregatedCount(): int
{
return count($this->aggregatedEntries);
}
public function getTotalProcessed(): int
{
return $this->totalProcessed;
}
public function getTotalAggregated(): int
{
return $this->totalAggregated;
}
public function getAggregationRate(): float
{
if ($this->totalProcessed === 0) {
return 0.0;
}
return $this->totalAggregated / $this->totalProcessed;
}
public function check(): HealthCheckResult
{
$aggregationRate = $this->getAggregationRate();
$details = [
'total_processed' => $this->totalProcessed,
'total_aggregated' => $this->totalAggregated,
'aggregation_rate' => round($aggregationRate * 100, 2) . '%',
'current_window_entries' => count($this->aggregatedEntries),
'max_entries' => $this->config->maxEntriesPerWindow,
'window_age_seconds' => Timestamp::now()->diffInSeconds($this->windowStart),
'config' => [
'flush_interval' => $this->config->flushIntervalSeconds,
'min_occurrences' => $this->config->minOccurrencesForAggregation,
],
];
$status = HealthStatus::HEALTHY;
$message = 'Aggregation working normally';
// Warning bei sehr hoher Aggregation (kann auf Probleme hinweisen)
if ($aggregationRate > 0.9 && $this->totalProcessed > 100) {
$status = HealthStatus::DEGRADED;
$message = sprintf('Very high aggregation rate: %.1f%%', $aggregationRate * 100);
}
// Warning bei fast vollem Window
if (count($this->aggregatedEntries) > ($this->config->maxEntriesPerWindow * 0.8)) {
$status = HealthStatus::DEGRADED;
$message = 'Aggregation window nearly full';
}
return new HealthCheckResult(
status: $status,
componentName: $this->getName(),
message: $message,
details: $details,
timestamp: new \DateTimeImmutable()
);
}
public function getName(): string
{
return 'aggregating_handler';
}
public function isHandling(LogRecord $record): bool
{
// Delegate to wrapped handler
return $this->handler->isHandling($record);
}
public function getCategory(): HealthCheckCategory
{
return HealthCheckCategory::INFRASTRUCTURE;
}
public function getTimeout(): int
{
return 3000; // 3 seconds timeout for aggregating handler health check
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
/**
* Buffered Log Handler für verbesserte Performance
*
* Sammelt Log-Einträge in einem Memory-Buffer und schreibt sie batch-weise.
* Reduziert I/O-Operationen und verbessert die Performance erheblich.
*
* Features:
* - Automatischer Flush bei Buffer-Größe
* - Zeitbasierter Flush
* - Sofortiger Flush für kritische Logs (ERROR, CRITICAL, etc.)
* - Destruktor stellt sicher, dass alle Logs geschrieben werden
*/
final class BufferedLogHandler implements LogHandler, \App\Framework\Health\HealthCheckInterface
{
/** @var LogRecord[] */
private array $buffer = [];
private Timestamp $lastFlushTime;
public function __construct(
private readonly LogHandler $handler,
private readonly int $bufferSize = 100,
private readonly float $flushIntervalSeconds = 5.0,
private readonly bool $flushOnError = true
) {
$this->lastFlushTime = Timestamp::now();
}
public function handle(LogRecord $record): void
{
$this->buffer[] = $record;
// Sofortiger Flush bei Errors (wenn aktiviert)
if ($this->flushOnError && $record->level->value >= LogLevel::ERROR->value) {
$this->flush();
return;
}
// Flush wenn Buffer voll
if (count($this->buffer) >= $this->bufferSize) {
$this->flush();
return;
}
// Zeitbasierter Flush
$now = Timestamp::now();
if ($now->diffInSeconds($this->lastFlushTime) >= $this->flushIntervalSeconds) {
$this->flush();
}
}
/**
* Schreibt alle gepufferten Logs
*/
public function flush(): void
{
if (empty($this->buffer)) {
return;
}
$records = $this->buffer;
$count = count($records);
$this->buffer = [];
$this->lastFlushTime = Timestamp::now();
// Report flush metrics
if (class_exists(\App\Framework\Logging\Metrics\LogMetricsCollector::class)) {
\App\Framework\Logging\Metrics\LogMetricsCollector::getInstance()
->recordBufferFlush('buffered', $count);
}
foreach ($records as $record) {
try {
$this->handler->handle($record);
} catch (\Throwable $e) {
// Bei Fehler: verbleibende Records wieder in Buffer
// und Exception werfen (wird von ResilientHandler gefangen)
$this->buffer = array_merge($records, $this->buffer);
throw $e;
}
}
}
/**
* Destruktor stellt sicher, dass alle Logs geschrieben werden
*/
public function __destruct()
{
try {
$this->flush();
} catch (\Throwable) {
// Silent fail im Destruktor
}
}
public function getBufferSize(): int
{
return count($this->buffer);
}
public function isEmpty(): bool
{
return empty($this->buffer);
}
public function check(): \App\Framework\Health\HealthCheckResult
{
$bufferSize = $this->getBufferSize();
$fillPercentage = ($bufferSize / $this->bufferSize) * 100;
$details = [
'buffer_size' => $bufferSize,
'max_buffer_size' => $this->bufferSize,
'fill_percentage' => round($fillPercentage, 2),
'last_flush' => $this->lastFlushTime->format('Y-m-d H:i:s'),
'seconds_since_last_flush' => Timestamp::now()->diffInSeconds($this->lastFlushTime),
];
$status = \App\Framework\Health\HealthStatus::HEALTHY;
$message = 'Buffer healthy';
if ($fillPercentage >= 90) {
$status = \App\Framework\Health\HealthStatus::DEGRADED;
$message = sprintf('Buffer nearly full: %.1f%%', $fillPercentage);
} elseif ($fillPercentage >= 75) {
$status = \App\Framework\Health\HealthStatus::DEGRADED;
$message = sprintf('Buffer filling up: %.1f%%', $fillPercentage);
}
return new \App\Framework\Health\HealthCheckResult(
status: $status,
componentName: $this->getName(),
message: $message,
details: $details,
timestamp: new \DateTimeImmutable()
);
}
public function getName(): string
{
return 'buffered_handler';
}
public function isHandling(LogRecord $record): bool
{
// Delegate to wrapped handler
return $this->handler->isHandling($record);
}
public function getCategory(): HealthCheckCategory
{
return HealthCheckCategory::INFRASTRUCTURE;
}
public function getTimeout(): int
{
return 3000; // 3 seconds timeout for buffered handler health check
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Config\Environment;
use App\Framework\Logging\Formatter\JsonFormatter;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Security\SensitiveDataRedactor;
/**
* Docker-optimierter JSON Handler für strukturierte Container-Logs
*
* Schreibt strukturierte JSON-Logs nach STDOUT für Docker Log-Aggregation.
* Ideal für Production-Deployments mit Elasticsearch, Datadog, etc.
*
* Features:
* - Compact JSON (eine Zeile pro Log)
* - Alle Aggregator-Felder (@timestamp, severity, environment, host, service)
* - STDOUT für Docker's JSON File Logging Driver
* - Minimale Latenz durch direkte echo-Ausgabe
*
* Docker Log Kommandos:
* - docker logs <container> --tail 50
* - docker logs <container> --follow
* - docker logs <container> --since 10m
*
* Mit jq formatieren:
* - docker logs <container> 2>&1 | jq .
* - docker logs <container> 2>&1 | jq 'select(.level == "ERROR")'
* - docker logs <container> 2>&1 | jq -r '[.timestamp, .level, .message] | @tsv'
*/
final readonly class DockerJsonHandler implements LogHandler
{
private JsonFormatter $formatter;
private LogLevel $minLevel;
private bool $prettyPrint;
public function __construct(
?Environment $env = null,
?string $serviceName = null,
LogLevel|int $minLevel = LogLevel::DEBUG,
bool $prettyPrint = false,
bool $redactSensitiveData = false,
?SensitiveDataRedactor $redactor = null
) {
$this->formatter = new JsonFormatter(
prettyPrint: $prettyPrint,
includeExtras: true,
flattenContext: true,
env: $env,
serviceName: $serviceName,
redactSensitiveData: $redactSensitiveData,
redactor: $redactor
);
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
$this->prettyPrint = $prettyPrint;
}
public function isHandling(LogRecord $record): bool
{
// Nur in CLI-Umgebung (Docker Container)
if (PHP_SAPI !== 'cli') {
return false;
}
return $record->level->value >= $this->minLevel->value;
}
public function handle(LogRecord $record): void
{
$json = ($this->formatter)($record);
// Direkt nach STDOUT für Docker's JSON File Logging Driver
echo $json . PHP_EOL;
}
/**
* Setzt minimales Log-Level
*/
public function setMinLevel(LogLevel|int $level): self
{
$minLevel = $level instanceof LogLevel ? $level : LogLevel::fromValue($level);
return new self(
env: null, // Environment wird vom Formatter gespeichert
serviceName: null,
minLevel: $minLevel,
prettyPrint: $this->prettyPrint
);
}
}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Config\Environment;
use App\Framework\Core\PathProvider;
use App\Framework\Filesystem\Serializers\JsonSerializer;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
@@ -12,6 +14,15 @@ use App\Framework\Logging\LogRecord;
/**
* Handler für die Ausgabe von Log-Einträgen als JSON in Dateien.
* Besonders nützlich für maschinelle Verarbeitung und Log-Aggregatoren.
*
* Nutzt JsonSerializer für einheitliche JSON-Ausgabe konsistent mit JsonFormatter.
*
* Standard-Felder für Log-Aggregatoren:
* - @timestamp: Elasticsearch-konformes Zeitstempelfeld
* - severity: RFC 5424 Severity Level (0-7)
* - environment: Deployment-Umgebung (production, staging, development)
* - host: Server-Hostname
* - service: Service-/Anwendungsname
*/
class JsonFileHandler implements LogHandler
{
@@ -35,6 +46,31 @@ class JsonFileHandler implements LogHandler
*/
private ?PathProvider $pathProvider = null;
/**
* @var JsonSerializer JSON Serializer für konsistente Ausgabe
*/
private JsonSerializer $serializer;
/**
* @var bool Flatten LogContext für bessere Aggregator-Kompatibilität
*/
private bool $flattenContext;
/**
* @var string Deployment-Umgebung (production, staging, development)
*/
private string $environment;
/**
* @var string Server-Hostname
*/
private string $host;
/**
* @var string Service-/Anwendungsname
*/
private string $serviceName;
/**
* Erstellt einen neuen JsonFileHandler
*
@@ -42,14 +78,21 @@ class JsonFileHandler implements LogHandler
* @param LogLevel|int $minLevel Minimales Level, ab dem dieser Handler aktiv wird
* @param array|null $includedFields Liste der Felder, die in der JSON-Ausgabe enthalten sein sollen (null = alle)
* @param PathProvider|null $pathProvider Optional: PathProvider für die Auflösung von Pfaden
* @param bool $flattenContext Flatten LogContext structured data (default: true)
* @param Environment|null $env Optional: Environment für Konfiguration
* @param string|null $serviceName Optional: Service-/Anwendungsname
*/
public function __construct(
string $logFile,
LogLevel|int $minLevel = LogLevel::INFO,
?array $includedFields = null,
?PathProvider $pathProvider = null
?PathProvider $pathProvider = null,
bool $flattenContext = true,
?Environment $env = null,
?string $serviceName = null
) {
$this->pathProvider = $pathProvider;
$this->flattenContext = $flattenContext;
// Pfad auflösen, falls PathProvider vorhanden
if ($this->pathProvider !== null && ! str_starts_with($logFile, '/')) {
@@ -59,16 +102,34 @@ class JsonFileHandler implements LogHandler
$this->logFile = $logFile;
$this->minLevel = $minLevel instanceof LogLevel ? $minLevel : LogLevel::fromValue($minLevel);
// Standardfelder, falls nicht anders angegeben
// Standardfelder, falls nicht anders angegeben (konsistent mit JsonFormatter)
$this->includedFields = $includedFields ?? [
'timestamp',
'level_name',
'@timestamp',
'level',
'level_value',
'severity',
'channel',
'message',
'environment',
'host',
'service',
'context',
'extra',
'channel',
];
// Compact JSON für Datei-Ausgabe (eine Zeile pro Log)
$this->serializer = JsonSerializer::compact();
// Environment Detection (production, staging, development)
$this->environment = $env?->getString('APP_ENV', 'production') ?? 'production';
// Host Detection
$this->host = gethostname() ?: 'unknown';
// Service Name (default: app name from env)
$this->serviceName = $serviceName ?? $env?->getString('APP_NAME', 'app') ?? 'app';
// Stelle sicher, dass das Verzeichnis existiert
$this->ensureDirectoryExists(dirname($logFile));
}
@@ -86,25 +147,83 @@ class JsonFileHandler implements LogHandler
*/
public function handle(LogRecord $record): void
{
// Alle Daten des Records als Array holen
$data = $record->toArray();
// Formatiere Record zu einheitlichem Array (konsistent mit JsonFormatter)
$data = $this->formatRecord($record);
// Nur die gewünschten Felder behalten
if (! empty($this->includedFields)) {
$data = array_intersect_key($data, array_flip($this->includedFields));
}
// Zeitstempel als ISO 8601 formatieren für bessere Interoperabilität
if (isset($data['datetime']) && $data['datetime'] instanceof \DateTimeInterface) {
$data['timestamp_iso'] = $data['datetime']->format(\DateTimeInterface::ATOM);
unset($data['datetime']); // DateTime-Objekt entfernen
}
// Als JSON formatieren und in die Datei schreiben
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR) . PHP_EOL;
// Als JSON formatieren mit JsonSerializer und in die Datei schreiben
$json = $this->serializer->serialize($data) . PHP_EOL;
$this->write($json);
}
/**
* Formatiert LogRecord zu einheitlichem Array für JSON-Serialisierung
* (Gleiche Logik wie JsonFormatter für Konsistenz)
*
* @return array<string, mixed>
*/
private function formatRecord(LogRecord $record): array
{
$timestamp = $record->timestamp->format('c'); // ISO 8601
$data = [
// Standard Log Fields
'timestamp' => $timestamp,
'@timestamp' => $timestamp, // Elasticsearch convention
'level' => $record->level->getName(),
'level_value' => $record->level->value,
'severity' => $record->level->toRFC5424(), // RFC 5424 (0-7)
'channel' => $record->channel,
'message' => $record->message,
// Infrastructure Fields (for log aggregators)
'environment' => $this->environment,
'host' => $this->host,
'service' => $this->serviceName,
];
// Context hinzufügen mit optionalem Flatten
$context = $record->context->toArray();
if ($this->flattenContext && isset($context['structured'])) {
// Flatten: Nur strukturierte Daten für bessere Aggregator-Kompatibilität
$data['context'] = $context['structured'];
} else {
// Raw: Gesamtes LogContext-Array
$data['context'] = $context;
}
// Extras hinzufügen (wenn vorhanden)
if (!empty($record->extra)) {
$data['extra'] = $record->extra;
}
// Nur gewünschte Felder behalten, in Reihenfolge von $includedFields
return $this->filterFields($data);
}
/**
* Filtert Array nach includedFields
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function filterFields(array $data): array
{
if (empty($this->includedFields)) {
return $data;
}
$filtered = [];
foreach ($this->includedFields as $field) {
if (array_key_exists($field, $data)) {
$filtered[$field] = $data[$field];
}
}
return $filtered;
}
/**
* Schreibt einen String in die Log-Datei
*/

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Metrics\LogMetricsCollector;
/**
* Metrics Reporting Handler
*
* Wrapper der Handler-Performance misst und an Metrics-System meldet.
*/
final readonly class MetricsReportingHandler implements LogHandler
{
public function __construct(
private LogHandler $handler,
private LogMetricsCollector $collector,
private string $handlerName
) {
}
public function handle(LogRecord $record): void
{
$startTime = Timestamp::now();
try {
$this->handler->handle($record);
} finally {
$endTime = Timestamp::now();
$milliseconds = $endTime->diffInMilliseconds($startTime);
$this->collector->recordHandlerLatency(
$this->handlerName,
$milliseconds
);
}
}
public function isHandling(LogRecord $record): bool
{
// Delegate to wrapped handler
return $this->handler->isHandling($record);
}
}

View File

@@ -9,6 +9,7 @@ use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ProcessLogCommand;
use App\Framework\Queue\Queue;
use App\Framework\Queue\ValueObjects\JobPayload;
final readonly class QueuedLogHandler implements LogHandler
{
@@ -26,6 +27,7 @@ final readonly class QueuedLogHandler implements LogHandler
public function handle(LogRecord $record): void
{
$job = new ProcessLogCommand($record);
$this->queue->push($job);
$payload = JobPayload::immediate($job);
$this->queue->push($payload);
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckInterface;
use App\Framework\Health\HealthCheckResult;
use App\Framework\Health\HealthStatus;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogRecord;
use App\Framework\RateLimit\RateLimiter;
/**
* Rate Limited Log Handler
*
* Limitiert Log-Rate basierend auf RateLimiter.
* Verhindert Log-Flooding bei wiederkehrenden Fehlern.
*
* Features:
* - Channel-basiertes Rate Limiting
* - Level-basiertes Rate Limiting
* - Message-Hash-basiertes Rate Limiting
* - Periodische Summary-Logs für gedrosselte Messages
*/
final class RateLimitedLogHandler implements LogHandler, HealthCheckInterface
{
private array $throttledCounts = [];
private int $totalThrottled = 0;
private Timestamp $lastSummaryTime;
public function __construct(
private readonly LogHandler $handler,
private readonly RateLimiter $rateLimiter,
private readonly string $keyPrefix = 'log',
private readonly int $summaryCadenceSeconds = 60
) {
$this->lastSummaryTime = Timestamp::now();
}
public function handle(LogRecord $record): void
{
$key = $this->buildRateLimitKey($record);
// Prüfe Rate Limit
if (!$this->rateLimiter->attempt($key)) {
$this->recordThrottled($record);
$this->maybeLogSummary();
return;
}
// Log durchgelassen
$this->handler->handle($record);
}
private function buildRateLimitKey(LogRecord $record): string
{
// Kombiniere Channel + Level + Message-Hash für granulares Rate Limiting
$messageHash = substr(md5($record->message), 0, 8);
return sprintf(
'%s:%s:%s:%s',
$this->keyPrefix,
$record->channel,
$record->level->getName(),
$messageHash
);
}
private function recordThrottled(LogRecord $record): void
{
$this->totalThrottled++;
$key = sprintf('%s:%s', $record->channel, $record->level->getName());
if (!isset($this->throttledCounts[$key])) {
$this->throttledCounts[$key] = [
'count' => 0,
'channel' => $record->channel,
'level' => $record->level->getName(),
'last_message' => $record->message,
];
}
$this->throttledCounts[$key]['count']++;
$this->throttledCounts[$key]['last_message'] = $record->message;
}
private function maybeLogSummary(): void
{
$now = Timestamp::now();
if ($now->diffInSeconds($this->lastSummaryTime) < $this->summaryCadenceSeconds) {
return;
}
if (empty($this->throttledCounts)) {
return;
}
// Log Summary
$summary = $this->buildSummary();
try {
$this->handler->handle($summary);
} catch (\Throwable) {
// Silent fail - don't cascade errors
}
// Reset
$this->throttledCounts = [];
$this->lastSummaryTime = $now;
}
private function buildSummary(): LogRecord
{
$totalCount = array_sum(array_column($this->throttledCounts, 'count'));
$message = sprintf(
'Rate limit summary: %d messages throttled in last %d seconds',
$totalCount,
$this->summaryCadenceSeconds
);
return new LogRecord(
level: \App\Framework\Logging\LogLevel::WARNING,
message: $message,
channel: 'logging',
context: \App\Framework\Logging\ValueObjects\LogContext::withData([
'throttled_messages' => $this->throttledCounts,
'total_throttled' => $totalCount,
'time_window_seconds' => $this->summaryCadenceSeconds,
]),
timestamp: new \DateTimeImmutable()
);
}
public function getTotalThrottled(): int
{
return $this->totalThrottled;
}
public function getThrottledCounts(): array
{
return $this->throttledCounts;
}
public function check(): HealthCheckResult
{
$details = [
'total_throttled' => $this->totalThrottled,
'throttled_by_channel_level' => $this->throttledCounts,
'summary_cadence_seconds' => $this->summaryCadenceSeconds,
];
// Warning wenn sehr viele Logs gedrosselt werden
$status = HealthStatus::HEALTHY;
$message = 'Rate limiting working normally';
if ($this->totalThrottled > 10000) {
$status = HealthStatus::DEGRADED;
$message = 'High number of throttled logs';
}
return new HealthCheckResult(
status: $status,
componentName: $this->getName(),
message: $message,
details: $details,
timestamp: new \DateTimeImmutable()
);
}
public function getName(): string
{
return 'rate_limited_handler';
}
public function isHandling(LogRecord $record): bool
{
// Delegate to wrapped handler
return $this->handler->isHandling($record);
}
public function getCategory(): HealthCheckCategory
{
return HealthCheckCategory::INFRASTRUCTURE;
}
public function getTimeout(): int
{
return 3000; // 3 seconds timeout for rate limited handler health check
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\CircuitBreaker\CircuitBreaker;
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogRecord;
/**
* Resilient Log Handler mit Fallback und Circuit Breaker
*
* Stellt sicher, dass Logging-Fehler niemals die Anwendung zum Absturz bringen.
* Bei Problemen mit dem primären Handler wird automatisch auf Fallback umgeschaltet.
*
* Features:
* - Circuit Breaker verhindert wiederholte Fehlerversuche
* - Automatischer Fallback bei Primär-Handler-Fehlern
* - Silent Failures als letztes Mittel (error_log)
* - Selbstheilend durch Circuit Breaker Half-Open State
*/
final class ResilientLogHandler implements LogHandler, \App\Framework\Health\HealthCheckInterface
{
private CircuitBreaker $circuitBreaker;
public function __construct(
private readonly LogHandler $primaryHandler,
private readonly LogHandler $fallbackHandler,
?CircuitBreaker $circuitBreaker = null
) {
$this->circuitBreaker = $circuitBreaker ?? new CircuitBreaker(
new CircuitBreakerConfig(
name: 'logging.primary',
failureThreshold: 5,
successThreshold: 2,
timeout: 60.0,
halfOpenRequests: 1
)
);
}
public function handle(LogRecord $record): void
{
// Circuit Breaker ist offen - direkt Fallback nutzen
if ($this->circuitBreaker->isOpen()) {
$this->handleWithFallback($record);
return;
}
try {
// Versuch mit primärem Handler
$this->circuitBreaker->call(
fn() => $this->primaryHandler->handle($record)
);
} catch (\Throwable $e) {
// Primärer Handler fehlgeschlagen - Fallback nutzen
$this->handleWithFallback($record, $e);
}
}
private function handleWithFallback(LogRecord $record, ?\Throwable $primaryError = null): void
{
try {
$this->fallbackHandler->handle($record);
// Log den Grund für Fallback-Nutzung (nur bei Fehlern)
if ($primaryError !== null) {
$this->fallbackHandler->handle(
new LogRecord(
level: $record->level,
message: 'Primary log handler failed, using fallback',
channel: 'logging',
context: $record->context->addData('error', $primaryError->getMessage()),
timestamp: $record->timestamp
)
);
}
} catch (\Throwable $fallbackError) {
// Beide Handler fehlgeschlagen - Silent Fail mit error_log
$this->emergencyLog($record, $primaryError, $fallbackError);
}
}
/**
* Letzter Ausweg: PHP error_log
* Logging darf niemals die Anwendung crashen
*/
private function emergencyLog(
LogRecord $record,
?\Throwable $primaryError,
\Throwable $fallbackError
): void {
$message = sprintf(
"[CRITICAL] All log handlers failed. Original: %s [%s] | Primary Error: %s | Fallback Error: %s",
$record->message,
$record->level->getName(),
$primaryError?->getMessage() ?? 'N/A',
$fallbackError->getMessage()
);
error_log($message);
}
public function isHealthy(): bool
{
return !$this->circuitBreaker->isOpen();
}
public function getCircuitBreakerState(): string
{
return $this->circuitBreaker->getState()->value;
}
public function check(): \App\Framework\Health\HealthCheckResult
{
$state = $this->circuitBreaker->getState();
$details = [
'circuit_breaker_state' => $state->value,
'is_healthy' => $this->isHealthy(),
];
$status = \App\Framework\Health\HealthStatus::HEALTHY;
$message = 'Primary handler operational';
if ($state === \App\Framework\CircuitBreaker\CircuitBreakerState::OPEN) {
$status = \App\Framework\Health\HealthStatus::UNHEALTHY;
$message = 'Circuit breaker is open - using fallback handler';
} elseif ($state === \App\Framework\CircuitBreaker\CircuitBreakerState::HALF_OPEN) {
$status = \App\Framework\Health\HealthStatus::DEGRADED;
$message = 'Circuit breaker is half-open - recovering';
}
return new \App\Framework\Health\HealthCheckResult(
status: $status,
componentName: $this->getName(),
message: $message,
details: $details,
timestamp: new \DateTimeImmutable()
);
}
public function getName(): string
{
return 'resilient_handler';
}
public function isHandling(LogRecord $record): bool
{
// Delegate to primary handler (if circuit is closed)
return $this->primaryHandler->isHandling($record);
}
public function getCategory(): HealthCheckCategory
{
return HealthCheckCategory::INFRASTRUCTURE;
}
public function getTimeout(): int
{
return 3000; // 3 seconds timeout for resilient handler health check
}
}

View File

@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\LogRotator;
/**
* Handler für Log-Rotation mit Size- und Time-based Strategien.
*
* Erweitert FileHandler mit automatischer Log-Rotation basierend auf:
* - Dateigröße (via LogRotator)
* - Zeit (täglich, wöchentlich, monatlich)
*
* Verwendung:
* ```php
* // Size-based rotation (10MB, 5 files)
* $handler = RotatingFileHandler::withSizeRotation(
* 'storage/logs/app.log',
* maxFileSize: Byte::fromMegabytes(10),
* maxFiles: 5
* );
*
* // Time-based rotation (täglich)
* $handler = RotatingFileHandler::daily('storage/logs/app.log');
*
* // Kombiniert (Size + Time)
* $handler = RotatingFileHandler::daily('storage/logs/app.log')
* ->withMaxSize(Byte::fromMegabytes(50));
* ```
*/
final class RotatingFileHandler extends FileHandler
{
private ?string $rotationFrequency = null;
private ?int $lastRotationCheck = null;
/**
* Factory: Size-based Rotation
*/
public static function withSizeRotation(
string $logFile,
Byte $maxFileSize = new Byte(10 * 1024 * 1024), // 10MB default
int $maxFiles = 5,
bool $compress = true,
LogLevel|int $minLevel = LogLevel::DEBUG,
?PathProvider $pathProvider = null
): self {
$rotator = new LogRotator($maxFileSize, $maxFiles, $compress);
return new self(
logFile: $logFile,
minLevel: $minLevel,
rotator: $rotator,
pathProvider: $pathProvider
);
}
/**
* Factory: Daily Rotation (täglich um Mitternacht)
*/
public static function daily(
string $logFile,
int $maxFiles = 7,
bool $compress = true,
LogLevel|int $minLevel = LogLevel::DEBUG,
?PathProvider $pathProvider = null
): self {
$rotator = new LogRotator(
maxFileSize: Byte::fromMegabytes(50),
maxFiles: $maxFiles,
compress: $compress
);
$handler = new self(
logFile: $logFile,
minLevel: $minLevel,
rotator: $rotator,
pathProvider: $pathProvider
);
$handler->rotationFrequency = 'daily';
return $handler;
}
/**
* Factory: Weekly Rotation (wöchentlich am Montag)
*/
public static function weekly(
string $logFile,
int $maxFiles = 4,
bool $compress = true,
LogLevel|int $minLevel = LogLevel::DEBUG,
?PathProvider $pathProvider = null
): self {
$rotator = new LogRotator(
maxFileSize: Byte::fromMegabytes(100),
maxFiles: $maxFiles,
compress: $compress
);
$handler = new self(
logFile: $logFile,
minLevel: $minLevel,
rotator: $rotator,
pathProvider: $pathProvider
);
$handler->rotationFrequency = 'weekly';
return $handler;
}
/**
* Factory: Monthly Rotation (monatlich am 1. des Monats)
*/
public static function monthly(
string $logFile,
int $maxFiles = 12,
bool $compress = true,
LogLevel|int $minLevel = LogLevel::DEBUG,
?PathProvider $pathProvider = null
): self {
$rotator = new LogRotator(
maxFileSize: Byte::fromMegabytes(200),
maxFiles: $maxFiles,
compress: $compress
);
$handler = new self(
logFile: $logFile,
minLevel: $minLevel,
rotator: $rotator,
pathProvider: $pathProvider
);
$handler->rotationFrequency = 'monthly';
return $handler;
}
/**
* Factory: Production-optimized (25MB, 10 files, täglich)
*/
public static function production(
string $logFile,
LogLevel|int $minLevel = LogLevel::INFO,
?PathProvider $pathProvider = null
): self {
$rotator = LogRotator::production();
$handler = new self(
logFile: $logFile,
minLevel: $minLevel,
rotator: $rotator,
pathProvider: $pathProvider
);
$handler->rotationFrequency = 'daily';
return $handler;
}
/**
* Verarbeitet einen Log-Eintrag mit Time-based Rotation
*/
public function handle(LogRecord $record): void
{
// Time-based Rotation prüfen (vor Parent-Handle)
if ($this->rotationFrequency !== null) {
$this->checkTimeBasedRotation();
}
// Parent FileHandler handle() führt Size-based Rotation aus
parent::handle($record);
}
/**
* Prüft ob Time-based Rotation notwendig ist
*
* Diese Methode wird direkt vor handle() aufgerufen und rotiert die Log-Datei,
* falls die Zeit-basierte Rotation-Bedingung erfüllt ist.
* Der Parent FileHandler hat Zugriff auf logFile und rotator via reflection.
*/
private function checkTimeBasedRotation(): void
{
$currentTime = time();
// Cache Rotation-Check für Performance (max. 1x pro Minute)
if ($this->lastRotationCheck !== null && $currentTime - $this->lastRotationCheck < 60) {
return;
}
$this->lastRotationCheck = $currentTime;
$shouldRotate = match ($this->rotationFrequency) {
'daily' => $this->shouldRotateDaily(),
'weekly' => $this->shouldRotateWeekly(),
'monthly' => $this->shouldRotateMonthly(),
default => false
};
// Nur rotieren wenn Rotation-Bedingung erfüllt ist
// Parent FileHandler hat den LogRotator, wir müssen ihn nicht direkt aufrufen
if ($shouldRotate) {
// Force rotation durch setzen einer sehr kleinen max file size temporär
// Der Parent FileHandler wird dann beim nächsten handle() rotieren
$this->triggerRotation();
}
}
/**
* Triggert eine Rotation durch Manipulation des Rotation-Status
*/
private function triggerRotation(): void
{
// Nutze Reflection um auf private logFile Property zuzugreifen
$reflection = new \ReflectionClass(parent::class);
$logFileProperty = $reflection->getProperty('logFile');
$logFile = $logFileProperty->getValue($this);
$rotatorProperty = $reflection->getProperty('rotator');
$rotator = $rotatorProperty->getValue($this);
// Rotiere direkt wenn Rotator vorhanden
if ($rotator instanceof LogRotator && file_exists($logFile)) {
$rotator->rotateLog($logFile);
}
}
/**
* Prüft ob Daily Rotation notwendig ist
*/
private function shouldRotateDaily(): bool
{
$logFile = $this->getLogFilePath();
if (!file_exists($logFile)) {
return false;
}
$fileDate = date('Y-m-d', filemtime($logFile));
$currentDate = date('Y-m-d');
// Rotiere wenn Log-Datei von gestern oder älter
return $fileDate < $currentDate;
}
/**
* Prüft ob Weekly Rotation notwendig ist
*/
private function shouldRotateWeekly(): bool
{
$logFile = $this->getLogFilePath();
if (!file_exists($logFile)) {
return false;
}
$fileWeek = date('Y-W', filemtime($logFile));
$currentWeek = date('Y-W');
// Rotiere wenn Log-Datei von letzter Woche oder älter
return $fileWeek < $currentWeek;
}
/**
* Prüft ob Monthly Rotation notwendig ist
*/
private function shouldRotateMonthly(): bool
{
$logFile = $this->getLogFilePath();
if (!file_exists($logFile)) {
return false;
}
$fileMonth = date('Y-m', filemtime($logFile));
$currentMonth = date('Y-m');
// Rotiere wenn Log-Datei von letztem Monat oder älter
return $fileMonth < $currentMonth;
}
/**
* Holt Log-File-Pfad via Reflection
*/
private function getLogFilePath(): string
{
$reflection = new \ReflectionClass(parent::class);
$logFileProperty = $reflection->getProperty('logFile');
return $logFileProperty->getValue($this);
}
/**
* Setzt maximale Dateigröße (für kombinierte Size + Time Rotation)
*/
public function withMaxSize(Byte $maxSize, int $maxFiles = 5, bool $compress = true): self
{
// Setze Rotator via Reflection um dynamic property warning zu vermeiden
$reflection = new \ReflectionClass(parent::class);
$rotatorProperty = $reflection->getProperty('rotator');
$newRotator = new LogRotator(
maxFileSize: $maxSize,
maxFiles: $maxFiles,
compress: $compress
);
$rotatorProperty->setValue($this, $newRotator);
return $this;
}
/**
* Gibt Rotation-Strategie-Info zurück
*/
public function getRotationStrategy(): array
{
// Prüfe via Reflection ob Rotator im Parent gesetzt ist
$reflection = new \ReflectionClass(parent::class);
$rotatorProperty = $reflection->getProperty('rotator');
$rotator = $rotatorProperty->getValue($this);
return [
'time_based' => $this->rotationFrequency ?? 'none',
'size_based' => $rotator !== null,
'last_check' => $this->lastRotationCheck
? date('Y-m-d H:i:s', $this->lastRotationCheck)
: null
];
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckInterface;
use App\Framework\Health\HealthCheckResult;
use App\Framework\Health\HealthStatus;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Sampling\SamplingConfig;
/**
* Sampling Log Handler
*
* Sampelt Logs basierend auf konfigurierbaren Raten pro Level.
* Reduziert Log-Volume in High-Load-Szenarien ohne kritische Logs zu verlieren.
*
* Features:
* - Level-basiertes Sampling
* - Probabilistic Sampling
* - Garantiert: ERROR+ Levels werden immer geloggt (konfigurierbar)
* - Metriken über gesampelte/verworfene Logs
*/
final class SamplingLogHandler implements LogHandler, HealthCheckInterface
{
private int $acceptedCount = 0;
private int $droppedCount = 0;
private array $droppedByLevel = [];
public function __construct(
private readonly LogHandler $handler,
private readonly SamplingConfig $config = new SamplingConfig()
) {
}
public function handle(LogRecord $record): void
{
// Sampling-Entscheidung
$accepted = $this->config->shouldSample($record->level);
if (!$accepted) {
$this->recordDropped($record->level);
// Report sampling metrics
if (class_exists(\App\Framework\Logging\Metrics\LogMetricsCollector::class)) {
\App\Framework\Logging\Metrics\LogMetricsCollector::getInstance()
->recordSampling($record->level, false);
}
return;
}
$this->acceptedCount++;
// Report sampling metrics
if (class_exists(\App\Framework\Logging\Metrics\LogMetricsCollector::class)) {
\App\Framework\Logging\Metrics\LogMetricsCollector::getInstance()
->recordSampling($record->level, true);
}
$this->handler->handle($record);
}
private function recordDropped(LogLevel $level): void
{
$this->droppedCount++;
$levelName = $level->getName();
if (!isset($this->droppedByLevel[$levelName])) {
$this->droppedByLevel[$levelName] = 0;
}
$this->droppedByLevel[$levelName]++;
}
public function getAcceptedCount(): int
{
return $this->acceptedCount;
}
public function getDroppedCount(): int
{
return $this->droppedCount;
}
public function getTotalCount(): int
{
return $this->acceptedCount + $this->droppedCount;
}
public function getDropRate(): float
{
$total = $this->getTotalCount();
if ($total === 0) {
return 0.0;
}
return $this->droppedCount / $total;
}
/**
* @return array<string, int>
*/
public function getDroppedByLevel(): array
{
return $this->droppedByLevel;
}
public function resetMetrics(): void
{
$this->acceptedCount = 0;
$this->droppedCount = 0;
$this->droppedByLevel = [];
}
public function check(): HealthCheckResult
{
$dropRate = $this->getDropRate();
$details = [
'accepted' => $this->acceptedCount,
'dropped' => $this->droppedCount,
'total' => $this->getTotalCount(),
'drop_rate' => round($dropRate * 100, 2) . '%',
'dropped_by_level' => $this->droppedByLevel,
'config' => $this->config->toArray(),
];
// Warning bei sehr hoher Drop-Rate (> 95%)
$status = HealthStatus::HEALTHY;
$message = 'Sampling working as expected';
if ($dropRate > 0.95 && $this->getTotalCount() > 100) {
$status = HealthStatus::DEGRADED;
$message = sprintf('Very high sampling drop rate: %.1f%%', $dropRate * 100);
}
return new HealthCheckResult(
status: $status,
componentName: $this->getName(),
message: $message,
details: $details,
timestamp: new \DateTimeImmutable()
);
}
public function getName(): string
{
return 'sampling_handler';
}
public function isHandling(LogRecord $record): bool
{
// We handle all levels but might sample them
return $this->handler->isHandling($record);
}
public function getCategory(): HealthCheckCategory
{
return HealthCheckCategory::INFRASTRUCTURE;
}
public function getTimeout(): int
{
return 3000; // 3 seconds timeout for sampling handler health check
}
}

View File

@@ -24,14 +24,14 @@ final class SyslogHandler implements LogHandler
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->value >= $this->minLevel->value;
return $record->level->value >= $this->minLevel->value;
}
public function handle(LogRecord $record): void
{
$this->openSyslog();
$priority = $this->mapLogLevelToSyslogPriority($record->getLevel());
$priority = $this->mapLogLevelToSyslogPriority($record->level);
$message = $this->formatMessage($record);
syslog($priority, $message);
@@ -55,14 +55,14 @@ final class SyslogHandler implements LogHandler
}
// Channel falls vorhanden
if ($record->getChannel()) {
$parts[] = "[{$record->getChannel()}]";
if ($record->channel) {
$parts[] = "[{$record->channel}]";
}
// Hauptnachricht
$parts[] = $record->getMessage();
$parts[] = $record->message;
// Context-Daten falls vorhanden
// Context-Daten, falls vorhanden
$context = $record->getContext();
if (! empty($context)) {
$parts[] = 'Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);

View File

@@ -28,15 +28,15 @@ final readonly class WebHandler implements LogHandler
return false;
}
return $record->getLevel()->value >= $this->minLevel->value;
return $record->level->value >= $this->minLevel->value;
}
public function handle(LogRecord $record): void
{
$timestamp = $record->getFormattedTimestamp();
$level = $record->getLevel()->getName();
$message = $record->getMessage();
$channel = $record->getChannel();
$level = $record->level->getName();
$message = $record->message;
$channel = $record->channel;
// Request-ID falls vorhanden
$requestId = $record->hasExtra('request_id')
@@ -55,7 +55,7 @@ final readonly class WebHandler implements LogHandler
$message
);
// Context-Daten falls vorhanden
// Context-Daten, falls vorhanden
$context = $record->getContext();
if (! empty($context)) {
$logLine .= ' | Context: ' . json_encode($context, JSON_UNESCAPED_SLASHES);