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:
Vendored
+20
-1
@@ -18,7 +18,26 @@
|
|||||||
"module": "barker",
|
"module": "barker",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}/barker",
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .db.base import reg # noqa: F401
|
|||||||
from .db.friendly_db_error import constraint_to_friendly
|
from .db.friendly_db_error import constraint_to_friendly
|
||||||
from .routers import (
|
from .routers import (
|
||||||
bundle,
|
bundle,
|
||||||
|
client_log,
|
||||||
customer,
|
customer,
|
||||||
customer_discount,
|
customer_discount,
|
||||||
db_settings,
|
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(change.router, prefix="/api/voucher", tags=["voucher"])
|
||||||
|
|
||||||
app.include_router(health.router, prefix="/health", tags=["health"])
|
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")
|
app.frontend("/", directory="frontend", check_dir="auto")
|
||||||
|
|
||||||
|
|
||||||
def init() -> None:
|
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()
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,7 @@ import { LayoutModule } from '@angular/cdk/layout';
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import {
|
import {
|
||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
|
ErrorHandler,
|
||||||
LOCALE_ID,
|
LOCALE_ID,
|
||||||
importProvidersFrom,
|
importProvidersFrom,
|
||||||
isDevMode,
|
isDevMode,
|
||||||
@@ -31,6 +32,8 @@ import { routes } from './app.routes';
|
|||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
import { authInterceptor } from './core/auth.interceptor';
|
import { authInterceptor } from './core/auth.interceptor';
|
||||||
import { delayInterceptor } from './core/delay-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 { jwtInterceptor } from './core/jwt.interceptor';
|
||||||
import { refreshInterceptor } from './core/refresh.interceptor';
|
import { refreshInterceptor } from './core/refresh.interceptor';
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
authInterceptor,
|
authInterceptor,
|
||||||
jwtInterceptor,
|
jwtInterceptor,
|
||||||
refreshInterceptor,
|
refreshInterceptor,
|
||||||
|
httpErrorInterceptor,
|
||||||
...(isDevMode() ? [delayInterceptor] : []),
|
...(isDevMode() ? [delayInterceptor] : []),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -71,6 +75,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
}),
|
}),
|
||||||
withComponentInputBinding(),
|
withComponentInputBinding(),
|
||||||
),
|
),
|
||||||
|
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||||
{ provide: DateAdapter, useClass: LuxonDateAdapter, deps: [MAT_DATE_LOCALE] },
|
{ provide: DateAdapter, useClass: LuxonDateAdapter, deps: [MAT_DATE_LOCALE] },
|
||||||
{ provide: MAT_DATE_FORMATS, useValue: dateFormat },
|
{ provide: MAT_DATE_FORMATS, useValue: dateFormat },
|
||||||
{ provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 3000 } },
|
{ provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 3000 } },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { Observable, throwError } from 'rxjs';
|
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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ErrorLoggerService {
|
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) {
|
public static log(serviceName = 'error-logger', message: string) {
|
||||||
console.log(`${serviceName}Service: ${message}`);
|
console.log(`${serviceName}Service: ${message}`);
|
||||||
}
|
}
|
||||||
@@ -14,12 +27,117 @@ export class ErrorLoggerService {
|
|||||||
* Let the app continue.
|
* Let the app continue.
|
||||||
*
|
*
|
||||||
* @param operation - name of the operation that failed
|
* @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') {
|
public handleError<T>(serviceName = 'error-logger', operation = 'operation') {
|
||||||
return (error: unknown): Observable<T> => {
|
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);
|
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user