*/ private const EVENT_KEY_MAP = [ self::EVENT_WARN_ONCE => 'warn_once', self::EVENT_AJAX_ERROR => 'ajax_error', ]; /** @var array */ private const ALLOWED_META_KEYS = [ 'action', 'code', 'component', 'error_code', 'feature', 'http_method', 'http_status', 'location', 'module', 'page_request_id', 'request_path', 'source', 'status_text', ]; public function __construct( private readonly SystemAuditService $systemAuditService, private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway, private readonly RateLimiterService $rateLimiterService, private readonly SessionStoreInterface $sessionStore ) { } /** * @param array $input * @return array{accepted:bool, reason:string} */ public function ingest(array $input): array { if (!$this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled()) { return ['accepted' => false, 'reason' => 'disabled']; } $payload = $this->normalizePayload($input); if ($payload === null) { return ['accepted' => false, 'reason' => 'invalid']; } if (!$this->isAllowedEvent($payload['event_type'])) { return ['accepted' => false, 'reason' => 'event_not_allowed']; } if (!$this->shouldSample($this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate())) { return ['accepted' => false, 'reason' => 'sampled_out']; } $now = time(); $sessionState = $this->loadSessionState(); if ($this->applyBurstLimit($sessionState, $now)) { $this->saveSessionState($sessionState); return ['accepted' => false, 'reason' => 'burst_limited']; } $subject = $this->subjectKey(); $rateDecision = $this->rateLimiterService->hit( self::INGEST_RATE_SCOPE, $subject, self::INGEST_MAX_HITS, self::INGEST_WINDOW_SECONDS, self::INGEST_BLOCK_SECONDS ); if (!(bool) ($rateDecision['allowed'] ?? true)) { $this->saveSessionState($sessionState); return ['accepted' => false, 'reason' => 'rate_limited']; } if ($this->isDuplicateAndTouch($sessionState, $payload['fingerprint_hash'], $now)) { $this->saveSessionState($sessionState); return ['accepted' => false, 'reason' => 'duplicate']; } $this->saveSessionState($sessionState); $outcome = $payload['severity'] === 'error' ? SystemAuditOutcome::Failed->value : SystemAuditOutcome::Success->value; $recordId = $this->systemAuditService->record($payload['event_type'], $outcome, [ 'target_type' => 'frontend', 'metadata' => [ 'severity' => $payload['severity'], 'message' => $payload['message'], 'fingerprint_hash' => $payload['fingerprint_hash'], 'occurred_at' => $payload['occurred_at'], 'meta' => $payload['meta'], ], ]); if ($recordId === null) { return ['accepted' => false, 'reason' => 'record_failed']; } return ['accepted' => true, 'reason' => 'logged']; } /** * @param array $input * @return array{ * event_type:string, * severity:string, * message:string, * fingerprint_hash:string, * occurred_at:string, * meta: array * }|null */ private function normalizePayload(array $input): ?array { $eventType = $this->normalizeEventType((string) ($input['event_type'] ?? '')); if ($eventType === '') { return null; } $message = $this->sanitizeText((string) ($input['message'] ?? ''), self::MESSAGE_MAX_LENGTH); if ($message === '') { return null; } $meta = $this->normalizeMeta($input['meta'] ?? []); $route = trim((string) ($meta['location'] ?? '')); $fingerprint = $this->sanitizeFingerprint((string) ($input['fingerprint'] ?? '')); if ($fingerprint === '') { $fingerprint = $this->sanitizeFingerprint( 'v1_' . substr(hash('sha256', $eventType . '|' . $route . '|' . $message), 0, 24) ); } if ($fingerprint === '') { return null; } return [ 'event_type' => $eventType, 'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? ''), $eventType), 'message' => $message, 'fingerprint_hash' => RequestContext::hashValue($fingerprint), 'occurred_at' => $this->normalizeOccurredAt((string) ($input['occurred_at'] ?? '')), 'meta' => $meta, ]; } private function normalizeEventType(string $eventType): string { $eventType = strtolower(trim($eventType)); if ($eventType === '') { return ''; } if (str_starts_with($eventType, 'frontend.')) { return isset(self::EVENT_KEY_MAP[$eventType]) ? $eventType : ''; } foreach (self::EVENT_KEY_MAP as $name => $short) { if ($eventType === $short) { return $name; } } return ''; } private function normalizeSeverity(string $severity, string $eventType): string { $severity = strtolower(trim($severity)); if (in_array($severity, ['info', 'warning', 'error'], true)) { return $severity; } return $eventType === self::EVENT_AJAX_ERROR ? 'error' : 'warning'; } private function normalizeOccurredAt(string $occurredAt): string { $occurredAt = trim($occurredAt); if ($occurredAt !== '') { try { $date = new DateTimeImmutable($occurredAt); return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); } catch (\Throwable) { // Fall back to now below. } } return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'); } private function sanitizeFingerprint(string $value): string { $value = strtolower(trim($value)); if ($value === '') { return ''; } $value = preg_replace('/[^a-z0-9:_-]/', '', $value) ?? ''; if ($value === '') { return ''; } return substr($value, 0, self::FINGERPRINT_MAX_LENGTH); } /** * @return array */ private function normalizeMeta(mixed $rawMeta): array { $source = []; if (is_array($rawMeta)) { $source = $rawMeta; } elseif (is_string($rawMeta) && trim($rawMeta) !== '') { $decoded = json_decode($rawMeta, true); if (is_array($decoded)) { $source = $decoded; } } if ($source === []) { return []; } $meta = []; $count = 0; foreach ($source as $rawKey => $rawValue) { if ($count >= self::META_MAX_ENTRIES) { break; } $key = strtolower(trim((string) $rawKey)); if ($key === '' || !in_array($key, self::ALLOWED_META_KEYS, true)) { continue; } if ($key === 'location' || $key === 'request_path') { $normalizedPath = $this->sanitizePath((string) $rawValue); if ($normalizedPath === '') { continue; } $meta[$key] = $normalizedPath; $count++; continue; } if ($key === 'http_status') { $status = (int) $rawValue; if ($status < 100 || $status > 599) { continue; } $meta[$key] = $status; $count++; continue; } if ($key === 'page_request_id') { $requestId = strtolower(trim((string) $rawValue)); if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $requestId) !== 1) { continue; } $meta[$key] = $requestId; $count++; continue; } $value = $this->sanitizeText((string) $rawValue, self::META_VALUE_MAX_LENGTH); if ($value === '') { continue; } $meta[$key] = $value; $count++; } return $meta; } private function sanitizePath(string $value): string { $value = trim($value); if ($value === '') { return ''; } if (filter_var($value, FILTER_VALIDATE_URL)) { $parsedPath = parse_url($value, PHP_URL_PATH); $value = is_string($parsedPath) ? $parsedPath : ''; } $value = (string) preg_replace('/[?#].*/', '', $value); $value = preg_replace('/\/[0-9]{3,}(?=\/|$)/', '/:id', $value) ?? $value; $value = preg_replace( '/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i', ':uuid', $value ) ?? $value; $value = trim($value); if ($value === '') { return ''; } if (!str_starts_with($value, '/')) { $value = '/' . $value; } return substr($value, 0, 255); } private function sanitizeText(string $value, int $maxLength): string { $value = trim($value); if ($value === '') { return ''; } $value = preg_replace('/\s+/', ' ', $value) ?? $value; $value = preg_replace('/([?&][a-z0-9_.-]{1,64}=)[^&#\s]+/i', '$1[REDACTED]', $value) ?? $value; $value = preg_replace('/(^|\s)(token|access_token|refresh_token|id_token|authorization|api_key|secret)=([^\s]+)/i', '$1$2=[REDACTED]', $value) ?? $value; $value = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $value) ?? $value; $value = preg_replace( '/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i', '[UUID]', $value ) ?? $value; $value = preg_replace('/\b[a-f0-9]{24,}\b/i', '[TOKEN]', $value) ?? $value; return substr($value, 0, max(1, $maxLength)); } private function isAllowedEvent(string $eventType): bool { $eventKey = self::EVENT_KEY_MAP[$eventType] ?? null; if ($eventKey === null) { return false; } $allowed = $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents(); return in_array($eventKey, $allowed, true); } private function shouldSample(float $sampleRate): bool { if ($sampleRate <= 0.0) { return false; } if ($sampleRate >= 1.0) { return true; } try { $max = 10000; $threshold = (int) floor($sampleRate * $max); return random_int(1, $max) <= max(1, $threshold); } catch (\Throwable) { $threshold = (int) floor($sampleRate * 10000); return mt_rand(1, 10000) <= max(1, $threshold); } } private function subjectKey(): string { $session = $this->sessionStore->all(); $userId = (int) ($session['user']['id'] ?? 0); if ($userId > 0) { return 'user:' . $userId; } $sessionId = trim(session_id()); if ($sessionId !== '') { return 'session:' . $sessionId; } $requestContext = RequestContext::context(); $ip = trim((string) ($requestContext['ip'] ?? '')); if ($ip !== '') { return 'ip:' . RequestContext::hashValue($ip); } return 'anonymous'; } /** * @return array{dedupe: array, burst: array{window_start:int,count:int}} */ private function loadSessionState(): array { $rawState = $this->sessionStore->get(self::SESSION_STATE_KEY, []); if (!is_array($rawState)) { $rawState = []; } $dedupe = is_array($rawState['dedupe'] ?? null) ? $rawState['dedupe'] : []; $burst = is_array($rawState['burst'] ?? null) ? $rawState['burst'] : []; return [ 'dedupe' => $dedupe, 'burst' => [ 'window_start' => is_int($burst['window_start'] ?? null) ? (int) $burst['window_start'] : 0, 'count' => is_int($burst['count'] ?? null) ? max(0, (int) $burst['count']) : 0, ], ]; } /** * @param array{dedupe: array, burst: array{window_start:int,count:int}} $state */ private function saveSessionState(array $state): void { $this->sessionStore->set(self::SESSION_STATE_KEY, $state); } /** * @param array{dedupe: array, burst: array{window_start:int,count:int}} $state */ private function applyBurstLimit(array &$state, int $now): bool { $windowStart = $state['burst']['window_start']; $count = max(0, $state['burst']['count']); if ($windowStart <= 0 || ($now - $windowStart) >= self::FAST_DROP_WINDOW_SECONDS) { $windowStart = $now; $count = 0; } if ($count >= self::FAST_DROP_MAX_HITS) { $state['burst'] = [ 'window_start' => $windowStart, 'count' => $count, ]; return true; } $state['burst'] = [ 'window_start' => $windowStart, 'count' => $count + 1, ]; return false; } /** * @param array{dedupe: array, burst: array{window_start:int,count:int}} $state */ private function isDuplicateAndTouch(array &$state, string $fingerprintHash, int $now): bool { $seen = $state['dedupe']; $cutoff = $now - self::DEDUPE_WINDOW_SECONDS; foreach ($seen as $key => $timestamp) { if (!is_string($key)) { unset($seen[$key]); continue; } if (!is_int($timestamp) || $timestamp < $cutoff) { unset($seen[$key]); } } if (isset($seen[$fingerprintHash]) && (int) $seen[$fingerprintHash] >= $cutoff) { $seen[$fingerprintHash] = $now; $isDuplicate = true; } else { $seen[$fingerprintHash] = $now; $isDuplicate = false; } if (count($seen) > 120) { arsort($seen, SORT_NUMERIC); $seen = array_slice($seen, 0, 120, true); } $state['dedupe'] = $seen; return $isDuplicate; } }