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,229 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Http\ServerRequest;
use App\Framework\Ulid\Ulid;
use Ramsey\Uuid\Uuid;
/**
* Correlation ID Value Object für Request-/Transaction-Tracking
*
* Correlation IDs ermöglichen das Verfolgen von zusammenhängenden Operations
* über Service-Grenzen, Async-Jobs und verteilte Systeme hinweg.
*
* Unterstützt verschiedene ID-Formate:
* - UUID v4 (Standard, via ramsey/uuid)
* - ULID (sortierbar, zeitbasiert, via Framework Ulid)
* - Custom String (z.B. von externen Systemen)
*/
final readonly class CorrelationId implements \Stringable, \JsonSerializable
{
private function __construct(
private string $value
) {
$this->validate($value);
}
/**
* Erstellt CorrelationId aus String
*
* @throws \InvalidArgumentException wenn Format ungültig
*/
public static function fromString(string $value): self
{
return new self($value);
}
/**
* Generiert neue UUID-basierte CorrelationId
*
* Nutzt ramsey/uuid für RFC 4122 konforme UUIDs
*/
public static function generate(): self
{
return new self(Uuid::uuid4()->toString());
}
/**
* Erstellt CorrelationId aus UUID
*/
public static function fromUuid(Uuid $uuid): self
{
return new self($uuid->toString());
}
/**
* Generiert neue ULID-basierte CorrelationId
*
* Vorteil: Sortierbar und zeitbasiert
* Nutzt Framework Ulid-Modul
*/
public static function generateUlid(): self
{
return new self((string) Ulid::generate());
}
/**
* Erstellt CorrelationId aus ULID
*/
public static function fromUlid(Ulid $ulid): self
{
return new self((string) $ulid);
}
/**
* Erstellt CorrelationId aus ServerRequest
*
* Liest Standard-Header (X-Correlation-ID, X-Request-ID)
* oder generiert neue ID falls nicht vorhanden
*
* Framework-konforme Methode für HTTP-Request-Handling
*/
public static function fromServerRequest(
ServerRequest $request,
string $headerName = 'X-Correlation-ID'
): self {
// Primärer Header
if ($request->hasHeader($headerName)) {
$value = trim($request->getHeaderLine($headerName));
if ($value !== '') {
try {
return self::fromString($value);
} catch (\InvalidArgumentException) {
// Invalid format - generate new one
}
}
}
// Fallback: X-Request-ID
if ($request->hasHeader('X-Request-ID')) {
$value = trim($request->getHeaderLine('X-Request-ID'));
if ($value !== '') {
try {
return self::fromString($value);
} catch (\InvalidArgumentException) {
// Invalid format - generate new one
}
}
}
return self::generate();
}
/**
* Erstellt CorrelationId aus PHP Superglobals
*
* Legacy-Methode für Kompatibilität mit bestehendem Code.
* Bevorzuge fromServerRequest() wo möglich.
*
* @param string $headerName Header-Name (default: X-Correlation-ID)
*/
public static function fromGlobals(string $headerName = 'X-Correlation-ID'): self
{
$headerKey = 'HTTP_' . str_replace('-', '_', strtoupper($headerName));
// Primärer Header
if (isset($_SERVER[$headerKey]) && is_string($_SERVER[$headerKey])) {
$value = trim($_SERVER[$headerKey]);
if ($value !== '') {
try {
return self::fromString($value);
} catch (\InvalidArgumentException) {
// Invalid format - continue
}
}
}
// Fallback: X-Request-ID
if (isset($_SERVER['HTTP_X_REQUEST_ID']) && is_string($_SERVER['HTTP_X_REQUEST_ID'])) {
$value = trim($_SERVER['HTTP_X_REQUEST_ID']);
if ($value !== '') {
try {
return self::fromString($value);
} catch (\InvalidArgumentException) {
// Invalid format - continue
}
}
}
return self::generate();
}
/**
* Gibt Correlation ID als String zurück
*/
public function toString(): string
{
return $this->value;
}
/**
* Prüft, ob ID im UUID-Format ist
*/
public function isUuid(): bool
{
return Uuid::isValid($this->value);
}
/**
* Prüft, ob ID im ULID-Format ist
*/
public function isUlid(): bool
{
return Ulid::isValid($this->value);
}
/**
* Gibt Kurzform der ID zurück (erste 8 Zeichen)
*
* Nützlich für Logging und Debugging
*/
public function toShortString(): string
{
return substr($this->value, 0, 8);
}
/**
* Vergleicht zwei CorrelationIds
*/
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Validiert das ID-Format
*
* @throws \InvalidArgumentException
*/
private function validate(string $value): void
{
if (trim($value) === '') {
throw new \InvalidArgumentException('Correlation ID cannot be empty');
}
if (strlen($value) > 255) {
throw new \InvalidArgumentException('Correlation ID too long (max 255 characters)');
}
// Erlaubte Zeichen: alphanumerisch, Bindestriche, Unterstriche
if (preg_match('/^[a-zA-Z0-9_-]+$/', $value) !== 1) {
throw new \InvalidArgumentException(
'Correlation ID contains invalid characters (allowed: a-z, A-Z, 0-9, -, _)'
);
}
}
public function __toString(): string
{
return $this->value;
}
public function jsonSerialize(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
/**
* Exception Context Value Object für strukturiertes Exception-Logging
*
* Erfasst alle relevanten Exception-Daten in strukturierter Form:
* - Exception Class, Message, Code
* - File, Line
* - Stack Trace (strukturiert)
* - Previous Exceptions (Chain)
* - Custom Exception Data
*/
final readonly class ExceptionContext implements \JsonSerializable
{
/**
* @param StackFrame[] $stackTrace
*/
public function __construct(
public string $class,
public string $message,
public int|string $code,
public string $file,
public int $line,
public array $stackTrace,
public ?self $previous = null,
public array $customData = []
) {
}
/**
* Erstellt ExceptionContext aus Throwable
*/
public static function fromThrowable(\Throwable $throwable): self
{
return new self(
class: get_class($throwable),
message: $throwable->getMessage(),
code: $throwable->getCode(),
file: $throwable->getFile(),
line: $throwable->getLine(),
stackTrace: self::parseStackTrace($throwable),
previous: $throwable->getPrevious() !== null
? self::fromThrowable($throwable->getPrevious())
: null,
customData: self::extractCustomData($throwable)
);
}
/**
* Parst Stack Trace zu strukturierten StackFrames
*
* @return StackFrame[]
*/
private static function parseStackTrace(\Throwable $throwable): array
{
$frames = [];
foreach ($throwable->getTrace() as $frame) {
$frames[] = StackFrame::fromArray($frame);
}
return $frames;
}
/**
* Extrahiert Custom-Daten aus Exception (wenn vorhanden)
*
* @return array<string, mixed>
*/
private static function extractCustomData(\Throwable $throwable): array
{
$data = [];
// Für Custom Exceptions mit zusätzlichen Properties
$reflection = new \ReflectionClass($throwable);
foreach ($reflection->getProperties() as $property) {
$name = $property->getName();
// Skip Standard-Exception-Properties
if (in_array($name, ['message', 'code', 'file', 'line', 'trace', 'previous'], true)) {
continue;
}
if (!$property->isPublic()) {
continue;
}
try {
$value = $property->getValue($throwable);
// Nur serialisierbare Daten
if (is_scalar($value) || is_array($value)) {
$data[$name] = $value;
}
} catch (\Throwable) {
// Property nicht verfügbar - überspringen
}
}
return $data;
}
/**
* Gibt kurze Exception-Klasse zurück (ohne Namespace)
*/
public function getShortClass(): string
{
$parts = explode('\\', $this->class);
return end($parts);
}
/**
* Gibt kurzen File-Pfad zurück
*/
public function getShortFile(): string
{
$projectRoot = dirname(__DIR__, 4);
if (str_starts_with($this->file, $projectRoot)) {
return substr($this->file, strlen($projectRoot) + 1);
}
return $this->file;
}
/**
* Zählt Anzahl der Previous Exceptions (Chain Length)
*/
public function getChainLength(): int
{
$count = 0;
$current = $this->previous;
while ($current !== null) {
$count++;
$current = $current->previous;
}
return $count;
}
/**
* Gibt alle Exceptions in der Chain zurück
*
* @return self[]
*/
public function getChain(): array
{
$chain = [$this];
$current = $this->previous;
while ($current !== null) {
$chain[] = $current;
$current = $current->previous;
}
return $chain;
}
/**
* Gibt erste X Frames des Stack Trace zurück
*
* @return StackFrame[]
*/
public function getTopFrames(int $limit = 5): array
{
return array_slice($this->stackTrace, 0, $limit);
}
/**
* Gibt lesbare Exception-Zusammenfassung zurück
*/
public function getSummary(): string
{
return sprintf(
'%s: %s in %s:%d',
$this->getShortClass(),
$this->message,
$this->getShortFile(),
$this->line
);
}
/**
* Konvertiert zu Array für Serialisierung
*
* @return array<string, mixed>
*/
public function toArray(bool $includeStackTrace = true, bool $includePrevious = true): array
{
$data = [
'class' => $this->class,
'short_class' => $this->getShortClass(),
'message' => $this->message,
'code' => $this->code,
'file' => $this->file,
'short_file' => $this->getShortFile(),
'line' => $this->line,
];
if ($includeStackTrace) {
$data['stack_trace'] = array_map(
fn(StackFrame $frame) => $frame->toArray(),
$this->stackTrace
);
$data['stack_trace_count'] = count($this->stackTrace);
}
if ($includePrevious && $this->previous !== null) {
$data['previous'] = $this->previous->toArray(
includeStackTrace: false, // Nur Top-Level mit vollem Trace
includePrevious: true
);
$data['chain_length'] = $this->getChainLength();
}
if (!empty($this->customData)) {
$data['custom_data'] = $this->customData;
}
return $data;
}
/**
* Gibt kompakte Darstellung für Logs zurück (ohne Stack Trace)
*
* @return array<string, mixed>
*/
public function toCompactArray(): array
{
$data = [
'class' => $this->getShortClass(),
'message' => $this->message,
'location' => sprintf('%s:%d', $this->getShortFile(), $this->line),
];
if ($this->code !== 0 && $this->code !== '') {
$data['code'] = $this->code;
}
if ($this->previous !== null) {
$data['previous'] = $this->previous->toCompactArray();
}
return $data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
public function __toString(): string
{
return $this->getSummary();
}
}

View File

@@ -19,17 +19,21 @@ final readonly class LogContext
/**
* @param array<string, mixed> $structured Strukturierte Daten (key-value pairs)
* @param string[] $tags Tags für Kategorisierung und Filterung
* @param CorrelationId|null $correlationId Correlation ID für Request-/Transaction-Tracking
* @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 ExceptionContext|null $exception Exception-Kontext für strukturiertes Exception-Logging
* @param array<string, mixed> $metadata Zusätzliche Metadaten
*/
public function __construct(
public array $structured = [],
public array $tags = [],
public ?CorrelationId $correlationId = null,
public ?TraceContext $trace = null,
public ?UserContext $user = null,
public ?RequestContext $request = null,
public ?ExceptionContext $exception = null,
public array $metadata = []
) {
}
@@ -66,9 +70,11 @@ final readonly class LogContext
return new self(
structured: array_merge($this->structured, [$key => $value]),
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -81,9 +87,11 @@ final readonly class LogContext
return new self(
structured: array_merge($this->structured, $data),
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -96,9 +104,11 @@ final readonly class LogContext
return new self(
structured: $this->structured,
tags: array_values(array_unique(array_merge($this->tags, $tags))),
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -111,6 +121,141 @@ final readonly class LogContext
return new self(trace: TraceContext::current());
}
/**
* Erstellt LogContext mit Correlation ID
*
* Correlation IDs ermöglichen das Verfolgen von zusammenhängenden
* Operations über Service-Grenzen hinweg.
*/
public static function withCorrelationId(CorrelationId $correlationId): self
{
return new self(correlationId: $correlationId);
}
/**
* Erstellt LogContext mit automatisch generierter Correlation ID
*/
public static function withGeneratedCorrelationId(): self
{
return new self(correlationId: CorrelationId::generate());
}
/**
* Erstellt LogContext mit Correlation ID aus HTTP Request (Globals)
*
* Nutzt PHP Superglobals für Kompatibilität.
* Für moderne HTTP-Handling bevorzuge withServerRequestCorrelationId()
*/
public static function withRequestCorrelationId(?string $headerName = null): self
{
return new self(correlationId: CorrelationId::fromGlobals($headerName ?? 'X-Correlation-ID'));
}
/**
* Erstellt LogContext mit Correlation ID aus ServerRequest
*
* Framework-konforme Methode für PSR-7 ServerRequest
*/
public static function withServerRequestCorrelationId(
\App\Framework\Http\ServerRequest $request,
string $headerName = 'X-Correlation-ID'
): self {
return new self(correlationId: CorrelationId::fromServerRequest($request, $headerName));
}
/**
* Erstellt LogContext mit Exception
*/
public static function withException(\Throwable $exception): self
{
return new self(exception: ExceptionContext::fromThrowable($exception));
}
/**
* Erstellt LogContext mit Exception und zusätzlichen Daten
*/
public static function withExceptionAndData(\Throwable $exception, array $data): self
{
return new self(
structured: $data,
exception: ExceptionContext::fromThrowable($exception)
);
}
/**
* Setzt Exception-Kontext
*/
public function withExceptionContext(ExceptionContext $exception): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $exception,
metadata: $this->metadata
);
}
/**
* Fügt Throwable hinzu
*/
public function addException(\Throwable $exception): self
{
return $this->withExceptionContext(ExceptionContext::fromThrowable($exception));
}
/**
* Holt Exception-Kontext
*/
public function getException(): ?ExceptionContext
{
return $this->exception;
}
/**
* Prüft, ob Exception-Kontext vorhanden ist
*/
public function hasException(): bool
{
return $this->exception !== null;
}
/**
* Setzt Correlation ID
*/
public function withCorrelation(CorrelationId $correlationId): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
/**
* Holt Correlation ID
*/
public function getCorrelationId(): ?CorrelationId
{
return $this->correlationId;
}
/**
* Prüft, ob Correlation ID vorhanden ist
*/
public function hasCorrelationId(): bool
{
return $this->correlationId !== null;
}
/**
* Setzt Trace-Kontext
*/
@@ -119,9 +264,11 @@ final readonly class LogContext
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -134,9 +281,11 @@ final readonly class LogContext
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $user,
request: $this->request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -149,9 +298,11 @@ final readonly class LogContext
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $request,
exception: $this->exception,
metadata: $this->metadata
);
}
@@ -164,9 +315,11 @@ final readonly class LogContext
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
metadata: array_merge($this->metadata, [$key => $value])
);
}
@@ -179,9 +332,11 @@ final readonly class LogContext
return new self(
structured: array_merge($this->structured, $other->structured),
tags: array_unique(array_merge($this->tags, $other->tags)),
correlationId: $other->correlationId ?? $this->correlationId,
trace: $other->trace ?? $this->trace,
user: $other->user ?? $this->user,
request: $other->request ?? $this->request,
exception: $other->exception ?? $this->exception,
metadata: array_merge($this->metadata, $other->metadata)
);
}
@@ -225,6 +380,10 @@ final readonly class LogContext
$result['tags'] = $this->tags;
}
if ($this->correlationId !== null) {
$result['correlation_id'] = $this->correlationId->toString();
}
if ($this->trace !== null) {
$result['trace'] = [
'trace_id' => $this->trace->getTraceId(),
@@ -240,6 +399,10 @@ final readonly class LogContext
$result['request'] = $this->request->toArray();
}
if ($this->exception !== null) {
$result['exception'] = $this->exception->toArray();
}
if (! empty($this->metadata)) {
$result['metadata'] = $this->metadata;
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\LogLevel;
/**
* Log Entry Value Object
* Immutable representation of a parsed log entry
*/
final readonly class LogEntry
{
public function __construct(
public \DateTimeImmutable $timestamp,
public LogLevel $level,
public string $message,
public string $context,
public string $raw,
public bool $parsed,
public FilePath $sourcePath
) {}
/**
* Create from parsed log line
*/
public static function fromParsedLine(
string $timestamp,
string $level,
string $messageWithContext,
string $raw,
FilePath $sourcePath
): self {
return new self(
timestamp: new \DateTimeImmutable($timestamp),
level: self::parseLevelFromString(strtoupper($level)),
message: self::extractMessage($messageWithContext),
context: self::extractContext($messageWithContext),
raw: $raw,
parsed: true,
sourcePath: $sourcePath
);
}
/**
* Parse LogLevel from string name using enum cases
*/
private static function parseLevelFromString(string $levelName): LogLevel
{
// Try to find case by name (similar to tryFrom pattern)
foreach (LogLevel::cases() as $case) {
if ($case->name === $levelName) {
return $case;
}
}
// Fallback for unknown levels
return LogLevel::INFO;
}
/**
* Create from unparsed log line
*/
public static function fromRawLine(string $line, FilePath $sourcePath): self
{
return new self(
timestamp: new \DateTimeImmutable(),
level: LogLevel::INFO,
message: $line,
context: '',
raw: $line,
parsed: false,
sourcePath: $sourcePath
);
}
/**
* Extract message from combined message/context string
*/
private static function extractMessage(string $messageWithContext): string
{
$contextStart = strpos($messageWithContext, '{"');
if ($contextStart === false) {
return trim($messageWithContext);
}
return trim(substr($messageWithContext, 0, $contextStart));
}
/**
* Extract context from combined message/context string
*/
private static function extractContext(string $messageWithContext): string
{
$contextStart = strpos($messageWithContext, '{"');
if ($contextStart === false) {
return '';
}
return trim(substr($messageWithContext, $contextStart));
}
/**
* Convert to array for backwards compatibility
*/
public function toArray(): array
{
return [
'timestamp' => $this->timestamp->format('Y-m-d H:i:s'),
'level' => $this->level->getName(),
'message' => $this->message,
'context' => $this->context,
'raw' => $this->raw,
'parsed' => $this->parsed,
'source_path' => $this->sourcePath->toString(),
];
}
/**
* Check if entry matches search term
*/
public function matchesSearch(string $search): bool
{
$searchLower = strtolower($search);
return str_contains(strtolower($this->message), $searchLower)
|| str_contains(strtolower($this->context), $searchLower)
|| str_contains(strtolower($this->raw), $searchLower);
}
/**
* Check if entry matches log level filter
*/
public function matchesLevel(LogLevel $level): bool
{
return $this->level === $level;
}
/**
* Check if entry is higher than or equal to minimum level
*/
public function isAtLeastLevel(LogLevel $minimumLevel): bool
{
return $this->level->value >= $minimumLevel->value;
}
/**
* Get formatted timestamp
*/
public function getFormattedTimestamp(string $format = 'Y-m-d H:i:s'): string
{
return $this->timestamp->format($format);
}
/**
* Get context as array
*/
public function getContextArray(): array
{
if (empty($this->context)) {
return [];
}
$decoded = json_decode($this->context, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Check if entry has context data
*/
public function hasContext(): bool
{
return !empty($this->context);
}
/**
* Get source file information
*/
public function getSourceInfo(): array
{
return [
'path' => $this->sourcePath->toString(),
'filename' => $this->sourcePath->getFilename(),
'size' => $this->sourcePath->getSize()->toHumanReadable(),
'modified' => date('Y-m-d H:i:s', $this->sourcePath->getModifiedTime()),
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
/**
* Log Name Value Object
* Validated log identifier
*/
final readonly class LogName
{
public function __construct(
public string $value
) {
$this->validate();
}
public static function fromString(string $value): self
{
return new self($value);
}
private function validate(): void
{
if (empty($this->value)) {
throw new \InvalidArgumentException('Log name cannot be empty');
}
// Only alphanumeric, underscore, and hyphen allowed
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->value)) {
throw new \InvalidArgumentException(
"Log name '{$this->value}' contains invalid characters. Only alphanumeric, underscore, and hyphen allowed."
);
}
if (strlen($this->value) > 100) {
throw new \InvalidArgumentException(
"Log name '{$this->value}' is too long. Maximum 100 characters allowed."
);
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Extract subdirectory from log name
* Format: {subdirectory}_{filename}
*/
public function getSubdirectory(): ?string
{
$parts = explode('_', $this->value, 2);
return count($parts) > 1 ? $parts[0] : null;
}
/**
* Extract filename from log name
* Format: {subdirectory}_{filename}
*/
public function getFilename(): string
{
$parts = explode('_', $this->value, 2);
return count($parts) > 1 ? $parts[1] : $this->value;
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\LogLevel;
/**
* Log Read Result Value Object
* Immutable result from reading log files
*/
final readonly class LogReadResult
{
/**
* @param array<int, LogEntry> $entries
*/
public function __construct(
public LogName $logName,
public FilePath $logPath,
public array $entries,
public int $totalEntries,
public int $limit,
public ?string $search = null,
public ?LogLevel $levelFilter = null
) {
// Validate entries array contains only LogEntry objects
foreach ($entries as $entry) {
if (!$entry instanceof LogEntry) {
throw new \InvalidArgumentException('All entries must be LogEntry instances');
}
}
}
/**
* Create empty result
*/
public static function empty(LogName $logName, FilePath $logPath): self
{
return new self(
logName: $logName,
logPath: $logPath,
entries: [],
totalEntries: 0,
limit: 0
);
}
/**
* Create from entries array
*/
public static function fromEntries(
LogName $logName,
FilePath $logPath,
array $entries,
int $limit,
?string $search = null,
?LogLevel $levelFilter = null
): self {
return new self(
logName: $logName,
logPath: $logPath,
entries: $entries,
totalEntries: count($entries),
limit: $limit,
search: $search,
levelFilter: $levelFilter
);
}
/**
* Check if result is empty
*/
public function isEmpty(): bool
{
return $this->totalEntries === 0;
}
/**
* Check if result has entries
*/
public function hasEntries(): bool
{
return $this->totalEntries > 0;
}
/**
* Get first entry
*/
public function first(): ?LogEntry
{
return $this->entries[0] ?? null;
}
/**
* Get last entry
*/
public function last(): ?LogEntry
{
return $this->entries[array_key_last($this->entries)] ?? null;
}
/**
* Check if search was applied
*/
public function hasSearch(): bool
{
return $this->search !== null && $this->search !== '';
}
/**
* Check if level filter was applied
*/
public function hasLevelFilter(): bool
{
return $this->levelFilter !== null;
}
/**
* Get entries filtered by level
*/
public function filterByLevel(LogLevel $level): self
{
$filtered = array_filter(
$this->entries,
fn(LogEntry $entry) => $entry->matchesLevel($level)
);
return new self(
logName: $this->logName,
logPath: $this->logPath,
entries: array_values($filtered),
totalEntries: count($filtered),
limit: $this->limit,
search: $this->search,
levelFilter: $level
);
}
/**
* Get entries filtered by search term
*/
public function filterBySearch(string $search): self
{
$filtered = array_filter(
$this->entries,
fn(LogEntry $entry) => $entry->matchesSearch($search)
);
return new self(
logName: $this->logName,
logPath: $this->logPath,
entries: array_values($filtered),
totalEntries: count($filtered),
limit: $this->limit,
search: $search,
levelFilter: $this->levelFilter
);
}
/**
* Get entries with minimum log level
*/
public function filterByMinimumLevel(LogLevel $minimumLevel): self
{
$filtered = array_filter(
$this->entries,
fn(LogEntry $entry) => $entry->isAtLeastLevel($minimumLevel)
);
return new self(
logName: $this->logName,
logPath: $this->logPath,
entries: array_values($filtered),
totalEntries: count($filtered),
limit: $this->limit,
search: $this->search,
levelFilter: $minimumLevel
);
}
/**
* Take first N entries
*/
public function take(int $count): self
{
$taken = array_slice($this->entries, 0, $count);
return new self(
logName: $this->logName,
logPath: $this->logPath,
entries: $taken,
totalEntries: count($taken),
limit: $count,
search: $this->search,
levelFilter: $this->levelFilter
);
}
/**
* Skip first N entries
*/
public function skip(int $count): self
{
$remaining = array_slice($this->entries, $count);
return new self(
logName: $this->logName,
logPath: $this->logPath,
entries: $remaining,
totalEntries: count($remaining),
limit: $this->limit,
search: $this->search,
levelFilter: $this->levelFilter
);
}
/**
* Convert to array for backwards compatibility
*/
public function toArray(): array
{
return [
'log_name' => $this->logName->value,
'log_path' => $this->logPath->toString(),
'entries' => array_map(
fn(LogEntry $entry) => $entry->toArray(),
$this->entries
),
'total_entries' => $this->totalEntries,
'limit' => $this->limit,
'search' => $this->search,
'level_filter' => $this->levelFilter?->getName(),
];
}
/**
* Get metadata about the result
*/
public function getMetadata(): array
{
return [
'log_name' => $this->logName->value,
'log_path' => $this->logPath->toString(),
'total_entries' => $this->totalEntries,
'limit' => $this->limit,
'has_search' => $this->hasSearch(),
'search_term' => $this->search,
'has_level_filter' => $this->hasLevelFilter(),
'level_filter' => $this->levelFilter?->getName(),
'is_empty' => $this->isEmpty(),
'file_info' => [
'size' => $this->logPath->getSize()->toHumanReadable(),
'modified' => date('Y-m-d H:i:s', $this->logPath->getModifiedTime()),
'readable' => $this->logPath->isReadable(),
],
];
}
/**
* Get statistics about log levels in result
*/
public function getLevelStatistics(): array
{
$stats = [];
foreach ($this->entries as $entry) {
$levelName = $entry->level->getName();
$stats[$levelName] = ($stats[$levelName] ?? 0) + 1;
}
// Sort by severity (highest first)
uksort($stats, function($a, $b) {
$levelA = LogLevel::cases()[array_search($a, array_column(LogLevel::cases(), 'name'))];
$levelB = LogLevel::cases()[array_search($b, array_column(LogLevel::cases(), 'name'))];
return $levelB->value <=> $levelA->value;
});
return $stats;
}
/**
* Create iterator for entries
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->entries);
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Logging\LogLevel;
/**
* Log Search Result Value Object
* Immutable result from searching across multiple log files
*/
final readonly class LogSearchResult
{
/**
* @param array<int, LogReadResult> $results
*/
public function __construct(
public string $searchTerm,
public array $results,
public int $totalMatches,
public int $filesSearched
) {
// Validate results array contains only LogReadResult objects
foreach ($results as $result) {
if (!$result instanceof LogReadResult) {
throw new \InvalidArgumentException('All results must be LogReadResult instances');
}
}
}
/**
* Create empty search result
*/
public static function empty(string $searchTerm): self
{
return new self(
searchTerm: $searchTerm,
results: [],
totalMatches: 0,
filesSearched: 0
);
}
/**
* Create from log read results
*/
public static function fromResults(string $searchTerm, array $results): self
{
$totalMatches = array_reduce(
$results,
fn(int $carry, LogReadResult $result) => $carry + $result->totalEntries,
0
);
return new self(
searchTerm: $searchTerm,
results: array_values($results), // Re-index
totalMatches: $totalMatches,
filesSearched: count($results)
);
}
/**
* Check if search returned any results
*/
public function hasMatches(): bool
{
return $this->totalMatches > 0;
}
/**
* Check if search is empty
*/
public function isEmpty(): bool
{
return $this->totalMatches === 0;
}
/**
* Get all entries from all results
*/
public function getAllEntries(): array
{
$allEntries = [];
foreach ($this->results as $result) {
$allEntries = array_merge($allEntries, $result->entries);
}
return $allEntries;
}
/**
* Get results filtered by log name
*/
public function filterByLogName(LogName $logName): self
{
$filtered = array_filter(
$this->results,
fn(LogReadResult $result) => $result->logName->equals($logName)
);
return new self(
searchTerm: $this->searchTerm,
results: array_values($filtered),
totalMatches: array_reduce(
$filtered,
fn(int $carry, LogReadResult $result) => $carry + $result->totalEntries,
0
),
filesSearched: count($filtered)
);
}
/**
* Get results filtered by minimum log level
*/
public function filterByMinimumLevel(LogLevel $minimumLevel): self
{
$filtered = array_map(
fn(LogReadResult $result) => $result->filterByMinimumLevel($minimumLevel),
$this->results
);
// Remove empty results
$filtered = array_filter($filtered, fn(LogReadResult $result) => !$result->isEmpty());
return new self(
searchTerm: $this->searchTerm,
results: array_values($filtered),
totalMatches: array_reduce(
$filtered,
fn(int $carry, LogReadResult $result) => $carry + $result->totalEntries,
0
),
filesSearched: count($filtered)
);
}
/**
* Sort results by most recent entries first
*/
public function sortByMostRecent(): self
{
$sorted = $this->results;
usort($sorted, function(LogReadResult $a, LogReadResult $b) {
$aLast = $a->last();
$bLast = $b->last();
if (!$aLast || !$bLast) {
return 0;
}
return $bLast->timestamp <=> $aLast->timestamp;
});
return new self(
searchTerm: $this->searchTerm,
results: $sorted,
totalMatches: $this->totalMatches,
filesSearched: $this->filesSearched
);
}
/**
* Take first N results
*/
public function take(int $count): self
{
$taken = array_slice($this->results, 0, $count);
return new self(
searchTerm: $this->searchTerm,
results: $taken,
totalMatches: array_reduce(
$taken,
fn(int $carry, LogReadResult $result) => $carry + $result->totalEntries,
0
),
filesSearched: count($taken)
);
}
/**
* Get results grouped by log level
*/
public function groupByLevel(): array
{
$grouped = [];
foreach ($this->results as $result) {
foreach ($result->entries as $entry) {
$levelName = $entry->level->getName();
$grouped[$levelName][] = $entry;
}
}
return $grouped;
}
/**
* Get statistics about the search
*/
public function getStatistics(): array
{
$allEntries = $this->getAllEntries();
$levelCounts = [];
foreach ($allEntries as $entry) {
$levelName = $entry->level->getName();
$levelCounts[$levelName] = ($levelCounts[$levelName] ?? 0) + 1;
}
return [
'search_term' => $this->searchTerm,
'total_matches' => $this->totalMatches,
'files_searched' => $this->filesSearched,
'level_distribution' => $levelCounts,
'files_with_matches' => array_map(
fn(LogReadResult $result) => [
'log_name' => $result->logName->value,
'matches' => $result->totalEntries,
],
$this->results
),
];
}
/**
* Convert to array for backwards compatibility
*/
public function toArray(): array
{
return [
'search_term' => $this->searchTerm,
'results' => array_map(
fn(LogReadResult $result) => $result->toArray(),
$this->results
),
'total_matches' => $this->totalMatches,
'files_searched' => $this->filesSearched,
'statistics' => $this->getStatistics(),
];
}
/**
* Get summary of search results
*/
public function getSummary(): string
{
if ($this->isEmpty()) {
return "No matches found for '{$this->searchTerm}'";
}
return sprintf(
"Found %d matches for '%s' across %d log file(s)",
$this->totalMatches,
$this->searchTerm,
$this->filesSearched
);
}
/**
* Create iterator for results
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->results);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Logging\LogLevel;
/**
* Configuration Value Object for LogViewer
* Immutable configuration for log viewing behavior
*/
final readonly class LogViewerConfig
{
/**
* @param string $storageLogsPath Base path for log storage
* @param array<int, string> $logDirectories Subdirectories to scan for logs
* @param int $defaultLimit Default number of log entries to return
* @param array<int, LogLevel> $logLevels Available log levels
*/
public function __construct(
public string $storageLogsPath,
public array $logDirectories,
public int $defaultLimit = 100,
public array $logLevels = [
LogLevel::DEBUG,
LogLevel::INFO,
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
]
) {
if ($defaultLimit < 1) {
throw new \InvalidArgumentException('Default limit must be at least 1');
}
if (empty($logDirectories)) {
throw new \InvalidArgumentException('At least one log directory must be specified');
}
if (empty($logLevels)) {
throw new \InvalidArgumentException('At least one log level must be specified');
}
}
/**
* Create default configuration for production
*/
public static function createDefault(): self
{
return new self(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app', 'debug', 'security'],
defaultLimit: 100
);
}
/**
* Create configuration for development with more verbose logging
*/
public static function createForDevelopment(): self
{
return new self(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app', 'debug', 'security', 'performance'],
defaultLimit: 500,
logLevels: [
LogLevel::DEBUG,
LogLevel::INFO,
LogLevel::NOTICE,
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
LogLevel::ALERT,
LogLevel::EMERGENCY,
]
);
}
/**
* Get full path for a subdirectory
*/
public function getSubdirectoryPath(string $subdirectory): string
{
return $this->storageLogsPath . '/' . $subdirectory;
}
/**
* Check if a log level is configured
*/
public function hasLogLevel(LogLevel $level): bool
{
foreach ($this->logLevels as $configuredLevel) {
if ($configuredLevel === $level) {
return true;
}
}
return false;
}
/**
* Get log level names as array
*
* @return array<int, string>
*/
public function getLogLevelNames(): array
{
return array_map(
fn(LogLevel $level) => $level->getName(),
$this->logLevels
);
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
/**
* Stack Frame Value Object für strukturierte Stack Traces
*
* Repräsentiert einen einzelnen Frame im Stack Trace mit allen relevanten Details.
*/
final readonly class StackFrame implements \JsonSerializable
{
public function __construct(
public string $file,
public int $line,
public ?string $function = null,
public ?string $class = null,
public ?string $type = null,
public array $args = []
) {
}
/**
* Erstellt StackFrame aus debug_backtrace Array
*
* @param array<string, mixed> $frame
*/
public static function fromArray(array $frame): self
{
return new self(
file: $frame['file'] ?? 'unknown',
line: $frame['line'] ?? 0,
function: $frame['function'] ?? null,
class: $frame['class'] ?? null,
type: $frame['type'] ?? null,
args: $frame['args'] ?? []
);
}
/**
* Gibt Kurzform des File-Pfads zurück (relativ zum Project Root wenn möglich)
*/
public function getShortFile(): string
{
$projectRoot = dirname(__DIR__, 4); // Von src/Framework/Logging/ValueObjects nach root
if (str_starts_with($this->file, $projectRoot)) {
return substr($this->file, strlen($projectRoot) + 1);
}
return $this->file;
}
/**
* Gibt vollständigen Method/Function Call zurück
*/
public function getCall(): string
{
$parts = [];
if ($this->class !== null) {
$parts[] = $this->class;
}
if ($this->type !== null) {
$parts[] = $this->type;
}
if ($this->function !== null) {
$parts[] = $this->function . '()';
}
return implode('', $parts);
}
/**
* Gibt lesbare Zeile im Format "at File:Line" zurück
*/
public function toString(): string
{
$call = $this->getCall();
if ($call !== '') {
return sprintf(
'at %s in %s:%d',
$call,
$this->getShortFile(),
$this->line
);
}
return sprintf(
'at %s:%d',
$this->getShortFile(),
$this->line
);
}
/**
* Konvertiert zu Array für Serialisierung
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = [
'file' => $this->file,
'short_file' => $this->getShortFile(),
'line' => $this->line,
];
if ($this->function !== null) {
$data['function'] = $this->function;
}
if ($this->class !== null) {
$data['class'] = $this->class;
}
if ($this->type !== null) {
$data['type'] = $this->type;
}
if (!empty($this->args)) {
$data['args'] = $this->serializeArgs();
}
return $data;
}
/**
* Serialisiert Arguments für Log-Ausgabe
*
* @return array<int, mixed>
*/
private function serializeArgs(): array
{
return array_map(
fn($arg) => $this->serializeValue($arg),
$this->args
);
}
/**
* Serialisiert einzelnen Wert (verhindert zu große Ausgaben)
*/
private function serializeValue(mixed $value): mixed
{
return match (true) {
is_object($value) => get_class($value),
is_array($value) => sprintf('array(%d)', count($value)),
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
is_string($value) && strlen($value) > 100 => substr($value, 0, 100) . '...',
default => $value,
};
}
public function jsonSerialize(): array
{
return $this->toArray();
}
public function __toString(): string
{
return $this->toString();
}
}