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
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:
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !== [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
309
src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php
Normal file
309
src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
35
src/Framework/ExceptionHandling/BasicErrorHandler.php
Normal file
35
src/Framework/ExceptionHandling/BasicErrorHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
54
src/Framework/ExceptionHandling/BasicShutdownHandler.php
Normal file
54
src/Framework/ExceptionHandling/BasicShutdownHandler.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
{}
|
||||
) {}
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,5 +4,10 @@ namespace App\Framework\ExceptionHandling;
|
||||
|
||||
interface ErrorHandlerInterface
|
||||
{
|
||||
|
||||
public function handle(
|
||||
int $severity,
|
||||
string $message,
|
||||
?string $file = null,
|
||||
?int $line = null,
|
||||
): bool;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
88
src/Framework/ExceptionHandling/ErrorSeverityType.php
Normal file
88
src/Framework/ExceptionHandling/ErrorSeverityType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(...));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
123
src/Framework/ExceptionHandling/ExceptionHandlingInitializer.php
Normal file
123
src/Framework/ExceptionHandling/ExceptionHandlingInitializer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
30
src/Framework/ExceptionHandling/FatalErrorTypes.php
Normal file
30
src/Framework/ExceptionHandling/FatalErrorTypes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/Framework/ExceptionHandling/Metrics/ExceptionMetrics.php
Normal file
43
src/Framework/ExceptionHandling/Metrics/ExceptionMetrics.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
34
src/Framework/ExceptionHandling/Recovery/RetryStrategy.php
Normal file
34
src/Framework/ExceptionHandling/Recovery/RetryStrategy.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
441
src/Framework/ExceptionHandling/ValueObjects/StackItem.php
Normal file
441
src/Framework/ExceptionHandling/ValueObjects/StackItem.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
118
src/Framework/ExceptionHandling/ValueObjects/StackTrace.php
Normal file
118
src/Framework/ExceptionHandling/ValueObjects/StackTrace.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user