Implement frontend and backend observability alignment
Align frontend and backend error observability with correlation IDs and structured telemetry for distributed tracing across systems. Backend changes: - Add CorrelationIdMiddleware to generate/extract correlation IDs - Include correlation_id in all ErrorResponse objects - Store correlation ID in structlog contextvars for automatic inclusion in logs - Add correlation ID to response headers (X-Correlation-ID) Frontend changes: - API client automatically generates session-scoped UUID4 and includes X-Correlation-ID header in all requests - Extract correlation ID from API error responses - Update error handlers to use telemetry with correlation IDs - Add telemetry logging to ErrorBoundary, PageErrorBoundary, SectionErrorBoundary - Implement redaction utilities for privacy-safe logging of sensitive data Documentation: - Add observability guidelines to Web-Development.md * Correlation ID usage patterns * Privacy & security best practices * Telemetry event structure * Redaction utilities for sensitive data - Add distributed tracing architecture section to Architecture.md * Correlation ID flow across frontend/backend * Example troubleshooting scenario * Implementation details for future enhancements Testing: - Add comprehensive tests for correlation middleware - Update error boundary tests to verify telemetry integration - Verify TypeScript and ESLint pass with no warnings Fixes: Issue #40 - Frontend and backend observability are not aligned Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { FetchError } from "../types/api";
|
||||
import { isAuthError, isAbortError } from "../types/api";
|
||||
import type { FetchError, ApiErrorPayload } from "../types/api";
|
||||
import { isAuthError, isAbortError, isApiError, isNetworkError } from "../types/api";
|
||||
import { recordWarning, recordError } from "./telemetry";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth error handler registration
|
||||
@@ -101,6 +102,19 @@ export function handleFetchError(
|
||||
// Auth errors are handled globally with registered handler or fallback logging.
|
||||
// This ensures auth errors are never silently swallowed.
|
||||
if (isAuthError(fetchError)) {
|
||||
// Extract correlation ID from auth error
|
||||
const correlationId = fetchError.correlationId;
|
||||
|
||||
recordWarning(
|
||||
"auth_error",
|
||||
`Authentication error (${fetchError.status})`,
|
||||
{
|
||||
status: fetchError.status,
|
||||
message: fetchError.message,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
|
||||
if (authErrorHandler) {
|
||||
authErrorHandler(fetchError);
|
||||
} else {
|
||||
@@ -116,6 +130,22 @@ export function handleFetchError(
|
||||
return;
|
||||
}
|
||||
|
||||
// Log other errors with correlation ID for tracing
|
||||
if (isApiError(fetchError)) {
|
||||
const apiError = fetchError as ApiErrorPayload;
|
||||
recordError(
|
||||
"api_error",
|
||||
new Error(apiError.message),
|
||||
{
|
||||
status: apiError.status,
|
||||
body_preview: apiError.body?.substring(0, 200),
|
||||
},
|
||||
apiError.correlationId,
|
||||
);
|
||||
} else if (isNetworkError(fetchError)) {
|
||||
recordError("network_error", new Error(fetchError.message), undefined, undefined);
|
||||
}
|
||||
|
||||
// Determine if setError expects FetchError or string by checking current behavior
|
||||
// For now, always pass FetchError; consuming code can extract message as needed
|
||||
setError(fetchError);
|
||||
@@ -179,12 +209,26 @@ export function normalizeFetchError(err: unknown, fallback: string = "Unknown er
|
||||
// Handle ApiError instances (for backward compatibility)
|
||||
if (err instanceof Error && err.name === "ApiError" && "status" in err) {
|
||||
const apiError = err as any;
|
||||
return {
|
||||
const errorPayload: ApiErrorPayload = {
|
||||
type: "api_error",
|
||||
status: apiError.status,
|
||||
body: apiError.body,
|
||||
message: apiError.message,
|
||||
correlationId: apiError.correlationId,
|
||||
};
|
||||
|
||||
// Extract parsed error response fields if available
|
||||
if (apiError.errorResponse) {
|
||||
errorPayload.code = apiError.errorResponse.code;
|
||||
errorPayload.detail = apiError.errorResponse.detail;
|
||||
errorPayload.metadata = apiError.errorResponse.metadata;
|
||||
// Prefer correlation_id from error response if present
|
||||
if (apiError.errorResponse.correlation_id) {
|
||||
errorPayload.correlationId = apiError.errorResponse.correlation_id;
|
||||
}
|
||||
}
|
||||
|
||||
return errorPayload;
|
||||
}
|
||||
|
||||
// Handle generic Error instances
|
||||
|
||||
274
frontend/src/utils/telemetry.ts
Normal file
274
frontend/src/utils/telemetry.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Frontend error telemetry service.
|
||||
*
|
||||
* Provides centralized, structured error logging with correlation IDs
|
||||
* for distributed tracing across frontend and backend systems.
|
||||
*
|
||||
* Privacy & Security:
|
||||
* - NEVER log passwords, tokens, session IDs, or sensitive user data
|
||||
* - Use `redact` utility to sanitize URLs and objects before logging
|
||||
* - PII should only be logged with explicit developer intent
|
||||
* - Telemetry is logged to console (development) or backend (production-ready)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Severity levels for telemetry events, matching backend structlog levels.
|
||||
*/
|
||||
export type TelemetrySeverity = "debug" | "info" | "warning" | "error" | "critical";
|
||||
|
||||
/**
|
||||
* Structured telemetry event.
|
||||
*
|
||||
* All telemetry is captured in a structured format that mirrors backend
|
||||
* structlog patterns, enabling consistent analysis across frontend and backend.
|
||||
*/
|
||||
export interface TelemetryEvent {
|
||||
/** Event name in snake_case (e.g., "api_error", "component_render_error"). */
|
||||
event: string;
|
||||
/** Severity level matching structlog conventions. */
|
||||
severity: TelemetrySeverity;
|
||||
/** Correlation ID for tracing across systems. */
|
||||
correlation_id?: string;
|
||||
/** Human-readable message. */
|
||||
message?: string;
|
||||
/** Optional error instance for stack traces and error info. */
|
||||
error?: Error;
|
||||
/** Additional structured context (must not contain PII). */
|
||||
context?: Record<string, unknown>;
|
||||
/** Timestamp when the event occurred. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry event handler callback.
|
||||
* Called when a telemetry event is recorded.
|
||||
*/
|
||||
type TelemetryHandler = (event: TelemetryEvent) => void;
|
||||
|
||||
/** Registered telemetry handlers (initially console logger). */
|
||||
let handlers: TelemetryHandler[] = [logToConsole];
|
||||
|
||||
/**
|
||||
* Log telemetry event to browser console.
|
||||
* In development, this provides immediate visibility to errors.
|
||||
* @internal
|
||||
*/
|
||||
function logToConsole(event: TelemetryEvent): void {
|
||||
const prefix = `[${event.severity.toUpperCase()}] ${event.event}`;
|
||||
const correlation = event.correlation_id ? ` [${event.correlation_id}]` : "";
|
||||
|
||||
const args = [
|
||||
`${prefix}${correlation}`,
|
||||
event.message || "",
|
||||
event.context || {},
|
||||
event.error ? event.error : "",
|
||||
].filter((arg) => arg !== "");
|
||||
|
||||
switch (event.severity) {
|
||||
case "debug":
|
||||
console.debug(...args);
|
||||
break;
|
||||
case "info":
|
||||
console.info(...args);
|
||||
break;
|
||||
case "warning":
|
||||
console.warn(...args);
|
||||
break;
|
||||
case "error":
|
||||
case "critical":
|
||||
console.error(...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom telemetry handler.
|
||||
* Handlers are called when telemetry events are recorded.
|
||||
* @param handler - Callback to invoke on telemetry events.
|
||||
*/
|
||||
export function registerTelemetryHandler(handler: TelemetryHandler): void {
|
||||
handlers.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all telemetry handlers and reinstall the console logger.
|
||||
* Useful for testing or resetting telemetry in single-page app contexts.
|
||||
*/
|
||||
export function resetTelemetryHandlers(): void {
|
||||
handlers = [logToConsole];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a telemetry event to all registered handlers.
|
||||
* @internal
|
||||
*/
|
||||
function dispatch(event: TelemetryEvent): void {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (e) {
|
||||
// Prevent telemetry errors from crashing the app
|
||||
console.error("Telemetry handler error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a debug-level telemetry event.
|
||||
*/
|
||||
export function recordDebug(
|
||||
event: string,
|
||||
message?: string,
|
||||
context?: Record<string, unknown>,
|
||||
correlationId?: string,
|
||||
): void {
|
||||
dispatch({
|
||||
event,
|
||||
severity: "debug",
|
||||
message,
|
||||
context,
|
||||
correlation_id: correlationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an info-level telemetry event.
|
||||
*/
|
||||
export function recordInfo(
|
||||
event: string,
|
||||
message?: string,
|
||||
context?: Record<string, unknown>,
|
||||
correlationId?: string,
|
||||
): void {
|
||||
dispatch({
|
||||
event,
|
||||
severity: "info",
|
||||
message,
|
||||
context,
|
||||
correlation_id: correlationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a warning-level telemetry event.
|
||||
*/
|
||||
export function recordWarning(
|
||||
event: string,
|
||||
message?: string,
|
||||
context?: Record<string, unknown>,
|
||||
correlationId?: string,
|
||||
): void {
|
||||
dispatch({
|
||||
event,
|
||||
severity: "warning",
|
||||
message,
|
||||
context,
|
||||
correlation_id: correlationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error-level telemetry event.
|
||||
* @param event - Event name in snake_case.
|
||||
* @param error - Error instance (will extract message and stack trace).
|
||||
* @param context - Optional structured context.
|
||||
* @param correlationId - Optional correlation ID for distributed tracing.
|
||||
*/
|
||||
export function recordError(
|
||||
event: string,
|
||||
error: Error,
|
||||
context?: Record<string, unknown>,
|
||||
correlationId?: string,
|
||||
): void {
|
||||
dispatch({
|
||||
event,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
error,
|
||||
context,
|
||||
correlation_id: correlationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a critical-level telemetry event.
|
||||
* Use for unrecoverable errors that require immediate attention.
|
||||
*/
|
||||
export function recordCritical(
|
||||
event: string,
|
||||
error: Error,
|
||||
context?: Record<string, unknown>,
|
||||
correlationId?: string,
|
||||
): void {
|
||||
dispatch({
|
||||
event,
|
||||
severity: "critical",
|
||||
message: error.message,
|
||||
error,
|
||||
context,
|
||||
correlation_id: correlationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive data from URLs and objects for safe logging.
|
||||
* Replaces passwords, tokens, and sensitive query parameters.
|
||||
* @param url - URL or string to redact.
|
||||
* @returns Safely redacted string.
|
||||
*/
|
||||
export function redact(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Redact sensitive query parameters
|
||||
const sensitiveParams = ["password", "token", "api_key", "secret", "key"];
|
||||
for (const param of sensitiveParams) {
|
||||
if (urlObj.searchParams.has(param)) {
|
||||
urlObj.searchParams.set(param, "[REDACTED]");
|
||||
}
|
||||
}
|
||||
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
// If URL parsing fails, use regex-based approach for relative URLs
|
||||
return url.replace(
|
||||
/[?&](password|token|api_key|secret|key)=[^&]*/gi,
|
||||
(_match, param: string) => `?${param}=[REDACTED]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive fields from an object for safe logging.
|
||||
* @param obj - Object to redact.
|
||||
* @returns New object with sensitive fields replaced with [REDACTED].
|
||||
*/
|
||||
export function redactObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const sensitiveFields = [
|
||||
"password",
|
||||
"token",
|
||||
"api_key",
|
||||
"secret",
|
||||
"key",
|
||||
"Authorization",
|
||||
"X-API-Key",
|
||||
"bangui_session",
|
||||
];
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (sensitiveFields.includes(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.includes("://")) {
|
||||
redacted[key] = redact(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
Reference in New Issue
Block a user