- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
302 lines
10 KiB
PHP
302 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\ErrorAggregation;
|
|
|
|
use App\Framework\Exception\Core\ErrorSeverity;
|
|
use App\Framework\Exception\ErrorCode;
|
|
use App\Framework\Exception\ErrorHandlerContext;
|
|
use App\Framework\Ulid\Ulid;
|
|
|
|
/**
|
|
* Represents a single error event for aggregation and analysis
|
|
*/
|
|
final readonly class ErrorEvent
|
|
{
|
|
public function __construct(
|
|
public Ulid $id,
|
|
public string $service,
|
|
public string $component,
|
|
public string $operation,
|
|
public ErrorCode $errorCode,
|
|
public string $errorMessage,
|
|
public ErrorSeverity $severity,
|
|
public \DateTimeImmutable $occurredAt,
|
|
public array $context = [],
|
|
public array $metadata = [],
|
|
public ?string $requestId = null,
|
|
public ?string $userId = null,
|
|
public ?string $clientIp = null,
|
|
public bool $isSecurityEvent = false,
|
|
public ?string $stackTrace = null,
|
|
public ?string $userAgent = null,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Creates ErrorEvent from ErrorHandlerContext
|
|
*/
|
|
public static function fromErrorHandlerContext(ErrorHandlerContext $context, \App\Framework\DateTime\Clock $clock): self
|
|
{
|
|
return new self(
|
|
id: new Ulid($clock),
|
|
service: self::extractServiceName($context),
|
|
component: $context->exception->component ?? 'unknown',
|
|
operation: $context->exception->operation ?? 'unknown',
|
|
errorCode: self::extractErrorCode($context),
|
|
errorMessage: self::extractUserMessage($context),
|
|
severity: self::determineSeverity($context),
|
|
occurredAt: new \DateTimeImmutable(),
|
|
context: $context->exception->data,
|
|
metadata: $context->exception->metadata,
|
|
requestId: $context->request->requestId,
|
|
userId: $context->request->userId ?? null,
|
|
clientIp: $context->request->clientIp,
|
|
isSecurityEvent: $context->exception->metadata['security_event'] ?? false,
|
|
stackTrace: self::extractStackTrace($context),
|
|
userAgent: $context->request->userAgent,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Converts to array for storage/transmission
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'id' => (string) $this->id,
|
|
'service' => $this->service,
|
|
'component' => $this->component,
|
|
'operation' => $this->operation,
|
|
'error_code' => $this->errorCode->value,
|
|
'error_message' => $this->errorMessage,
|
|
'severity' => $this->severity->value,
|
|
'occurred_at' => $this->occurredAt->format('c'),
|
|
'context' => $this->context,
|
|
'metadata' => $this->metadata,
|
|
'request_id' => $this->requestId,
|
|
'user_id' => $this->userId,
|
|
'client_ip' => $this->clientIp,
|
|
'is_security_event' => $this->isSecurityEvent,
|
|
'stack_trace' => $this->stackTrace,
|
|
'user_agent' => $this->userAgent,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Creates from array (for deserialization)
|
|
*/
|
|
public static function fromArray(array $data): self
|
|
{
|
|
return new self(
|
|
id: Ulid::fromString($data['id']),
|
|
service: $data['service'],
|
|
component: $data['component'],
|
|
operation: $data['operation'],
|
|
errorCode: ErrorCode::from($data['error_code']),
|
|
errorMessage: $data['error_message'],
|
|
severity: ErrorSeverity::from($data['severity']),
|
|
occurredAt: new \DateTimeImmutable($data['occurred_at']),
|
|
context: $data['context'] ?? [],
|
|
metadata: $data['metadata'] ?? [],
|
|
requestId: $data['request_id'],
|
|
userId: $data['user_id'],
|
|
clientIp: $data['client_ip'],
|
|
isSecurityEvent: $data['is_security_event'] ?? false,
|
|
stackTrace: $data['stack_trace'],
|
|
userAgent: $data['user_agent'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets fingerprint for grouping similar errors
|
|
*/
|
|
public function getFingerprint(): string
|
|
{
|
|
$components = [
|
|
$this->service,
|
|
$this->component,
|
|
$this->operation,
|
|
$this->errorCode->value,
|
|
// Normalize error message to group similar errors
|
|
$this->normalizeErrorMessage($this->errorMessage),
|
|
];
|
|
|
|
return hash('sha256', implode('|', $components));
|
|
}
|
|
|
|
/**
|
|
* Checks if this error should trigger an alert
|
|
*/
|
|
public function shouldTriggerAlert(): bool
|
|
{
|
|
// Critical and error severity always trigger alerts
|
|
if (in_array($this->severity, [ErrorSeverity::CRITICAL, ErrorSeverity::ERROR])) {
|
|
return true;
|
|
}
|
|
|
|
// Security events always trigger alerts
|
|
if ($this->isSecurityEvent) {
|
|
return true;
|
|
}
|
|
|
|
// Check metadata for explicit alert requirement
|
|
return $this->metadata['requires_alert'] ?? false;
|
|
}
|
|
|
|
/**
|
|
* Gets alert urgency level
|
|
*/
|
|
public function getAlertUrgency(): AlertUrgency
|
|
{
|
|
if ($this->severity === ErrorSeverity::CRITICAL || $this->isSecurityEvent) {
|
|
return AlertUrgency::URGENT;
|
|
}
|
|
|
|
if ($this->severity === ErrorSeverity::ERROR) {
|
|
return AlertUrgency::HIGH;
|
|
}
|
|
|
|
if ($this->severity === ErrorSeverity::WARNING) {
|
|
return AlertUrgency::MEDIUM;
|
|
}
|
|
|
|
return AlertUrgency::LOW;
|
|
}
|
|
|
|
private static function extractServiceName(ErrorHandlerContext $context): string
|
|
{
|
|
// Try to extract service from request URI
|
|
$uri = $context->request->requestUri;
|
|
|
|
if ($uri !== null) {
|
|
if (str_starts_with($uri, '/api/')) {
|
|
return 'api';
|
|
}
|
|
|
|
if (str_starts_with($uri, '/admin/')) {
|
|
return 'admin';
|
|
}
|
|
}
|
|
|
|
// Extract from component if available
|
|
if ($context->exception->component) {
|
|
return strtolower($context->exception->component);
|
|
}
|
|
|
|
return 'web';
|
|
}
|
|
|
|
private static function extractErrorCode(ErrorHandlerContext $context): ErrorCode
|
|
{
|
|
// Try to get ErrorCode from original exception if it's a FrameworkException
|
|
if (isset($context->exception->data['original_exception'])) {
|
|
$originalException = $context->exception->data['original_exception'];
|
|
if ($originalException instanceof \App\Framework\Exception\FrameworkException) {
|
|
$errorCode = $originalException->getErrorCode();
|
|
if ($errorCode !== null) {
|
|
return $errorCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: Use SystemErrorCode::RESOURCE_EXHAUSTED as generic error
|
|
return \App\Framework\Exception\Core\SystemErrorCode::RESOURCE_EXHAUSTED;
|
|
}
|
|
|
|
private static function extractUserMessage(ErrorHandlerContext $context): string
|
|
{
|
|
// Try user_message first
|
|
if (isset($context->exception->data['user_message'])) {
|
|
return $context->exception->data['user_message'];
|
|
}
|
|
|
|
// Try exception_message (stored by ErrorHandler)
|
|
if (isset($context->exception->data['exception_message'])) {
|
|
return $context->exception->data['exception_message'];
|
|
}
|
|
|
|
// Try to get from original_exception if it's a FrameworkException
|
|
if (isset($context->exception->data['original_exception'])) {
|
|
$originalException = $context->exception->data['original_exception'];
|
|
if ($originalException instanceof \App\Framework\Exception\FrameworkException) {
|
|
return $originalException->getMessage();
|
|
}
|
|
if ($originalException instanceof \Throwable) {
|
|
return $originalException->getMessage();
|
|
}
|
|
}
|
|
|
|
// Fallback to operation and component
|
|
$operation = $context->exception->operation ?? 'unknown_operation';
|
|
$component = $context->exception->component ?? 'unknown_component';
|
|
|
|
return "Error in {$component} during {$operation}";
|
|
}
|
|
|
|
private static function determineSeverity(ErrorHandlerContext $context): ErrorSeverity
|
|
{
|
|
// Security events are always critical
|
|
if ($context->exception->metadata['security_event'] ?? false) {
|
|
return ErrorSeverity::CRITICAL;
|
|
}
|
|
|
|
// Check explicit severity in metadata
|
|
if (isset($context->exception->metadata['severity'])) {
|
|
return ErrorSeverity::tryFrom($context->exception->metadata['severity']) ?? ErrorSeverity::ERROR;
|
|
}
|
|
|
|
// Get severity from ErrorCode if available
|
|
$errorCode = self::extractErrorCode($context);
|
|
if ($errorCode !== null) {
|
|
return $errorCode->getSeverity();
|
|
}
|
|
|
|
// Determine from HTTP status
|
|
$httpStatus = $context->metadata['http_status'] ?? 500;
|
|
|
|
return match (true) {
|
|
$httpStatus >= 500 => ErrorSeverity::ERROR,
|
|
$httpStatus >= 400 => ErrorSeverity::WARNING,
|
|
default => ErrorSeverity::INFO,
|
|
};
|
|
}
|
|
|
|
private static function extractStackTrace(ErrorHandlerContext $context): ?string
|
|
{
|
|
// Don't include stack traces for security events in production
|
|
if (($context->exception->metadata['security_event'] ?? false) && ! ($_ENV['APP_DEBUG'] ?? false)) {
|
|
return null;
|
|
}
|
|
|
|
// Extract from exception debug data
|
|
if (isset($context->exception->debug['stack_trace'])) {
|
|
return $context->exception->debug['stack_trace'];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function normalizeErrorMessage(string $message): string
|
|
{
|
|
// Remove specific details to group similar errors
|
|
$normalized = $message;
|
|
|
|
// Remove file paths
|
|
$normalized = preg_replace('#/[^\s]+#', '/path/to/file', $normalized);
|
|
|
|
// Remove specific IDs/numbers
|
|
$normalized = preg_replace('#\b\d+\b#', 'N', $normalized);
|
|
|
|
// Remove timestamps
|
|
$normalized = preg_replace('#\d{4}-\d{2}-\d{2}[\sT]\d{2}:\d{2}:\d{2}#', 'TIMESTAMP', $normalized);
|
|
|
|
// Remove ULIDs/UUIDs
|
|
$normalized = preg_replace('#[0-9A-HJ-NP-TV-Z]{26}#', 'ULID', $normalized);
|
|
$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);
|
|
|
|
return $normalized;
|
|
}
|
|
}
|