144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
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<string, number>();
|
|
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<T>(serviceName = 'error-logger', operation = 'operation') {
|
|
return (error: unknown): Observable<T> => {
|
|
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<string, string> = {
|
|
'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<string, unknown> | null): Record<string, unknown> | null {
|
|
if (!data || typeof data !== 'object') {
|
|
return data ?? null;
|
|
}
|
|
const clean: Record<string, unknown> = {};
|
|
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<string, unknown>);
|
|
} else {
|
|
clean[key] = value;
|
|
}
|
|
}
|
|
return clean;
|
|
}
|
|
}
|