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:
2026-02-04 11:47:01 +01:00
parent 2ed60fdcc1
commit d3c6773be5
48 changed files with 5846 additions and 32 deletions

View File

@@ -49,8 +49,10 @@
"vitest": "^2.1.0"
},
"dependencies": {
"@sentry/sveltekit": "^8.50.0",
"@tanstack/svelte-query": "^5.66.0",
"@vite-pwa/sveltekit": "^0.6.8",
"web-vitals": "^4.2.0",
"workbox-window": "^7.3.0"
},
"packageManager": "pnpm@10.28.2"

1223
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

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

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