feat: Observabilité et monitoring complet
Implémentation complète de la stack d'observabilité pour le monitoring de la plateforme multi-tenant Classeo. ## Error Tracking (GlitchTip) - Intégration Sentry SDK avec GlitchTip auto-hébergé - Scrubber PII avant envoi (RGPD: emails, tokens JWT, NIR français) - Contexte enrichi: tenant_id, user_id, correlation_id - Configuration backend (sentry.yaml) et frontend (sentry.ts) ## Metrics (Prometheus) - Endpoint /metrics avec restriction IP en production - Métriques HTTP: requests_total, request_duration_seconds (histogramme) - Métriques sécurité: login_failures_total par tenant - Métriques santé: health_check_status (postgres, redis, rabbitmq) - Storage Redis pour persistance entre requêtes ## Logs (Loki) - Processors Monolog: CorrelationIdLogProcessor, PiiScrubberLogProcessor - Détection PII: emails, téléphones FR, tokens JWT, NIR français - Labels structurés: tenant_id, correlation_id, level ## Dashboards (Grafana) - Dashboard principal: latence P50/P95/P99, error rate, RPS - Dashboard par tenant: métriques isolées par sous-domaine - Dashboard infrastructure: santé postgres/redis/rabbitmq - Datasources avec UIDs fixes pour portabilité ## Alertes (Alertmanager) - HighApiLatencyP95/P99: SLA monitoring (200ms/500ms) - HighErrorRate: error rate > 1% pendant 2 min - ExcessiveLoginFailures: détection brute force - ApplicationUnhealthy: health check failures ## Infrastructure - InfrastructureHealthChecker: service partagé (DRY) - HealthCheckController: endpoint /health pour load balancers - Pre-push hook: make ci && make e2e avant push
This commit is contained in:
21
frontend/src/lib/monitoring/index.ts
Normal file
21
frontend/src/lib/monitoring/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Frontend monitoring module.
|
||||
*
|
||||
* Provides error tracking (Sentry/GlitchTip) and performance monitoring (Web Vitals).
|
||||
*
|
||||
* @see Story 1.8 - T8: Frontend Monitoring
|
||||
*/
|
||||
|
||||
export {
|
||||
initSentry,
|
||||
setUserContext,
|
||||
clearUserContext,
|
||||
captureError,
|
||||
addBreadcrumb
|
||||
} from './sentry';
|
||||
|
||||
export {
|
||||
initWebVitals,
|
||||
createDefaultReporter,
|
||||
type VitalMetric
|
||||
} from './webVitals';
|
||||
130
frontend/src/lib/monitoring/sentry.ts
Normal file
130
frontend/src/lib/monitoring/sentry.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Sentry/GlitchTip initialization for frontend error tracking.
|
||||
*
|
||||
* @see Story 1.8 - T8: Frontend Monitoring (AC: #1)
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
|
||||
/**
|
||||
* Initialize Sentry for error tracking.
|
||||
*
|
||||
* Call this once in +hooks.client.ts or +layout.svelte.
|
||||
* Critical: No PII is sent to GlitchTip (RGPD compliance).
|
||||
*/
|
||||
export function initSentry(options: {
|
||||
dsn: string;
|
||||
environment: string;
|
||||
userId?: string;
|
||||
tenantId?: string;
|
||||
}): void {
|
||||
if (!options.dsn) {
|
||||
console.warn('[Sentry] DSN not configured, error tracking disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: options.dsn,
|
||||
environment: options.environment,
|
||||
|
||||
// Capture 100% of errors
|
||||
sampleRate: 1.0,
|
||||
|
||||
// Disable performance tracing (using server-side Prometheus)
|
||||
tracesSampleRate: 0.0,
|
||||
|
||||
// CRITICAL: No PII in error reports (RGPD compliance)
|
||||
sendDefaultPii: false,
|
||||
|
||||
// Scrub sensitive data before sending
|
||||
beforeSend(event) {
|
||||
// Remove any accidentally captured PII
|
||||
if (event.request?.headers) {
|
||||
delete event.request.headers['Authorization'];
|
||||
delete event.request.headers['Cookie'];
|
||||
}
|
||||
|
||||
// Remove email-like strings from breadcrumbs
|
||||
if (event.breadcrumbs) {
|
||||
event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
|
||||
if (breadcrumb.message && breadcrumb.message.includes('@')) {
|
||||
breadcrumb.message = '[EMAIL_REDACTED]';
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
// Ignore common non-errors
|
||||
ignoreErrors: [
|
||||
// Browser extensions
|
||||
'ResizeObserver loop',
|
||||
'ResizeObserver loop limit exceeded',
|
||||
// Network errors (expected in offline scenarios)
|
||||
'NetworkError',
|
||||
'Failed to fetch',
|
||||
'Load failed',
|
||||
// User cancellation
|
||||
'AbortError',
|
||||
]
|
||||
});
|
||||
|
||||
// Set user context (ID only, no PII)
|
||||
if (options.userId) {
|
||||
Sentry.setUser({ id: options.userId });
|
||||
}
|
||||
|
||||
// Set tenant context as tag
|
||||
if (options.tenantId) {
|
||||
Sentry.setTag('tenant_id', options.tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user context after login.
|
||||
*
|
||||
* Only sends user ID, never email or name (RGPD compliance).
|
||||
*/
|
||||
export function setUserContext(userId: string, tenantId?: string): void {
|
||||
Sentry.setUser({ id: userId });
|
||||
|
||||
if (tenantId) {
|
||||
Sentry.setTag('tenant_id', tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user context on logout.
|
||||
*/
|
||||
export function clearUserContext(): void {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture an error manually.
|
||||
*
|
||||
* Use this for caught exceptions that should still be tracked.
|
||||
*/
|
||||
export function captureError(error: unknown, context?: Record<string, unknown>): void {
|
||||
Sentry.captureException(error, context ? { extra: context } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a breadcrumb for debugging.
|
||||
*
|
||||
* Breadcrumbs show the trail of actions leading to an error.
|
||||
*/
|
||||
export function addBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
Sentry.addBreadcrumb({
|
||||
category,
|
||||
message,
|
||||
level: 'info',
|
||||
...(data && { data })
|
||||
});
|
||||
}
|
||||
115
frontend/src/lib/monitoring/webVitals.ts
Normal file
115
frontend/src/lib/monitoring/webVitals.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Web Vitals monitoring for frontend performance.
|
||||
*
|
||||
* Captures Core Web Vitals (LCP, FID, CLS) and sends to analytics.
|
||||
* These metrics are critical for user experience and SEO.
|
||||
*
|
||||
* @see Story 1.8 - T8.4: Web Vitals (FCP, LCP, TTI)
|
||||
*/
|
||||
|
||||
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals';
|
||||
|
||||
type VitalsReporter = (metric: VitalMetric) => void;
|
||||
|
||||
export interface VitalMetric {
|
||||
name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';
|
||||
value: number;
|
||||
rating: 'good' | 'needs-improvement' | 'poor';
|
||||
delta: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Vitals thresholds (Core Web Vitals 2024 standards).
|
||||
*
|
||||
* - LCP (Largest Contentful Paint): < 2.5s good, < 4s needs improvement
|
||||
* - FCP (First Contentful Paint): < 1.8s good, < 3s needs improvement
|
||||
* - INP (Interaction to Next Paint): < 200ms good, < 500ms needs improvement
|
||||
* - CLS (Cumulative Layout Shift): < 0.1 good, < 0.25 needs improvement
|
||||
* - TTFB (Time to First Byte): < 800ms good, < 1.8s needs improvement
|
||||
*/
|
||||
const THRESHOLDS = {
|
||||
LCP: { good: 2500, needsImprovement: 4000 },
|
||||
FCP: { good: 1800, needsImprovement: 3000 },
|
||||
INP: { good: 200, needsImprovement: 500 },
|
||||
CLS: { good: 0.1, needsImprovement: 0.25 },
|
||||
TTFB: { good: 800, needsImprovement: 1800 }
|
||||
} as const;
|
||||
|
||||
function getRating(
|
||||
name: VitalMetric['name'],
|
||||
value: number
|
||||
): 'good' | 'needs-improvement' | 'poor' {
|
||||
const threshold = THRESHOLDS[name];
|
||||
if (value <= threshold.good) return 'good';
|
||||
if (value <= threshold.needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
function createVitalMetric(metric: Metric): VitalMetric {
|
||||
return {
|
||||
name: metric.name as VitalMetric['name'],
|
||||
value: metric.value,
|
||||
rating: getRating(metric.name as VitalMetric['name'], metric.value),
|
||||
delta: metric.delta,
|
||||
id: metric.id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Web Vitals collection.
|
||||
*
|
||||
* @param reporter - Callback function to report metrics (e.g., to analytics)
|
||||
*/
|
||||
export function initWebVitals(reporter: VitalsReporter): void {
|
||||
// Core Web Vitals
|
||||
onLCP((metric) => reporter(createVitalMetric(metric)));
|
||||
onCLS((metric) => reporter(createVitalMetric(metric)));
|
||||
onINP((metric) => reporter(createVitalMetric(metric)));
|
||||
|
||||
// Other Web Vitals
|
||||
onFCP((metric) => reporter(createVitalMetric(metric)));
|
||||
onTTFB((metric) => reporter(createVitalMetric(metric)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reporter that logs to console (dev) or sends to backend (prod).
|
||||
*/
|
||||
export function createDefaultReporter(options: {
|
||||
endpoint?: string;
|
||||
debug?: boolean;
|
||||
}): VitalsReporter {
|
||||
return (metric: VitalMetric) => {
|
||||
// Log in development
|
||||
if (options.debug) {
|
||||
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
||||
}
|
||||
|
||||
// Send to analytics endpoint in production
|
||||
if (options.endpoint) {
|
||||
// Use sendBeacon for reliability during page unload
|
||||
const body = JSON.stringify({
|
||||
metric: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
id: metric.id,
|
||||
timestamp: Date.now(),
|
||||
url: window.location.href
|
||||
});
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(options.endpoint, body);
|
||||
} else {
|
||||
fetch(options.endpoint, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true
|
||||
}).catch(() => {
|
||||
// Silently fail - vitals are best-effort
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user