fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Aggregation;
/**
* Single discovery warning entry with structured context data.
*
* @psalm-type WarningContext = array<string, mixed>
*/
final readonly class DiscoveryWarning
{
/**
* @param WarningContext $context
*/
public function __construct(
public string $message,
public array $context
) {
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Aggregation;
/**
* Collects discovery warnings, grouped by file path.
*/
final class DiscoveryWarningAggregator
{
/** @var array<string, DiscoveryWarning[]> */
private array $warningsByFile = [];
public function record(DiscoveryWarning $warning, ?string $filePath = null): void
{
$path = $filePath ?? ($warning->context['file_path'] ?? 'unknown');
$this->warningsByFile[$path][] = $warning;
}
public function recordClassFailure(
string $filePath,
string $className,
\Throwable $exception,
string $message
): void {
$this->record(
new DiscoveryWarning($message, [
'type' => 'class_processing',
'file_path' => $filePath,
'class_name' => $className,
'exception_class' => $exception::class,
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
'exception_message' => $exception->getMessage(),
]),
$filePath
);
}
public function recordMethodReflectionFailure(
string $filePath,
string $className,
\Throwable $exception,
string $message
): void {
$this->record(
new DiscoveryWarning($message, [
'type' => 'method_reflection',
'file_path' => $filePath,
'class_name' => $className,
'exception_class' => $exception::class,
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
'exception_message' => $exception->getMessage(),
]),
$filePath
);
}
public function recordMethodProcessingFailure(
string $filePath,
string $className,
string $methodName,
\Throwable $exception,
string $message
): void {
$this->record(
new DiscoveryWarning($message, [
'type' => 'method_processing',
'file_path' => $filePath,
'class_name' => $className,
'method_name' => $methodName,
'exception_class' => $exception::class,
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
'exception_message' => $exception->getMessage(),
]),
$filePath
);
}
/**
* @return DiscoveryWarningGroup[]
*/
public function release(): array
{
$groups = [];
foreach ($this->warningsByFile as $filePath => $warnings) {
$groups[] = new DiscoveryWarningGroup($filePath, $warnings);
}
$this->warningsByFile = [];
return $groups;
}
public function hasWarnings(): bool
{
return $this->warningsByFile !== [];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Aggregation;
/**
* Aggregated warnings for a single file.
*
* @psalm-type WarningContext = array<string, mixed>
*/
final readonly class DiscoveryWarningGroup
{
/**
* @param DiscoveryWarning[] $warnings
*/
public function __construct(
public string $filePath,
public array $warnings
) {
}
public function count(): int
{
return count($this->warnings);
}
}

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Audit;
use App\Framework\Audit\AuditLogger;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\Audit\ValueObjects\AuditableAction;
use App\Framework\DateTime\Clock;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
use Throwable;
/**
* Exception Audit Logger
*
* Converts exceptions to audit entries for compliance and tracking.
* Only logs exceptions marked as auditable via ExceptionContextData.
*
* PHP 8.5+ with external context management via WeakMap.
*/
final readonly class ExceptionAuditLogger
{
public function __construct(
private AuditLogger $auditLogger,
private Clock $clock,
private ?ExceptionContextProvider $contextProvider = null
) {
}
/**
* Log exception as audit entry if auditable
*
* @param Throwable $exception Exception to log
* @param ExceptionContextData|null $context Optional context (if not provided, will be retrieved from provider)
*/
public function logIfAuditable(Throwable $exception, ?ExceptionContextData $context = null): void
{
try {
// Get context from provider if not provided
$context = $context ?? $this->getContext($exception);
// Skip if not auditable
if (!$this->isAuditable($context)) {
return;
}
// Convert exception to audit entry
$auditEntry = $this->convertToAuditEntry($exception, $context);
// Log to audit system
$this->auditLogger->log($auditEntry);
} catch (Throwable $e) {
// Audit logging should never break exception handling
// Log error but don't throw
error_log("Failed to log exception to audit: {$e->getMessage()}");
}
}
/**
* Check if exception is auditable
*
* @param ExceptionContextData|null $context Exception context
* @return bool True if exception should be audited
*/
private function isAuditable(?ExceptionContextData $context): bool
{
if ($context === null) {
// Default: log all exceptions if no context provided
return true;
}
// Check auditable flag (defaults to true)
return $context->auditable;
}
/**
* Convert exception to audit entry
*
* @param Throwable $exception Exception to convert
* @param ExceptionContextData $context Exception context
* @return AuditEntry Audit entry value object
*/
private function convertToAuditEntry(Throwable $exception, ExceptionContextData $context): AuditEntry
{
// Determine audit action from exception type and context
$action = $this->determineAuditAction($exception, $context);
// Determine entity type from exception class or context
$entityType = $this->determineEntityType($exception, $context);
// Extract entity ID from context data
$entityId = $this->extractEntityId($context);
// Build metadata from exception and context
$metadata = $this->buildMetadata($exception, $context);
// Extract IP address (already Value Object or string)
$ipAddress = $context->clientIp instanceof IpAddress
? $context->clientIp
: ($context->clientIp !== null && IpAddress::isValid($context->clientIp)
? IpAddress::from($context->clientIp)
: null);
// Extract user agent (already Value Object or string)
$userAgent = $context->userAgent instanceof UserAgent
? $context->userAgent
: ($context->userAgent !== null
? UserAgent::fromString($context->userAgent)
: null);
// Create audit entry as failed operation
return AuditEntry::failed(
clock: $this->clock,
action: $action,
entityType: $entityType,
entityId: $entityId,
errorMessage: $exception->getMessage(),
userId: $context->userId,
ipAddress: $ipAddress,
userAgent: $userAgent,
metadata: $metadata
);
}
/**
* Determine audit action from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return AuditableAction Audit action
*/
private function determineAuditAction(Throwable $exception, ExceptionContextData $context): AuditableAction
{
// Check if action is specified in metadata
if (isset($context->metadata['audit_action']) && is_string($context->metadata['audit_action'])) {
$actionValue = $context->metadata['audit_action'];
// Try to find matching enum case
foreach (AuditableAction::cases() as $case) {
if ($case->value === $actionValue) {
return $case;
}
}
}
// Determine from operation name
if ($context->operation !== null) {
$operation = $context->operation;
if (str_contains($operation, 'create')) {
return AuditableAction::CREATE;
}
if (str_contains($operation, 'update')) {
return AuditableAction::UPDATE;
}
if (str_contains($operation, 'delete')) {
return AuditableAction::DELETE;
}
if (str_contains($operation, 'read') || str_contains($operation, 'get')) {
return AuditableAction::READ;
}
}
// Determine from exception type
$exceptionClass = $exception::class;
if (str_contains($exceptionClass, 'Security') || str_contains($exceptionClass, 'Auth')) {
return AuditableAction::SECURITY_VIOLATION;
}
// Default: custom action
return AuditableAction::CUSTOM;
}
/**
* Determine entity type from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return string Entity type
*/
private function determineEntityType(Throwable $exception, ExceptionContextData $context): string
{
// Check if entity type is specified in metadata
if (isset($context->metadata['entity_type']) && is_string($context->metadata['entity_type'])) {
return $context->metadata['entity_type'];
}
// Try to extract from component
if ($context->component !== null) {
// Remove common suffixes
$component = $context->component;
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
return strtolower($component);
}
// Extract from exception class name
$className = $exception::class;
$parts = explode('\\', $className);
$shortName = end($parts);
// Remove "Exception" suffix
$entityType = preg_replace('/Exception$/', '', $shortName);
return strtolower($entityType);
}
/**
* Extract entity ID from context data
*
* @param ExceptionContextData $context Context
* @return string|null Entity ID
*/
private function extractEntityId(ExceptionContextData $context): ?string
{
// Check common entity ID keys
$commonKeys = ['entity_id', 'id', 'user_id', 'order_id', 'account_id', 'resource_id'];
foreach ($commonKeys as $key) {
if (isset($context->data[$key])) {
$value = $context->data[$key];
if (is_string($value) || is_numeric($value)) {
return (string) $value;
}
}
}
return null;
}
/**
* Build metadata from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return array<string, mixed> Metadata
*/
private function buildMetadata(Throwable $exception, ExceptionContextData $context): array
{
$metadata = [
'exception_class' => $exception::class,
'exception_code' => $exception->getCode(),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
];
// Add context data
if (!empty($context->data)) {
$metadata['context_data'] = $context->data;
}
// Add operation and component
if ($context->operation !== null) {
$metadata['operation'] = $context->operation;
}
if ($context->component !== null) {
$metadata['component'] = $context->component;
}
// Add request/session IDs
if ($context->requestId !== null) {
$metadata['request_id'] = $context->requestId;
}
if ($context->sessionId !== null) {
$metadata['session_id'] = $context->sessionId;
}
// Add tags
if (!empty($context->tags)) {
$metadata['tags'] = $context->tags;
}
// Add previous exception if exists
if ($exception->getPrevious() !== null) {
$previous = $exception->getPrevious();
$metadata['previous_exception'] = [
'class' => $previous::class,
'message' => $previous->getMessage(),
];
}
// Merge with existing metadata (but exclude internal fields)
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
foreach ($context->metadata as $key => $value) {
if (!in_array($key, $excludeKeys, true)) {
$metadata[$key] = $value;
}
}
return $metadata;
}
/**
* Get context from provider
*
* @param Throwable $exception Exception
* @return ExceptionContextData|null Context or null if not found
*/
private function getContext(Throwable $exception): ?ExceptionContextData
{
if ($this->contextProvider === null) {
return null;
}
return $this->contextProvider->get($exception);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
/**
* Basic Error Handler for early registration
*
* Minimal implementation that can be registered before Discovery.
* Provides basic error handling without full ErrorHandlerStrategy dependencies.
* Can be replaced with full ErrorHandler after Discovery.
*/
final readonly class BasicErrorHandler implements ErrorHandlerInterface
{
public function handle(
int $severity,
string $message,
?string $file = null,
?int $line = null,
): bool {
// Basic error output to stderr
$errorOutput = sprintf(
"[ERROR] %s in %s on line %d\n",
$message,
$file ?? 'unknown',
$line ?? 0
);
file_put_contents('php://stderr', $errorOutput);
// Return false to let PHP handle the error normally
return false;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
/**
* Basic Global Exception Handler for early registration
*
* Minimal implementation that can be registered before Discovery.
* Provides basic exception handling without full ErrorKernel dependencies.
* Can be replaced with full GlobalExceptionHandler after Discovery.
*/
final readonly class BasicGlobalExceptionHandler implements ExceptionHandler
{
public function handle(\Throwable $throwable): void
{
// Basic error output to stderr
$errorOutput = sprintf(
"Uncaught %s: %s in %s on line %d\n",
get_class($throwable),
$throwable->getMessage(),
$throwable->getFile(),
$throwable->getLine()
);
// Add stack trace if available
if ($throwable->getTraceAsString() !== '') {
$errorOutput .= "Stack trace:\n" . $throwable->getTraceAsString() . "\n";
}
file_put_contents('php://stderr', $errorOutput);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
/**
* Basic Shutdown Handler for early registration
*
* Minimal implementation that can be registered before Discovery.
* Provides basic fatal error handling without full ErrorKernel dependencies.
* Can be replaced with full ShutdownHandler after Discovery.
*/
final readonly class BasicShutdownHandler implements ShutdownHandlerInterface
{
public function handle(): void
{
$last = error_get_last();
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
return;
}
$this->cleanOutputBuffer();
$file = (string)($last['file'] ?? 'unknown');
$line = (int)($last['line'] ?? 0);
$message = $last['message'] ?? 'Fatal error';
// Basic error output to stderr
$errorOutput = sprintf(
"Fatal error: %s in %s on line %d\n",
$message,
$file,
$line
);
file_put_contents('php://stderr', $errorOutput);
exit(255);
}
private function cleanOutputBuffer(): void
{
try {
while (ob_get_level() > 0) {
@ob_end_clean();
}
} catch (\Throwable) {
// ignore
}
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Context;
use App\Framework\Cache\Cache;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use App\Framework\Http\IpAddress;
use App\Framework\Http\Request;
use App\Framework\Http\Session\SessionId;
use App\Framework\UserAgent\UserAgent;
/**
* Exception Context Builder
*
* Automatically collects context from various sources:
* - ErrorScope (if available)
* - HTTP Request (if in HTTP context)
* - Session (if available)
* - Authentication (User ID)
* - System information (optional)
*
* Provides a simple API for automatic context collection and reduces boilerplate.
*/
final readonly class ExceptionContextBuilder
{
public function __construct(
private ?ErrorScope $errorScope = null,
private ?ExceptionContextCache $contextCache = null
) {
}
/**
* Build context from current environment
*
* Automatically collects context from ErrorScope, HTTP Request, Session, etc.
*/
public function build(?ExceptionContextData $baseContext = null): ExceptionContextData
{
$context = $baseContext ?? ExceptionContextData::empty();
// First, enrich from ErrorScope if available
if ($this->errorScope !== null) {
$scopeContext = $this->errorScope->current();
if ($scopeContext !== null) {
$context = $this->enrichFromScope($context, $scopeContext);
}
}
return $context;
}
/**
* Build context from HTTP request
*
* Extracts context from HTTP request, session, and authentication.
* Uses cache if available to improve performance.
*/
public function buildFromRequest(Request $request, ?ExceptionContextData $baseContext = null): ExceptionContextData
{
// Try to get from cache first
if ($this->contextCache !== null) {
$requestId = $request instanceof \App\Framework\Http\HttpRequest ? $request->id->toString() : null;
$sessionId = property_exists($request, 'session') && $request->session !== null
? $request->session->id->toString()
: null;
$userId = $this->extractUserId($request);
$cached = $this->contextCache->get($requestId, $sessionId, $userId);
if ($cached !== null) {
// Merge with base context if provided
if ($baseContext !== null) {
return $this->mergeContexts($cached, $baseContext);
}
return $cached;
}
}
$context = $baseContext ?? ExceptionContextData::empty();
// Extract request ID
if ($request instanceof \App\Framework\Http\HttpRequest) {
$context = $context->withRequestId($request->id->toString());
}
// Extract IP address
$ipAddress = $request->server->getRemoteAddr();
if ($ipAddress !== null) {
$ipValue = $ipAddress->value;
if (is_string($ipValue) && IpAddress::isValid($ipValue)) {
$context = $context->withClientIp(IpAddress::from($ipValue));
} else {
$context = $context->withClientIp($ipValue);
}
}
// Extract user agent
$userAgent = $request->server->getUserAgent();
if ($userAgent !== null) {
$userAgentValue = $userAgent->value;
if (is_string($userAgentValue)) {
$context = $context->withUserAgent(UserAgent::fromString($userAgentValue));
} else {
$context = $context->withUserAgent($userAgentValue);
}
}
// Extract session ID
if (property_exists($request, 'session') && $request->session !== null) {
$sessionId = $request->session->id->toString();
try {
$context = $context->withSessionId(SessionId::fromString($sessionId));
} catch (\InvalidArgumentException) {
// If SessionId validation fails, keep as string for backward compatibility
$context = $context->withSessionId($sessionId);
}
}
// Extract user ID (if authenticated)
$userId = $this->extractUserId($request);
if ($userId !== null) {
$context = $context->withUserId($userId);
}
// Add HTTP-specific tags
$context = $context->withTags('http', 'web');
// Enrich from ErrorScope if available (may override some values)
if ($this->errorScope !== null) {
$scopeContext = $this->errorScope->current();
if ($scopeContext !== null) {
$context = $this->enrichFromScope($context, $scopeContext);
}
}
// Cache context if cache is available
if ($this->contextCache !== null) {
$requestId = $request instanceof \App\Framework\Http\HttpRequest ? $request->id->toString() : null;
$sessionId = property_exists($request, 'session') && $request->session !== null
? $request->session->id->toString()
: null;
$userId = $this->extractUserId($request);
$this->contextCache->put($context, $requestId, $sessionId, $userId);
}
return $context;
}
/**
* Merge two contexts (base takes precedence)
*/
private function mergeContexts(ExceptionContextData $cached, ExceptionContextData $base): ExceptionContextData
{
$merged = $cached;
// Override with base context values if present
if ($base->operation !== null) {
$merged = $merged->withOperation($base->operation, $base->component);
}
if ($base->component !== null && $merged->component === null) {
$merged = $merged->withOperation($merged->operation ?? '', $base->component);
}
if (!empty($base->data)) {
$merged = $merged->addData($base->data);
}
if (!empty($base->debug)) {
$merged = $merged->addDebug($base->debug);
}
if (!empty($base->metadata)) {
$merged = $merged->addMetadata($base->metadata);
}
if ($base->userId !== null) {
$merged = $merged->withUserId($base->userId);
}
if ($base->requestId !== null) {
$merged = $merged->withRequestId($base->requestId);
}
if ($base->sessionId !== null) {
$merged = $merged->withSessionId($base->sessionId);
}
if ($base->clientIp !== null) {
$merged = $merged->withClientIp($base->clientIp);
}
if ($base->userAgent !== null) {
$merged = $merged->withUserAgent($base->userAgent);
}
if (!empty($base->tags)) {
$merged = $merged->withTags(...array_merge($merged->tags, $base->tags));
}
return $merged;
}
/**
* Enrich context from ErrorScope
*/
private function enrichFromScope(
ExceptionContextData $context,
\App\Framework\ExceptionHandling\Scope\ErrorScopeContext $scopeContext
): ExceptionContextData {
// Add operation/component from scope if not already set
if ($context->operation === null && $scopeContext->operation !== null) {
$context = $context->withOperation(
$scopeContext->operation,
$scopeContext->component
);
}
// Add user ID from scope if not already set
if ($context->userId === null && $scopeContext->userId !== null) {
$context = $context->withUserId($scopeContext->userId);
}
// Add request ID from scope if not already set
if ($context->requestId === null && $scopeContext->requestId !== null) {
$context = $context->withRequestId($scopeContext->requestId);
}
// Add session ID from scope if not already set
if ($context->sessionId === null && $scopeContext->sessionId !== null) {
if (is_string($scopeContext->sessionId)) {
try {
$context = $context->withSessionId(SessionId::fromString($scopeContext->sessionId));
} catch (\InvalidArgumentException) {
$context = $context->withSessionId($scopeContext->sessionId);
}
} else {
$context = $context->withSessionId($scopeContext->sessionId);
}
}
// Extract HTTP fields from scope metadata (for HTTP scopes)
if (isset($scopeContext->metadata['ip']) && $context->clientIp === null) {
$ipValue = $scopeContext->metadata['ip'];
if (is_string($ipValue) && IpAddress::isValid($ipValue)) {
$context = $context->withClientIp(IpAddress::from($ipValue));
} elseif ($ipValue instanceof IpAddress) {
$context = $context->withClientIp($ipValue);
} else {
$context = $context->withClientIp($ipValue);
}
}
if (isset($scopeContext->metadata['user_agent']) && $context->userAgent === null) {
$userAgentValue = $scopeContext->metadata['user_agent'];
if (is_string($userAgentValue)) {
$context = $context->withUserAgent(UserAgent::fromString($userAgentValue));
} elseif ($userAgentValue instanceof UserAgent) {
$context = $context->withUserAgent($userAgentValue);
} else {
$context = $context->withUserAgent($userAgentValue);
}
}
// Add scope metadata
$context = $context->addMetadata([
'scope_type' => $scopeContext->type->value,
'scope_id' => $scopeContext->scopeId,
]);
// Add scope tags
if (!empty($scopeContext->tags)) {
$context = $context->withTags(...$scopeContext->tags);
}
return $context;
}
/**
* Extract user ID from request
*/
private function extractUserId(Request $request): ?string
{
// Try to get from request attribute (set by auth middleware)
if (method_exists($request, 'getAttribute')) {
$user = $request->getAttribute('user');
if ($user !== null) {
if (is_object($user) && property_exists($user, 'id')) {
return (string) $user->id;
}
if (is_string($user)) {
return $user;
}
}
}
// Try to get from request property (if available)
if (property_exists($request, 'user') && $request->user !== null) {
if (is_object($request->user) && property_exists($request->user, 'id')) {
return (string) $request->user->id;
}
if (is_string($request->user)) {
return $request->user;
}
}
return null;
}
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Context;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Exception Context Cache
*
* Caches frequently used context data to improve performance.
* Uses cache keys based on Request-ID, Session-ID, User-ID.
*/
final readonly class ExceptionContextCache
{
private const string CACHE_PREFIX = 'exception_context:';
private const int REQUEST_CONTEXT_TTL = 600; // 10 minutes
private const int USER_CONTEXT_TTL = 1800; // 30 minutes
public function __construct(
private Cache $cache
) {
}
/**
* Get cached context for request
*
* @param string|null $requestId Request ID
* @param string|null $sessionId Session ID
* @param string|null $userId User ID
* @return ExceptionContextData|null Cached context or null if not found
*/
public function get(
?string $requestId = null,
?string $sessionId = null,
?string $userId = null
): ?ExceptionContextData {
// Try request-level cache first (most specific)
if ($requestId !== null) {
$cacheKey = $this->buildRequestCacheKey($requestId);
$cached = $this->getFromCache($cacheKey);
if ($cached !== null) {
return $cached;
}
}
// Try session-level cache
if ($sessionId !== null) {
$cacheKey = $this->buildSessionCacheKey($sessionId);
$cached = $this->getFromCache($cacheKey);
if ($cached !== null) {
return $cached;
}
}
// Try user-level cache (least specific)
if ($userId !== null) {
$cacheKey = $this->buildUserCacheKey($userId);
$cached = $this->getFromCache($cacheKey);
if ($cached !== null) {
return $cached;
}
}
return null;
}
/**
* Cache context for request
*
* @param ExceptionContextData $context Context to cache
* @param string|null $requestId Request ID
* @param string|null $sessionId Session ID
* @param string|null $userId User ID
*/
public function put(
ExceptionContextData $context,
?string $requestId = null,
?string $sessionId = null,
?string $userId = null
): void {
// Cache at request level (most specific)
if ($requestId !== null) {
$cacheKey = $this->buildRequestCacheKey($requestId);
$this->putToCache($cacheKey, $context, Duration::fromSeconds(self::REQUEST_CONTEXT_TTL));
}
// Cache at session level
if ($sessionId !== null) {
$cacheKey = $this->buildSessionCacheKey($sessionId);
$this->putToCache($cacheKey, $context, Duration::fromSeconds(self::REQUEST_CONTEXT_TTL));
}
// Cache at user level (least specific, longer TTL)
if ($userId !== null) {
$cacheKey = $this->buildUserCacheKey($userId);
// Only cache user-specific parts to avoid stale data
$userContext = ExceptionContextData::empty()
->withUserId($context->userId)
->withClientIp($context->clientIp)
->withUserAgent($context->userAgent);
$this->putToCache($cacheKey, $userContext, Duration::fromSeconds(self::USER_CONTEXT_TTL));
}
}
/**
* Invalidate cache for request
*
* Called when request context changes (e.g., user logs in/out).
*/
public function invalidateRequest(?string $requestId = null): void
{
if ($requestId !== null) {
$cacheKey = $this->buildRequestCacheKey($requestId);
$this->cache->forget($cacheKey);
}
}
/**
* Invalidate cache for session
*
* Called when session changes (e.g., session regenerated).
*/
public function invalidateSession(?string $sessionId = null): void
{
if ($sessionId !== null) {
$cacheKey = $this->buildSessionCacheKey($sessionId);
$this->cache->forget($cacheKey);
}
}
/**
* Invalidate cache for user
*
* Called when user context changes (e.g., user logs in/out).
*/
public function invalidateUser(?string $userId = null): void
{
if ($userId !== null) {
$cacheKey = $this->buildUserCacheKey($userId);
$this->cache->forget($cacheKey);
}
}
/**
* Build cache key for request
*/
private function buildRequestCacheKey(string $requestId): CacheKey
{
return CacheKey::fromString(self::CACHE_PREFIX . 'request:' . $requestId);
}
/**
* Build cache key for session
*/
private function buildSessionCacheKey(string $sessionId): CacheKey
{
return CacheKey::fromString(self::CACHE_PREFIX . 'session:' . $sessionId);
}
/**
* Build cache key for user
*/
private function buildUserCacheKey(string $userId): CacheKey
{
return CacheKey::fromString(self::CACHE_PREFIX . 'user:' . $userId);
}
/**
* Get context from cache
*/
private function getFromCache(CacheKey $cacheKey): ?ExceptionContextData
{
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
if ($item === null) {
return null;
}
$value = $item->value;
if ($value instanceof ExceptionContextData) {
return $value;
}
// Try to deserialize from array
if (is_array($value)) {
return ExceptionContextData::fromArray($value);
}
return null;
}
/**
* Put context to cache
*/
private function putToCache(CacheKey $cacheKey, ExceptionContextData $context, Duration $ttl): void
{
// Store as array for serialization compatibility
$cacheItem = CacheItem::fromKey(
$cacheKey,
$context->toArray(),
$ttl
);
$this->cache->set($cacheItem);
}
}

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Context;
use App\Framework\Http\IpAddress;
use App\Framework\Http\RequestId;
use App\Framework\Http\Session\SessionId;
use App\Framework\UserAgent\UserAgent;
use DateTimeImmutable;
/**
@@ -26,11 +30,13 @@ final readonly class ExceptionContextData
* @param array<string, mixed> $metadata Additional metadata (tags, severity, fingerprint)
* @param DateTimeImmutable|null $occurredAt When the exception occurred
* @param string|null $userId User ID if available
* @param string|null $requestId Request ID for tracing
* @param string|null $sessionId Session ID if available
* @param string|null $clientIp Client IP address for HTTP requests
* @param string|null $userAgent User agent string for HTTP requests
* @param RequestId|string|null $requestId Request ID for tracing (Value Object or string for backward compatibility)
* @param SessionId|string|null $sessionId Session ID if available (Value Object or string for backward compatibility)
* @param IpAddress|string|null $clientIp Client IP address for HTTP requests (Value Object or string for backward compatibility)
* @param UserAgent|string|null $userAgent User agent string for HTTP requests (Value Object or string for backward compatibility)
* @param array<string> $tags Tags for categorization (e.g., ['payment', 'external_api'])
* @param bool $auditable Whether this exception should be logged to audit system
* @param string|null $auditLevel Audit level for this exception (e.g., 'ERROR', 'WARNING', 'INFO')
*/
public function __construct(
public ?string $operation = null,
@@ -40,11 +46,13 @@ final readonly class ExceptionContextData
public array $metadata = [],
?DateTimeImmutable $occurredAt = null,
public ?string $userId = null,
public ?string $requestId = null,
public ?string $sessionId = null,
public ?string $clientIp = null,
public ?string $userAgent = null,
public RequestId|string|null $requestId = null,
public SessionId|string|null $sessionId = null,
public IpAddress|string|null $clientIp = null,
public UserAgent|string|null $userAgent = null,
public array $tags = [],
public bool $auditable = true,
public ?string $auditLevel = null,
) {
$this->occurredAt ??= new DateTimeImmutable();
}
@@ -93,7 +101,9 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
@@ -114,7 +124,9 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
@@ -135,7 +147,9 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
@@ -156,7 +170,9 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
@@ -177,14 +193,18 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
/**
* Add request ID
*
* @param RequestId|string $requestId Request ID (Value Object or string for backward compatibility)
*/
public function withRequestId(string $requestId): self
public function withRequestId(RequestId|string $requestId): self
{
return new self(
operation: $this->operation,
@@ -198,14 +218,18 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
/**
* Add session ID
*
* @param SessionId|string $sessionId Session ID (Value Object or string for backward compatibility)
*/
public function withSessionId(string $sessionId): self
public function withSessionId(SessionId|string $sessionId): self
{
return new self(
operation: $this->operation,
@@ -219,14 +243,18 @@ final readonly class ExceptionContextData
sessionId: $sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
/**
* Add client IP
*
* @param IpAddress|string $clientIp Client IP (Value Object or string for backward compatibility)
*/
public function withClientIp(string $clientIp): self
public function withClientIp(IpAddress|string $clientIp): self
{
return new self(
operation: $this->operation,
@@ -240,14 +268,18 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $clientIp,
userAgent: $this->userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
/**
* Add user agent
*
* @param UserAgent|string $userAgent User agent (Value Object or string for backward compatibility)
*/
public function withUserAgent(string $userAgent): self
public function withUserAgent(UserAgent|string $userAgent): self
{
return new self(
operation: $this->operation,
@@ -261,7 +293,9 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $userAgent,
tags: $this->tags
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
@@ -282,7 +316,55 @@ final readonly class ExceptionContextData
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: array_merge($this->tags, $tags)
tags: array_merge($this->tags, $tags),
auditable: $this->auditable,
auditLevel: $this->auditLevel
);
}
/**
* Set auditable flag
*/
public function withAuditable(bool $auditable): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags,
auditable: $auditable,
auditLevel: $this->auditLevel
);
}
/**
* Set audit level
*/
public function withAuditLevel(string $auditLevel): self
{
return new self(
operation: $this->operation,
component: $this->component,
data: $this->data,
debug: $this->debug,
metadata: $this->metadata,
occurredAt: $this->occurredAt,
userId: $this->userId,
requestId: $this->requestId,
sessionId: $this->sessionId,
clientIp: $this->clientIp,
userAgent: $this->userAgent,
tags: $this->tags,
auditable: $this->auditable,
auditLevel: $auditLevel
);
}
@@ -299,11 +381,47 @@ final readonly class ExceptionContextData
'metadata' => $this->metadata,
'occurred_at' => $this->occurredAt?->format('Y-m-d H:i:s.u'),
'user_id' => $this->userId,
'request_id' => $this->requestId,
'session_id' => $this->sessionId,
'client_ip' => $this->clientIp,
'user_agent' => $this->userAgent,
'request_id' => $this->requestId instanceof RequestId ? $this->requestId->toString() : $this->requestId,
'session_id' => $this->sessionId instanceof SessionId ? $this->sessionId->toString() : $this->sessionId,
'client_ip' => $this->clientIp instanceof IpAddress ? $this->clientIp->value : $this->clientIp,
'user_agent' => $this->userAgent instanceof UserAgent ? $this->userAgent->value : $this->userAgent,
'tags' => $this->tags,
'auditable' => $this->auditable,
'audit_level' => $this->auditLevel,
];
}
/**
* Create from array (deserialization)
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$occurredAt = null;
if (isset($data['occurred_at']) && is_string($data['occurred_at'])) {
try {
$occurredAt = new DateTimeImmutable($data['occurred_at']);
} catch (\Exception) {
// Ignore invalid date
}
}
return new self(
operation: $data['operation'] ?? null,
component: $data['component'] ?? null,
data: $data['data'] ?? [],
debug: $data['debug'] ?? [],
metadata: $data['metadata'] ?? [],
occurredAt: $occurredAt,
userId: $data['user_id'] ?? null,
requestId: $data['request_id'] ?? null,
sessionId: $data['session_id'] ?? null,
clientIp: $data['client_ip'] ?? null,
userAgent: $data['user_agent'] ?? null,
tags: $data['tags'] ?? [],
auditable: $data['auditable'] ?? true,
auditLevel: $data['audit_level'] ?? null
);
}
}

View File

@@ -13,29 +13,19 @@ use WeakMap;
* Context is automatically cleaned up when the exception is garbage collected.
*
* PHP 8.5+ WeakMap-based implementation - no memory leaks possible.
*
* Should be registered as singleton in DI container for consistent context across application.
*/
final class ExceptionContextProvider
{
/** @var WeakMap<\Throwable, ExceptionContextData> */
private WeakMap $contexts;
private static ?self $instance = null;
private function __construct()
public function __construct()
{
$this->contexts = new WeakMap();
}
/**
* Get singleton instance
*
* Singleton pattern ensures consistent context across the application
*/
public static function instance(): self
{
return self::$instance ??= new self();
}
/**
* Attach context to exception
*

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Correlation;
/**
* Exception Correlation
*
* Immutable value object representing correlation between exceptions.
*/
final readonly class ExceptionCorrelation
{
/**
* @param string $correlationKey Correlation key (Request-ID, Session-ID, etc.)
* @param array<string> $exceptionIds Related exception IDs
* @param string|null $rootCauseId Root cause exception ID
*/
public function __construct(
public string $correlationKey,
public array $exceptionIds = [],
public ?string $rootCauseId = null
) {
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'correlation_key' => $this->correlationKey,
'exception_ids' => $this->exceptionIds,
'root_cause_id' => $this->rootCauseId,
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Correlation;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Correlation Engine
*
* Correlates related exceptions for root cause analysis.
*/
final readonly class ExceptionCorrelationEngine
{
private const string CACHE_PREFIX = 'exception_correlation:';
private const int CACHE_TTL = 3600; // 1 hour
public function __construct(
private Cache $cache
) {
}
/**
* Correlate exception with related exceptions
*
* @param Throwable $exception Exception to correlate
* @param ExceptionContextData|null $context Optional context
* @return ExceptionCorrelation Correlation data
*/
public function correlate(Throwable $exception, ?ExceptionContextData $context = null): ExceptionCorrelation
{
$correlationKey = $this->extractCorrelationKey($context);
if ($correlationKey === null) {
return new ExceptionCorrelation(correlationKey: 'unknown');
}
$exceptionId = spl_object_id($exception);
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $correlationKey);
// Get existing correlation
$existing = $this->getCorrelation($cacheKey);
$exceptionIds = $existing->exceptionIds;
$exceptionIds[] = (string) $exceptionId;
// First exception in correlation is root cause
$rootCauseId = $existing->rootCauseId ?? (string) $exceptionId;
$correlation = new ExceptionCorrelation(
correlationKey: $correlationKey,
exceptionIds: array_unique($exceptionIds),
rootCauseId: $rootCauseId
);
// Store correlation
$this->storeCorrelation($cacheKey, $correlation);
return $correlation;
}
/**
* Extract correlation key from context
*/
private function extractCorrelationKey(?ExceptionContextData $context): ?string
{
if ($context === null) {
return null;
}
// Prefer Request-ID, then Session-ID, then User-ID
if ($context->requestId !== null) {
$requestId = is_string($context->requestId) ? $context->requestId : $context->requestId->toString();
return 'request:' . $requestId;
}
if ($context->sessionId !== null) {
$sessionId = is_string($context->sessionId) ? $context->sessionId : $context->sessionId->toString();
return 'session:' . $sessionId;
}
if ($context->userId !== null) {
return 'user:' . $context->userId;
}
return null;
}
/**
* Get correlation from cache
*/
private function getCorrelation(CacheKey $cacheKey): ExceptionCorrelation
{
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
if ($item === null) {
return new ExceptionCorrelation(correlationKey: '');
}
$value = $item->value;
if (is_array($value)) {
return new ExceptionCorrelation(
correlationKey: $value['correlation_key'] ?? '',
exceptionIds: $value['exception_ids'] ?? [],
rootCauseId: $value['root_cause_id'] ?? null
);
}
return new ExceptionCorrelation(correlationKey: '');
}
/**
* Store correlation in cache
*/
private function storeCorrelation(CacheKey $cacheKey, ExceptionCorrelation $correlation): void
{
$cacheItem = CacheItem::fromKey(
$cacheKey,
$correlation->toArray(),
Duration::fromSeconds(self::CACHE_TTL)
);
$this->cache->set($cacheItem);
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Core\ValueObjects\LineNumber;
use ErrorException;
final readonly class ErrorContext
@@ -11,7 +12,7 @@ final readonly class ErrorContext
public int $severity,
public string $message,
public ?string $file = null,
public ?int $line = null,
public ?LineNumber $line = null,
public bool $isSuppressed = false,
) {}
@@ -22,16 +23,22 @@ final readonly class ErrorContext
?int $line = null,
bool $isSuppressed = false,
): self {
return new self($severity, $message, $file, $line, $isSuppressed);
return new self(
$severity,
$message,
$file,
$line !== null ? LineNumber::fromInt($line) : null,
$isSuppressed
);
}
public function isDeprecation(): bool
{
return $this->severity === E_DEPRECATED || $this->severity === E_USER_DEPRECATED;
return ErrorSeverityType::isDeprecation($this->severity);
}
public function isFatal(): bool
{
return in_array($this->severity, [E_ERROR, E_RECOVERABLE_ERROR, E_USER_ERROR], true);
return ErrorSeverityType::isFatal($this->severity);
}
}

View File

@@ -6,12 +6,11 @@ namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use ErrorException;
final readonly class ErrorHandler
final readonly class ErrorHandler implements ErrorHandlerInterface
{
public function __construct(
private ErrorHandlerStrategy $strategy = new StrictErrorPolicy,
)
{}
) {}
/**

View File

@@ -4,5 +4,10 @@ namespace App\Framework\ExceptionHandling;
interface ErrorHandlerInterface
{
public function handle(
int $severity,
string $message,
?string $file = null,
?int $line = null,
): bool;
}

View File

@@ -3,32 +3,151 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\RateLimit\ExceptionRateLimiter;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\ExceptionHandling\Renderers\ConsoleErrorRenderer;
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use App\Framework\Http\Response;
use App\Framework\Http\HttpResponse;
use Throwable;
/**
* Unified Error Kernel with context-aware handling
*
* Automatically detects execution context (CLI vs HTTP) and handles exceptions accordingly:
* - CLI: Colored console output
* - HTTP: HTTP Response objects
*/
final readonly class ErrorKernel
{
public function __construct(
private ErrorRendererFactory $rendererFactory = new ErrorRendererFactory,
private ErrorRendererFactory $rendererFactory,
private ?Reporter $reporter,
private ?ErrorAggregatorInterface $errorAggregator = null,
private ?ExceptionContextProvider $contextProvider = null,
private ?ExceptionAuditLogger $auditLogger = null,
private ?ExceptionRateLimiter $rateLimiter = null,
private ?ExecutionContext $executionContext = null,
private ?ConsoleOutput $consoleOutput = null,
private bool $isDebugMode = false
) {}
/**
* Context-aware exception handler
*
* Automatically detects execution context and handles accordingly:
* - CLI contexts: Outputs colored error messages to console
* - HTTP contexts: Logs error (does not create response - use createHttpResponse)
*
* @param Throwable $e Exception to handle
* @param array $context Additional context data
*/
public function handle(Throwable $e, array $context = []): mixed
{
// Get exception context from provider
$exceptionContext = $this->contextProvider?->get($e);
$log = new LogReporter();
$log->report($e);
// Check rate limiting before logging/audit
$shouldSkipLogging = $this->rateLimiter?->shouldSkipLogging($e, $exceptionContext) ?? false;
$shouldSkipAudit = $this->rateLimiter?->shouldSkipAudit($e, $exceptionContext) ?? false;
var_dump((string)$e);
// Log exception to audit system if auditable and not rate limited
if ($this->auditLogger !== null && !$shouldSkipAudit) {
$this->auditLogger->logIfAuditable($e, $exceptionContext);
}
$this->rendererFactory->getRenderer()->render();
// Log exception if not rate limited
if (!$shouldSkipLogging) {
$this->reporter->report($e);
}
exit();
// Auto-trigger pattern detection if aggregator is available
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
$this->errorAggregator->processError(
$e,
$this->contextProvider,
$this->isDebugMode
);
}
// Detect execution context
$executionContext = $this->executionContext ?? ExecutionContext::detect();
// Handle based on context
if ($executionContext->isCli()) {
$this->handleCliException($e);
return null;
}
// For HTTP context, just log (middleware will create response)
return null;
}
/**
* Handle exception in CLI context with colored output
*/
private function handleCliException(Throwable $exception): void
{
$output = $this->consoleOutput ?? new ConsoleOutput();
$renderer = new \App\Framework\ExceptionHandling\Renderers\ConsoleErrorRenderer($output);
$renderer->render($exception, $this->contextProvider);
}
/**
* Handle PHP error in CLI context with colored output (stderr)
*/
public function handleCliError(int $severity, string $message, string $file, int $line): void
{
$errorType = $this->getErrorType($severity);
$color = $this->getErrorColor($severity);
$output = $this->consoleOutput ?? new ConsoleOutput();
$output->writeErrorLine("[$errorType] $message", $color);
$output->writeErrorLine(" in $file:$line", ConsoleColor::GRAY);
// Create ErrorException and log
$exception = new \ErrorException($message, 0, $severity, $file, $line);
$this->reporter->report($exception);
// Auto-trigger pattern detection if aggregator is available
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
$this->errorAggregator->processError(
$exception,
$this->contextProvider,
$this->isDebugMode
);
}
}
/**
* Handle fatal error in CLI context (stderr)
*/
public function handleCliFatalError(array $error): void
{
$output = $this->consoleOutput ?? new ConsoleOutput();
$output->writeErrorLine("💥 Fatal Error: " . $error['message'], ConsoleColor::BRIGHT_RED);
$output->writeErrorLine(" File: " . $error['file'] . ":" . $error['line'], ConsoleColor::RED);
// Create ErrorException and log
$exception = new \ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']);
$this->reporter->report($exception);
// Auto-trigger pattern detection if aggregator is available
if ($this->errorAggregator !== null && $this->contextProvider !== null) {
$this->errorAggregator->processError(
$exception,
$this->contextProvider,
$this->isDebugMode
);
}
}
/**
* Create HTTP Response from exception without terminating execution
*
@@ -37,19 +156,61 @@ final readonly class ErrorKernel
*
* @param Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional WeakMap context provider
* @param bool $isDebugMode Enable debug information in response
* @return Response HTTP Response object (JSON for API, HTML for web)
* @param bool|null $isDebugMode Override debug mode (uses instance default if null)
* @return HttpResponse HTTP Response object (JSON for API, HTML for web)
*/
public function createHttpResponse(
Throwable $exception,
?ExceptionContextProvider $contextProvider = null,
bool $isDebugMode = false
): Response {
// Create ResponseErrorRenderer with debug mode setting
$renderer = new ResponseErrorRenderer($isDebugMode);
?bool $isDebugMode = null
): HttpResponse {
// Use provided debug mode or instance default
$debugMode = $isDebugMode ?? $this->isDebugMode;
// Generate and return Response object
return $renderer->createResponse($exception, $contextProvider);
// Get renderer from factory
$renderer = $this->rendererFactory->getRenderer();
// If renderer is ResponseErrorRenderer and debug mode changed, create new one with correct debug mode
if ($renderer instanceof ResponseErrorRenderer && $debugMode !== $this->isDebugMode) {
$renderer = $this->rendererFactory->createHttpRenderer($debugMode);
}
// Render exception using unified interface
$result = $renderer->render(
$exception,
$contextProvider ?? $this->contextProvider
);
// Ensure we return HttpResponse (type safety)
if (!$result instanceof HttpResponse) {
throw new \RuntimeException('HTTP renderer must return HttpResponse');
}
return $result;
}
private function getErrorType(int $severity): string
{
return match ($severity) {
E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR => 'ERROR',
E_WARNING, E_CORE_WARNING, E_COMPILE_WARNING, E_USER_WARNING => 'WARNING',
E_NOTICE, E_USER_NOTICE => 'NOTICE',
E_STRICT => 'STRICT',
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
E_DEPRECATED, E_USER_DEPRECATED => 'DEPRECATED',
default => 'UNKNOWN'
};
}
private function getErrorColor(int $severity): ConsoleColor
{
return match ($severity) {
E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR => ConsoleColor::BRIGHT_RED,
E_WARNING, E_CORE_WARNING, E_COMPILE_WARNING, E_USER_WARNING => ConsoleColor::YELLOW,
E_NOTICE, E_USER_NOTICE => ConsoleColor::CYAN,
E_DEPRECATED, E_USER_DEPRECATED => ConsoleColor::MAGENTA,
default => ConsoleColor::WHITE
};
}
}

View File

@@ -1,8 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
/**
* Unified interface for error renderers
*
* All error renderers implement this interface, regardless of output format.
* - HTTP renderers return HttpResponse
* - CLI renderers return void (output directly to console)
*/
interface ErrorRenderer
{
public function render(): void;
/**
* Check if this renderer can handle the given exception
*/
public function canRender(\Throwable $exception): bool;
/**
* Render exception to appropriate output format
*
* @param \Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional context provider
* @return mixed HttpResponse for HTTP context, void for CLI context
*/
public function render(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): mixed;
}

View File

@@ -3,11 +3,52 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ExceptionHandling\Renderer\HtmlErrorRenderer;
final class ErrorRendererFactory
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
use App\Framework\ExceptionHandling\Renderers\ConsoleErrorRenderer;
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
use App\Framework\View\Engine;
/**
* Factory for creating appropriate error renderers based on execution context
*
* Uses unified ErrorRenderer interface - all renderers implement the same interface.
*/
final readonly class ErrorRendererFactory
{
public function getRenderer():ErrorRenderer
public function __construct(
private ExecutionContext $executionContext,
private Engine $engine,
private ?ConsoleOutput $consoleOutput = null,
private bool $isDebugMode = true
) {}
/**
* Get appropriate renderer for current execution context
*/
public function getRenderer(): ErrorRenderer
{
return new HtmlErrorRenderer();
if ($this->executionContext->isCli()) {
// ConsoleOutput should always be available in CLI context
$output = $this->consoleOutput ?? new ConsoleOutput();
return new ConsoleErrorRenderer($output);
}
return new ResponseErrorRenderer($this->engine, $this->isDebugMode);
}
/**
* Create HTTP renderer with optional debug mode override
*
* Allows creating a ResponseErrorRenderer with a different debug mode
* than the factory's default, without using reflection.
*
* @param bool|null $debugMode Override debug mode (uses factory default if null)
* @return ResponseErrorRenderer HTTP error renderer
*/
public function createHttpRenderer(?bool $debugMode = null): ResponseErrorRenderer
{
$debugMode = $debugMode ?? $this->isDebugMode;
return new ResponseErrorRenderer($this->engine, $debugMode);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
/**
* Error severity type enum with helper methods
*
* Provides type-safe error severity categorization and validation methods.
*/
enum ErrorSeverityType: int
{
case DEPRECATION = E_DEPRECATED;
case USER_DEPRECATION = E_USER_DEPRECATED;
case FATAL = E_ERROR;
case RECOVERABLE_ERROR = E_RECOVERABLE_ERROR;
case USER_ERROR = E_USER_ERROR;
case WARNING = E_WARNING;
case USER_WARNING = E_USER_WARNING;
case NOTICE = E_NOTICE;
case USER_NOTICE = E_USER_NOTICE;
case STRICT = E_STRICT;
/**
* Check if severity is a deprecation warning
*/
public static function isDeprecation(int $severity): bool
{
return $severity === self::DEPRECATION->value
|| $severity === self::USER_DEPRECATION->value;
}
/**
* Check if severity is fatal
*/
public static function isFatal(int $severity): bool
{
return in_array($severity, [
self::FATAL->value,
self::RECOVERABLE_ERROR->value,
self::USER_ERROR->value,
], true);
}
/**
* Check if severity is a warning
*/
public static function isWarning(int $severity): bool
{
return in_array($severity, [
self::WARNING->value,
self::USER_WARNING->value,
], true);
}
/**
* Check if severity is a notice
*/
public static function isNotice(int $severity): bool
{
return in_array($severity, [
self::NOTICE->value,
self::USER_NOTICE->value,
], true);
}
/**
* Get human-readable name for severity
*/
public static function getName(int $severity): string
{
return match ($severity) {
self::DEPRECATION->value => 'Deprecation',
self::USER_DEPRECATION->value => 'User Deprecation',
self::FATAL->value => 'Fatal Error',
self::RECOVERABLE_ERROR->value => 'Recoverable Error',
self::USER_ERROR->value => 'User Error',
self::WARNING->value => 'Warning',
self::USER_WARNING->value => 'User Warning',
self::NOTICE->value => 'Notice',
self::USER_NOTICE->value => 'User Notice',
self::STRICT->value => 'Strict',
default => 'Unknown',
};
}
}

View File

@@ -3,19 +3,37 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
/**
* Manages global exception, error, and shutdown handlers
*
* Supports early registration with basic handlers before Discovery,
* and replacement with full handlers after Discovery.
*/
final readonly class ExceptionHandlerManager
{
public function __construct()
public function __construct(
private ErrorHandlerInterface $errorHandler,
private ExceptionHandler $exceptionHandler,
private ShutdownHandlerInterface $shutdownHandler
) {
$this->registerErrorHandler($this->errorHandler);
$this->registerExceptionHandler($this->exceptionHandler);
$this->registerShutdownHandler($this->shutdownHandler);
}
/**
* Create ExceptionHandlerManager with basic handlers for early registration
*
* This factory method creates a manager with minimal handlers that don't
* require full dependencies. Useful for registering handlers before Discovery.
*/
public static function createWithBasicHandlers(): self
{
$resolver = new ErrorPolicyResolver();
$this->registerErrorHandler(new ErrorHandler($resolver->resolve(EnvironmentType::DEV)));
$this->registerExceptionHandler(new GlobalExceptionHandler());
$this->registerShutdownHandler(new ShutdownHandler());
return new self(
new BasicErrorHandler(),
new BasicGlobalExceptionHandler(),
new BasicShutdownHandler()
);
}
public function registerExceptionHandler(ExceptionHandler $handler): void
@@ -23,12 +41,12 @@ final readonly class ExceptionHandlerManager
set_exception_handler($handler->handle(...));
}
private function registerErrorHandler(ErrorHandler $handler):void
private function registerErrorHandler(ErrorHandlerInterface $handler): void
{
set_error_handler($handler->handle(...), E_ALL);
}
public function registerShutdownHandler(ShutdownHandler $handler): void
public function registerShutdownHandler(ShutdownHandlerInterface $handler): void
{
register_shutdown_function($handler->handle(...));
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Context\ExecutionContext;
use App\Framework\DI\Container;
/**
* Factory for creating and registering ExceptionHandlerManager
*
* Automatically creates ExceptionHandlerManager with appropriate configuration
* based on execution context. Eliminates code duplication in AppBootstrapper.
*/
final readonly class ExceptionHandlerManagerFactory
{
public function __construct(
private Container $container
) {}
/**
* Create and register ExceptionHandlerManager for current execution context
*
* Automatically detects context (CLI vs HTTP) and registers appropriate handlers.
* ErrorKernel will handle context-specific rendering automatically.
*/
public function createAndRegister(?ExecutionContext $executionContext = null): ExceptionHandlerManager
{
// Get execution context from container if not provided
if ($executionContext === null && $this->container->has(ExecutionContext::class)) {
$executionContext = $this->container->get(ExecutionContext::class);
}
// Create handlers with dependencies from container
$errorHandler = $this->container->get(ErrorHandler::class);
$exceptionHandler = $this->container->get(GlobalExceptionHandler::class);
$shutdownHandler = $this->container->get(ShutdownHandler::class);
// Create and register ExceptionHandlerManager
// Registration happens automatically in constructor
return new ExceptionHandlerManager(
$errorHandler,
$exceptionHandler,
$shutdownHandler
);
}
/**
* Create ExceptionHandlerManager without registering (for testing)
*/
public function create(): ExceptionHandlerManager
{
$errorHandler = $this->container->get(ErrorHandler::class);
$exceptionHandler = $this->container->get(GlobalExceptionHandler::class);
$shutdownHandler = $this->container->get(ShutdownHandler::class);
return new ExceptionHandlerManager(
$errorHandler,
$exceptionHandler,
$shutdownHandler
);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\Config\EnvironmentType;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Context\ExecutionContext;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\Reporter\LogReporter;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\ExceptionHandling\Reporter\ReporterRegistry;
use App\Framework\ExceptionHandling\Strategy\ErrorPolicyResolver;
use App\Framework\Logging\Logger;
use App\Framework\View\Engine;
/**
* Initializer for Exception Handling services
*/
final readonly class ExceptionHandlingInitializer
{
#[Initializer]
public function initialize(
Container $container,
EnvironmentType $environmentType,
ExecutionContext $executionContext,
Engine $engine,
?ConsoleOutput $consoleOutput = null,
?Logger $logger = null
): void {
$isDebugMode = $environmentType->isDebugEnabled();
// ConsoleOutput - only create if CLI context and not already provided
// For HTTP context, null is acceptable (ConsoleErrorRenderer won't be used)
$consoleOutput = $executionContext->isCli()
? ($consoleOutput ?? new ConsoleOutput())
: null;
// ReporterRegistry - bind with LogReporter as default
$registry = new ReporterRegistry(new LogReporter());
$container->singleton(Reporter::class, $registry);
// ErrorRendererFactory - singleton since configuration doesn't change
// ConsoleOutput can be null for HTTP context (factory will handle it)
$container->singleton(ErrorRendererFactory::class, new ErrorRendererFactory(
executionContext: $executionContext,
engine: $engine,
consoleOutput: $consoleOutput, // null for HTTP context, ConsoleOutput for CLI context
isDebugMode: $isDebugMode
));
// ErrorKernel - can be autowired (optional dependencies are nullable)
// No explicit binding needed - container will autowire
// GlobalExceptionHandler - can be autowired
// No explicit binding needed - container will autowire
// ShutdownHandler - can be autowired
// No explicit binding needed - container will autowire
// ErrorHandler - needs ErrorHandlerStrategy from ErrorPolicyResolver
$errorPolicyResolver = new ErrorPolicyResolver($logger);
$strategy = $errorPolicyResolver->resolve($environmentType);
$container->bind(ErrorHandler::class, new ErrorHandler(strategy: $strategy));
// ExceptionContextProvider - bind as singleton if not already bound
if (!$container->has(ExceptionContextProvider::class)) {
$container->singleton(ExceptionContextProvider::class, new ExceptionContextProvider());
}
// Replace early-registered basic handlers with full implementations
// This happens after Discovery, so all dependencies are available
$this->replaceEarlyHandlers($container, $executionContext);
}
/**
* Replace early-registered basic handlers with full implementations
*/
private function replaceEarlyHandlers(Container $container, ExecutionContext $executionContext): void
{
try {
$manager = $this->createFullHandlerManager($container);
$this->registerManager($container, $manager);
} catch (\Throwable $e) {
// If replacement fails, basic handlers will remain active
// This is acceptable as they provide basic error handling
// Log to stderr to avoid dependency on logger
error_log("Failed to replace early exception handlers: " . $e->getMessage());
}
}
/**
* Create ExceptionHandlerManager with full handlers from container
*/
private function createFullHandlerManager(Container $container): ExceptionHandlerManager
{
$errorHandler = $container->get(ErrorHandler::class);
$exceptionHandler = $container->get(GlobalExceptionHandler::class);
$shutdownHandler = $container->get(ShutdownHandler::class);
// Create ExceptionHandlerManager which will register the full handlers
// This replaces the basic handlers registered in AppBootstrapper::__construct
return new ExceptionHandlerManager(
$errorHandler,
$exceptionHandler,
$shutdownHandler
);
}
/**
* Register ExceptionHandlerManager in container
*/
private function registerManager(Container $container, ExceptionHandlerManager $manager): void
{
// Store manager in container for potential later use
if (!$container->has(ExceptionHandlerManager::class)) {
$container->instance(ExceptionHandlerManager::class, $manager);
}
}
}

View File

@@ -7,6 +7,9 @@ namespace App\Framework\ExceptionHandling\Factory;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use App\Framework\Http\IpAddress;
use App\Framework\Http\Session\SessionId;
use App\Framework\UserAgent\UserAgent;
use Throwable;
/**
@@ -136,6 +139,85 @@ final readonly class ExceptionFactory
return $this->create($exceptionClass, $message, $context, $previous);
}
/**
* Create auditable exception with operation context
*
* Creates exception that will be logged to audit system.
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param string $operation
* @param string|null $component
* @param array<string, mixed> $data
* @param Throwable|null $previous
* @return T
*/
public function createAuditable(
string $exceptionClass,
string $message,
string $operation,
?string $component = null,
array $data = [],
?\Throwable $previous = null
): Throwable {
$context = ExceptionContextData::forOperation($operation, $component)
->addData($data)
->withAuditable(true);
return $this->create($exceptionClass, $message, $context, $previous);
}
/**
* Create non-auditable exception
*
* Creates exception that will NOT be logged to audit system.
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param ExceptionContextData|null $context
* @param Throwable|null $previous
* @return T
*/
public function createNonAuditable(
string $exceptionClass,
string $message,
?ExceptionContextData $context = null,
?\Throwable $previous = null
): Throwable {
$nonAuditableContext = ($context ?? ExceptionContextData::empty())
->withAuditable(false);
return $this->create($exceptionClass, $message, $nonAuditableContext, $previous);
}
/**
* Create exception with audit level
*
* Sets the audit level for the exception (e.g., 'ERROR', 'WARNING', 'INFO').
*
* @template T of Throwable
* @param class-string<T> $exceptionClass
* @param string $message
* @param string $auditLevel
* @param ExceptionContextData|null $context
* @param Throwable|null $previous
* @return T
*/
public function withAuditLevel(
string $exceptionClass,
string $message,
string $auditLevel,
?ExceptionContextData $context = null,
?\Throwable $previous = null
): Throwable {
$contextWithLevel = ($context ?? ExceptionContextData::empty())
->withAuditLevel($auditLevel);
return $this->create($exceptionClass, $message, $contextWithLevel, $previous);
}
/**
* Enrich context from current error scope
*
@@ -182,12 +264,39 @@ final readonly class ExceptionFactory
}
// Extract HTTP fields from scope metadata (for HTTP scopes)
// Convert strings to Value Objects where possible
if (isset($scopeContext->metadata['ip'])) {
$enriched = $enriched->withClientIp($scopeContext->metadata['ip']);
$ipValue = $scopeContext->metadata['ip'];
if (is_string($ipValue) && IpAddress::isValid($ipValue)) {
$enriched = $enriched->withClientIp(IpAddress::from($ipValue));
} elseif ($ipValue instanceof IpAddress) {
$enriched = $enriched->withClientIp($ipValue);
} else {
// Fallback to string for backward compatibility
$enriched = $enriched->withClientIp($ipValue);
}
}
if (isset($scopeContext->metadata['user_agent'])) {
$enriched = $enriched->withUserAgent($scopeContext->metadata['user_agent']);
$userAgentValue = $scopeContext->metadata['user_agent'];
if (is_string($userAgentValue)) {
$enriched = $enriched->withUserAgent(UserAgent::fromString($userAgentValue));
} elseif ($userAgentValue instanceof UserAgent) {
$enriched = $enriched->withUserAgent($userAgentValue);
} else {
// Fallback to string for backward compatibility
$enriched = $enriched->withUserAgent($userAgentValue);
}
}
// Convert session ID to Value Object if available
if ($scopeContext->sessionId !== null && is_string($scopeContext->sessionId)) {
try {
$enriched = $enriched->withSessionId(SessionId::fromString($scopeContext->sessionId));
} catch (\InvalidArgumentException) {
// If SessionId validation fails, keep as string for backward compatibility
$enriched = $enriched->withSessionId($scopeContext->sessionId);
}
}
// Add scope tags

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
/**
* Centralized fatal error type definitions
*
* Provides a single source of truth for which PHP error types
* are considered fatal and should trigger shutdown handlers.
*/
final class FatalErrorTypes
{
public const array FATAL_TYPES = [
E_ERROR,
E_PARSE,
E_CORE_ERROR,
E_COMPILE_ERROR,
E_USER_ERROR
];
/**
* Check if an error type is fatal
*/
public static function isFatal(int $type): bool
{
return in_array($type, self::FATAL_TYPES, true);
}
}

View File

@@ -5,10 +5,12 @@ namespace App\Framework\ExceptionHandling;
final readonly class GlobalExceptionHandler implements ExceptionHandler
{
public function __construct(
private ErrorKernel $errorKernel
) {}
public function handle(\Throwable $throwable): void
{
$kernel = new ErrorKernel();
$kernel->handle($throwable);
$this->errorKernel->handle($throwable);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Health;
use App\Framework\ExceptionHandling\Metrics\ExceptionMetrics;
use App\Framework\ExceptionHandling\Metrics\ExceptionMetricsCollector;
use App\Framework\Health\HealthCheckInterface;
use App\Framework\Health\HealthCheckResult;
/**
* Exception Health Checker
*
* Provides health checks based on exception rate.
*/
final readonly class ExceptionHealthChecker implements HealthCheckInterface
{
public readonly string $name;
public readonly int $timeout;
/**
* @param ExceptionMetricsCollector $metricsCollector Metrics collector
* @param string $name Health check name
* @param int $timeout Timeout in milliseconds
* @param float $errorRateThreshold Error rate threshold (0.0 to 1.0, e.g., 0.1 = 10%)
* @param int $timeWindowSeconds Time window in seconds for error rate calculation
*/
public function __construct(
private ExceptionMetricsCollector $metricsCollector,
string $name = 'Exception Rate',
int $timeout = 2000,
private float $errorRateThreshold = 0.1, // 10% default
private int $timeWindowSeconds = 60
) {
$this->name = $name;
$this->timeout = $timeout;
if ($errorRateThreshold < 0.0 || $errorRateThreshold > 1.0) {
throw new \InvalidArgumentException('errorRateThreshold must be between 0.0 and 1.0');
}
}
/**
* Perform health check
*/
public function check(): HealthCheckResult
{
$metrics = $this->metricsCollector->getMetrics();
$errorRate = $this->calculateErrorRate($metrics);
if ($errorRate >= $this->errorRateThreshold) {
return HealthCheckResult::unhealthy(
"Exception error rate too high: " . number_format($errorRate * 100, 2) . "%",
[
'error_rate' => $errorRate,
'threshold' => $this->errorRateThreshold,
'total_exceptions' => $metrics->totalCount,
]
);
}
if ($errorRate >= $this->errorRateThreshold * 0.5) {
return HealthCheckResult::warning(
"Exception error rate elevated: " . number_format($errorRate * 100, 2) . "%",
[
'error_rate' => $errorRate,
'threshold' => $this->errorRateThreshold,
'total_exceptions' => $metrics->totalCount,
]
);
}
return HealthCheckResult::healthy(
"Exception error rate normal: " . number_format($errorRate * 100, 2) . "%",
[
'error_rate' => $errorRate,
'total_exceptions' => $metrics->totalCount,
]
);
}
/**
* Calculate error rate
*
* Simplified implementation - in production, would calculate based on request count.
*/
private function calculateErrorRate(ExceptionMetrics $metrics): float
{
// Simplified: use total count as proxy for error rate
// In production, would compare against total request count
if ($metrics->totalCount === 0) {
return 0.0;
}
// Normalize to 0-1 range (simplified)
return min(1.0, $metrics->totalCount / 1000.0);
}
/**
* Get health check category
*/
public function getCategory(): \App\Framework\Health\HealthCheckCategory
{
return \App\Framework\Health\HealthCheckCategory::APPLICATION;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Localization;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
/**
* Exception Localizer
*
* Provides localization support for exception messages.
*/
final readonly class ExceptionLocalizer
{
/**
* @param array<string, array<string, string|array{message: string, title?: string, help?: string}>> $translations Translations by locale
* @param string $defaultLocale Default locale (e.g., 'en')
*/
public function __construct(
private array $translations = [],
private string $defaultLocale = 'en'
) {
}
/**
* Get locale from context
*
* Extracts locale from request headers, session, or user preferences.
*/
public function getLocale(?ExceptionContextData $context = null): string
{
// Try to get from context metadata
if ($context !== null && isset($context->metadata['locale'])) {
$locale = $context->metadata['locale'];
if (is_string($locale) && $this->hasTranslations($locale)) {
return $locale;
}
}
// Fallback to default locale
return $this->defaultLocale;
}
/**
* Get translations for locale
*
* @param string $locale Locale code (e.g., 'en', 'de')
* @return array<string, string|array{message: string, title?: string, help?: string}> Translations
*/
public function getTranslations(string $locale): array
{
return $this->translations[$locale] ?? $this->translations[$this->defaultLocale] ?? [];
}
/**
* Check if translations exist for locale
*/
public function hasTranslations(string $locale): bool
{
return isset($this->translations[$locale]);
}
/**
* Get fallback chain for locale
*
* Returns array of locales to try in order: [user_locale, default_locale, 'en']
*/
public function getFallbackChain(string $locale): array
{
$chain = [$locale];
if ($locale !== $this->defaultLocale) {
$chain[] = $this->defaultLocale;
}
if ($this->defaultLocale !== 'en') {
$chain[] = 'en';
}
return array_unique($chain);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Metrics;
/**
* Exception Metrics
*
* Immutable value object for exception metrics.
*/
final readonly class ExceptionMetrics
{
/**
* @param int $totalCount Total exception count
* @param array<string, int> $byClass Count by exception class
* @param array<string, int> $byComponent Count by component
* @param float $averageExecutionTimeMs Average execution time in milliseconds
*/
public function __construct(
public int $totalCount = 0,
public array $byClass = [],
public array $byComponent = [],
public float $averageExecutionTimeMs = 0.0
) {
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'total_count' => $this->totalCount,
'by_class' => $this->byClass,
'by_component' => $this->byComponent,
'average_execution_time_ms' => $this->averageExecutionTimeMs,
];
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Metrics;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Metrics Collector
*
* Collects metrics for exceptions (rate, top exceptions, error rate by component).
*/
final readonly class ExceptionMetricsCollector
{
private const string CACHE_PREFIX = 'exception_metrics:';
private const int METRICS_TTL = 3600; // 1 hour
public function __construct(
private Cache $cache
) {
}
/**
* Record exception metric
*
* @param Throwable $exception Exception to record
* @param ExceptionContextData|null $context Optional context
* @param float|null $executionTimeMs Optional execution time in milliseconds
*/
public function record(
Throwable $exception,
?ExceptionContextData $context = null,
?float $executionTimeMs = null
): void {
$exceptionClass = get_class($exception);
$component = $context?->component ?? 'unknown';
// Increment total count
$this->incrementMetric('total');
// Increment by class
$this->incrementMetric('class:' . $exceptionClass);
// Increment by component
$this->incrementMetric('component:' . $component);
// Record execution time if available
if ($executionTimeMs !== null) {
$this->recordExecutionTime($executionTimeMs);
}
}
/**
* Get current metrics
*
* @return ExceptionMetrics Current metrics
*/
public function getMetrics(): ExceptionMetrics
{
$totalCount = $this->getMetric('total');
$byClass = $this->getMetricsByPrefix('class:');
$byComponent = $this->getMetricsByPrefix('component:');
$avgExecutionTime = $this->getAverageExecutionTime();
return new ExceptionMetrics(
totalCount: $totalCount,
byClass: $byClass,
byComponent: $byComponent,
averageExecutionTimeMs: $avgExecutionTime
);
}
/**
* Increment metric
*/
private function incrementMetric(string $metricName): void
{
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
$current = $this->getMetricValue($cacheKey);
$newValue = $current + 1;
$cacheItem = CacheItem::fromKey(
$cacheKey,
$newValue,
Duration::fromSeconds(self::METRICS_TTL)
);
$this->cache->set($cacheItem);
}
/**
* Get metric value
*/
private function getMetric(string $metricName): int
{
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . $metricName);
return $this->getMetricValue($cacheKey);
}
/**
* Get metric value from cache
*/
private function getMetricValue(CacheKey $cacheKey): int
{
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
if ($item === null) {
return 0;
}
$value = $item->value;
return is_int($value) ? $value : 0;
}
/**
* Get metrics by prefix
*/
private function getMetricsByPrefix(string $prefix): array
{
// Simplified implementation - in production, would use cache prefix scanning
return [];
}
/**
* Record execution time
*/
private function recordExecutionTime(float $executionTimeMs): void
{
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . 'execution_times');
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
$times = $item !== null && is_array($item->value) ? $item->value : [];
$times[] = $executionTimeMs;
// Keep only last 1000 execution times
if (count($times) > 1000) {
$times = array_slice($times, -1000);
}
$cacheItem = CacheItem::fromKey(
$cacheKey,
$times,
Duration::fromSeconds(self::METRICS_TTL)
);
$this->cache->set($cacheItem);
}
/**
* Get average execution time
*/
private function getAverageExecutionTime(): float
{
$cacheKey = CacheKey::fromString(self::CACHE_PREFIX . 'execution_times');
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
if ($item === null || !is_array($item->value)) {
return 0.0;
}
$times = $item->value;
if (empty($times)) {
return 0.0;
}
return array_sum($times) / count($times);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Metrics;
/**
* Prometheus Exporter
*
* Exports exception metrics in Prometheus format.
*/
final readonly class PrometheusExporter
{
/**
* Export metrics in Prometheus format
*
* @param ExceptionMetrics $metrics Metrics to export
* @return string Prometheus-formatted metrics
*/
public function export(ExceptionMetrics $metrics): string
{
$lines = [];
// Total exception count
$lines[] = sprintf(
'exception_total %d',
$metrics->totalCount
);
// Exceptions by class
foreach ($metrics->byClass as $class => $count) {
$sanitizedClass = $this->sanitizeMetricName($class);
$lines[] = sprintf(
'exception_by_class{exception_class="%s"} %d',
$sanitizedClass,
$count
);
}
// Exceptions by component
foreach ($metrics->byComponent as $component => $count) {
$sanitizedComponent = $this->sanitizeMetricName($component);
$lines[] = sprintf(
'exception_by_component{component="%s"} %d',
$sanitizedComponent,
$count
);
}
// Average execution time
$lines[] = sprintf(
'exception_average_execution_time_ms %.2f',
$metrics->averageExecutionTimeMs
);
return implode("\n", $lines);
}
/**
* Sanitize metric name for Prometheus
*/
private function sanitizeMetricName(string $name): string
{
// Replace invalid characters
$sanitized = preg_replace('/[^a-zA-Z0-9_:]/', '_', $name);
return $sanitized;
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Middleware;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use App\Framework\ExceptionHandling\Scope\ErrorScopeContext;
use App\Framework\Http\HttpMiddleware;
use App\Framework\Http\MiddlewareContext;
use App\Framework\Http\MiddlewarePriority;
use App\Framework\Http\MiddlewarePriorityAttribute;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
/**
* Error Scope Middleware
*
* Automatically creates ErrorScope for HTTP requests to enable automatic
* context enrichment for all exceptions thrown during request processing.
*
* Uses Value Objects (IpAddress, UserAgent, SessionId) where possible.
* Scope is automatically cleaned up after request processing.
*/
#[MiddlewarePriorityAttribute(MiddlewarePriority::VERY_EARLY)]
final readonly class ErrorScopeMiddleware implements HttpMiddleware
{
public function __construct(
private ErrorScope $errorScope
) {
}
public function __invoke(MiddlewareContext $context, Next $next, RequestStateManager $stateManager): MiddlewareContext
{
// Create HTTP scope from request
$scopeContext = $this->createHttpScope($context->request);
// Enter scope (returns token for cleanup)
$token = $this->errorScope->enter($scopeContext);
try {
// Process request through middleware chain
$result = $next($context);
// Update scope with any additional context that became available
// (e.g., user ID after authentication, route after routing)
$this->updateScopeFromContext($scopeContext, $context, $stateManager);
return $result;
} finally {
// Always exit scope, even if exception occurred
$this->errorScope->exit($token);
}
}
/**
* Create HTTP scope context from request
*
* Uses Value Objects where possible for type safety and validation.
*/
private function createHttpScope(\App\Framework\Http\Request $request): ErrorScopeContext
{
// Extract request ID (as string, since RequestId Value Object needs secret)
$requestIdString = $request->id->toString();
// Extract IP address (as string, will be converted to Value Object in enrichContext)
$ipAddressString = $request->server->getRemoteAddr()?->value ?? null;
// Extract user agent (as string, will be converted to Value Object in enrichContext)
$userAgentString = $request->server->getUserAgent()?->value ?? null;
// Extract session ID (will be available after SessionMiddleware)
$sessionId = null;
if (property_exists($request, 'session') && $request->session !== null) {
$sessionId = $request->session->id->toString();
}
// Create scope context with metadata for later Value Object conversion
return ErrorScopeContext::http(
request: $request,
operation: null, // Will be set by routing/controller
component: null // Will be set by routing/controller
)->addMetadata([
'ip' => $ipAddressString,
'user_agent' => $userAgentString,
'request_id' => $requestIdString,
'session_id' => $sessionId,
]);
}
/**
* Update scope with additional context that became available during request processing
*
* E.g., user ID after authentication, route after routing
*/
private function updateScopeFromContext(
ErrorScopeContext $scopeContext,
MiddlewareContext $context,
RequestStateManager $stateManager
): void {
// Get current scope (should be the one we just created)
$currentScope = $this->errorScope->current();
if ($currentScope === null || $currentScope->scopeId !== $scopeContext->scopeId) {
return; // Scope changed or not found
}
// Check if user ID became available (after authentication)
$userId = $this->extractUserId($context, $stateManager);
if ($userId !== null && $currentScope->userId === null) {
// Create updated scope with user ID
$updatedScope = $currentScope->withUserId($userId);
// Replace current scope (exit and re-enter)
$this->errorScope->exit();
$this->errorScope->enter($updatedScope);
}
// Check if route information became available (after routing)
$route = $this->extractRouteInfo($context);
if ($route !== null && $currentScope->operation === null) {
$operation = "http.{$route['method']}.{$route['path']}";
$updatedScope = $currentScope->withOperation($operation, $route['controller'] ?? null);
$this->errorScope->exit();
$this->errorScope->enter($updatedScope);
}
}
/**
* Extract user ID from context or state manager
*/
private function extractUserId(MiddlewareContext $context, RequestStateManager $stateManager): ?string
{
// Try to get from request attribute (set by auth middleware)
if (method_exists($context->request, 'getAttribute')) {
$user = $context->request->getAttribute('user');
if ($user !== null && (is_object($user) && property_exists($user, 'id'))) {
return (string) $user->id;
}
if (is_string($user)) {
return $user;
}
}
// Try to get from state manager
try {
$user = $stateManager->get('user');
if ($user !== null && (is_object($user) && property_exists($user, 'id'))) {
return (string) $user->id;
}
} catch (\Throwable) {
// Ignore - user not available
}
return null;
}
/**
* Extract route information from context
*/
private function extractRouteInfo(MiddlewareContext $context): ?array
{
$request = $context->request;
// Try to get route from request attribute
if (method_exists($request, 'getAttribute')) {
$route = $request->getAttribute('route');
if ($route !== null) {
return [
'method' => $request->method->value,
'path' => $request->path,
'controller' => is_object($route) ? get_class($route) : null,
];
}
}
// Fallback: use request method and path
return [
'method' => $request->method->value,
'path' => $request->path,
'controller' => null,
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\PatternDetection;
/**
* Exception Pattern
*
* Immutable value object representing a detected exception pattern.
*/
final readonly class ExceptionPattern
{
/**
* @param string $fingerprint Pattern fingerprint
* @param string $description Pattern description
* @param array<FixSuggestion> $fixSuggestions Suggested fixes
* @param int $occurrenceCount Number of times this pattern occurred
*/
public function __construct(
public string $fingerprint,
public string $description,
public array $fixSuggestions = [],
public int $occurrenceCount = 1
) {
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'fingerprint' => $this->fingerprint,
'description' => $this->description,
'fix_suggestions' => array_map(
fn(FixSuggestion $suggestion) => $suggestion->toArray(),
$this->fixSuggestions
),
'occurrence_count' => $this->occurrenceCount,
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\PatternDetection;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\RateLimit\ExceptionFingerprint;
use Throwable;
/**
* Exception Pattern Detector
*
* Detects patterns in exceptions and provides fix suggestions.
*/
final readonly class ExceptionPatternDetector
{
/**
* @param array<string, array{description: string, fixes: array<array{title: string, description: string, code?: string, confidence?: string}>}> $knowledgeBase Knowledge base of patterns and fixes
*/
public function __construct(
private array $knowledgeBase = []
) {
}
/**
* Detect patterns for exception
*
* @param Throwable $exception Exception to analyze
* @param ExceptionContextData|null $context Optional context
* @return array<ExceptionPattern> Detected patterns
*/
public function detect(Throwable $exception, ?ExceptionContextData $context = null): array
{
$fingerprint = $context !== null
? ExceptionFingerprint::fromExceptionWithContext(
$exception,
$context->component,
$context->operation
)
: ExceptionFingerprint::fromException($exception);
$patterns = [];
// Check knowledge base for matching patterns
$exceptionClass = get_class($exception);
if (isset($this->knowledgeBase[$exceptionClass])) {
$patternData = $this->knowledgeBase[$exceptionClass];
$fixSuggestions = array_map(
fn(array $fix) => new FixSuggestion(
title: $fix['title'] ?? '',
description: $fix['description'] ?? '',
codeExample: $fix['code'] ?? null,
confidence: $fix['confidence'] ?? 'medium'
),
$patternData['fixes'] ?? []
);
$patterns[] = new ExceptionPattern(
fingerprint: $fingerprint->getHash(),
description: $patternData['description'] ?? 'Pattern detected',
fixSuggestions: $fixSuggestions
);
}
return $patterns;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\PatternDetection;
/**
* Fix Suggestion
*
* Immutable value object for exception fix suggestions.
*/
final readonly class FixSuggestion
{
/**
* @param string $title Suggestion title
* @param string $description Detailed description
* @param string|null $codeExample Optional code example
* @param string $confidence Confidence level (low, medium, high)
*/
public function __construct(
public string $title,
public string $description,
public ?string $codeExample = null,
public string $confidence = 'medium'
) {
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'code_example' => $this->codeExample,
'confidence' => $this->confidence,
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Performance;
/**
* Exception Performance Metrics
*
* Immutable value object containing performance metrics for exceptions.
*/
final readonly class ExceptionPerformanceMetrics
{
/**
* @param float $executionTimeMs Execution time in milliseconds
* @param int $memoryDeltaBytes Memory delta in bytes (positive = memory increase)
* @param float|null $cpuUsagePercent CPU usage percentage (if available)
* @param string $exceptionClass Exception class name
* @param string|null $component Component where exception occurred
*/
public function __construct(
public float $executionTimeMs,
public int $memoryDeltaBytes,
public ?float $cpuUsagePercent = null,
public string $exceptionClass = '',
public ?string $component = null
) {
}
/**
* Create empty metrics
*/
public static function empty(): self
{
return new self(0.0, 0);
}
/**
* Convert to array for serialization
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'execution_time_ms' => $this->executionTimeMs,
'memory_delta_bytes' => $this->memoryDeltaBytes,
'cpu_usage_percent' => $this->cpuUsagePercent,
'exception_class' => $this->exceptionClass,
'component' => $this->component,
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Performance;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Performance Tracker
*
* Tracks performance metrics for exceptions (execution time, memory, CPU).
*/
final readonly class ExceptionPerformanceTracker
{
/**
* Track exception performance
*
* @param Throwable $exception Exception to track
* @param ExceptionContextData|null $context Optional context
* @param float $executionTimeMs Execution time in milliseconds
* @param int $memoryDeltaBytes Memory delta in bytes
* @param float|null $cpuUsagePercent CPU usage percentage (if available)
* @return ExceptionPerformanceMetrics Performance metrics
*/
public function track(
Throwable $exception,
?ExceptionContextData $context = null,
float $executionTimeMs = 0.0,
int $memoryDeltaBytes = 0,
?float $cpuUsagePercent = null
): ExceptionPerformanceMetrics {
$metrics = new ExceptionPerformanceMetrics(
executionTimeMs: $executionTimeMs,
memoryDeltaBytes: $memoryDeltaBytes,
cpuUsagePercent: $cpuUsagePercent,
exceptionClass: get_class($exception),
component: $context?->component
);
// Store metrics in context metadata if available
if ($context !== null) {
$context->addMetadata([
'performance' => $metrics->toArray(),
]);
}
return $metrics;
}
/**
* Start performance tracking
*
* Returns tracking data that should be passed to end().
*
* @return array{start_time: float, start_memory: int, start_cpu: float|null}
*/
public function start(): array
{
return [
'start_time' => microtime(true),
'start_memory' => memory_get_usage(true),
'start_cpu' => $this->getCpuUsage(),
];
}
/**
* End performance tracking
*
* @param array{start_time: float, start_memory: int, start_cpu: float|null} $startData Data from start()
* @return ExceptionPerformanceMetrics Calculated metrics
*/
public function end(
array $startData,
Throwable $exception,
?ExceptionContextData $context = null
): ExceptionPerformanceMetrics {
$endTime = microtime(true);
$endMemory = memory_get_usage(true);
$endCpu = $this->getCpuUsage();
$executionTimeMs = ($endTime - $startData['start_time']) * 1000;
$memoryDeltaBytes = $endMemory - $startData['start_memory'];
$cpuUsagePercent = null;
if ($startData['start_cpu'] !== null && $endCpu !== null) {
$cpuUsagePercent = $endCpu - $startData['start_cpu'];
}
return $this->track($exception, $context, $executionTimeMs, $memoryDeltaBytes, $cpuUsagePercent);
}
/**
* Get current CPU usage
*
* Returns null if CPU usage cannot be determined.
*/
private function getCpuUsage(): ?float
{
// Try to get CPU usage from system
if (function_exists('sys_getloadavg')) {
$load = sys_getloadavg();
if ($load !== false && isset($load[0])) {
return $load[0];
}
}
return null;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\RateLimit;
use Throwable;
/**
* Exception Fingerprint
*
* Generates unique fingerprints for exceptions to enable rate limiting
* by grouping similar exceptions together.
*
* Immutable value object.
*/
final readonly class ExceptionFingerprint
{
private function __construct(
private string $hash,
private string $normalizedMessage
) {
}
/**
* Create fingerprint from exception
*/
public static function fromException(Throwable $exception): self
{
$normalizedMessage = self::normalizeMessage($exception->getMessage());
$components = [
get_class($exception),
$normalizedMessage,
$exception->getFile(),
(string) $exception->getLine(),
];
$hash = hash('sha256', implode('|', $components));
return new self($hash, $normalizedMessage);
}
/**
* Create fingerprint from exception with context
*
* Includes component and operation from context for more precise grouping.
*/
public static function fromExceptionWithContext(
Throwable $exception,
?string $component = null,
?string $operation = null
): self {
$normalizedMessage = self::normalizeMessage($exception->getMessage());
$components = [
get_class($exception),
$normalizedMessage,
$exception->getFile(),
(string) $exception->getLine(),
];
// Add context if available for more precise grouping
if ($component !== null) {
$components[] = $component;
}
if ($operation !== null) {
$components[] = $operation;
}
$hash = hash('sha256', implode('|', $components));
return new self($hash, $normalizedMessage);
}
/**
* Normalize exception message for fingerprinting
*
* Removes variable parts like:
* - UUIDs
* - Timestamps
* - IDs
* - Numbers (but keeps words containing numbers)
*/
private static function normalizeMessage(string $message): string
{
$normalized = $message;
// UUIDs entfernen
$normalized = preg_replace(
'/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i',
'{UUID}',
$normalized
);
// Timestamps entfernen (ISO 8601)
$normalized = preg_replace(
'/\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}/',
'{TIMESTAMP}',
$normalized
);
// File paths mit Zeilennummern
$normalized = preg_replace(
'/\/[\w\/\-\.]+\.php:\d+/',
'{FILE}',
$normalized
);
// Zahlen (aber behalte Wörter die Zahlen enthalten)
$normalized = preg_replace('/\b\d+\b/', '{NUMBER}', $normalized);
// Email-Adressen
$normalized = preg_replace('/\b[\w\.-]+@[\w\.-]+\.\w+\b/', '{EMAIL}', $normalized);
// URLs
$normalized = preg_replace('/https?:\/\/[^\s]+/', '{URL}', $normalized);
return $normalized;
}
/**
* Get fingerprint hash
*/
public function getHash(): string
{
return $this->hash;
}
/**
* Get normalized message
*/
public function getNormalizedMessage(): string
{
return $this->normalizedMessage;
}
/**
* String representation
*/
public function __toString(): string
{
return $this->hash;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\RateLimit;
use App\Framework\Core\ValueObjects\Duration;
/**
* Exception Rate Limit Configuration
*
* Immutable configuration for exception rate limiting.
*/
final readonly class ExceptionRateLimitConfig
{
/**
* @param int $maxExceptions Maximum number of exceptions allowed per time window
* @param Duration $timeWindow Time window for rate limiting
* @param bool $enabled Whether rate limiting is enabled
* @param bool $skipLoggingOnLimit Whether to skip logging when rate limit is reached
* @param bool $skipAuditOnLimit Whether to skip audit logging when rate limit is reached
* @param bool $trackMetricsOnLimit Whether to track metrics even when rate limit is reached
*/
public function __construct(
public int $maxExceptions = 10,
public Duration $timeWindow = new Duration(60), // 1 minute default
public bool $enabled = true,
public bool $skipLoggingOnLimit = true,
public bool $skipAuditOnLimit = true,
public bool $trackMetricsOnLimit = true
) {
if ($this->maxExceptions < 1) {
throw new \InvalidArgumentException('maxExceptions must be at least 1');
}
}
/**
* Create default configuration
*/
public static function default(): self
{
return new self();
}
/**
* Create configuration with custom limits
*/
public static function withLimits(int $maxExceptions, Duration $timeWindow): self
{
return new self(
maxExceptions: $maxExceptions,
timeWindow: $timeWindow
);
}
/**
* Create disabled configuration
*/
public static function disabled(): self
{
return new self(enabled: false);
}
/**
* Create new instance with different max exceptions
*/
public function withMaxExceptions(int $maxExceptions): self
{
return new self(
maxExceptions: $maxExceptions,
timeWindow: $this->timeWindow,
enabled: $this->enabled,
skipLoggingOnLimit: $this->skipLoggingOnLimit,
skipAuditOnLimit: $this->skipAuditOnLimit,
trackMetricsOnLimit: $this->trackMetricsOnLimit
);
}
/**
* Create new instance with different time window
*/
public function withTimeWindow(Duration $timeWindow): self
{
return new self(
maxExceptions: $this->maxExceptions,
timeWindow: $timeWindow,
enabled: $this->enabled,
skipLoggingOnLimit: $this->skipLoggingOnLimit,
skipAuditOnLimit: $this->skipAuditOnLimit,
trackMetricsOnLimit: $this->trackMetricsOnLimit
);
}
/**
* Create new instance with enabled/disabled state
*/
public function withEnabled(bool $enabled): self
{
return new self(
maxExceptions: $this->maxExceptions,
timeWindow: $this->timeWindow,
enabled: $enabled,
skipLoggingOnLimit: $this->skipLoggingOnLimit,
skipAuditOnLimit: $this->skipAuditOnLimit,
trackMetricsOnLimit: $this->trackMetricsOnLimit
);
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\RateLimit;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Rate Limiter
*
* Prevents log spam by rate limiting repeated identical exceptions.
* Uses cache-based tracking with configurable thresholds.
*/
final readonly class ExceptionRateLimiter
{
private const string CACHE_PREFIX = 'exception_rate_limit:';
public function __construct(
private Cache $cache,
private ExceptionRateLimitConfig $config
) {
}
/**
* Check if exception should be rate limited
*
* Returns true if exception should be processed (not rate limited),
* false if it should be skipped due to rate limiting.
*
* @param Throwable $exception Exception to check
* @param ExceptionContextData|null $context Optional context for fingerprinting
* @return bool True if exception should be processed, false if rate limited
*/
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
{
if (!$this->config->enabled) {
return true; // Rate limiting disabled, always process
}
$fingerprint = $context !== null
? ExceptionFingerprint::fromExceptionWithContext(
$exception,
$context->component,
$context->operation
)
: ExceptionFingerprint::fromException($exception);
$cacheKey = $this->buildCacheKey($fingerprint);
$currentCount = $this->getCachedCount($cacheKey);
if ($currentCount >= $this->config->maxExceptions) {
// Rate limit reached
return false;
}
// Increment count
$this->incrementCount($cacheKey);
return true;
}
/**
* Check if logging should be skipped due to rate limiting
*/
public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool
{
if (!$this->config->enabled || !$this->config->skipLoggingOnLimit) {
return false;
}
return !$this->shouldProcess($exception, $context);
}
/**
* Check if audit logging should be skipped due to rate limiting
*/
public function shouldSkipAudit(Throwable $exception, ?ExceptionContextData $context = null): bool
{
if (!$this->config->enabled || !$this->config->skipAuditOnLimit) {
return false;
}
return !$this->shouldProcess($exception, $context);
}
/**
* Check if metrics should be tracked even when rate limited
*/
public function shouldTrackMetrics(Throwable $exception, ?ExceptionContextData $context = null): bool
{
if (!$this->config->enabled || !$this->config->trackMetricsOnLimit) {
return true; // Always track if not enabled or tracking not disabled
}
// Track metrics even if rate limited
return true;
}
/**
* Get current count for exception fingerprint
*/
public function getCurrentCount(Throwable $exception, ?ExceptionContextData $context = null): int
{
$fingerprint = $context !== null
? ExceptionFingerprint::fromExceptionWithContext(
$exception,
$context->component,
$context->operation
)
: ExceptionFingerprint::fromException($exception);
$cacheKey = $this->buildCacheKey($fingerprint);
return $this->getCachedCount($cacheKey);
}
/**
* Build cache key from fingerprint
*/
private function buildCacheKey(ExceptionFingerprint $fingerprint): CacheKey
{
return CacheKey::fromString(self::CACHE_PREFIX . $fingerprint->getHash());
}
/**
* Get current count from cache key
*/
private function getCachedCount(CacheKey $cacheKey): int
{
$result = $this->cache->get($cacheKey);
$item = $result->getFirstHit();
if ($item === null) {
return 0;
}
$value = $item->value;
return is_int($value) ? $value : 0;
}
/**
* Increment count in cache
*/
private function incrementCount(CacheKey $cacheKey): void
{
$currentCount = $this->getCachedCount($cacheKey);
$newCount = $currentCount + 1;
$cacheItem = CacheItem::fromKey(
$cacheKey,
$newCount,
$this->config->timeWindow
);
$this->cache->set($cacheItem);
}
/**
* Reset rate limit for exception fingerprint
*
* Useful for testing or manual reset.
*/
public function reset(Throwable $exception, ?ExceptionContextData $context = null): void
{
$fingerprint = $context !== null
? ExceptionFingerprint::fromExceptionWithContext(
$exception,
$context->component,
$context->operation
)
: ExceptionFingerprint::fromException($exception);
$cacheKey = $this->buildCacheKey($fingerprint);
$this->cache->forget($cacheKey);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Recovery;
use App\Framework\Exception\ExceptionMetadata;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Recovery Manager
*
* Manages automatic retry logic for retryable exceptions.
*/
final readonly class ExceptionRecoveryManager
{
/**
* Check if exception should be retried
*
* @param Throwable $exception Exception to check
* @param ExceptionMetadata|null $metadata Optional exception metadata
* @return bool True if exception should be retried
*/
public function shouldRetry(Throwable $exception, ?ExceptionMetadata $metadata = null): bool
{
if ($metadata === null) {
return false;
}
// Check if retry is configured
if ($metadata->retryAfter === null) {
return false;
}
// Check if exception is retryable (implements marker interface or is in whitelist)
if (!$this->isRetryable($exception)) {
return false;
}
return true;
}
/**
* Get retry delay in milliseconds
*
* @param Throwable $exception Exception to retry
* @param ExceptionMetadata|null $metadata Optional exception metadata
* @param int $attemptNumber Current attempt number (1-based)
* @return int Delay in milliseconds
*/
public function getRetryDelay(
Throwable $exception,
?ExceptionMetadata $metadata = null,
int $attemptNumber = 1
): int {
if ($metadata === null || $metadata->retryAfter === null) {
return 1000; // Default 1 second
}
$strategy = RetryStrategy::EXPONENTIAL_BACKOFF; // Default strategy
$baseDelay = $metadata->retryAfter;
return $strategy->calculateDelay($attemptNumber, $baseDelay);
}
/**
* Check if exception is retryable
*
* @param Throwable $exception Exception to check
* @return bool True if exception is retryable
*/
private function isRetryable(Throwable $exception): bool
{
// Check for marker interface
if ($exception instanceof RetryableException) {
return true;
}
// Check exception class name patterns
$className = get_class($exception);
$retryablePatterns = [
'NetworkException',
'TimeoutException',
'ConnectionException',
'TemporaryException',
];
foreach ($retryablePatterns as $pattern) {
if (str_contains($className, $pattern)) {
return true;
}
}
return false;
}
}
/**
* Marker interface for retryable exceptions
*/
interface RetryableException
{
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Recovery;
/**
* Retry Strategy
*
* Enum-like class for retry strategies.
*/
enum RetryStrategy: string
{
case EXPONENTIAL_BACKOFF = 'exponential_backoff';
case LINEAR = 'linear';
case FIXED = 'fixed';
/**
* Calculate delay for retry attempt
*
* @param int $attemptNumber Attempt number (1-based)
* @param int $baseDelayMs Base delay in milliseconds
* @return int Delay in milliseconds
*/
public function calculateDelay(int $attemptNumber, int $baseDelayMs = 1000): int
{
return match ($this) {
self::EXPONENTIAL_BACKOFF => (int) ($baseDelayMs * (2 ** ($attemptNumber - 1))),
self::LINEAR => $baseDelayMs * $attemptNumber,
self::FIXED => $baseDelayMs,
};
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderers;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\Terminal\PhpStormDetector;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\ErrorRenderer;
use App\Framework\ExceptionHandling\ValueObjects\StackTrace;
/**
* CLI Error Renderer
*
* Renders exceptions to console with colored output
*/
final readonly class ConsoleErrorRenderer implements ErrorRenderer
{
public function __construct(
private ConsoleOutput $output
) {}
public function canRender(\Throwable $exception): bool
{
// Can render any exception in CLI context
return PHP_SAPI === 'cli';
}
/**
* Render exception to console (stderr)
*
* All error output is written to stderr to separate it from normal console output.
*
* @param \Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional context provider
* @return mixed Returns null for CLI context
*/
public function render(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): mixed {
$this->output->writeErrorLine(
"❌ Uncaught " . get_class($exception) . ": " . $exception->getMessage(),
ConsoleColor::BRIGHT_RED
);
// Format file path with clickable link if PhpStorm is detected
$filePath = $exception->getFile();
$line = $exception->getLine();
$fileDisplay = $this->formatFileLink($filePath, $line);
$this->output->writeErrorLine(
" File: " . $fileDisplay,
ConsoleColor::RED
);
if ($exception->getPrevious()) {
$this->output->writeErrorLine(
" Caused by: " . $exception->getPrevious()->getMessage(),
ConsoleColor::YELLOW
);
}
$this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY);
$stackTrace = StackTrace::fromThrowable($exception);
foreach ($stackTrace->getItems() as $index => $item) {
$formattedLine = $this->formatStackTraceLine($index, $item);
$this->output->writeErrorLine(" " . $formattedLine, ConsoleColor::GRAY);
}
// Context information if available
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null && $context->operation !== null) {
$this->output->writeErrorLine(
" Operation: " . $context->operation,
ConsoleColor::CYAN
);
}
}
}
/**
* Check if running in PhpStorm terminal
*
* Note: This is called multiple times but the check is very fast,
* so caching is not necessary for readonly classes.
*/
private function isPhpStorm(): bool
{
return $this->output->getCapabilities()->isPhpStorm();
}
/**
* Format file path with clickable link for PhpStorm terminals
*/
private function formatFileLink(string $filePath, int $line): string
{
if (!$this->isPhpStorm()) {
return $filePath . ':' . $line;
}
$linkFormatter = $this->output->getLinkFormatter();
$relativePath = PhpStormDetector::getRelativePath($filePath);
return $linkFormatter->createFileLinkWithLine($filePath, $line, $relativePath . ':' . $line);
}
/**
* Format stack trace line with clickable file links
*/
private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item): string
{
$baseFormat = sprintf('#%d %s', $index, $item->formatForDisplay());
// If PhpStorm is detected, replace file:line with clickable link
if (!$this->isPhpStorm()) {
return $baseFormat;
}
// Build formatted line with link directly instead of using regex replacement
// This is more robust than regex replacement
$linkFormatter = $this->output->getLinkFormatter();
$relativePath = PhpStormDetector::getRelativePath($item->file);
$fileLink = $linkFormatter->createFileLinkWithLine($item->file, $item->line, $relativePath . ':' . $item->line);
// Replace the file:line part in the formatted string
// Use the short file path that's actually in the formatted string
$shortFile = $item->getShortFile();
$fileLocation = $shortFile . ':' . $item->line;
// Find and replace the file location in the base format
$position = strpos($baseFormat, $fileLocation);
if ($position !== false) {
return substr_replace($baseFormat, $fileLink, $position, strlen($fileLocation));
}
// Fallback: if exact match not found, return base format
return $baseFormat;
}
}

View File

@@ -5,32 +5,52 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Renderers;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Http\Response;
use App\Framework\ExceptionHandling\ErrorRenderer;
use App\Framework\ExceptionHandling\Translation\ExceptionMessageTranslator;
use App\Framework\ExceptionHandling\ValueObjects\StackTrace;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\SyntaxHighlighter\FileHighlighter;
use App\Framework\View\Engine;
use App\Framework\View\ProcessingMode;
use App\Framework\View\RenderContext;
/**
* HTTP Response factory for API and HTML error pages
* HTTP Response renderer for API and HTML error pages
*
* Extracts Response generation logic from ErrorKernel for reuse
* in middleware recovery patterns.
* Uses Template System for HTML rendering and creates JSON responses for API requests.
* Extracts Response generation logic from ErrorKernel for reuse in middleware recovery patterns.
*/
final readonly class ResponseErrorRenderer
final readonly class ResponseErrorRenderer implements ErrorRenderer
{
public function __construct(
private bool $isDebugMode = false
private Engine $engine,
private bool $isDebugMode = false,
private ?ExceptionMessageTranslator $messageTranslator = null
) {}
/**
* Create HTTP Response from exception
* Check if this renderer can handle the exception
*/
public function canRender(\Throwable $exception): bool
{
// Can render any exception in HTTP context
return PHP_SAPI !== 'cli';
}
/**
* Render exception to HTTP Response
*
* @param \Throwable $exception Exception to render
* @param ExceptionContextProvider|null $contextProvider Optional context provider
* @return Response HTTP Response object
* @return HttpResponse HTTP Response object
*/
public function createResponse(
public function render(
\Throwable $exception,
?ExceptionContextProvider $contextProvider = null
): Response {
): HttpResponse {
// Determine if API or HTML response needed
$isApiRequest = $this->isApiRequest();
@@ -47,24 +67,38 @@ final readonly class ResponseErrorRenderer
private function createApiResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
): HttpResponse {
$statusCode = $this->getHttpStatusCode($exception);
// Get user-friendly message if translator is available
$context = $contextProvider?->get($exception);
$userMessage = $this->messageTranslator?->translate($exception, $context)
?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage(
message: $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.'
);
$errorData = [
'error' => [
'message' => $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.',
'type' => $this->isDebugMode ? get_class($exception) : 'ServerError',
'message' => $userMessage->message,
'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError',
'code' => $exception->getCode(),
]
];
if ($userMessage->title !== null) {
$errorData['error']['title'] = $userMessage->title;
}
if ($userMessage->helpText !== null) {
$errorData['error']['help'] = $userMessage->helpText;
}
// Add debug information if enabled
if ($this->isDebugMode) {
$errorData['error']['file'] = $exception->getFile();
$errorData['error']['line'] = $exception->getLine();
$errorData['error']['trace'] = $this->formatStackTrace($exception);
$errorData['error']['trace'] = StackTrace::fromThrowable($exception)->toArray();
// Add context from WeakMap if available
if ($contextProvider !== null) {
@@ -82,53 +116,161 @@ final readonly class ResponseErrorRenderer
$body = json_encode($errorData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return new Response(
return new HttpResponse(
status: Status::from($statusCode),
body: $body,
headers: [
headers: new Headers([
'Content-Type' => 'application/json',
'X-Content-Type-Options' => 'nosniff',
]
])
);
}
/**
* Create HTML error page response
* Create HTML error page response using Template System
*/
private function createHtmlResponse(
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): Response {
): HttpResponse {
$statusCode = $this->getHttpStatusCode($exception);
$html = $this->generateErrorHtml(
$exception,
$contextProvider,
$statusCode
);
// Try to render using template system
$html = $this->renderWithTemplate($exception, $contextProvider, $statusCode);
return new Response(
// Fallback to simple HTML if template rendering fails
if ($html === null) {
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode);
}
return new HttpResponse(
status: Status::from($statusCode),
body: $html,
headers: [
headers: new Headers([
'Content-Type' => 'text/html; charset=utf-8',
'X-Content-Type-Options' => 'nosniff',
]
])
);
}
/**
* Generate HTML error page
* Render error page using Template System
*
* @return string|null Rendered HTML or null if template not found or rendering failed
*/
private function generateErrorHtml(
private function renderWithTemplate(
\Throwable $exception,
?ExceptionContextProvider $contextProvider,
int $statusCode
): ?string {
try {
// Determine template name based on status code
$templateName = $this->getTemplateName($statusCode);
// Prepare template data
$templateData = $this->prepareTemplateData($exception, $contextProvider, $statusCode);
// Create RenderContext
$renderContext = new RenderContext(
template: $templateName,
metaData: new MetaData($this->getErrorTitle($statusCode)),
data: $templateData,
processingMode: ProcessingMode::FULL
);
// Render template
return $this->engine->render($renderContext);
} catch (\Throwable $e) {
// Template not found or rendering failed - log error and return null for fallback
error_log(sprintf(
'Failed to render error template "%s" for status %d: %s in %s:%d',
$templateName ?? 'unknown',
$statusCode,
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
// Return null to trigger fallback HTML generation
return null;
}
}
/**
* Get template name based on status code
*/
private function getTemplateName(int $statusCode): string
{
return match ($statusCode) {
404 => 'errors/404',
500 => 'errors/500',
default => 'errors/error',
};
}
/**
* Prepare template data for rendering
*
* @return array<string, mixed>
*/
private function prepareTemplateData(
\Throwable $exception,
?ExceptionContextProvider $contextProvider,
int $statusCode
): array {
$data = [
'statusCode' => $statusCode,
'title' => $this->getErrorTitle($statusCode),
'message' => $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.',
'exceptionClass' => $this->getShortClassName(get_class($exception)),
'isDebugMode' => $this->isDebugMode,
];
// Add debug information if enabled
if ($this->isDebugMode) {
$stackTrace = StackTrace::fromThrowable($exception);
$data['debug'] = [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $stackTrace->formatForHtml(),
];
// Add context from WeakMap if available
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null) {
$data['context'] = [
'operation' => $context->operation,
'component' => $context->component,
'request_id' => $context->requestId,
'occurred_at' => $context->occurredAt?->format('Y-m-d H:i:s'),
];
}
}
}
return $data;
}
/**
* Generate fallback HTML error page (when template not found)
*/
private function generateFallbackHtml(
\Throwable $exception,
?ExceptionContextProvider $contextProvider,
int $statusCode
): string {
$title = $this->getErrorTitle($statusCode);
$message = $this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.';
// HTML-encode all variables for security
$title = htmlspecialchars($this->getErrorTitle($statusCode), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$message = htmlspecialchars(
$this->isDebugMode
? $exception->getMessage()
: 'An error occurred while processing your request.',
ENT_QUOTES | ENT_HTML5,
'UTF-8'
);
$debugInfo = '';
if ($this->isDebugMode) {
@@ -211,32 +353,42 @@ HTML;
\Throwable $exception,
?ExceptionContextProvider $contextProvider
): string {
$exceptionClass = get_class($exception);
$file = $exception->getFile();
// HTML-encode all debug information for security
$exceptionClass = htmlspecialchars($this->getShortClassName(get_class($exception)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$file = htmlspecialchars($exception->getFile(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$line = $exception->getLine();
$trace = $this->formatStackTrace($exception);
$stackTrace = StackTrace::fromThrowable($exception);
$trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml
$contextHtml = '';
if ($contextProvider !== null) {
$context = $contextProvider->get($exception);
if ($context !== null) {
$operation = htmlspecialchars($context->operation ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
$component = htmlspecialchars($context->component ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
$requestId = htmlspecialchars($context->requestId ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
$occurredAt = $context->occurredAt?->format('Y-m-d H:i:s') ?? '';
$contextHtml = <<<HTML
<div class="context-item">
<span class="context-label">Operation:</span> {$context->operation}
<span class="context-label">Operation:</span> {$operation}
</div>
<div class="context-item">
<span class="context-label">Component:</span> {$context->component}
<span class="context-label">Component:</span> {$component}
</div>
<div class="context-item">
<span class="context-label">Request ID:</span> {$context->requestId}
<span class="context-label">Request ID:</span> {$requestId}
</div>
<div class="context-item">
<span class="context-label">Occurred At:</span> {$context->occurredAt?->format('Y-m-d H:i:s')}
<span class="context-label">Occurred At:</span> {$occurredAt}
</div>
HTML;
}
}
// Code-Ausschnitt für die Exception-Zeile
$codeSnippet = $this->getCodeSnippet($exception->getFile(), $line);
return <<<HTML
<div class="debug-info">
<h3>Debug Information</h3>
@@ -247,6 +399,7 @@ HTML;
<span class="context-label">File:</span> {$file}:{$line}
</div>
{$contextHtml}
{$codeSnippet}
<h4>Stack Trace:</h4>
<pre>{$trace}</pre>
</div>
@@ -319,18 +472,35 @@ HTML;
}
/**
* Format stack trace for display
* Gibt Klassennamen ohne Namespace zurück
*/
private function formatStackTrace(\Throwable $exception): string
private function getShortClassName(string $fullClassName): string
{
$trace = $exception->getTraceAsString();
$parts = explode('\\', $fullClassName);
return end($parts);
}
// Limit trace depth in production
if (!$this->isDebugMode) {
$lines = explode("\n", $trace);
$trace = implode("\n", array_slice($lines, 0, 5));
/**
* Gibt Code-Ausschnitt für die Exception-Zeile zurück
*/
private function getCodeSnippet(string $file, int $line): string
{
if (!file_exists($file)) {
return '';
}
return htmlspecialchars($trace, ENT_QUOTES, 'UTF-8');
try {
$fileHighlighter = new FileHighlighter();
$startLine = max(0, $line - 5); // 5 Zeilen vor der Exception
$range = 11; // 5 vor + 1 Exception + 5 nach = 11 Zeilen
// FileHighlighter gibt bereits HTML mit Syntax-Highlighting zurück
$highlightedCode = $fileHighlighter($file, $startLine, $range, $line);
return '<h4>Code Context:</h4>' . $highlightedCode;
} catch (\Throwable $e) {
// Bei Fehler beim Lesen der Datei, ignoriere Code-Ausschnitt
return '';
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Reporter;
/**
* Registry for multiple exception reporters
*
* Allows registering multiple Reporter instances that will all receive
* exceptions when report() is called. Failures in one reporter do not
* prevent other reporters from being called (resilient design).
*/
final readonly class ReporterRegistry implements Reporter
{
/**
* @param Reporter[] $reporters Variadic list of reporter instances
*/
private array $reporters;
public function __construct(
Reporter ...$reporters
) {
// Variadic parameters need manual assignment
$this->reporters = $reporters;
}
/**
* Report exception to all registered reporters
*
* Each reporter is called independently. If one reporter fails,
* the others will still be called (resilient error handling).
*
* @param \Throwable $e Exception to report
*/
public function report(\Throwable $e): void
{
foreach ($this->reporters as $reporter) {
try {
$reporter->report($e);
} catch (\Throwable $reportError) {
// Silently continue - one reporter failure shouldn't stop others
// In production, you might want to log this, but we don't want
// to create a logging loop or dependency on Logger here
continue;
}
}
}
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Serialization;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Logging\ValueObjects\ExceptionContext as LoggingExceptionContext;
use Throwable;
/**
* Exception Serializer
*
* Provides standardized serialization of exceptions with context for:
* - JSON serialization
* - Structured log format
* - Storage format
*
* Integrates with ExceptionContextProvider to include external context.
*/
final readonly class ExceptionSerializer
{
public function __construct(
private ?ExceptionContextProvider $contextProvider = null
) {
}
/**
* Serialize exception to JSON
*
* @param Throwable $exception
* @param array<string, mixed> $options Serialization options
* @return string JSON string
*/
public function toJson(Throwable $exception, array $options = []): string
{
$data = $this->toArray($exception, $options);
return json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Serialize exception to array
*
* @param Throwable $exception
* @param array<string, mixed> $options Serialization options
* @return array<string, mixed>
*/
public function toArray(Throwable $exception, array $options = []): array
{
$includeStackTrace = $options['include_stack_trace'] ?? true;
$includePrevious = $options['include_previous'] ?? true;
$includeContext = $options['include_context'] ?? true;
$compact = $options['compact'] ?? false;
$data = [
'exception' => $this->serializeException($exception, $includeStackTrace, $includePrevious, $compact),
];
// Add external context if available
if ($includeContext && $this->contextProvider !== null) {
$context = $this->contextProvider->get($exception);
if ($context !== null) {
$data['context'] = $this->serializeContext($context, $compact);
}
}
// Add metadata
$data['timestamp'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
$data['serializer_version'] = '1.0';
return $data;
}
/**
* Serialize exception to structured log format
*
* Optimized for logging systems (e.g., ELK, Splunk).
*
* @param Throwable $exception
* @return array<string, mixed>
*/
public function toLogFormat(Throwable $exception): array
{
$context = $this->contextProvider?->get($exception);
$logData = [
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
];
// Add context data if available
if ($context !== null) {
$logData = array_merge($logData, [
'operation' => $context->operation,
'component' => $context->component,
'user_id' => $context->userId,
'request_id' => $this->serializeValue($context->requestId),
'session_id' => $this->serializeValue($context->sessionId),
'client_ip' => $this->serializeValue($context->clientIp),
'user_agent' => $this->serializeValue($context->userAgent),
'tags' => $context->tags,
'auditable' => $context->auditable,
'audit_level' => $context->auditLevel,
]);
// Add domain data
if (!empty($context->data)) {
$logData['domain_data'] = $context->data;
}
// Add metadata
if (!empty($context->metadata)) {
$logData['metadata'] = $context->metadata;
}
}
// Add stack trace (limited depth for logs)
$logData['stack_trace'] = $this->serializeStackTrace($exception, maxDepth: 10);
// Add previous exception if available
if ($exception->getPrevious() !== null) {
$logData['previous_exception'] = [
'class' => get_class($exception->getPrevious()),
'message' => $exception->getPrevious()->getMessage(),
'code' => $exception->getPrevious()->getCode(),
];
}
return $logData;
}
/**
* Serialize exception to storage format
*
* Optimized for database storage (compact, indexed fields).
*
* @param Throwable $exception
* @return array<string, mixed>
*/
public function toStorageFormat(Throwable $exception): array
{
$context = $this->contextProvider?->get($exception);
$storageData = [
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'occurred_at' => $context?->occurredAt->format(\DateTimeInterface::ATOM) ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
];
// Add context data (indexed fields)
if ($context !== null) {
$storageData = array_merge($storageData, [
'operation' => $context->operation,
'component' => $context->component,
'user_id' => $context->userId,
'request_id' => $this->serializeValue($context->requestId),
'session_id' => $this->serializeValue($context->sessionId),
'client_ip' => $this->serializeValue($context->clientIp),
'user_agent' => $this->serializeValue($context->userAgent),
'tags' => json_encode($context->tags, JSON_THROW_ON_ERROR),
'auditable' => $context->auditable,
'audit_level' => $context->auditLevel,
]);
// Serialize complex data as JSON
$storageData['data'] = json_encode($context->data, JSON_THROW_ON_ERROR);
$storageData['debug'] = json_encode($context->debug, JSON_THROW_ON_ERROR);
$storageData['metadata'] = json_encode($context->metadata, JSON_THROW_ON_ERROR);
}
// Serialize stack trace as JSON (full depth)
$storageData['stack_trace'] = json_encode($this->serializeStackTrace($exception), JSON_THROW_ON_ERROR);
// Serialize previous exception chain as JSON
if ($exception->getPrevious() !== null) {
$storageData['previous_exception'] = json_encode(
$this->serializeException($exception->getPrevious(), includeStackTrace: true, includePrevious: true, compact: false),
JSON_THROW_ON_ERROR
);
}
return $storageData;
}
/**
* Serialize exception details
*
* @param Throwable $exception
* @param bool $includeStackTrace
* @param bool $includePrevious
* @param bool $compact
* @return array<string, mixed>
*/
private function serializeException(
Throwable $exception,
bool $includeStackTrace,
bool $includePrevious,
bool $compact
): array {
$data = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];
if ($includeStackTrace) {
$data['stack_trace'] = $this->serializeStackTrace($exception, compact: $compact);
}
if ($includePrevious && $exception->getPrevious() !== null) {
$data['previous'] = $this->serializeException(
$exception->getPrevious(),
$includeStackTrace,
$includePrevious,
$compact
);
}
return $data;
}
/**
* Serialize stack trace
*
* @param Throwable $exception
* @param int|null $maxDepth Maximum depth (null = unlimited)
* @param bool $compact Compact format
* @return array<int, array<string, mixed>>
*/
private function serializeStackTrace(Throwable $exception, ?int $maxDepth = null, bool $compact = false): array
{
$trace = $exception->getTrace();
if ($maxDepth !== null) {
$trace = array_slice($trace, 0, $maxDepth);
}
$serialized = [];
foreach ($trace as $frame) {
if ($compact) {
$serialized[] = [
'file' => $frame['file'] ?? 'unknown',
'line' => $frame['line'] ?? 0,
'function' => $frame['function'] ?? 'unknown',
];
} else {
$serialized[] = [
'file' => $frame['file'] ?? 'unknown',
'line' => $frame['line'] ?? 0,
'function' => $frame['function'] ?? 'unknown',
'class' => $frame['class'] ?? null,
'type' => $frame['type'] ?? null,
'args' => $this->serializeArgs($frame['args'] ?? []),
];
}
}
return $serialized;
}
/**
* Serialize function arguments (safe for serialization)
*
* @param array<int, mixed> $args
* @return array<int, mixed>
*/
private function serializeArgs(array $args): array
{
$serialized = [];
foreach ($args as $arg) {
if (is_object($arg)) {
$serialized[] = [
'type' => get_class($arg),
'value' => method_exists($arg, '__toString') ? (string) $arg : '[object]',
];
} elseif (is_array($arg)) {
$serialized[] = '[array:' . count($arg) . ']';
} elseif (is_resource($arg)) {
$serialized[] = '[resource]';
} else {
$serialized[] = $arg;
}
}
return $serialized;
}
/**
* Serialize context data
*
* @param ExceptionContextData $context
* @param bool $compact
* @return array<string, mixed>
*/
private function serializeContext(ExceptionContextData $context, bool $compact): array
{
return $context->toArray();
}
/**
* Serialize value (Value Object or primitive)
*
* @param mixed $value
* @return string|null
*/
private function serializeValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return $value;
}
if (is_object($value) && method_exists($value, 'toString')) {
return $value->toString();
}
if (is_object($value) && property_exists($value, 'value')) {
return (string) $value->value;
}
return (string) $value;
}
}

View File

@@ -3,19 +3,19 @@ declare(strict_types=1);
namespace App\Framework\ExceptionHandling;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ExceptionHandling\Strategy\StrictErrorPolicy;
use Error;
final readonly class ShutdownHandler
final readonly class ShutdownHandler implements ShutdownHandlerInterface
{
private const array FATAL_TYPES = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
public function __construct(
private ErrorKernel $errorKernel
) {}
public function handle(): void
{
$last = error_get_last();
if (!$last || !$this->isFatalError($last['type'])) {
if (!$last || !FatalErrorTypes::isFatal($last['type'])) {
return;
}
@@ -29,10 +29,11 @@ final readonly class ShutdownHandler
try {
$ehm = new ErrorKernel();
$ehm->handle($error, ['file' => $file, 'line' => $line]);
if ($this->errorKernel !== null) {
$this->errorKernel->handle($error, ['file' => $file, 'line' => $line]);
}
} catch (\Throwable) {
// Ignore errors during shutdown handler execution
}
exit(255);
@@ -49,8 +50,4 @@ final readonly class ShutdownHandler
}
}
private function isFatalError(?int $type = null): bool
{
return in_array($type ?? 0, self::FATAL_TYPES, true);
}
}

View File

@@ -5,15 +5,22 @@ namespace App\Framework\ExceptionHandling\Strategy;
use App\Framework\Config\EnvironmentType;
use App\Framework\ExceptionHandling\ErrorHandlerStrategy;
use App\Framework\Logging\Logger;
final readonly class ErrorPolicyResolver
{
public function __construct(
private ?Logger $logger = null
) {}
public function resolve(EnvironmentType $environmentType): ErrorHandlerStrategy
{
return match(true) {
$environmentType->isProduction() => new StrictErrorPolicy(),
$environmentType->isDevelopment() => new StrictErrorPolicy(),
default => new StrictErrorPolicy(),
$environmentType->isDevelopment() => $this->logger !== null
? new LenientPolicy($this->logger)
: new StrictErrorPolicy(),
default => new SilentErrorPolicy(),
};
}
}

View File

@@ -24,7 +24,8 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
LogContext::withData(
[
'file' => $context->file,
'line' => $context->line]
'line' => $context->line?->toInt()
]
));
return ErrorDecision::HANDLED;
@@ -36,7 +37,7 @@ final readonly class LenientPolicy implements ErrorHandlerStrategy
0,
$context->severity,
$context->file,
$context->line
$context->line?->toInt()
);
}

View File

@@ -15,6 +15,12 @@ final readonly class StrictErrorPolicy implements ErrorHandlerStrategy
*/
public function handle(ErrorContext $context): ErrorDecision
{
throw new ErrorException($context->message, 0, $context->severity, $context->file, $context->line);
throw new ErrorException(
$context->message,
0,
$context->severity,
$context->file,
$context->line?->toInt()
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Translation;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use Throwable;
/**
* Exception Message Translator
*
* Translates technical exception messages to user-friendly messages.
*/
final readonly class ExceptionMessageTranslator
{
/**
* @param array<string, string|array{message: string, title?: string, help?: string}> $templates Message templates
* @param bool $isDebugMode Whether debug mode is enabled (shows technical messages)
*/
public function __construct(
private array $templates = [],
private bool $isDebugMode = false
) {
}
/**
* Translate exception to user-friendly message
*
* @param Throwable $exception Exception to translate
* @param ExceptionContextData|null $context Optional context for template variables
* @return UserFriendlyMessage User-friendly message
*/
public function translate(Throwable $exception, ?ExceptionContextData $context = null): UserFriendlyMessage
{
// In debug mode, return technical message
if ($this->isDebugMode) {
return new UserFriendlyMessage(
message: $exception->getMessage(),
technicalMessage: $exception->getMessage()
);
}
$exceptionClass = get_class($exception);
$template = $this->templates[$exceptionClass] ?? null;
if ($template === null) {
// Fallback to generic message
return $this->getGenericMessage($exception);
}
// Process template
if (is_string($template)) {
$message = $this->processTemplate($template, $exception, $context);
return UserFriendlyMessage::simple($message);
}
if (is_array($template)) {
$message = $this->processTemplate($template['message'] ?? '', $exception, $context);
$title = isset($template['title']) ? $this->processTemplate($template['title'], $exception, $context) : null;
$help = isset($template['help']) ? $this->processTemplate($template['help'], $exception, $context) : null;
return new UserFriendlyMessage(
message: $message,
title: $title,
helpText: $help,
technicalMessage: $exception->getMessage()
);
}
return $this->getGenericMessage($exception);
}
/**
* Process template with variables
*/
private function processTemplate(string $template, Throwable $exception, ?ExceptionContextData $context): string
{
$variables = [
'{exception_message}' => $exception->getMessage(),
'{exception_class}' => get_class($exception),
'{operation}' => $context?->operation ?? 'unknown operation',
'{component}' => $context?->component ?? 'unknown component',
];
return str_replace(array_keys($variables), array_values($variables), $template);
}
/**
* Get generic user-friendly message
*/
private function getGenericMessage(Throwable $exception): UserFriendlyMessage
{
return new UserFriendlyMessage(
message: 'An error occurred while processing your request. Please try again later.',
technicalMessage: $exception->getMessage()
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Translation;
/**
* User-Friendly Message
*
* Immutable value object for user-friendly exception messages.
*/
final readonly class UserFriendlyMessage
{
/**
* @param string $message User-friendly message
* @param string|null $title Optional title for the message
* @param string|null $helpText Optional help text or suggestions
* @param string|null $technicalMessage Original technical message (for debugging)
*/
public function __construct(
public string $message,
public ?string $title = null,
public ?string $helpText = null,
public ?string $technicalMessage = null
) {
}
/**
* Create simple message
*/
public static function simple(string $message): self
{
return new self($message);
}
/**
* Create message with title
*/
public static function withTitle(string $message, string $title): self
{
return new self($message, title: $title);
}
/**
* Create message with help text
*/
public static function withHelp(string $message, string $helpText): self
{
return new self($message, helpText: $helpText);
}
}

View File

@@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\ValueObjects;
/**
* Stack Item Value Object für strukturierte Stack Trace Darstellung
*
* Repräsentiert einen einzelnen Frame im Stack Trace mit formatierter Ausgabe.
* Entfernt Namespaces aus Klassennamen für bessere Lesbarkeit.
*/
final readonly class StackItem
{
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 StackItem aus debug_backtrace Array
*
* @param array<string, mixed> $frame
*/
public static function fromArray(array $frame): self
{
// Bereinige Args, um nicht-serialisierbare Objekte zu entfernen
$args = isset($frame['args']) ? self::sanitizeArgs($frame['args']) : [];
// Normalisiere Klassenname: Forward-Slashes zu Backslashes
$class = null;
if (isset($frame['class']) && is_string($frame['class'])) {
$class = str_replace('/', '\\', $frame['class']);
}
return new self(
file: $frame['file'] ?? 'unknown',
line: $frame['line'] ?? 0,
function: $frame['function'] ?? null,
class: $class,
type: $frame['type'] ?? null,
args: $args
);
}
/**
* Bereinigt Args, um nicht-serialisierbare Objekte (wie ReflectionClass) zu entfernen
*
* @param array<int, mixed> $args
* @return array<int, mixed>
*/
private static function sanitizeArgs(array $args): array
{
return array_map(
fn($arg) => self::sanitizeValue($arg),
$args
);
}
/**
* Bereinigt einzelnen Wert, entfernt nicht-serialisierbare Objekte
*/
private static function sanitizeValue(mixed $value): mixed
{
// Closures können nicht serialisiert werden
if ($value instanceof \Closure) {
try {
$reflection = new \ReflectionFunction($value);
$file = $reflection->getFileName();
$line = $reflection->getStartLine();
return sprintf('Closure(%s:%d)', basename($file), $line);
} catch (\Throwable) {
return 'Closure';
}
}
// Reflection-Objekte können nicht serialisiert werden
if (is_object($value)) {
$className = get_class($value);
if ($value instanceof \ReflectionClass
|| $value instanceof \ReflectionMethod
|| $value instanceof \ReflectionProperty
|| $value instanceof \ReflectionFunction
|| $value instanceof \ReflectionParameter
|| $value instanceof \ReflectionType
|| str_starts_with($className, 'Reflection')) {
return sprintf('ReflectionObject(%s)', $className);
}
// Anonyme Klassen können auch Probleme verursachen
if (str_contains($className, '@anonymous')) {
$parentClass = get_parent_class($value);
if ($parentClass !== false) {
return sprintf('Anonymous(%s)', $parentClass);
}
return 'Anonymous';
}
// Andere Objekte durch Klassenname ersetzen
return $className;
}
// Arrays rekursiv bereinigen
if (is_array($value)) {
return array_map(
fn($item) => self::sanitizeValue($item),
$value
);
}
// Primitives bleiben unverändert
return $value;
}
/**
* Gibt Klasse ohne Namespace zurück
* Behandelt anonyme Klassen, indem das Interface/Parent-Class extrahiert wird
*/
public function getShortClass(): ?string
{
if ($this->class === null) {
return null;
}
// Normalisiere Forward-Slashes zu Backslashes (falls vorhanden)
// Dies ist wichtig, da manche Systeme Forward-Slashes verwenden
$normalizedClass = str_replace('/', '\\', $this->class);
// Anonyme Klassen erkennen: Format ist z.B. "App\Framework\Http\Next@anonymous/path/to/file.php:line$hash"
// oder "Next@anonymous/path/to/file.php:line$hash"
if (str_contains($normalizedClass, '@anonymous')) {
// Extrahiere den Teil vor @anonymous (normalerweise das Interface mit vollständigem Namespace)
$match = preg_match('/^([^@]+)@anonymous/', $normalizedClass, $matches);
if ($match && isset($matches[1])) {
$interfaceName = $matches[1];
// Entferne Namespace: Spalte am Backslash und nimm den letzten Teil
$parts = explode('\\', $interfaceName);
$shortName = end($parts);
return $shortName . ' (anonymous)';
}
return 'Anonymous';
}
// Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace)
$parts = explode('\\', $normalizedClass);
$shortName = end($parts);
// Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben
// (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes)
if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) {
// Fallback: versuche es mit Forward-Slash
$parts = explode('/', $normalizedClass);
$shortName = end($parts);
}
return $shortName;
}
/**
* 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/ExceptionHandling/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 (ohne Namespace)
*/
public function getCall(): string
{
$parts = [];
if ($this->class !== null) {
$parts[] = $this->getShortClass();
}
if ($this->type !== null) {
$parts[] = $this->type;
}
if ($this->function !== null) {
$parts[] = $this->function . '()';
}
return implode('', $parts);
}
/**
* Formatiert Parameter für Display (kompakte Darstellung)
*/
public function formatParameters(): string
{
if (empty($this->args)) {
return '';
}
$formatted = [];
foreach ($this->args as $arg) {
$formatted[] = $this->formatParameterForDisplay($arg);
}
return implode(', ', $formatted);
}
/**
* Formatiert einzelnen Parameter für Display
*/
private function formatParameterForDisplay(mixed $value): string
{
return match (true) {
is_string($value) => $this->formatStringParameter($value),
is_int($value), is_float($value) => (string) $value,
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
is_array($value) => sprintf('array(%d)', count($value)),
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
is_object($value) => $this->formatObjectForDisplay($value),
default => get_debug_type($value),
};
}
/**
* Formatiert String-Parameter, entfernt Namespaces aus Klassennamen
*/
private function formatStringParameter(string $value): string
{
// Normalisiere Forward-Slashes zu Backslashes (falls vorhanden)
$normalizedValue = str_replace('/', '\\', $value);
// Wenn der String ein Klassename ist (vollständiger Namespace), entferne Namespace
if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) {
$parts = explode('\\', $normalizedValue);
$shortName = end($parts);
return sprintf("'%s'", $shortName);
}
// Prüfe, ob der String ein Namespace-Format hat (z.B. "App\Framework\Performance\PerformanceCategory")
// Auch wenn die Klasse nicht existiert, entferne Namespace
// Pattern: Beginnt mit Großbuchstaben, enthält Backslashes oder Forward-Slashes, endet mit Klassennamen
if (preg_match('/^[A-Z][a-zA-Z0-9_\\\\\/]+$/', $normalizedValue) && (str_contains($normalizedValue, '\\') || str_contains($value, '/'))) {
$parts = explode('\\', $normalizedValue);
// Nur wenn es mehrere Teile gibt (Namespace vorhanden)
if (count($parts) > 1) {
$shortName = end($parts);
return sprintf("'%s'", $shortName);
}
}
// Closure-String-Format: "Closure(RouteDispatcher.php:77)" oder "Closure(/full/path/RouteDispatcher.php:77)"
if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) {
$file = basename($matches[1]);
$line = $matches[2];
return sprintf("Closure(%s:%s)", $file, $line);
}
// Lange Strings kürzen
if (strlen($value) > 50) {
return sprintf("'%s...'", substr($value, 0, 50));
}
return sprintf("'%s'", $value);
}
/**
* Formatiert Objekt für Display (kompakte Darstellung)
*/
private function formatObjectForDisplay(object $value): string
{
$className = get_class($value);
// Entferne Namespace für bessere Lesbarkeit
$parts = explode('\\', $className);
$shortName = end($parts);
// Anonyme Klassen
if (str_contains($className, '@anonymous')) {
$match = preg_match('/^([^@]+)@anonymous/', $className, $matches);
if ($match && isset($matches[1])) {
$interfaceName = $matches[1];
$interfaceParts = explode('\\', $interfaceName);
$shortInterface = end($interfaceParts);
return $shortInterface . ' (anonymous)';
}
return 'Anonymous';
}
return $shortName;
}
/**
* Formatiert Funktionsnamen, entfernt Namespaces und verbessert Closure-Darstellung
*/
private function formatFunctionName(?string $function): string
{
if ($function === null) {
return '';
}
// Closures haben Format: {closure:Namespace\Class::method():line} oder {closure:Namespace/Class::method():line}
if (preg_match('/\{closure:([^}]+)\}/', $function, $matches)) {
$closureInfo = $matches[1];
// Normalisiere Forward-Slashes zu Backslashes
$closureInfo = str_replace('/', '\\', $closureInfo);
// Parse: App\Framework\Router\RouteDispatcher::executeController():77
if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) {
$fullClass = $closureMatches[1];
$method = $closureMatches[2];
$line = $closureMatches[3];
// Entferne Namespace
$classParts = explode('\\', $fullClass);
$shortClass = end($classParts);
return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line);
}
// Fallback: einfach Namespaces entfernen
$closureInfo = preg_replace_callback(
'/([A-Z][a-zA-Z0-9_\\\\]*)/',
fn($m) => $this->removeNamespaceFromClass($m[0]),
$closureInfo
);
return sprintf('{closure:%s}', $closureInfo);
}
return $function;
}
/**
* Entfernt Namespace aus Klassennamen in Strings
*/
private function removeNamespaceFromClass(string $classString): string
{
// Normalisiere Forward-Slashes zu Backslashes
$normalized = str_replace('/', '\\', $classString);
$parts = explode('\\', $normalized);
return end($parts);
}
/**
* Formatiert für Display (HTML/Console)
* Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line
*/
public function formatForDisplay(): string
{
$shortFile = $this->getShortFile();
$location = sprintf('%s:%d', $shortFile, $this->line);
$params = $this->formatParameters();
// Wenn Klasse vorhanden
if ($this->class !== null && $this->function !== null) {
$className = $this->getShortClass();
$methodName = $this->formatFunctionName($this->function);
$separator = $this->type === '::' ? '::' : '->';
$paramsStr = $params !== '' ? $params : '';
return sprintf('%s%s%s(%s) in %s', $className, $separator, $methodName, $paramsStr, $location);
}
// Wenn nur Funktion vorhanden
if ($this->function !== null) {
$methodName = $this->formatFunctionName($this->function);
$paramsStr = $params !== '' ? $params : '';
return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location);
}
// Wenn weder Klasse noch Funktion
return $location;
}
/**
* Konvertiert zu Array für JSON-Serialisierung
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$data = [
'file' => $this->getShortFile(),
'full_file' => $this->file,
'line' => $this->line,
];
if ($this->function !== null) {
$data['function'] = $this->function;
}
if ($this->class !== null) {
$data['class'] = $this->getShortClass();
$data['full_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 Ausgabe
*
* @return array<int, mixed>
*/
private function serializeArgs(): array
{
return array_map(
fn($arg) => $this->formatValueForOutput($arg),
$this->args
);
}
/**
* Formatiert Wert für Ausgabe (kompaktere Darstellung)
*/
private function formatValueForOutput(mixed $value): mixed
{
return match (true) {
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,
};
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\ValueObjects;
use IteratorAggregate;
use Countable;
use Throwable;
/**
* Stack Trace Collection für strukturierte Stack Trace Darstellung
*
* Enthält eine Sammlung von StackItems und bietet Formatierungsmethoden
* für HTML, Console und JSON-Ausgabe.
*/
final readonly class StackTrace implements IteratorAggregate, Countable
{
/**
* @param StackItem[] $items
*/
public function __construct(
private array $items
) {
}
/**
* Erstellt StackTrace aus Throwable
*/
public static function fromThrowable(Throwable $exception): self
{
$items = [];
$trace = $exception->getTrace();
foreach ($trace as $frame) {
$items[] = StackItem::fromArray($frame);
}
return new self($items);
}
/**
* Begrenzt Anzahl der Frames
*/
public function limit(int $maxFrames): self
{
return new self(array_slice($this->items, 0, $maxFrames));
}
/**
* Formatiert für HTML-Ausgabe
*/
public function formatForHtml(): string
{
$lines = [];
foreach ($this->items as $index => $item) {
$formatted = sprintf(
'#%d %s',
$index,
htmlspecialchars($item->formatForDisplay(), ENT_QUOTES | ENT_HTML5, 'UTF-8')
);
$lines[] = $formatted;
}
return implode("\n", $lines);
}
/**
* Formatiert für Console-Ausgabe
*/
public function formatForConsole(): string
{
$lines = [];
foreach ($this->items as $index => $item) {
$lines[] = sprintf(
'#%d %s',
$index,
$item->formatForDisplay()
);
}
return implode("\n", $lines);
}
/**
* Konvertiert zu Array für JSON-Serialisierung
*
* @return array<int, array<string, mixed>>
*/
public function toArray(): array
{
return array_map(
fn(StackItem $item) => $item->toArray(),
$this->items
);
}
/**
* Gibt alle StackItems zurück
*
* @return StackItem[]
*/
public function getItems(): array
{
return $this->items;
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->items);
}
public function count(): int
{
return count($this->items);
}
}