From 6cf42d36415433f0dd62f9a9e8a39a1cdefc87a3 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Wed, 2 Sep 2026 02:35:53 +0000 Subject: [PATCH] Added a handler to send the client errors to the backend. Updated the launch settings to reload the fastapi app on file changes. --- .vscode/launch.json | 21 ++- barker/barker/main.py | 8 +- barker/barker/routers/client_log.py | 66 ++++++++++ barker/barker/schemas/client_log.py | 15 +++ bookie/src/app/app.config.ts | 5 + bookie/src/app/core/client-log.ts | 11 ++ bookie/src/app/core/error-logger.service.ts | 124 +++++++++++++++++- bookie/src/app/core/global-error-handler.ts | 26 ++++ bookie/src/app/core/http-error.interceptor.ts | 40 ++++++ 9 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 barker/barker/routers/client_log.py create mode 100644 barker/barker/schemas/client_log.py create mode 100644 bookie/src/app/core/client-log.ts create mode 100644 bookie/src/app/core/global-error-handler.ts create mode 100644 bookie/src/app/core/http-error.interceptor.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index bf49c7e..b6e3c66 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,26 @@ "module": "barker", "args": [], "cwd": "${workspaceFolder}/barker", - "justMyCode": true + "justMyCode": true, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": "Python: FastAPI (Live Reload)", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "barker.main:app", + "--host", + "127.0.0.1", + "--port", + "8000", + "--reload" + ], + "cwd": "${workspaceFolder}/barker", + "subProcess": true, + "justMyCode": true, + "envFile": "${workspaceFolder}/.env" } ] } diff --git a/barker/barker/main.py b/barker/barker/main.py index a1d728d..55a0f2b 100644 --- a/barker/barker/main.py +++ b/barker/barker/main.py @@ -17,6 +17,7 @@ from .db.base import reg # noqa: F401 from .db.friendly_db_error import constraint_to_friendly from .routers import ( bundle, + client_log, customer, customer_discount, db_settings, @@ -162,8 +163,13 @@ app.include_router(split.router, prefix="/api", tags=["voucher"]) app.include_router(change.router, prefix="/api/voucher", tags=["voucher"]) app.include_router(health.router, prefix="/health", tags=["health"]) +app.include_router(client_log.router, prefix="/api/client-logs", tags=["logs"]) app.frontend("/", directory="frontend", check_dir="auto") def init() -> None: - uvicorn.run(app, host=settings.HOST, port=settings.PORT) + uvicorn.run("barker.main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG) + + +if __name__ == "__main__": + init() diff --git a/barker/barker/routers/client_log.py b/barker/barker/routers/client_log.py new file mode 100644 index 0000000..a2e2112 --- /dev/null +++ b/barker/barker/routers/client_log.py @@ -0,0 +1,66 @@ +import logging + +from typing import Any + +import jwt + +from fastapi import APIRouter, Request, status +from jwt import PyJWTError + +from ..core.config import settings +from ..schemas.client_log import ClientErrorPayload + + +router = APIRouter() +logger = logging.getLogger("frontend.client") + + +def _extract_user_from_request(request: Request) -> str | None: + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None + token = auth_header.removeprefix("Bearer ").strip() + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + user: Any = payload.get("sub") + return str(user) if user else None + except PyJWTError, Exception: + return None + + +@router.post("", status_code=status.HTTP_204_NO_CONTENT) +async def log_client_error(payload: ClientErrorPayload, request: Request) -> None: + user = _extract_user_from_request(request) + client_ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + + log_context = { + "url": payload.url, + "source": payload.source, + "status_code": payload.status_code, + "endpoint": payload.endpoint, + "component": payload.component, + "user": user or "anonymous", + "client_ip": client_ip, + "user_agent": user_agent, + "extra": payload.extra, + } + + log_msg = ( + f"Frontend [{payload.source}] {payload.message} | " + f"URL: {payload.url} | " + f"User: {user or 'anonymous'} | " + f"IP: {client_ip}" + ) + if payload.endpoint: + log_msg += f" | Endpoint: {payload.endpoint} (Status: {payload.status_code})" + + if payload.level == "warning": + logger.warning(log_msg, extra={"client_context": log_context}) + elif payload.level == "info": + logger.info(log_msg, extra={"client_context": log_context}) + else: + logger.error( + log_msg + (f"\nStack:\n{payload.stack}" if payload.stack else ""), + extra={"client_context": log_context}, + ) diff --git a/barker/barker/schemas/client_log.py b/barker/barker/schemas/client_log.py new file mode 100644 index 0000000..9c23ddf --- /dev/null +++ b/barker/barker/schemas/client_log.py @@ -0,0 +1,15 @@ +from typing import Any, Literal + +from pydantic import BaseModel + + +class ClientErrorPayload(BaseModel): + message: str + stack: str | None = None + url: str + source: Literal["unhandled_error", "http_resource", "service_error", "global_error"] = "unhandled_error" + status_code: int | None = None + endpoint: str | None = None + level: Literal["error", "warning", "info"] = "error" + component: str | None = None + extra: dict[str, Any] | None = None diff --git a/bookie/src/app/app.config.ts b/bookie/src/app/app.config.ts index da677bf..e7e77b0 100644 --- a/bookie/src/app/app.config.ts +++ b/bookie/src/app/app.config.ts @@ -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 } }, diff --git a/bookie/src/app/core/client-log.ts b/bookie/src/app/core/client-log.ts new file mode 100644 index 0000000..7fe8cd0 --- /dev/null +++ b/bookie/src/app/core/client-log.ts @@ -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 | null; +} diff --git a/bookie/src/app/core/error-logger.service.ts b/bookie/src/app/core/error-logger.service.ts index ce5ec8a..84e934c 100644 --- a/bookie/src/app/core/error-logger.service.ts +++ b/bookie/src/app/core/error-logger.service.ts @@ -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(); + 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(serviceName = 'error-logger', operation = 'operation') { return (error: unknown): Observable => { - 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 = { + '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; + } } diff --git a/bookie/src/app/core/global-error-handler.ts b/bookie/src/app/core/global-error-handler.ts new file mode 100644 index 0000000..fd9e97a --- /dev/null +++ b/bookie/src/app/core/global-error-handler.ts @@ -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', + }); + } +} diff --git a/bookie/src/app/core/http-error.interceptor.ts b/bookie/src/app/core/http-error.interceptor.ts new file mode 100644 index 0000000..b932d37 --- /dev/null +++ b/bookie/src/app/core/http-error.interceptor.ts @@ -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); + }), + ); +};