Added a handler to send the client errors to the backend. Updated the launch settings to reload the fastapi app on file changes.

This commit is contained in:
2026-09-02 02:35:53 +00:00
parent cf680cd254
commit 6cf42d3641
9 changed files with 311 additions and 5 deletions
+5
View File
@@ -2,6 +2,7 @@ import { LayoutModule } from '@angular/cdk/layout';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
ApplicationConfig,
ErrorHandler,
LOCALE_ID,
importProvidersFrom,
isDevMode,
@@ -31,6 +32,8 @@ import { routes } from './app.routes';
import { AuthService } from './auth/auth.service';
import { authInterceptor } from './core/auth.interceptor';
import { delayInterceptor } from './core/delay-interceptor';
import { GlobalErrorHandler } from './core/global-error-handler';
import { httpErrorInterceptor } from './core/http-error.interceptor';
import { jwtInterceptor } from './core/jwt.interceptor';
import { refreshInterceptor } from './core/refresh.interceptor';
@@ -61,6 +64,7 @@ export const appConfig: ApplicationConfig = {
authInterceptor,
jwtInterceptor,
refreshInterceptor,
httpErrorInterceptor,
...(isDevMode() ? [delayInterceptor] : []),
]),
),
@@ -71,6 +75,7 @@ export const appConfig: ApplicationConfig = {
}),
withComponentInputBinding(),
),
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
{ provide: DateAdapter, useClass: LuxonDateAdapter, deps: [MAT_DATE_LOCALE] },
{ provide: MAT_DATE_FORMATS, useValue: dateFormat },
{ provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 3000 } },
+11
View File
@@ -0,0 +1,11 @@
export interface ClientErrorPayload {
message: string;
stack?: string | null;
url: string;
source?: 'unhandled_error' | 'http_resource' | 'service_error' | 'global_error';
status_code?: number | null;
endpoint?: string | null;
level?: 'error' | 'warning' | 'info';
component?: string | null;
extra?: Record<string, unknown> | null;
}
+121 -3
View File
@@ -1,10 +1,23 @@
import { Injectable } from '@angular/core';
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}`);
}
@@ -14,12 +27,117 @@ export class ErrorLoggerService {
* Let the app continue.
*
* @param operation - name of the operation that failed
* @param result - optional value to return as the observable result
*/
public handleError<T>(serviceName = 'error-logger', operation = 'operation') {
return (error: unknown): Observable<T> => {
ErrorLoggerService.log(serviceName, `${operation} failed: ${error}`);
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;
}
}
@@ -0,0 +1,26 @@
import { ErrorHandler, inject, Injectable } from '@angular/core';
import { ErrorLoggerService } from './error-logger.service';
@Injectable({
providedIn: 'root',
})
export class GlobalErrorHandler implements ErrorHandler {
private errorLogger = inject(ErrorLoggerService);
public handleError(error: unknown): void {
// Keep standard console.error for local debugging
console.error('Unhandled application error:', error);
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.errorLogger.sendToBackend({
message: `Uncaught Error: ${message}`,
stack: stack ?? null,
url: typeof window !== 'undefined' ? window.location.href : '',
source: 'unhandled_error',
level: 'error',
});
}
}
@@ -0,0 +1,40 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ErrorLoggerService } from './error-logger.service';
const IGNORED_URLS = ['/api/client-logs'];
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
const errorLogger = inject(ErrorLoggerService);
return next(req).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse) {
const isIgnored = IGNORED_URLS.some((url) => req.url.includes(url));
if (!isIgnored) {
const detail =
typeof error.error === 'object' && error.error !== null
? JSON.stringify(error.error)
: String(error.error || error.message);
const isServerError = error.status >= 500 || error.status === 0;
errorLogger.sendToBackend({
message: `HTTP ${error.status}: ${detail}`,
url: typeof window !== 'undefined' ? window.location.href : '',
source: 'http_resource',
status_code: error.status,
endpoint: req.url,
level: isServerError ? 'error' : 'warning',
extra: {
method: req.method,
},
});
}
}
return throwError(() => error);
}),
);
};