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:
2026-04-30 18:32:19 +02:00
parent 9a43123b3a
commit 3d1a6f5538
16 changed files with 916 additions and 54 deletions

View File

@@ -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

View 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;
}