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

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ pnpm-debug.log*
correlation_id
tenant_id
test-results/
compose.override.yaml

View File

@@ -4,40 +4,81 @@
# Docker
# =============================================================================
# Fichiers compose - ajouter monitoring si MONITORING=1
COMPOSE_FILES := -f compose.yaml
ifdef MONITORING
COMPOSE_FILES += -f compose.monitoring.yaml
endif
.PHONY: up
up: ## Lancer tous les services
docker compose up -d
up: ## Lancer les services (ajouter MONITORING=1 pour inclure observabilité)
docker compose $(COMPOSE_FILES) up -d
.PHONY: up-full
up-full: ## Lancer TOUS les services (app + monitoring)
docker compose -f compose.yaml -f compose.monitoring.yaml up -d
.PHONY: down
down: ## Arrêter tous les services
docker compose down
down: ## Arrêter tous les services (app + monitoring)
docker compose -f compose.yaml -f compose.monitoring.yaml down --remove-orphans
.PHONY: restart
restart: ## Redémarrer tous les services
docker compose down
docker compose up -d
restart: ## Redémarrer les services
docker compose $(COMPOSE_FILES) down
docker compose $(COMPOSE_FILES) up -d
.PHONY: rebuild
rebuild: ## Reconstruire et relancer les services (sans cache)
docker compose down
docker compose build --no-cache
docker compose up -d
docker compose $(COMPOSE_FILES) down
docker compose $(COMPOSE_FILES) build --no-cache
docker compose $(COMPOSE_FILES) up -d
.PHONY: build
build: ## Reconstruire les images Docker (sans cache)
docker compose build --no-cache
docker compose $(COMPOSE_FILES) build --no-cache
.PHONY: logs
logs: ## Voir les logs de tous les services (Ctrl+C pour quitter)
docker compose logs -f
docker compose $(COMPOSE_FILES) logs -f
.PHONY: ps
ps: ## Afficher le statut des services
docker compose ps
docker compose $(COMPOSE_FILES) ps
.PHONY: clean
clean: ## Supprimer volumes et images locales
docker compose down -v --rmi local
docker compose -f compose.yaml -f compose.monitoring.yaml down -v --rmi local
# =============================================================================
# Monitoring
# =============================================================================
.PHONY: monitoring-up
monitoring-up: ## Lancer uniquement les services de monitoring
docker compose -f compose.monitoring.yaml up -d
.PHONY: monitoring-down
monitoring-down: ## Arrêter les services de monitoring
docker compose -f compose.monitoring.yaml down
.PHONY: monitoring-logs
monitoring-logs: ## Voir les logs du monitoring
docker compose -f compose.monitoring.yaml logs -f
.PHONY: grafana
grafana: ## Ouvrir Grafana dans le navigateur (http://localhost:3001)
@echo "Grafana: http://localhost:3001 (admin/admin)"
@command -v xdg-open >/dev/null && xdg-open http://localhost:3001 || echo "Ouvrir manuellement: http://localhost:3001"
.PHONY: prometheus
prometheus: ## Ouvrir Prometheus dans le navigateur (http://localhost:9090)
@echo "Prometheus: http://localhost:9090"
@command -v xdg-open >/dev/null && xdg-open http://localhost:9090 || echo "Ouvrir manuellement: http://localhost:9090"
.PHONY: glitchtip
glitchtip: ## Ouvrir GlitchTip dans le navigateur (http://localhost:8081)
@echo "GlitchTip: http://localhost:8081"
@command -v xdg-open >/dev/null && xdg-open http://localhost:8081 || echo "Ouvrir manuellement: http://localhost:8081"
# =============================================================================
# Shell
@@ -172,6 +213,13 @@ ci: ## Lancer TOUS les tests et checks (comme en CI)
# Scripts
# =============================================================================
.PHONY: setup-hooks
setup-hooks: ## Installer les git hooks (pre-push: make ci && make e2e)
@echo "Installation des git hooks..."
@cp scripts/hooks/pre-push .git/hooks/pre-push
@chmod +x .git/hooks/pre-push
@echo "✅ Git hooks installés (pre-push)"
.PHONY: check-bc
check-bc: ## Vérifier l'isolation des Bounded Contexts
./scripts/check-bc-isolation.sh

View File

