import { inject, Injectable } from '@angular/core'; import { Observable, throwError } from 'rxjs'; import { AuthService } from '../auth/auth.service'; import { ClientErrorPayload } from './client-log'; const LOG_ENDPOINT = '/api/client-logs'; const DEDUPE_WINDOW_MS = 5000; const RATE_LIMIT_WINDOW_MS = 60000; const MAX_LOGS_PER_WINDOW = 10; const SENSITIVE_KEYS = new Set(['password', 'token', 'authorization', 'secret', 'credit_card']); @Injectable({ providedIn: 'root', }) export class ErrorLoggerService { private static dedupeCache = new Map(); private static logTimestamps: number[] = []; private authService = inject(AuthService, { optional: true }); public static log(serviceName = 'error-logger', message: string) { console.log(`${serviceName}Service: ${message}`); } /** * Handle Http operation that failed. * Let the app continue. * * @param operation - name of the operation that failed */ public handleError(serviceName = 'error-logger', operation = 'operation') { return (error: unknown): Observable => { const errorMsg = error instanceof Error ? error.message : String(error); const stack = error instanceof Error ? error.stack : undefined; ErrorLoggerService.log(serviceName, `${operation} failed: ${errorMsg}`); this.sendToBackend({ message: `${serviceName}: ${operation} failed: ${errorMsg}`, stack: stack ?? null, url: typeof window !== 'undefined' ? window.location.href : '', component: serviceName, source: 'service_error', level: 'error', }); return throwError(() => error); }; } /** * Dispatches a structured error payload to the backend with rate-limiting, * deduplication, recursion protection, and sensitive data scrubbing. */ public sendToBackend(payload: ClientErrorPayload): void { if (typeof window === 'undefined') { return; } // 1. Recursion Guard: Ignore logs about the logging endpoint itself if (payload.endpoint?.includes(LOG_ENDPOINT) || payload.url.includes(LOG_ENDPOINT)) { return; } const now = Date.now(); // 2. Deduplication Guard: Check if identical error was dispatched recently const fingerprint = `${payload.source || ''}:${payload.status_code || ''}:${payload.endpoint || ''}:${payload.message}:${payload.url}`; const lastSeen = ErrorLoggerService.dedupeCache.get(fingerprint); if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) { return; } ErrorLoggerService.dedupeCache.set(fingerprint, now); // Prune stale deduplication cache entries for (const [key, timestamp] of ErrorLoggerService.dedupeCache.entries()) { if (now - timestamp > DEDUPE_WINDOW_MS * 2) { ErrorLoggerService.dedupeCache.delete(key); } } // 3. Rate-Limiting Guard: Max X logs per window ErrorLoggerService.logTimestamps = ErrorLoggerService.logTimestamps.filter((ts) => now - ts < RATE_LIMIT_WINDOW_MS); if (ErrorLoggerService.logTimestamps.length >= MAX_LOGS_PER_WINDOW) { console.warn('Frontend error logging throttled: rate limit exceeded.'); return; } ErrorLoggerService.logTimestamps.push(now); // 4. Sanitize sensitive fields const sanitizedPayload: ClientErrorPayload = { ...payload, extra: this.sanitizeData(payload.extra), }; // 5. Send via fetch (with keepalive: true) or sendBeacon const headers: Record = { 'Content-Type': 'application/json', }; const token = this.authService?.user()?.access_token; if (token) { headers['Authorization'] = `Bearer ${token}`; } const bodyString = JSON.stringify(sanitizedPayload); try { if (typeof fetch !== 'undefined') { fetch(LOG_ENDPOINT, { method: 'POST', headers, body: bodyString, keepalive: true, }).catch((err) => { // Never re-log transport errors into the error service console.warn('Failed to send error log to backend:', err); }); } else if (typeof navigator !== 'undefined' && navigator.sendBeacon) { const blob = new Blob([bodyString], { type: 'application/json' }); navigator.sendBeacon(LOG_ENDPOINT, blob); } } catch (transportErr) { console.warn('Error during error dispatch transport:', transportErr); } } private sanitizeData(data?: Record | null): Record | null { if (!data || typeof data !== 'object') { return data ?? null; } const clean: Record = {}; for (const [key, value] of Object.entries(data)) { if (SENSITIVE_KEYS.has(key.toLowerCase())) { clean[key] = '[REDACTED]'; } else if (value && typeof value === 'object' && !Array.isArray(value)) { clean[key] = this.sanitizeData(value as Record); } else { clean[key] = value; } } return clean; } }