@@ -72,6 +72,46 @@ make token-beta role=ROLE_PROF email=prof@test.com
| Mailpit | http://localhost:8025 | Emails de test |
| Mercure | http://localhost:3000/.well-known/mercure | SSE Hub |
#### Monitoring (optionnel)
Lancer avec `make up-full` pour activer le stack de monitoring :
| Service | URL | Description |
|---------|-----|-------------|
| Grafana | http://localhost:3001 | Dashboards (admin/admin) |
| Prometheus | http://localhost:9090 | Métriques |
| GlitchTip | http://localhost:8081 | Error tracking |
| Loki | http://localhost:3100 | Logs centralisés |
| Alertmanager | http://localhost:9093 | Gestion alertes |
##### Stack de monitoring expliquée
**GlitchTip** - Error tracking (compatible Sentry)
- Capture automatiquement les exceptions PHP et les envoie avec leur stack trace
- Regroupe les erreurs similaires pour éviter le bruit
- Configuration : ajouter `SENTRY_DSN` dans `compose.override.yaml`
**Prometheus** - Métriques & alertes
- Collecte les métriques applicatives (latence, requêtes, erreurs) toutes les 15s
- Déclenche des alertes si les SLAs sont menacés (P95 > 200ms, error rate > 1%)
- Requêtes PromQL : http://localhost:9090/graph
**Grafana** - Dashboards visuels
- Dashboard principal : vue globale des métriques applicatives
- Dashboard per-tenant : métriques filtrées par établissement
- Credentials : admin/admin
**Loki + Promtail** - Logs centralisés
- Promtail collecte les logs de tous les conteneurs Docker
- Loki les stocke et permet les requêtes LogQL
- Accès via Grafana → Explore → Loki
- Exemple : `{container_name="classeo_php"} |= "error"`
**Alertmanager** - Notification des alertes
- Reçoit les alertes de Prometheus et les route vers les bons canaux
- En dev : envoie les emails à Mailpit (http://localhost:8025)
- En prod : configurable pour Slack, PagerDuty, email, etc.
## Stack Technique
### Backend
@@ -138,7 +178,9 @@ Chaque Bounded Context suit la même structure :
### Workflow quotidien
```bash
make up # Démarrer les services
make up # Démarrer les services (app uniquement)
make up-full # Démarrer avec monitoring (Grafana, Prometheus, Loki...)
make down # Arrêter tous les services
make logs # Suivre les logs
make test # Lancer les tests avant commit
make check # Vérifier la qualité du code

View File

@@ -89,3 +89,11 @@ TURNSTILE_FAIL_OPEN=true
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
###> sentry/sentry-symfony ###
# GlitchTip DSN for error tracking (Sentry-compatible)
# Set this after creating a project in GlitchTip UI at http://localhost:8081
SENTRY_DSN=
# Environment label for error reports
SENTRY_ENVIRONMENT=development
###< sentry/sentry-symfony ###

View File

@@ -17,7 +17,9 @@
"doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"promphp/prometheus_client_php": "^2.14",
"ramsey/uuid": "^4.7",
"sentry/sentry-symfony": "^5.8",
"symfony/amqp-messenger": "^8.0",
"symfony/asset": "^8.0",
"symfony/console": "^8.0",

675
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0",
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2",
"packages": [
{
"name": "api-platform/core",
@@ -1457,6 +1457,182 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "21dc724a0583619cd1652f673303492272778051"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
"reference": "21dc724a0583619cd1652f673303492272778051",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.8.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2025-08-23T21:21:41+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
@@ -1814,6 +1990,74 @@
},
"time": "2026-01-12T15:59:08+00:00"
},
{
"name": "promphp/prometheus_client_php",
"version": "v2.14.1",
"source": {
"type": "git",
"url": "https://github.com/PromPHP/prometheus_client_php.git",
"reference": "a283aea8269287dc35313a0055480d950c59ac1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/a283aea8269287dc35313a0055480d950c59ac1f",
"reference": "a283aea8269287dc35313a0055480d950c59ac1f",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.4|^8.0"
},
"replace": {
"endclothing/prometheus_client_php": "*",
"jimdo/prometheus_client_php": "*",
"lkaemmerling/prometheus_client_php": "*"
},
"require-dev": {
"guzzlehttp/guzzle": "^6.3|^7.0",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^1.5.4",
"phpstan/phpstan-phpunit": "^1.1.0",
"phpstan/phpstan-strict-rules": "^1.1.0",
"phpunit/phpunit": "^9.4",
"squizlabs/php_codesniffer": "^3.6",
"symfony/polyfill-apcu": "^1.6"
},
"suggest": {
"ext-apc": "Required if using APCu.",
"ext-pdo": "Required if using PDO.",
"ext-redis": "Required if using Redis.",
"promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.",
"symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"Prometheus\\": "src/Prometheus/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Lukas Kämmerling",
"email": "kontakt@lukas-kaemmerling.de"
}
],
"description": "Prometheus instrumentation library for PHP applications.",
"support": {
"issues": "https://github.com/PromPHP/prometheus_client_php/issues",
"source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.14.1"
},
"time": "2025-04-14T07:59:43+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@@ -2014,6 +2258,114 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/link",
"version": "2.0.1",
@@ -2120,6 +2472,50 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "ramsey/collection",
"version": "2.1.1",
@@ -2274,6 +2670,196 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sentry/sentry",
"version": "4.19.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "1c21d60bebe67c0122335bd3fe977990435af0a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3",
"reference": "1c21d60bebe67c0122335bd3fe977990435af0a3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5|^9.6",
"vimeo/psalm": "^4.17"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.19.1"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-12-02T15:57:41+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.8.3",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "e82559a078b26c8f8592289e98a25b203527a9c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/e82559a078b26c8f8592289e98a25b203527a9c6",
"reference": "e82559a078b26c8f8592289e98a25b203527a9c6",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.19.1",
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/polyfill-php80": "^1.22",
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
},
"require-dev": {
"doctrine/dbal": "^2.13||^3.3||^4.0",
"doctrine/doctrine-bundle": "^2.6||^3.0",
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
"masterminds/html5": "^2.8",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "1.12.5",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-symfony": "1.4.10",
"phpunit/phpunit": "^8.5.40||^9.6.21",
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/monolog-bundle": "^3.4||^4.0",
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"vimeo/psalm": "^4.3||^5.16.0"
},
"suggest": {
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
},
"type": "symfony-bundle",
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Sentry\\SentryBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Symfony integration for Sentry (http://getsentry.com)",
"homepage": "http://getsentry.com",
"keywords": [
"errors",
"logging",
"sentry",
"symfony"
],
"support": {
"issues": "https://github.com/getsentry/sentry-symfony/issues",
"source": "https://github.com/getsentry/sentry-symfony/tree/5.8.3"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-12-18T09:26:49+00:00"
},
{
"name": "symfony/amqp-messenger",
"version": "v8.0.4",
@@ -5496,6 +6082,93 @@
],
"time": "2026-01-27T16:18:07+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531",
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-03T23:40:55+00:00"
},
{
"name": "symfony/rate-limiter",
"version": "v8.0.5",

View File

@@ -14,4 +14,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['all' => true],
];

View File

@@ -16,6 +16,11 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# Monitoring endpoints - no authentication, restricted by IP in production
monitoring:
pattern: ^/(health|metrics)$
stateless: true
security: false
api_login:
pattern: ^/api/login$
stateless: true

View File

@@ -0,0 +1,24 @@
# Sentry/GlitchTip Configuration
# Error tracking with automatic context enrichment
#
# To enable error tracking:
# 1. Set up GlitchTip at http://localhost:8081 (via make up-full)
# 2. Create a project and get the DSN
# 3. Add SENTRY_DSN to .env.local
sentry:
dsn: '%env(default::SENTRY_DSN)%'
register_error_handler: false # Disable when DSN is empty
options:
environment: '%env(SENTRY_ENVIRONMENT)%'
send_default_pii: false # CRITICAL: No PII in error reports (RGPD)
when@prod:
sentry:
register_error_handler: true # Enable in production
options:
before_send: 'App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback'
when@test:
sentry:
dsn: ''

View File

@@ -167,6 +167,62 @@ services:
App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface:
alias: App\Shared\Infrastructure\Captcha\TurnstileValidator
# =============================================================================
# Monitoring & Observability (Story 1.8)
# =============================================================================
# Prometheus CollectorRegistry - uses Redis for persistence between requests
Prometheus\Storage\Redis:
factory: ['App\Shared\Infrastructure\Monitoring\PrometheusStorageFactory', 'createRedisStorage']
arguments:
$redisUrl: '%env(REDIS_URL)%'
Prometheus\CollectorRegistry:
arguments:
$storageAdapter: '@Prometheus\Storage\Redis'
# Sentry/GlitchTip PII scrubber callback
App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback: ~
# Infrastructure Health Checker - shared service for health checks (DRY)
App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker:
arguments:
$redisUrl: '%env(REDIS_URL)%'
# Interface alias for InfrastructureHealthChecker (allows testing with stubs)
App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface:
alias: App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker
# Health Check Controller - uses shared InfrastructureHealthChecker
App\Shared\Infrastructure\Monitoring\HealthCheckController: ~
# Metrics Controller - restricted to internal networks in production
App\Shared\Infrastructure\Monitoring\MetricsController:
arguments:
$appEnv: '%kernel.environment%'
# Health Metrics Collector - exposes health_check_status gauge
App\Shared\Infrastructure\Monitoring\HealthMetricsCollector: ~
# Interface alias for HealthMetricsCollector (allows testing with stubs)
App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface:
alias: App\Shared\Infrastructure\Monitoring\HealthMetricsCollector
# Sentry context enricher - adds tenant/user/correlation_id to error reports
# Explicitly registered to ensure HubInterface dependency is resolved
App\Shared\Infrastructure\Monitoring\SentryContextEnricher:
arguments:
$sentryHub: '@Sentry\State\HubInterface'
# Monolog processors for structured logging
App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor:
tags:
- { name: monolog.processor }
App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor:
tags:
- { name: monolog.processor }
# =============================================================================
# Test environment overrides
# =============================================================================

View File

@@ -8,6 +8,7 @@ use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
use App\Shared\Infrastructure\Tenant\TenantResolver;
@@ -41,6 +42,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
private MessageBusInterface $eventBus,
private Clock $clock,
private TenantResolver $tenantResolver,
private MetricsCollector $metricsCollector,
) {
}
@@ -68,6 +70,9 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
occurredOn: $this->clock->now(),
));
// Record metric for Prometheus alerting
$this->metricsCollector->recordLoginFailure('invalid_credentials');
// If the IP was just blocked
if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement(

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Monolog processor that adds correlation_id and tenant_id to all log entries.
*
* Enables distributed tracing by ensuring every log entry can be correlated
* with its originating request, even across async boundaries.
*
* @see Story 1.8 - T5.2: Processor to add correlation_id automatically
* @see Story 1.8 - T5.3: Processor to add tenant_id automatically
*/
final readonly class CorrelationIdLogProcessor implements ProcessorInterface
{
public function __construct(
private TenantContext $tenantContext,
) {
}
public function __invoke(LogRecord $record): LogRecord
{
$extra = $record->extra;
// Add correlation ID for distributed tracing
$correlationId = CorrelationIdHolder::get();
if ($correlationId !== null) {
$extra['correlation_id'] = $correlationId->value();
}
// Add tenant ID for multi-tenant filtering (use subdomain for consistency with Prometheus metrics)
if ($this->tenantContext->hasTenant()) {
$extra['tenant_id'] = $this->tenantContext->getCurrentTenantConfig()->subdomain;
}
return $record->with(extra: $extra);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use DateTimeImmutable;
use DateTimeInterface;
use function in_array;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Health check endpoint for monitoring and load balancers.
*
* Returns aggregated health status of all critical dependencies.
* Used by Grafana and Prometheus for uptime monitoring.
*
* @see Story 1.8 - T7: Health Check Endpoint (AC: #2)
*/
#[Route('/health', name: 'health_check', methods: ['GET'])]
final readonly class HealthCheckController
{
public function __construct(
private InfrastructureHealthCheckerInterface $healthChecker,
) {
}
public function __invoke(): JsonResponse
{
$checks = $this->healthChecker->checkAll();
$allHealthy = !in_array(false, $checks, true);
$status = $allHealthy ? 'healthy' : 'unhealthy';
// Return 200 for healthy (instance is operational)
// Return 503 when unhealthy (core dependencies are down)
$httpStatus = $status === 'unhealthy' ? Response::HTTP_SERVICE_UNAVAILABLE : Response::HTTP_OK;
return new JsonResponse([
'status' => $status,
'checks' => $checks,
'timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::RFC3339_EXTENDED),
], $httpStatus);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use Prometheus\CollectorRegistry;
use Prometheus\Gauge;
/**
* Collects infrastructure health metrics for Prometheus.
*
* Exposes health_check_status gauge for each service (postgres, redis, rabbitmq).
* Values: 1 = healthy, 0 = unhealthy
*
* These metrics are used by Grafana "Infrastructure Health" dashboard panels.
*
* @see Story 1.8 - T7: Health Check Endpoint
*/
final class HealthMetricsCollector implements HealthMetricsCollectorInterface
{
private const string NAMESPACE = 'classeo';
private Gauge $healthStatus;
public function __construct(
private readonly CollectorRegistry $registry,
private readonly InfrastructureHealthCheckerInterface $healthChecker,
) {
$this->healthStatus = $this->registry->getOrRegisterGauge(
self::NAMESPACE,
'health_check_status',
'Health status of infrastructure services (1=healthy, 0=unhealthy)',
['service'],
);
}
/**
* Update all health metrics.
*
* Called before rendering metrics to ensure fresh health status.
*/
public function collect(): void
{
$checks = $this->healthChecker->checkAll();
foreach ($checks as $service => $isHealthy) {
$this->healthStatus->set($isHealthy ? 1.0 : 0.0, [$service]);
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
/**
* Interface for health metrics collection.
*
* Allows testing MetricsController without depending on final HealthMetricsCollector.
*/
interface HealthMetricsCollectorInterface
{
/**
* Update all health metrics.
*
* Called before rendering metrics to ensure fresh health status.
*/
public function collect(): void;
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use Doctrine\DBAL\Connection;
use Redis;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
/**
* Centralized infrastructure health checking service.
*
* Used by both HealthCheckController and HealthMetricsCollector
* to avoid code duplication (DRY principle).
*
* @see Story 1.8 - T7: Health Check Endpoint
*/
final readonly class InfrastructureHealthChecker implements InfrastructureHealthCheckerInterface
{
public function __construct(
private Connection $connection,
private HttpClientInterface $httpClient,
private string $redisUrl = 'redis://redis:6379',
private string $rabbitmqManagementUrl = 'http://rabbitmq:15672',
private string $rabbitmqUser = 'guest',
private string $rabbitmqPassword = 'guest',
) {
}
public function checkPostgres(): bool
{
try {
$this->connection->executeQuery('SELECT 1');
return true;
} catch (Throwable) {
return false;
}
}
public function checkRedis(): bool
{
try {
$parsed = parse_url($this->redisUrl);
$host = $parsed['host'] ?? 'redis';
$port = $parsed['port'] ?? 6379;
$redis = new Redis();
$redis->connect($host, $port, 2.0); // 2 second timeout
$pong = $redis->ping();
$redis->close();
return $pong === true || $pong === '+PONG' || $pong === 'PONG';
} catch (Throwable) {
return false;
}
}
public function checkRabbitMQ(): bool
{
try {
// Check RabbitMQ via management API health check endpoint
$response = $this->httpClient->request('GET', $this->rabbitmqManagementUrl . '/api/health/checks/alarms', [
'auth_basic' => [$this->rabbitmqUser, $this->rabbitmqPassword],
'timeout' => 2,
]);
if ($response->getStatusCode() !== 200) {
return false;
}
$data = $response->toArray();
// RabbitMQ returns {"status":"ok"} when healthy
return ($data['status'] ?? '') === 'ok';
} catch (Throwable) {
return false;
}
}
/**
* Check all services and return aggregated status.
*
* @return array{postgres: bool, redis: bool, rabbitmq: bool}
*/
public function checkAll(): array
{
return [
'postgres' => $this->checkPostgres(),
'redis' => $this->checkRedis(),
'rabbitmq' => $this->checkRabbitMQ(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
/**
* Interface for infrastructure health checking.
*
* Allows testing controllers that depend on health checks without
* requiring real infrastructure connections.
*/
interface InfrastructureHealthCheckerInterface
{
public function checkPostgres(): bool;
public function checkRedis(): bool;
public function checkRabbitMQ(): bool;
/**
* Check all services and return aggregated status.
*
* @return array{postgres: bool, redis: bool, rabbitmq: bool}
*/
public function checkAll(): array;
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use function is_string;
use Prometheus\CollectorRegistry;
use Prometheus\Counter;
use Prometheus\Histogram;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Collects HTTP request metrics for Prometheus.
*
* Tracks request latency (P50, P95, P99), error rates, and requests per second.
* Metrics are labeled by tenant for multi-tenant analysis.
*
* @see Story 1.8 - T3.4: Custom metrics (requests_total, request_duration_seconds)
*/
final class MetricsCollector
{
private const string NAMESPACE = 'classeo';
private Counter $requestsTotal;
private Histogram $requestDuration;
private Counter $loginFailures;
private ?float $requestStartTime = null;
public function __construct(
private readonly CollectorRegistry $registry,
private readonly TenantContext $tenantContext,
) {
$this->initializeMetrics();
}
private function initializeMetrics(): void
{
$this->requestsTotal = $this->registry->getOrRegisterCounter(
self::NAMESPACE,
'http_requests_total',
'Total number of HTTP requests',
['method', 'route', 'status', 'tenant_id'],
);
$this->requestDuration = $this->registry->getOrRegisterHistogram(
self::NAMESPACE,
'http_request_duration_seconds',
'HTTP request duration in seconds',
['method', 'route', 'tenant_id'],
// Buckets optimized for SLA monitoring (P95 < 200ms, P99 < 500ms)
[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.75, 1.0, 2.5, 5.0, 10.0],
);
$this->loginFailures = $this->registry->getOrRegisterCounter(
self::NAMESPACE,
'login_failures_total',
'Total number of failed login attempts',
['tenant_id', 'reason'],
);
}
#[AsEventListener(event: KernelEvents::REQUEST, priority: 1024)]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->requestStartTime = microtime(true);
}
#[AsEventListener(event: KernelEvents::TERMINATE, priority: 1024)]
public function onKernelTerminate(TerminateEvent $event): void
{
if ($this->requestStartTime === null) {
return;
}
$request = $event->getRequest();
$response = $event->getResponse();
// Skip metrics endpoints to avoid self-referential noise
$routeValue = $request->attributes->get('_route', 'unknown');
$route = is_string($routeValue) ? $routeValue : 'unknown';
if (in_array($route, ['prometheus_metrics', 'health_check'], true)) {
$this->requestStartTime = null;
return;
}
$method = $request->getMethod();
$status = (string) $response->getStatusCode();
$tenantId = $this->tenantContext->hasTenant()
? $this->tenantContext->getCurrentTenantConfig()->subdomain
: 'none';
$duration = microtime(true) - $this->requestStartTime;
// Record request count
$this->requestsTotal->inc([$method, $route, $status, $tenantId]);
// Record request duration
$this->requestDuration->observe($duration, [$method, $route, $tenantId]);
$this->requestStartTime = null;
}
/**
* Record a failed login attempt.
*
* Called by the authentication system to track brute force attempts.
*/
public function recordLoginFailure(string $reason = 'invalid_credentials'): void
{
$tenantId = $this->tenantContext->hasTenant()
? $this->tenantContext->getCurrentTenantConfig()->subdomain
: 'none';
$this->loginFailures->inc([$tenantId, $reason]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;
/**
* Exposes Prometheus metrics endpoint.
*
* Collects and exposes application metrics for Prometheus scraping.
* Metrics include request latency, error rates, and custom business metrics.
*
* Security: In production, this endpoint is restricted to internal Docker network IPs.
* For additional security, configure your reverse proxy (nginx/traefik) to block
* external access to /metrics.
*
* @see Story 1.8 - T3.3: Expose /metrics endpoint in backend
*/
#[Route('/metrics', name: 'prometheus_metrics', methods: ['GET'])]
final readonly class MetricsController
{
/**
* Internal network CIDR ranges allowed to access metrics.
*/
private const array ALLOWED_NETWORKS = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
];
public function __construct(
private CollectorRegistry $registry,
private HealthMetricsCollectorInterface $healthMetrics,
private string $appEnv = 'dev',
) {
}
public function __invoke(Request $request): Response
{
// In production, restrict to internal networks only
if ($this->appEnv === 'prod' && !$this->isInternalRequest($request)) {
throw new AccessDeniedHttpException('Metrics endpoint is restricted to internal networks.');
}
// Collect fresh health metrics before rendering
$this->healthMetrics->collect();
$renderer = new RenderTextFormat();
$metrics = $renderer->render($this->registry->getMetricFamilySamples());
return new Response(
$metrics,
Response::HTTP_OK,
['Content-Type' => RenderTextFormat::MIME_TYPE],
);
}
private function isInternalRequest(Request $request): bool
{
$clientIp = $request->getClientIp();
if ($clientIp === null) {
return false;
}
foreach (self::ALLOWED_NETWORKS as $network) {
if ($this->ipInRange($clientIp, $network)) {
return true;
}
}
return false;
}
private function ipInRange(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
$ip = ip2long($ip);
$subnet = ip2long($subnet);
$mask = -1 << (32 - (int) $bits);
$subnet &= $mask;
return ($ip & $mask) === $subnet;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
/**
* Centralized PII (Personally Identifiable Information) patterns.
*
* Used by both log processors and error tracking to ensure consistent
* PII filtering across all observability systems (RGPD compliance).
*
* @see Story 1.8 - NFR-OB3: RGPD compliance in logs and error reports
*/
final class PiiPatterns
{
/**
* Keys that may contain PII and should be redacted.
*/
public const array SENSITIVE_KEYS = [
// Authentication
'password',
'token',
'secret',
'key',
'authorization',
'cookie',
'session',
'refresh_token',
'access_token',
// Personal data
'email',
'phone',
'address',
'ip',
'user_agent',
// French field names
'nom',
'prenom',
'adresse',
'telephone',
'nir',
'numero_securite_sociale',
'securite_sociale',
// English field names
'name',
'firstname',
'lastname',
'first_name',
'last_name',
];
/**
* Regex patterns that indicate PII in values.
*/
public const array VALUE_PATTERNS = [
'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', // Email
'/\b\d{10,}\b/', // Phone numbers
'/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', // IPv4
'/\b[12]\d{2}(0[1-9]|1[0-2])\d{2}\d{3}\d{3}\d{2}\b/', // French NIR (numéro de sécurité sociale)
];
/**
* Check if a key name suggests it contains PII.
*/
public static function isSensitiveKey(string $key): bool
{
$normalizedKey = strtolower($key);
foreach (self::SENSITIVE_KEYS as $pattern) {
if (str_contains($normalizedKey, $pattern)) {
return true;
}
}
return false;
}
/**
* Check if a value matches PII patterns.
*/
public static function containsPii(string $value): bool
{
foreach (self::VALUE_PATTERNS as $pattern) {
if (preg_match($pattern, $value)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use const FILTER_VALIDATE_EMAIL;
use function is_array;
use function is_string;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Monolog processor that scrubs PII from log entries before sending to Loki.
*
* Critical for RGPD compliance (NFR-S3): No personal data in centralized logs.
*
* @see Story 1.8 - T5.4: Filter PII from logs (scrubber processor)
*/
final class PiiScrubberLogProcessor implements ProcessorInterface
{
public function __invoke(LogRecord $record): LogRecord
{
$context = $this->scrubArray($record->context);
$extra = $this->scrubArray($record->extra);
return $record->with(context: $context, extra: $extra);
}
/**
* @param array<array-key, mixed> $data
*
* @return array<array-key, mixed>
*/
private function scrubArray(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_string($key) && $this->isPiiKey($key)) {
$result[$key] = '[REDACTED]';
} elseif (is_array($value)) {
$result[$key] = $this->scrubArray($value);
} elseif (is_string($value) && $this->looksLikePii($value)) {
$result[$key] = '[REDACTED]';
} else {
$result[$key] = $value;
}
}
return $result;
}
private function isPiiKey(string $key): bool
{
return PiiPatterns::isSensitiveKey($key);
}
private function looksLikePii(string $value): bool
{
// Filter email addresses
if (filter_var($value, FILTER_VALIDATE_EMAIL) !== false) {
return true;
}
// Filter JWT tokens
if (preg_match('/^eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/', $value)) {
return true;
}
// Filter French phone numbers
if (preg_match('/^(?:\+33|0)[1-9](?:[0-9]{2}){4}$/', preg_replace('/\s/', '', $value) ?? '')) {
return true;
}
// Filter French NIR (numéro de sécurité sociale) - RGPD critical
$cleanValue = preg_replace('/[\s.-]/', '', $value) ?? '';
if (preg_match('/^[12]\d{2}(0[1-9]|1[0-2])\d{2}\d{3}\d{3}\d{2}$/', $cleanValue)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use Prometheus\Storage\Redis;
/**
* Factory to create Prometheus Redis storage from REDIS_URL.
*/
final readonly class PrometheusStorageFactory
{
public static function createRedisStorage(string $redisUrl): Redis
{
$parsed = parse_url($redisUrl);
return new Redis([
'host' => $parsed['host'] ?? 'redis',
'port' => $parsed['port'] ?? 6379,
]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use const FILTER_VALIDATE_EMAIL;
use function is_array;
use function is_string;
use Sentry\Event;
use Sentry\EventHint;
/**
* Scrubs PII from Sentry events before sending to GlitchTip.
*
* Critical for RGPD compliance (NFR-S3): No personal data in error reports.
* This callback runs as the last step before sending to the error tracking service.
*
* @see Story 1.8 - T1.4: Filter PII before send (scrubber)
*/
final class SentryBeforeSendCallback
{
public function __invoke(Event $event, ?EventHint $hint): Event
{
// Scrub request data
$request = $event->getRequest();
if (!empty($request)) {
$this->scrubArray($request);
$event->setRequest($request);
}
// Scrub extra context
$extra = $event->getExtra();
if (!empty($extra)) {
$this->scrubArray($extra);
$event->setExtra($extra);
}
// Scrub tags that might contain PII
$tags = $event->getTags();
if (!empty($tags)) {
$this->scrubStringArray($tags);
$event->setTags($tags);
}
// Never drop the event - we want all errors tracked
return $event;
}
/**
* Recursively scrub PII from an array.
*
* @param array<array-key, mixed> $data
*/
private function scrubArray(array &$data): void
{
foreach ($data as $key => &$value) {
if (is_string($key) && $this->isPiiKey($key)) {
$value = '[FILTERED]';
} elseif (is_array($value)) {
$this->scrubArray($value);
} elseif (is_string($value) && $this->looksLikePii($value)) {
$value = '[FILTERED]';
}
}
}
/**
* Scrub PII from a string-only array (tags).
*
* @param array<string, string> $data
*/
private function scrubStringArray(array &$data): void
{
foreach ($data as $key => &$value) {
if ($this->isPiiKey($key) || $this->looksLikePii($value)) {
$value = '[FILTERED]';
}
}
}
private function isPiiKey(string $key): bool
{
return PiiPatterns::isSensitiveKey($key);
}
private function looksLikePii(string $value): bool
{
// Filter email-like patterns
if (filter_var($value, FILTER_VALIDATE_EMAIL) !== false) {
return true;
}
// Filter JWT tokens
if (preg_match('/^eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/', $value)) {
return true;
}
// Filter UUIDs in specific contexts (but not all - some are legitimate IDs)
// We keep UUIDs as they're often needed for debugging
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Sentry\State\HubInterface;
use Sentry\State\Scope;
use Sentry\UserDataBag;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Enriches Sentry error reports with tenant, user, and correlation context.
*
* Runs after authentication so user context is available.
* Critical: Filters PII before sending to GlitchTip (NFR-S3 compliance).
*/
final readonly class SentryContextEnricher
{
public function __construct(
private HubInterface $sentryHub,
private TenantContext $tenantContext,
private Security $security,
) {
}
/**
* Enrich Sentry scope with request context.
*
* Uses CONTROLLER event (after firewall) so user context is available.
* Correlation ID and tenant are already resolved by their respective middlewares.
*/
#[AsEventListener(event: KernelEvents::CONTROLLER, priority: -100)]
public function onKernelController(ControllerEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->sentryHub->configureScope(function (Scope $scope): void {
// Add correlation ID for distributed tracing
$correlationId = CorrelationIdHolder::get();
if ($correlationId !== null) {
$scope->setTag('correlation_id', $correlationId->value());
}
// Add tenant context (use subdomain for consistency with metrics)
if ($this->tenantContext->hasTenant()) {
$scope->setTag('tenant_id', $this->tenantContext->getCurrentTenantConfig()->subdomain);
}
// Add user context (ID only - no PII)
$user = $this->security->getUser();
if ($user !== null) {
// Only send user ID, never email or username (RGPD compliance)
$scope->setUser(new UserDataBag(
id: method_exists($user, 'getId') ? (string) $user->getId() : null,
));
}
});
}
}

View File

@@ -39,6 +39,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
'/_profiler',
'/_wdt',
'/_error',
'/health',
'/metrics',
];
public function onKernelRequest(RequestEvent $event): void
@@ -49,16 +51,17 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
$request = $event->getRequest();
$path = $request->getPathInfo();
$host = $request->getHost();
// Skip tenant resolution for public paths (docs, profiler, etc.)
// Check if this is a public path (docs, profiler, login, etc.)
$isPublicPath = false;
foreach (self::PUBLIC_PATHS as $publicPath) {
if (str_starts_with($path, $publicPath)) {
return;
$isPublicPath = true;
break;
}
}
$host = $request->getHost();
try {
$config = $this->resolver->resolve($host);
$this->context->setCurrentTenant($config);
@@ -66,7 +69,12 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
// Store tenant config in request for easy access
$request->attributes->set('_tenant', $config);
} catch (TenantNotFoundException) {
// Return 404 with generic message - DO NOT reveal tenant existence
// For public paths, allow requests without tenant context (metrics will show "none")
if ($isPublicPath) {
return;
}
// For protected paths, return 404 with generic message - DO NOT reveal tenant existence
$response = new JsonResponse(
[
'status' => Response::HTTP_NOT_FOUND,

View File

@@ -121,6 +121,18 @@
"bin/phpunit"
]
},
"sentry/sentry-symfony": {
"version": "5.8",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "12f504985eb24e3b20a9e41e0ec7e398798d18f0"
},
"files": [
"config/packages/sentry.yaml"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* @see Story 1.8 - T5.2: Processor to add correlation_id automatically
* @see Story 1.8 - T5.3: Processor to add tenant_id automatically
*/
#[CoversClass(CorrelationIdLogProcessor::class)]
final class CorrelationIdLogProcessorTest extends TestCase
{
private TenantContext $tenantContext;
private CorrelationIdLogProcessor $processor;
protected function setUp(): void
{
CorrelationIdHolder::clear();
$this->tenantContext = new TenantContext();
$this->processor = new CorrelationIdLogProcessor($this->tenantContext);
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
$this->tenantContext->clear();
}
#[Test]
public function itAddsCorrelationIdToLogRecord(): void
{
$correlationId = CorrelationId::fromString('01234567-89ab-cdef-0123-456789abcdef');
CorrelationIdHolder::set($correlationId);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayHasKey('correlation_id', $result->extra);
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->extra['correlation_id']);
}
#[Test]
public function itAddsTenantIdToLogRecord(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'test-school',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayHasKey('tenant_id', $result->extra);
self::assertSame('test-school', $result->extra['tenant_id']);
}
#[Test]
public function itAddsBothCorrelationIdAndTenantId(): void
{
$correlationId = CorrelationId::fromString('11111111-2222-3333-4444-555555555555');
CorrelationIdHolder::set($correlationId);
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('66666666-7777-8888-9999-aaaaaaaaaaaa'),
subdomain: 'school',
databaseUrl: 'postgresql://test@localhost/school',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertSame('11111111-2222-3333-4444-555555555555', $result->extra['correlation_id']);
self::assertSame('school', $result->extra['tenant_id']);
}
#[Test]
public function itDoesNotAddCorrelationIdWhenNotSet(): void
{
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayNotHasKey('correlation_id', $result->extra);
}
#[Test]
public function itDoesNotAddTenantIdWhenNoTenant(): void
{
$record = $this->createLogRecord();
$result = ($this->processor)($record);
self::assertArrayNotHasKey('tenant_id', $result->extra);
}
#[Test]
public function itPreservesExistingExtraData(): void
{
$correlationId = CorrelationId::fromString('abcdef12-3456-7890-abcd-ef1234567890');
CorrelationIdHolder::set($correlationId);
$record = $this->createLogRecord(extra: ['existing_key' => 'existing_value']);
$result = ($this->processor)($record);
self::assertSame('existing_value', $result->extra['existing_key']);
self::assertSame('abcdef12-3456-7890-abcd-ef1234567890', $result->extra['correlation_id']);
}
/**
* @param array<string, mixed> $extra
*/
private function createLogRecord(array $extra = []): LogRecord
{
return new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: 'Test message',
context: [],
extra: $extra,
);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\HealthCheckController;
use App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* Stub for InfrastructureHealthCheckerInterface.
*/
final class InfrastructureHealthCheckerStub implements InfrastructureHealthCheckerInterface
{
/**
* @param array{postgres: bool, redis: bool, rabbitmq: bool} $checks
*/
public function __construct(
private readonly array $checks = ['postgres' => true, 'redis' => true, 'rabbitmq' => true],
) {
}
public function checkPostgres(): bool
{
return $this->checks['postgres'];
}
public function checkRedis(): bool
{
return $this->checks['redis'];
}
public function checkRabbitMQ(): bool
{
return $this->checks['rabbitmq'];
}
public function checkAll(): array
{
return $this->checks;
}
}
/**
* @see Story 1.8 - T7: Health Check Endpoint
*/
#[CoversClass(HealthCheckController::class)]
final class HealthCheckControllerTest extends TestCase
{
private function createController(
?InfrastructureHealthCheckerInterface $healthChecker = null,
): HealthCheckController {
$healthChecker ??= new InfrastructureHealthCheckerStub();
return new HealthCheckController($healthChecker);
}
#[Test]
public function itReturnsHealthyWhenAllServicesAreUp(): void
{
$controller = $this->createController();
$response = $controller();
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertSame('healthy', $data['status']);
self::assertTrue($data['checks']['postgres']);
self::assertTrue($data['checks']['redis']);
self::assertTrue($data['checks']['rabbitmq']);
}
#[Test]
public function itReturnsUnhealthyWhenPostgresIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => false,
'redis' => true,
'rabbitmq' => true,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertSame('unhealthy', $data['status']);
self::assertFalse($data['checks']['postgres']);
}
#[Test]
public function itIncludesTimestampInResponse(): void
{
$controller = $this->createController();
$response = $controller();
$data = json_decode($response->getContent(), true);
self::assertArrayHasKey('timestamp', $data);
self::assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}$/',
$data['timestamp'],
);
}
#[Test]
public function itReturnsAllServiceChecks(): void
{
$controller = $this->createController();
$response = $controller();
$data = json_decode($response->getContent(), true);
self::assertArrayHasKey('postgres', $data['checks']);
self::assertArrayHasKey('redis', $data['checks']);
self::assertArrayHasKey('rabbitmq', $data['checks']);
}
#[Test]
public function itReturnsUnhealthyWhenRabbitmqIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => true,
'redis' => true,
'rabbitmq' => false,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertFalse($data['checks']['rabbitmq']);
}
#[Test]
public function itReturnsUnhealthyWhenRedisIsDown(): void
{
$checker = new InfrastructureHealthCheckerStub([
'postgres' => true,
'redis' => false,
'rabbitmq' => true,
]);
$controller = $this->createController($checker);
$response = $controller();
self::assertSame(Response::HTTP_SERVICE_UNAVAILABLE, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
self::assertFalse($data['checks']['redis']);
}
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantContext;
use App\Shared\Infrastructure\Tenant\TenantId;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Prometheus\CollectorRegistry;
use Prometheus\Counter;
use Prometheus\Histogram;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* @see Story 1.8 - T3.4: Custom metrics (requests_total, request_duration_seconds)
*/
#[CoversClass(MetricsCollector::class)]
final class MetricsCollectorTest extends TestCase
{
private CollectorRegistry $registry;
private TenantContext $tenantContext;
private MetricsCollector $collector;
private Counter $requestsCounter;
private Histogram $durationHistogram;
private Counter $loginFailuresCounter;
protected function setUp(): void
{
$this->requestsCounter = $this->createMock(Counter::class);
$this->durationHistogram = $this->createMock(Histogram::class);
$this->loginFailuresCounter = $this->createMock(Counter::class);
$this->registry = $this->createMock(CollectorRegistry::class);
$this->registry->method('getOrRegisterCounter')
->willReturnCallback(fn (string $ns, string $name) => match ($name) {
'http_requests_total' => $this->requestsCounter,
'login_failures_total' => $this->loginFailuresCounter,
default => $this->createMock(Counter::class),
});
$this->registry->method('getOrRegisterHistogram')
->willReturn($this->durationHistogram);
$this->tenantContext = new TenantContext();
$this->collector = new MetricsCollector($this->registry, $this->tenantContext);
}
protected function tearDown(): void
{
$this->tenantContext->clear();
}
#[Test]
public function itRecordsRequestMetricsWithoutTenant(): void
{
$request = Request::create('/api/users', 'GET');
$request->attributes->set('_route', 'get_users');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
// Simulate request start
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
// Expect metrics to be recorded with tenant_id="none"
$this->requestsCounter->expects(self::once())
->method('inc')
->with(['GET', 'get_users', '200', 'none']);
$this->durationHistogram->expects(self::once())
->method('observe')
->with(
self::greaterThan(0),
['GET', 'get_users', 'none'],
);
// Simulate request end
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itRecordsRequestMetricsWithTenant(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-alpha',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$request = Request::create('/api/users', 'POST');
$request->attributes->set('_route', 'create_user');
$response = new Response('', 201);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::once())
->method('inc')
->with(['POST', 'create_user', '201', 'ecole-alpha']);
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itSkipsMetricsEndpoint(): void
{
$request = Request::create('/metrics', 'GET');
$request->attributes->set('_route', 'prometheus_metrics');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
// Should NOT record metrics for /metrics endpoint
$this->requestsCounter->expects(self::never())->method('inc');
$this->durationHistogram->expects(self::never())->method('observe');
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itSkipsHealthEndpoint(): void
{
$request = Request::create('/health', 'GET');
$request->attributes->set('_route', 'health_check');
$response = new Response('', 200);
$kernel = $this->createMock(HttpKernelInterface::class);
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::never())->method('inc');
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
#[Test]
public function itRecordsLoginFailureWithoutTenant(): void
{
$this->loginFailuresCounter->expects(self::once())
->method('inc')
->with(['none', 'invalid_credentials']);
$this->collector->recordLoginFailure();
}
#[Test]
public function itRecordsLoginFailureWithTenant(): void
{
$tenantConfig = new TenantConfig(
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
subdomain: 'ecole-beta',
databaseUrl: 'postgresql://test@localhost/test',
);
$this->tenantContext->setCurrentTenant($tenantConfig);
$this->loginFailuresCounter->expects(self::once())
->method('inc')
->with(['ecole-beta', 'rate_limited']);
$this->collector->recordLoginFailure('rate_limited');
}
#[Test]
public function itIgnoresSubrequests(): void
{
$request = Request::create('/api/test', 'GET');
$kernel = $this->createMock(HttpKernelInterface::class);
// Subrequest should be ignored
$requestEvent = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);
$this->collector->onKernelRequest($requestEvent);
$this->requestsCounter->expects(self::never())->method('inc');
$response = new Response('', 200);
$terminateEvent = new TerminateEvent($kernel, $request, $response);
$this->collector->onKernelTerminate($terminateEvent);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface;
use App\Shared\Infrastructure\Monitoring\MetricsController;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Prometheus\CollectorRegistry;
use Prometheus\MetricFamilySamples;
use Prometheus\RenderTextFormat;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Stub for HealthMetricsCollectorInterface that doesn't make real connections.
*/
final class HealthMetricsCollectorStub implements HealthMetricsCollectorInterface
{
private bool $collected = false;
public function collect(): void
{
$this->collected = true;
}
public function wasCollectCalled(): bool
{
return $this->collected;
}
}
/**
* @see Story 1.8 - T3.3: Expose /metrics endpoint in backend
*/
#[CoversClass(MetricsController::class)]
final class MetricsControllerTest extends TestCase
{
private function createController(
?CollectorRegistry $registry = null,
string $appEnv = 'dev',
): MetricsController {
$registry ??= $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([]);
$healthMetrics = new HealthMetricsCollectorStub();
return new MetricsController($registry, $healthMetrics, $appEnv);
}
#[Test]
public function itReturnsMetricsWithCorrectContentType(): void
{
$controller = $this->createController();
$request = Request::create('/metrics');
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame(RenderTextFormat::MIME_TYPE, $response->headers->get('Content-Type'));
}
#[Test]
public function itRendersMetricsFromRegistry(): void
{
$sample = new MetricFamilySamples([
'name' => 'test_counter',
'type' => 'counter',
'help' => 'A test counter',
'labelNames' => [],
'samples' => [
[
'name' => 'test_counter',
'labelNames' => [],
'labelValues' => [],
'value' => 42,
],
],
]);
$registry = $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([$sample]);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics);
$request = Request::create('/metrics');
$response = $controller($request);
$content = $response->getContent();
self::assertStringContainsString('test_counter', $content);
self::assertStringContainsString('42', $content);
}
#[Test]
public function itReturnsEmptyResponseWhenNoMetrics(): void
{
$controller = $this->createController();
$request = Request::create('/metrics');
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertIsString($response->getContent());
}
#[Test]
public function itAllowsInternalIpInProduction(): void
{
$controller = $this->createController(appEnv: 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '172.18.0.5']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itBlocksExternalIpInProduction(): void
{
$registry = $this->createMock(CollectorRegistry::class);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics, 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Metrics endpoint is restricted to internal networks.');
$controller($request);
}
#[Test]
public function itAllowsAnyIpInDev(): void
{
$controller = $this->createController(appEnv: 'dev');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '8.8.8.8']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itAllowsLocalhostInProduction(): void
{
$controller = $this->createController(appEnv: 'prod');
$request = Request::create('/metrics', server: ['REMOTE_ADDR' => '127.0.0.1']);
$response = $controller($request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function itCollectsHealthMetricsBeforeRendering(): void
{
$registry = $this->createMock(CollectorRegistry::class);
$registry->method('getMetricFamilySamples')->willReturn([]);
$healthMetrics = new HealthMetricsCollectorStub();
$controller = new MetricsController($registry, $healthMetrics);
$request = Request::create('/metrics');
$controller($request);
self::assertTrue($healthMetrics->wasCollectCalled());
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor;
use DateTimeImmutable;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* @see Story 1.8 - T5.4: Filter PII from logs (scrubber processor)
*/
#[CoversClass(PiiScrubberLogProcessor::class)]
final class PiiScrubberLogProcessorTest extends TestCase
{
private PiiScrubberLogProcessor $processor;
protected function setUp(): void
{
$this->processor = new PiiScrubberLogProcessor();
}
#[Test]
public function itRedactsEmailInContext(): void
{
$record = $this->createLogRecord(
context: ['email' => 'user@example.com'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['email']);
}
#[Test]
public function itRedactsPasswordInContext(): void
{
$record = $this->createLogRecord(
context: ['password' => 'secret123'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['password']);
}
#[Test]
public function itRedactsNestedPii(): void
{
$record = $this->createLogRecord(
context: [
'user' => [
'id' => 'uuid-123',
'email' => 'user@example.com',
'name' => 'John Doe',
],
],
);
$result = ($this->processor)($record);
self::assertSame('uuid-123', $result->context['user']['id']);
self::assertSame('[REDACTED]', $result->context['user']['email']);
self::assertSame('[REDACTED]', $result->context['user']['name']);
}
#[Test]
public function itRedactsJwtTokensByValue(): void
{
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
$record = $this->createLogRecord(
context: ['auth_header' => $jwt],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['auth_header']);
}
#[Test]
public function itRedactsEmailValues(): void
{
$record = $this->createLogRecord(
context: ['some_field' => 'contact@school.fr'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context['some_field']);
}
#[Test]
public function itPreservesSafeValues(): void
{
$record = $this->createLogRecord(
context: [
'correlation_id' => '01234567-89ab-cdef-0123-456789abcdef',
'tenant_id' => 'tenant-uuid',
'event_type' => 'UserCreated',
'count' => 42,
],
);
$result = ($this->processor)($record);
self::assertSame('01234567-89ab-cdef-0123-456789abcdef', $result->context['correlation_id']);
self::assertSame('tenant-uuid', $result->context['tenant_id']);
self::assertSame('UserCreated', $result->context['event_type']);
self::assertSame(42, $result->context['count']);
}
#[Test]
public function itRedactsPiiInExtra(): void
{
$record = $this->createLogRecord(
extra: ['user_email' => 'admin@classeo.fr'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->extra['user_email']);
}
#[Test]
#[DataProvider('piiKeyProvider')]
public function itRedactsVariousPiiKeys(string $key): void
{
$record = $this->createLogRecord(
context: [$key => 'sensitive_value'],
);
$result = ($this->processor)($record);
self::assertSame('[REDACTED]', $result->context[$key]);
}
/**
* @return iterable<string, array{string}>
*/
public static function piiKeyProvider(): iterable
{
yield 'email' => ['email'];
yield 'password' => ['password'];
yield 'token' => ['token'];
yield 'secret' => ['secret'];
yield 'authorization' => ['authorization'];
yield 'cookie' => ['cookie'];
yield 'phone' => ['phone'];
yield 'address' => ['address'];
yield 'nom' => ['nom'];
yield 'prenom' => ['prenom'];
yield 'name' => ['name'];
yield 'firstname' => ['firstname'];
yield 'lastname' => ['lastname'];
yield 'ip' => ['ip'];
yield 'user_agent' => ['user_agent'];
yield 'user_email' => ['user_email'];
yield 'auth_token' => ['auth_token'];
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $extra
*/
private function createLogRecord(
string $message = 'Test message',
array $context = [],
array $extra = [],
): LogRecord {
return new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: $message,
context: $context,
extra: $extra,
);
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Monitoring;
use App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Sentry\Event;
/**
* Tests for PII scrubbing in Sentry events.
*
* Critical for RGPD compliance: ensures no personal data is sent to error tracking.
*
* @see Story 1.8 - T1.4: Filter PII before send (scrubber)
*/
#[CoversClass(SentryBeforeSendCallback::class)]
final class SentryBeforeSendCallbackTest extends TestCase
{
private SentryBeforeSendCallback $callback;
protected function setUp(): void
{
$this->callback = new SentryBeforeSendCallback();
}
#[Test]
public function itNeverDropsEvents(): void
{
$event = Event::createEvent();
$result = ($this->callback)($event, null);
self::assertNotNull($result);
self::assertSame($event, $result);
}
#[Test]
public function itFiltersEmailFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['user_email' => 'john@example.com', 'action' => 'login']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['user_email']);
self::assertSame('login', $extra['action']);
}
#[Test]
public function itFiltersPasswordFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['password' => 'secret123', 'user_id' => 'john']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['password']);
self::assertSame('john', $extra['user_id']);
}
#[Test]
public function itFiltersTokenFromExtra(): void
{
$event = Event::createEvent();
$event->setExtra(['auth_token' => 'abc123xyz', 'status' => 'active']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['auth_token']);
self::assertSame('active', $extra['status']);
}
#[Test]
public function itFiltersJwtTokensByValue(): void
{
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
$event = Event::createEvent();
$event->setExtra(['data' => $jwt]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['data']);
}
#[Test]
public function itFiltersEmailValues(): void
{
$event = Event::createEvent();
$event->setExtra(['contact' => 'user@example.com']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['contact']);
}
#[Test]
public function itFiltersNestedPii(): void
{
$event = Event::createEvent();
$event->setExtra([
'user' => [
'email' => 'nested@example.com',
'id' => 123,
],
]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra['user']['email']);
self::assertSame(123, $extra['user']['id']);
}
#[Test]
public function itFiltersPiiFromTags(): void
{
$event = Event::createEvent();
$event->setTags(['user_email' => 'tagged@example.com', 'environment' => 'prod']);
$result = ($this->callback)($event, null);
$tags = $result->getTags();
self::assertSame('[FILTERED]', $tags['user_email']);
self::assertSame('prod', $tags['environment']);
}
#[Test]
#[DataProvider('piiKeysProvider')]
public function itFiltersVariousPiiKeys(string $key): void
{
$event = Event::createEvent();
$event->setExtra([$key => 'sensitive_value']);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('[FILTERED]', $extra[$key], "Key '$key' should be filtered");
}
/**
* @return iterable<string, array{string}>
*/
public static function piiKeysProvider(): iterable
{
yield 'email' => ['email'];
yield 'password' => ['password'];
yield 'token' => ['token'];
yield 'secret' => ['secret'];
yield 'key' => ['api_key'];
yield 'authorization' => ['authorization'];
yield 'cookie' => ['cookie'];
yield 'session' => ['session_id'];
yield 'phone' => ['phone'];
yield 'address' => ['address'];
yield 'ip' => ['client_ip'];
yield 'nom' => ['nom'];
yield 'prenom' => ['prenom'];
yield 'name' => ['name'];
yield 'firstname' => ['firstname'];
yield 'lastname' => ['lastname'];
}
#[Test]
public function itPreservesSafeValues(): void
{
$event = Event::createEvent();
$event->setExtra([
'error_code' => 'E001',
'count' => 42,
'enabled' => true,
'data' => ['a', 'b', 'c'],
]);
$result = ($this->callback)($event, null);
$extra = $result->getExtra();
self::assertSame('E001', $extra['error_code']);
self::assertSame(42, $extra['count']);
self::assertTrue($extra['enabled']);
self::assertSame(['a', 'b', 'c'], $extra['data']);
}
}

206
compose.monitoring.yaml Normal file
View File

@@ -0,0 +1,206 @@
# =============================================================================
# MONITORING & OBSERVABILITY SERVICES
# =============================================================================
# Usage: docker compose -f compose.yaml -f compose.monitoring.yaml up -d
# =============================================================================
services:
# =============================================================================
# ERROR TRACKING - GlitchTip (Sentry-compatible)
# =============================================================================
glitchtip:
image: glitchtip/glitchtip:v4.1
container_name: classeo_glitchtip
depends_on:
glitchtip-db:
condition: service_healthy
glitchtip-redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql://glitchtip:glitchtip@glitchtip-db:5432/glitchtip
SECRET_KEY: ${GLITCHTIP_SECRET_KEY:-change_me_in_production_very_secret_key}
REDIS_URL: redis://glitchtip-redis:6379/0
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:-http://localhost:8081}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-glitchtip@classeo.local}
EMAIL_URL: ${EMAIL_URL:-smtp://mailpit:1025}
CELERY_WORKER_AUTOSCALE: "1,3"
CELERY_WORKER_MAX_TASKS_PER_CHILD: "10000"
ENABLE_ORGANIZATION_CREATION: "true"
ENABLE_USER_REGISTRATION: "true"
ports:
- "8081:8080"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/_health/')\""]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped
glitchtip-worker:
image: glitchtip/glitchtip:v4.1
container_name: classeo_glitchtip_worker
depends_on:
glitchtip-db:
condition: service_healthy
glitchtip-redis:
condition: service_healthy
environment:
DATABASE_URL: postgresql://glitchtip:glitchtip@glitchtip-db:5432/glitchtip
SECRET_KEY: ${GLITCHTIP_SECRET_KEY:-change_me_in_production_very_secret_key}
REDIS_URL: redis://glitchtip-redis:6379/0
command: ./bin/run-celery-with-beat.sh
restart: unless-stopped
glitchtip-db:
image: postgres:18.1-alpine
container_name: classeo_glitchtip_db
environment:
POSTGRES_DB: glitchtip
POSTGRES_USER: glitchtip
POSTGRES_PASSWORD: glitchtip
volumes:
- glitchtip_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
glitchtip-redis:
image: redis:7.4-alpine
container_name: classeo_glitchtip_redis
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- glitchtip_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
restart: unless-stopped
# =============================================================================
# METRICS - Prometheus
# =============================================================================
prometheus:
image: prom/prometheus:v3.2.0
container_name: classeo_prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
- '--web.enable-lifecycle'
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
ports:
- "9090:9090"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
# =============================================================================
# DASHBOARDS - Grafana
# =============================================================================
grafana:
image: grafana/grafana:11.4.0
container_name: classeo_grafana
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3001}
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
depends_on:
prometheus:
condition: service_healthy
loki:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# =============================================================================
# LOGS - Loki
# =============================================================================
loki:
image: grafana/loki:3.3.2
container_name: classeo_loki
command: -config.file=/etc/loki/config.yml
volumes:
- ./monitoring/loki/config.yml:/etc/loki/config.yml:ro
- loki_data:/loki
ports:
- "3100:3100"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
# =============================================================================
# LOG COLLECTOR - Promtail
# =============================================================================
promtail:
image: grafana/promtail:3.3.2
container_name: classeo_promtail
command: -config.file=/etc/promtail/config.yml
volumes:
- ./monitoring/promtail/config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
# =============================================================================
# ALERTING - Alertmanager
# =============================================================================
alertmanager:
image: prom/alertmanager:v0.28.0
container_name: classeo_alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
- ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
ports:
- "9093:9093"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
# =============================================================================
# VOLUMES PERSISTANTS MONITORING
# =============================================================================
volumes:
glitchtip_postgres_data:
glitchtip_redis_data:
prometheus_data:
grafana_data:
loki_data:
alertmanager_data:

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

View File

@@ -0,0 +1,95 @@
# Alertmanager Configuration for Classeo
# NFR-OB2: Notification channels for SLA alerts
global:
resolve_timeout: 5m
# SMTP settings for email alerts (configure in production)
smtp_smarthost: 'mailpit:1025'
smtp_from: 'alertmanager@classeo.local'
smtp_require_tls: false
# Templates for notification messages
templates:
- '/etc/alertmanager/templates/*.tmpl'
# Routing tree for alert handling
route:
# Default receiver
receiver: 'platform-team'
# Group alerts by alertname and severity
group_by: ['alertname', 'severity']
# Wait time before sending first notification
group_wait: 30s
# Wait time before sending next batch
group_interval: 5m
# Wait time before resending same alert
repeat_interval: 4h
# Child routes for specific teams
routes:
# Critical alerts: immediate notification
- receiver: 'platform-team-critical'
match:
severity: critical
group_wait: 10s
repeat_interval: 1h
# Security alerts: route to security team
- receiver: 'security-team'
match:
team: security
group_wait: 30s
repeat_interval: 2h
# Inhibition rules - suppress less severe alerts when critical alert is firing
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'instance']
# Notification receivers
receivers:
# Default platform team receiver
- name: 'platform-team'
email_configs:
- to: 'platform@classeo.local'
send_resolved: true
# Slack integration (configure webhook in production)
# slack_configs:
# - api_url: '${SLACK_WEBHOOK_URL}'
# channel: '#platform-alerts'
# send_resolved: true
# title: '{{ .Status | toUpper }}: {{ .CommonLabels.alertname }}'
# text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
# Critical alerts - higher priority
- name: 'platform-team-critical'
email_configs:
- to: 'platform-critical@classeo.local'
send_resolved: true
# Slack integration for critical alerts
# slack_configs:
# - api_url: '${SLACK_WEBHOOK_URL}'
# channel: '#platform-critical'
# send_resolved: true
# title: ':rotating_light: CRITICAL: {{ .CommonLabels.alertname }}'
# text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
# PagerDuty integration (configure in production)
# pagerduty_configs:
# - service_key: '${PAGERDUTY_SERVICE_KEY}'
# severity: critical
# Security team receiver
- name: 'security-team'
email_configs:
- to: 'security@classeo.local'
send_resolved: true
# Slack integration for security alerts
# slack_configs:
# - api_url: '${SLACK_SECURITY_WEBHOOK_URL}'
# channel: '#security-alerts'
# send_resolved: true
# title: ':lock: Security Alert: {{ .CommonLabels.alertname }}'
# text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'

View File

@@ -0,0 +1,16 @@
# Grafana Dashboard Provisioning
# Auto-loads dashboards from JSON files
apiVersion: 1
providers:
- name: 'Classeo Dashboards'
orgId: 1
folder: 'Classeo'
folderUid: 'classeo'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards/json

View File

@@ -0,0 +1,466 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"panels": [],
"title": "SLA Overview",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 150 },
{ "color": "red", "value": 200 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(classeo_http_request_duration_seconds_bucket{job=\"classeo-backend\"}[5m])) by (le)) * 1000",
"legendFormat": "P95",
"refId": "A"
}
],
"title": "API Latency P95",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 350 },
{ "color": "red", "value": 500 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "histogram_quantile(0.99, sum(rate(classeo_http_request_duration_seconds_bucket{job=\"classeo-backend\"}[5m])) by (le)) * 1000",
"legendFormat": "P99",
"refId": "A"
}
],
"title": "API Latency P99",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 1 }
]
},
"unit": "percent"
}
},
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{job=\"classeo-backend\",status=~\"5..\"}[5m])) / sum(rate(classeo_http_requests_total{job=\"classeo-backend\"}[5m])) * 100",
"legendFormat": "Error Rate",
"refId": "A"
}
],
"title": "Error Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "reqps"
}
},
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{job=\"classeo-backend\"}[5m]))",
"legendFormat": "RPS",
"refId": "A"
}
],
"title": "Requests/Second",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"id": 6,
"panels": [],
"title": "Request Metrics",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "line" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"id": 7,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(classeo_http_request_duration_seconds_bucket{job=\"classeo-backend\"}[5m])) by (le)) * 1000",
"legendFormat": "P50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum(rate(classeo_http_request_duration_seconds_bucket{job=\"classeo-backend\"}[5m])) by (le)) * 1000",
"legendFormat": "P95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum(rate(classeo_http_request_duration_seconds_bucket{job=\"classeo-backend\"}[5m])) by (le)) * 1000",
"legendFormat": "P99",
"refId": "C"
}
],
"title": "API Latency Distribution",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" }
},
"mappings": [],
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"id": 8,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{job=\"classeo-backend\",status=~\"2..\"}[5m])) by (status)",
"legendFormat": "{{ status }}",
"refId": "A"
},
{
"expr": "sum(rate(classeo_http_requests_total{job=\"classeo-backend\",status=~\"4..\"}[5m])) by (status)",
"legendFormat": "{{ status }}",
"refId": "B"
},
{
"expr": "sum(rate(classeo_http_requests_total{job=\"classeo-backend\",status=~\"5..\"}[5m])) by (status)",
"legendFormat": "{{ status }}",
"refId": "C"
}
],
"title": "Requests by Status Code",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
"id": 9,
"panels": [],
"title": "Infrastructure Health",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
}
},
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 15 },
"id": 10,
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "up{job=\"classeo-backend\"}",
"legendFormat": "Backend",
"refId": "A"
}
],
"title": "Backend",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{ "options": { "healthy": { "color": "green", "index": 0, "text": "HEALTHY" }, "degraded": { "color": "yellow", "index": 1, "text": "DEGRADED" }, "unhealthy": { "color": "red", "index": 2, "text": "UNHEALTHY" } }, "type": "value" }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 15 },
"id": 11,
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "classeo_health_check_status{service=\"postgres\"}",
"legendFormat": "PostgreSQL",
"refId": "A"
}
],
"title": "PostgreSQL",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{ "options": { "healthy": { "color": "green", "index": 0, "text": "HEALTHY" }, "degraded": { "color": "yellow", "index": 1, "text": "DEGRADED" }, "unhealthy": { "color": "red", "index": 2, "text": "UNHEALTHY" } }, "type": "value" }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 15 },
"id": 12,
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "classeo_health_check_status{service=\"redis\"}",
"legendFormat": "Redis",
"refId": "A"
}
],
"title": "Redis",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [
{ "options": { "healthy": { "color": "green", "index": 0, "text": "HEALTHY" }, "degraded": { "color": "yellow", "index": 1, "text": "DEGRADED" }, "unhealthy": { "color": "red", "index": 2, "text": "UNHEALTHY" } }, "type": "value" }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 15 },
"id": 13,
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "classeo_health_check_status{service=\"rabbitmq\"}",
"legendFormat": "RabbitMQ",
"refId": "A"
}
],
"title": "RabbitMQ",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 19 },
"id": 14,
"panels": [],
"title": "Logs",
"type": "row"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 },
"id": 15,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "{service=\"php\"} |= ``",
"legendFormat": "",
"refId": "A"
}
],
"title": "Backend Logs",
"type": "logs"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["classeo", "sla", "overview"],
"templating": {
"list": [
{
"current": { "selected": false, "text": "All", "value": "$__all" },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"definition": "label_values(classeo_http_requests_total, tenant_id)",
"hide": 0,
"includeAll": true,
"label": "Tenant",
"multi": true,
"name": "tenant_id",
"options": [],
"query": { "qryType": 1, "query": "label_values(classeo_http_requests_total, tenant_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery" },
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "Classeo - Main Dashboard",
"uid": "classeo-main",
"version": 1,
"weekStart": ""
}

View File

@@ -0,0 +1,354 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [
{
"asDropdown": false,
"icon": "external link",
"includeVars": false,
"keepTime": true,
"tags": [],
"targetBlank": true,
"title": "Main Dashboard",
"tooltip": "",
"type": "link",
"url": "/d/classeo-main"
}
],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 1,
"panels": [],
"title": "Tenant: $tenant_id",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 150 },
{ "color": "red", "value": 200 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(classeo_http_request_duration_seconds_bucket{tenant_id=\"$tenant_id\"}[5m])) by (le)) * 1000",
"legendFormat": "P95",
"refId": "A"
}
],
"title": "Tenant P95 Latency",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 1 }
]
},
"unit": "percent"
}
},
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{tenant_id=\"$tenant_id\",status=~\"5..\"}[5m])) / sum(rate(classeo_http_requests_total{tenant_id=\"$tenant_id\"}[5m])) * 100",
"legendFormat": "Error Rate",
"refId": "A"
}
],
"title": "Tenant Error Rate",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "reqps"
}
},
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{tenant_id=\"$tenant_id\"}[5m]))",
"legendFormat": "RPS",
"refId": "A"
}
],
"title": "Tenant RPS",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
"unit": "none"
}
},
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_login_failures_total{tenant_id=\"$tenant_id\"}[5m])) * 60",
"legendFormat": "Failed Logins/min",
"refId": "A"
}
],
"title": "Login Failures/min",
"type": "stat"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
"id": 6,
"panels": [],
"title": "Request Metrics",
"type": "row"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "line" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 }
]
},
"unit": "ms"
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"id": 7,
"options": {
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(classeo_http_request_duration_seconds_bucket{tenant_id=\"$tenant_id\"}[5m])) by (le)) * 1000",
"legendFormat": "P50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, sum(rate(classeo_http_request_duration_seconds_bucket{tenant_id=\"$tenant_id\"}[5m])) by (le)) * 1000",
"legendFormat": "P95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, sum(rate(classeo_http_request_duration_seconds_bucket{tenant_id=\"$tenant_id\"}[5m])) by (le)) * 1000",
"legendFormat": "P99",
"refId": "C"
}
],
"title": "Latency Distribution",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" }
},
"mappings": [],
"unit": "reqps"
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
"id": 8,
"options": {
"legend": { "calcs": ["mean", "sum"], "displayMode": "table", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "sum(rate(classeo_http_requests_total{tenant_id=\"$tenant_id\"}[5m])) by (route)",
"legendFormat": "{{ route }}",
"refId": "A"
}
],
"title": "Requests by Route",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
"id": 9,
"panels": [],
"title": "Logs",
"type": "row"
},
{
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 15 },
"id": 10,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
"targets": [
{
"expr": "{tenant_id=\"$tenant_id\"}",
"legendFormat": "",
"refId": "A"
}
],
"title": "Tenant Logs",
"type": "logs"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["classeo", "tenant", "multi-tenant"],
"templating": {
"list": [
{
"current": {},
"datasource": { "type": "prometheus", "uid": "prometheus" },
"definition": "label_values(classeo_http_requests_total, tenant_id)",
"hide": 0,
"includeAll": false,
"label": "Tenant",
"multi": false,
"name": "tenant_id",
"options": [],
"query": { "qryType": 1, "query": "label_values(classeo_http_requests_total, tenant_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery" },
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"type": "query"
}
]
},
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "Classeo - Per Tenant",
"uid": "classeo-tenant",
"version": 1,
"weekStart": ""
}

View File

@@ -0,0 +1,44 @@
# Grafana Datasources Provisioning
# Auto-configures Prometheus and Loki connections
apiVersion: 1
datasources:
# Prometheus - Metrics
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
timeInterval: "15s"
httpMethod: POST
# Loki - Logs
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
editable: false
jsonData:
maxLines: 1000
derivedFields:
# Link correlation_id to traces
- name: correlation_id
matcherRegex: '"correlation_id":"([^"]+)"'
url: '/explore?orgId=1&left=["now-1h","now","Loki",{"expr":"{correlation_id=\"$${__value.raw}\"}"}]'
datasourceUid: loki
urlDisplayLabel: "View correlated logs"
# Alertmanager
- name: Alertmanager
uid: alertmanager
type: alertmanager
access: proxy
url: http://alertmanager:9093
editable: false
jsonData:
implementation: prometheus

View File

@@ -0,0 +1,61 @@
# Loki Configuration for Classeo
# NFR-OB4: Log retention 30 days
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: info
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://alertmanager:9093
# NFR-OB4: 30 days retention
limits_config:
retention_period: 720h # 30 days
max_query_length: 721h
max_query_parallelism: 32
max_entries_limit_per_query: 10000
ingestion_rate_mb: 4
ingestion_burst_size_mb: 6
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: filesystem
analytics:
reporting_enabled: false

View File

@@ -0,0 +1,143 @@
# Prometheus Alert Rules for Classeo
# NFR-OB2: Automated alerts when SLA threatened (< 5 min detection)
groups:
# =============================================================================
# SLA & Performance Alerts
# =============================================================================
- name: sla_alerts
rules:
# NFR-P4: API response time P95 < 200ms
- alert: HighApiLatencyP95
expr: histogram_quantile(0.95, sum(rate(classeo_http_request_duration_seconds_bucket{job="classeo-backend"}[5m])) by (le)) > 0.2
for: 2m
labels:
severity: warning
team: platform
annotations:
summary: "API P95 latency above SLA threshold"
description: "P95 latency is {{ $value | humanizeDuration }} (threshold: 200ms)"
runbook_url: "https://docs.classeo.local/runbooks/high-latency"
# NFR-P5: API response time P99 < 500ms
- alert: HighApiLatencyP99
expr: histogram_quantile(0.99, sum(rate(classeo_http_request_duration_seconds_bucket{job="classeo-backend"}[5m])) by (le)) > 0.5
for: 5m
labels:
severity: critical
team: platform
annotations:
summary: "API P99 latency critically high"
description: "P99 latency is {{ $value | humanizeDuration }} (threshold: 500ms)"
runbook_url: "https://docs.classeo.local/runbooks/high-latency"
# Error rate > 1% (AC3: error rate > 1% pendant 2 min)
- alert: HighErrorRate
expr: sum(rate(classeo_http_requests_total{status=~"5.."}[2m])) / sum(rate(classeo_http_requests_total[2m])) > 0.01
for: 2m
labels:
severity: critical
team: platform
annotations:
summary: "High error rate detected"
description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
runbook_url: "https://docs.classeo.local/runbooks/high-error-rate"
# =============================================================================
# Infrastructure Alerts
# =============================================================================
- name: infrastructure_alerts
rules:
# Redis memory usage
- alert: RedisHighMemoryUsage
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
for: 5m
labels:
severity: warning
team: platform
annotations:
summary: "Redis memory usage above 80%"
description: "Redis is using {{ $value | humanizePercentage }} of available memory"
runbook_url: "https://docs.classeo.local/runbooks/redis-memory"
# Database connection issues
- alert: DatabaseConnectionFailed
expr: pg_up == 0
for: 1m
labels:
severity: critical
team: platform
annotations:
summary: "PostgreSQL connection failed"
description: "Cannot connect to PostgreSQL database"
runbook_url: "https://docs.classeo.local/runbooks/database-down"
# RabbitMQ queue backlog
- alert: RabbitMQQueueBacklog
expr: rabbitmq_queue_messages > 10000
for: 10m
labels:
severity: warning
team: platform
annotations:
summary: "RabbitMQ queue backlog growing"
description: "Queue has {{ $value }} messages pending"
runbook_url: "https://docs.classeo.local/runbooks/rabbitmq-backlog"
# =============================================================================
# Security Alerts
# =============================================================================
- name: security_alerts
rules:
# NFR-S2: Excessive login failures (potential brute force)
- alert: ExcessiveLoginFailures
expr: sum(rate(classeo_login_failures_total[5m])) > 10
for: 2m
labels:
severity: warning
team: security
annotations:
summary: "Excessive login failures detected"
description: "More than 10 failed logins per minute"
runbook_url: "https://docs.classeo.local/runbooks/brute-force"
# Per-tenant excessive login failures
- alert: TenantExcessiveLoginFailures
expr: sum by (tenant_id) (rate(classeo_login_failures_total[5m])) > 5
for: 5m
labels:
severity: warning
team: security
annotations:
summary: "Excessive login failures for tenant {{ $labels.tenant_id }}"
description: "More than 5 failed logins per minute for single tenant"
runbook_url: "https://docs.classeo.local/runbooks/brute-force"
# =============================================================================
# Application Health Alerts
# =============================================================================
- name: application_alerts
rules:
# Backend scrape target down
- alert: ApplicationUnhealthy
expr: up{job="classeo-backend"} == 0
for: 1m
labels:
severity: critical
team: platform
annotations:
summary: "Backend application is down"
description: "Cannot scrape metrics from backend - application may be crashed or unreachable"
runbook_url: "https://docs.classeo.local/runbooks/health-check"
# Infrastructure service unhealthy (postgres, redis, rabbitmq)
- alert: InfrastructureServiceUnhealthy
expr: classeo_health_check_status == 0
for: 2m
labels:
severity: warning
team: platform
annotations:
summary: "Infrastructure service {{ $labels.service }} is unhealthy"
description: "Health check for {{ $labels.service }} is failing"
runbook_url: "https://docs.classeo.local/runbooks/degraded-mode"

View File

@@ -0,0 +1,52 @@
# Prometheus Configuration for Classeo
# Scrapes metrics from PHP backend and other services
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
environment: ${ENVIRONMENT:-development}
project: classeo
# Alerting configuration
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
# Load alert rules
rule_files:
- /etc/prometheus/alerts.yml
# Scrape configurations
scrape_configs:
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# PHP Backend metrics
- job_name: 'classeo-backend'
metrics_path: '/metrics'
static_configs:
- targets: ['php:8000']
relabel_configs:
- source_labels: [__address__]
target_label: instance
replacement: 'classeo-backend'
# Redis metrics (via redis_exporter would be added in production)
# For now, we rely on application-level metrics
# PostgreSQL metrics (via postgres_exporter would be added in production)
# For now, we rely on application-level metrics
# RabbitMQ metrics
- job_name: 'rabbitmq'
static_configs:
- targets: ['rabbitmq:15692']
relabel_configs:
- source_labels: [__address__]
target_label: instance
replacement: 'classeo-rabbitmq'

View File

@@ -0,0 +1,72 @@
# Promtail Configuration for Classeo
# Collects logs from Docker containers and ships to Loki
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
# Docker container logs via Docker socket
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
# Only scrape classeo containers
- source_labels: ['__meta_docker_container_name']
regex: '/classeo_.*'
action: keep
# Extract container name as label
- source_labels: ['__meta_docker_container_name']
regex: '/classeo_(.*)'
target_label: service
# Add environment label
- source_labels: []
target_label: environment
replacement: ${ENVIRONMENT:-development}
# Add project label
- source_labels: []
target_label: project
replacement: classeo
pipeline_stages:
# Parse JSON logs from PHP backend
- json:
expressions:
level: level
message: message
channel: channel
correlation_id: extra.correlation_id
tenant_id: extra.tenant_id
user_id: context.user_id
timestamp: datetime
source: log
# Extract labels from parsed JSON
- labels:
level:
channel:
correlation_id:
tenant_id:
# Set timestamp from log entry
- timestamp:
source: timestamp
format: "2006-01-02T15:04:05.000000Z07:00"
fallback_formats:
- "2006-01-02T15:04:05Z07:00"
- RFC3339
# Filter out health check noise
- match:
selector: '{service="php"}'
stages:
- drop:
expression: '.*GET /health.*'
drop_counter_reason: health_check_noise
- drop:
expression: '.*GET /metrics.*'
drop_counter_reason: metrics_endpoint_noise

17
scripts/hooks/pre-push Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Pre-push hook: runs CI checks and E2E tests before pushing
# This ensures code quality and prevents broken builds on the remote.
#
# Install: make setup-hooks
# Skip: git push --no-verify
set -e
echo "🔍 Running CI checks before push..."
make ci
echo "🧪 Running E2E tests..."
make e2e
echo "✅ All checks passed! Pushing..."