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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ pnpm-debug.log*
|
|||||||
correlation_id
|
correlation_id
|
||||||
tenant_id
|
tenant_id
|
||||||
test-results/
|
test-results/
|
||||||
|
compose.override.yaml
|
||||||
|
|||||||
76
Makefile
76
Makefile
@@ -4,40 +4,81 @@
|
|||||||
# Docker
|
# Docker
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# Fichiers compose - ajouter monitoring si MONITORING=1
|
||||||
|
COMPOSE_FILES := -f compose.yaml
|
||||||
|
ifdef MONITORING
|
||||||
|
COMPOSE_FILES += -f compose.monitoring.yaml
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: up
|
.PHONY: up
|
||||||
up: ## Lancer tous les services
|
up: ## Lancer les services (ajouter MONITORING=1 pour inclure observabilité)
|
||||||
docker compose up -d
|
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
|
.PHONY: down
|
||||||
down: ## Arrêter tous les services
|
down: ## Arrêter tous les services (app + monitoring)
|
||||||
docker compose down
|
docker compose -f compose.yaml -f compose.monitoring.yaml down --remove-orphans
|
||||||
|
|
||||||
.PHONY: restart
|
.PHONY: restart
|
||||||
restart: ## Redémarrer tous les services
|
restart: ## Redémarrer les services
|
||||||
docker compose down
|
docker compose $(COMPOSE_FILES) down
|
||||||
docker compose up -d
|
docker compose $(COMPOSE_FILES) up -d
|
||||||
|
|
||||||
.PHONY: rebuild
|
.PHONY: rebuild
|
||||||
rebuild: ## Reconstruire et relancer les services (sans cache)
|
rebuild: ## Reconstruire et relancer les services (sans cache)
|
||||||
docker compose down
|
docker compose $(COMPOSE_FILES) down
|
||||||
docker compose build --no-cache
|
docker compose $(COMPOSE_FILES) build --no-cache
|
||||||
docker compose up -d
|
docker compose $(COMPOSE_FILES) up -d
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Reconstruire les images Docker (sans cache)
|
build: ## Reconstruire les images Docker (sans cache)
|
||||||
docker compose build --no-cache
|
docker compose $(COMPOSE_FILES) build --no-cache
|
||||||
|
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
logs: ## Voir les logs de tous les services (Ctrl+C pour quitter)
|
logs: ## Voir les logs de tous les services (Ctrl+C pour quitter)
|
||||||
docker compose logs -f
|
docker compose $(COMPOSE_FILES) logs -f
|
||||||
|
|
||||||
.PHONY: ps
|
.PHONY: ps
|
||||||
ps: ## Afficher le statut des services
|
ps: ## Afficher le statut des services
|
||||||
docker compose ps
|
docker compose $(COMPOSE_FILES) ps
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## Supprimer volumes et images locales
|
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
|
# Shell
|
||||||
@@ -172,6 +213,13 @@ ci: ## Lancer TOUS les tests et checks (comme en CI)
|
|||||||
# Scripts
|
# 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
|
.PHONY: check-bc
|
||||||
check-bc: ## Vérifier l'isolation des Bounded Contexts
|
check-bc: ## Vérifier l'isolation des Bounded Contexts
|
||||||
./scripts/check-bc-isolation.sh
|
./scripts/check-bc-isolation.sh
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -72,6 +72,46 @@ make token-beta role=ROLE_PROF email=prof@test.com
|
|||||||
| Mailpit | http://localhost:8025 | Emails de test |
|
| Mailpit | http://localhost:8025 | Emails de test |
|
||||||
| Mercure | http://localhost:3000/.well-known/mercure | SSE Hub |
|
| 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
|
## Stack Technique
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
@@ -138,7 +178,9 @@ Chaque Bounded Context suit la même structure :
|
|||||||
### Workflow quotidien
|
### Workflow quotidien
|
||||||
|
|
||||||
```bash
|
```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 logs # Suivre les logs
|
||||||
make test # Lancer les tests avant commit
|
make test # Lancer les tests avant commit
|
||||||
make check # Vérifier la qualité du code
|
make check # Vérifier la qualité du code
|
||||||
|
|||||||
@@ -89,3 +89,11 @@ TURNSTILE_FAIL_OPEN=true
|
|||||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||||
LOCK_DSN=flock
|
LOCK_DSN=flock
|
||||||
###< symfony/lock ###
|
###< 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 ###
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"doctrine/orm": "^3.3",
|
"doctrine/orm": "^3.3",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
|
"promphp/prometheus_client_php": "^2.14",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
|
"sentry/sentry-symfony": "^5.8",
|
||||||
"symfony/amqp-messenger": "^8.0",
|
"symfony/amqp-messenger": "^8.0",
|
||||||
"symfony/asset": "^8.0",
|
"symfony/asset": "^8.0",
|
||||||
"symfony/console": "^8.0",
|
"symfony/console": "^8.0",
|
||||||
|
|||||||
675
backend/composer.lock
generated
675
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0",
|
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/core",
|
"name": "api-platform/core",
|
||||||
@@ -1457,6 +1457,182 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"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",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -1814,6 +1990,74 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-12T15:59:08+00:00"
|
"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",
|
"name": "psr/cache",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -2014,6 +2258,114 @@
|
|||||||
},
|
},
|
||||||
"time": "2019-01-08T18:20:26+00:00"
|
"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",
|
"name": "psr/link",
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -2120,6 +2472,50 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"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",
|
"name": "ramsey/collection",
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
@@ -2274,6 +2670,196 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"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",
|
"name": "symfony/amqp-messenger",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -5496,6 +6082,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-27T16:18:07+00:00"
|
"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",
|
"name": "symfony/rate-limiter",
|
||||||
"version": "v8.0.5",
|
"version": "v8.0.5",
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ return [
|
|||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
|
Sentry\SentryBundle\SentryBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ security:
|
|||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
security: false
|
security: false
|
||||||
|
# Monitoring endpoints - no authentication, restricted by IP in production
|
||||||
|
monitoring:
|
||||||
|
pattern: ^/(health|metrics)$
|
||||||
|
stateless: true
|
||||||
|
security: false
|
||||||
api_login:
|
api_login:
|
||||||
pattern: ^/api/login$
|
pattern: ^/api/login$
|
||||||
stateless: true
|
stateless: true
|
||||||
|
|||||||
24
backend/config/packages/sentry.yaml
Normal file
24
backend/config/packages/sentry.yaml
Normal 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: ''
|
||||||
@@ -167,6 +167,62 @@ services:
|
|||||||
App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface:
|
App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface:
|
||||||
alias: App\Shared\Infrastructure\Captcha\TurnstileValidator
|
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
|
# Test environment overrides
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
|||||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
|
||||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||||
@@ -41,6 +42,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
|||||||
private MessageBusInterface $eventBus,
|
private MessageBusInterface $eventBus,
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
private TenantResolver $tenantResolver,
|
private TenantResolver $tenantResolver,
|
||||||
|
private MetricsCollector $metricsCollector,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +70,9 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
|||||||
occurredOn: $this->clock->now(),
|
occurredOn: $this->clock->now(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Record metric for Prometheus alerting
|
||||||
|
$this->metricsCollector->recordLoginFailure('invalid_credentials');
|
||||||
|
|
||||||
// If the IP was just blocked
|
// If the IP was just blocked
|
||||||
if ($result->ipBlocked) {
|
if ($result->ipBlocked) {
|
||||||
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php
Normal file
95
backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
|||||||
'/_profiler',
|
'/_profiler',
|
||||||
'/_wdt',
|
'/_wdt',
|
||||||
'/_error',
|
'/_error',
|
||||||
|
'/health',
|
||||||
|
'/metrics',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function onKernelRequest(RequestEvent $event): void
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
@@ -49,16 +51,17 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
|||||||
|
|
||||||
$request = $event->getRequest();
|
$request = $event->getRequest();
|
||||||
$path = $request->getPathInfo();
|
$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) {
|
foreach (self::PUBLIC_PATHS as $publicPath) {
|
||||||
if (str_starts_with($path, $publicPath)) {
|
if (str_starts_with($path, $publicPath)) {
|
||||||
return;
|
$isPublicPath = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $request->getHost();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$config = $this->resolver->resolve($host);
|
$config = $this->resolver->resolve($host);
|
||||||
$this->context->setCurrentTenant($config);
|
$this->context->setCurrentTenant($config);
|
||||||
@@ -66,7 +69,12 @@ final readonly class TenantMiddleware implements EventSubscriberInterface
|
|||||||
// Store tenant config in request for easy access
|
// Store tenant config in request for easy access
|
||||||
$request->attributes->set('_tenant', $config);
|
$request->attributes->set('_tenant', $config);
|
||||||
} catch (TenantNotFoundException) {
|
} 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(
|
$response = new JsonResponse(
|
||||||
[
|
[
|
||||||
'status' => Response::HTTP_NOT_FOUND,
|
'status' => Response::HTTP_NOT_FOUND,
|
||||||
|
|||||||
@@ -121,6 +121,18 @@
|
|||||||
"bin/phpunit"
|
"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": {
|
"symfony/console": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
206
compose.monitoring.yaml
Normal 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:
|
||||||
@@ -49,8 +49,10 @@
|
|||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sentry/sveltekit": "^8.50.0",
|
||||||
"@tanstack/svelte-query": "^5.66.0",
|
"@tanstack/svelte-query": "^5.66.0",
|
||||||
"@vite-pwa/sveltekit": "^0.6.8",
|
"@vite-pwa/sveltekit": "^0.6.8",
|
||||||
|
"web-vitals": "^4.2.0",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.28.2"
|
"packageManager": "pnpm@10.28.2"
|
||||||
|
|||||||
1223
frontend/pnpm-lock.yaml
generated
1223
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
21
frontend/src/lib/monitoring/index.ts
Normal file
21
frontend/src/lib/monitoring/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Frontend monitoring module.
|
||||||
|
*
|
||||||
|
* Provides error tracking (Sentry/GlitchTip) and performance monitoring (Web Vitals).
|
||||||
|
*
|
||||||
|
* @see Story 1.8 - T8: Frontend Monitoring
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
initSentry,
|
||||||
|
setUserContext,
|
||||||
|
clearUserContext,
|
||||||
|
captureError,
|
||||||
|
addBreadcrumb
|
||||||
|
} from './sentry';
|
||||||
|
|
||||||
|
export {
|
||||||
|
initWebVitals,
|
||||||
|
createDefaultReporter,
|
||||||
|
type VitalMetric
|
||||||
|
} from './webVitals';
|
||||||
130
frontend/src/lib/monitoring/sentry.ts
Normal file
130
frontend/src/lib/monitoring/sentry.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Sentry/GlitchTip initialization for frontend error tracking.
|
||||||
|
*
|
||||||
|
* @see Story 1.8 - T8: Frontend Monitoring (AC: #1)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/sveltekit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Sentry for error tracking.
|
||||||
|
*
|
||||||
|
* Call this once in +hooks.client.ts or +layout.svelte.
|
||||||
|
* Critical: No PII is sent to GlitchTip (RGPD compliance).
|
||||||
|
*/
|
||||||
|
export function initSentry(options: {
|
||||||
|
dsn: string;
|
||||||
|
environment: string;
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}): void {
|
||||||
|
if (!options.dsn) {
|
||||||
|
console.warn('[Sentry] DSN not configured, error tracking disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: options.dsn,
|
||||||
|
environment: options.environment,
|
||||||
|
|
||||||
|
// Capture 100% of errors
|
||||||
|
sampleRate: 1.0,
|
||||||
|
|
||||||
|
// Disable performance tracing (using server-side Prometheus)
|
||||||
|
tracesSampleRate: 0.0,
|
||||||
|
|
||||||
|
// CRITICAL: No PII in error reports (RGPD compliance)
|
||||||
|
sendDefaultPii: false,
|
||||||
|
|
||||||
|
// Scrub sensitive data before sending
|
||||||
|
beforeSend(event) {
|
||||||
|
// Remove any accidentally captured PII
|
||||||
|
if (event.request?.headers) {
|
||||||
|
delete event.request.headers['Authorization'];
|
||||||
|
delete event.request.headers['Cookie'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove email-like strings from breadcrumbs
|
||||||
|
if (event.breadcrumbs) {
|
||||||
|
event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
|
||||||
|
if (breadcrumb.message && breadcrumb.message.includes('@')) {
|
||||||
|
breadcrumb.message = '[EMAIL_REDACTED]';
|
||||||
|
}
|
||||||
|
return breadcrumb;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ignore common non-errors
|
||||||
|
ignoreErrors: [
|
||||||
|
// Browser extensions
|
||||||
|
'ResizeObserver loop',
|
||||||
|
'ResizeObserver loop limit exceeded',
|
||||||
|
// Network errors (expected in offline scenarios)
|
||||||
|
'NetworkError',
|
||||||
|
'Failed to fetch',
|
||||||
|
'Load failed',
|
||||||
|
// User cancellation
|
||||||
|
'AbortError',
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set user context (ID only, no PII)
|
||||||
|
if (options.userId) {
|
||||||
|
Sentry.setUser({ id: options.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set tenant context as tag
|
||||||
|
if (options.tenantId) {
|
||||||
|
Sentry.setTag('tenant_id', options.tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user context after login.
|
||||||
|
*
|
||||||
|
* Only sends user ID, never email or name (RGPD compliance).
|
||||||
|
*/
|
||||||
|
export function setUserContext(userId: string, tenantId?: string): void {
|
||||||
|
Sentry.setUser({ id: userId });
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
Sentry.setTag('tenant_id', tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear user context on logout.
|
||||||
|
*/
|
||||||
|
export function clearUserContext(): void {
|
||||||
|
Sentry.setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture an error manually.
|
||||||
|
*
|
||||||
|
* Use this for caught exceptions that should still be tracked.
|
||||||
|
*/
|
||||||
|
export function captureError(error: unknown, context?: Record<string, unknown>): void {
|
||||||
|
Sentry.captureException(error, context ? { extra: context } : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a breadcrumb for debugging.
|
||||||
|
*
|
||||||
|
* Breadcrumbs show the trail of actions leading to an error.
|
||||||
|
*/
|
||||||
|
export function addBreadcrumb(
|
||||||
|
category: string,
|
||||||
|
message: string,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
Sentry.addBreadcrumb({
|
||||||
|
category,
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
...(data && { data })
|
||||||
|
});
|
||||||
|
}
|
||||||
115
frontend/src/lib/monitoring/webVitals.ts
Normal file
115
frontend/src/lib/monitoring/webVitals.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Web Vitals monitoring for frontend performance.
|
||||||
|
*
|
||||||
|
* Captures Core Web Vitals (LCP, FID, CLS) and sends to analytics.
|
||||||
|
* These metrics are critical for user experience and SEO.
|
||||||
|
*
|
||||||
|
* @see Story 1.8 - T8.4: Web Vitals (FCP, LCP, TTI)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals';
|
||||||
|
|
||||||
|
type VitalsReporter = (metric: VitalMetric) => void;
|
||||||
|
|
||||||
|
export interface VitalMetric {
|
||||||
|
name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';
|
||||||
|
value: number;
|
||||||
|
rating: 'good' | 'needs-improvement' | 'poor';
|
||||||
|
delta: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Vitals thresholds (Core Web Vitals 2024 standards).
|
||||||
|
*
|
||||||
|
* - LCP (Largest Contentful Paint): < 2.5s good, < 4s needs improvement
|
||||||
|
* - FCP (First Contentful Paint): < 1.8s good, < 3s needs improvement
|
||||||
|
* - INP (Interaction to Next Paint): < 200ms good, < 500ms needs improvement
|
||||||
|
* - CLS (Cumulative Layout Shift): < 0.1 good, < 0.25 needs improvement
|
||||||
|
* - TTFB (Time to First Byte): < 800ms good, < 1.8s needs improvement
|
||||||
|
*/
|
||||||
|
const THRESHOLDS = {
|
||||||
|
LCP: { good: 2500, needsImprovement: 4000 },
|
||||||
|
FCP: { good: 1800, needsImprovement: 3000 },
|
||||||
|
INP: { good: 200, needsImprovement: 500 },
|
||||||
|
CLS: { good: 0.1, needsImprovement: 0.25 },
|
||||||
|
TTFB: { good: 800, needsImprovement: 1800 }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function getRating(
|
||||||
|
name: VitalMetric['name'],
|
||||||
|
value: number
|
||||||
|
): 'good' | 'needs-improvement' | 'poor' {
|
||||||
|
const threshold = THRESHOLDS[name];
|
||||||
|
if (value <= threshold.good) return 'good';
|
||||||
|
if (value <= threshold.needsImprovement) return 'needs-improvement';
|
||||||
|
return 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVitalMetric(metric: Metric): VitalMetric {
|
||||||
|
return {
|
||||||
|
name: metric.name as VitalMetric['name'],
|
||||||
|
value: metric.value,
|
||||||
|
rating: getRating(metric.name as VitalMetric['name'], metric.value),
|
||||||
|
delta: metric.delta,
|
||||||
|
id: metric.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Web Vitals collection.
|
||||||
|
*
|
||||||
|
* @param reporter - Callback function to report metrics (e.g., to analytics)
|
||||||
|
*/
|
||||||
|
export function initWebVitals(reporter: VitalsReporter): void {
|
||||||
|
// Core Web Vitals
|
||||||
|
onLCP((metric) => reporter(createVitalMetric(metric)));
|
||||||
|
onCLS((metric) => reporter(createVitalMetric(metric)));
|
||||||
|
onINP((metric) => reporter(createVitalMetric(metric)));
|
||||||
|
|
||||||
|
// Other Web Vitals
|
||||||
|
onFCP((metric) => reporter(createVitalMetric(metric)));
|
||||||
|
onTTFB((metric) => reporter(createVitalMetric(metric)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default reporter that logs to console (dev) or sends to backend (prod).
|
||||||
|
*/
|
||||||
|
export function createDefaultReporter(options: {
|
||||||
|
endpoint?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
}): VitalsReporter {
|
||||||
|
return (metric: VitalMetric) => {
|
||||||
|
// Log in development
|
||||||
|
if (options.debug) {
|
||||||
|
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to analytics endpoint in production
|
||||||
|
if (options.endpoint) {
|
||||||
|
// Use sendBeacon for reliability during page unload
|
||||||
|
const body = JSON.stringify({
|
||||||
|
metric: metric.name,
|
||||||
|
value: metric.value,
|
||||||
|
rating: metric.rating,
|
||||||
|
delta: metric.delta,
|
||||||
|
id: metric.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: window.location.href
|
||||||
|
});
|
||||||
|
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
navigator.sendBeacon(options.endpoint, body);
|
||||||
|
} else {
|
||||||
|
fetch(options.endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
keepalive: true
|
||||||
|
}).catch(() => {
|
||||||
|
// Silently fail - vitals are best-effort
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
95
monitoring/alertmanager/alertmanager.yml
Normal file
95
monitoring/alertmanager/alertmanager.yml
Normal 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 }}'
|
||||||
16
monitoring/grafana/provisioning/dashboards/dashboards.yml
Normal file
16
monitoring/grafana/provisioning/dashboards/dashboards.yml
Normal 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
|
||||||
466
monitoring/grafana/provisioning/dashboards/json/main.json
Normal file
466
monitoring/grafana/provisioning/dashboards/json/main.json
Normal 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": ""
|
||||||
|
}
|
||||||
354
monitoring/grafana/provisioning/dashboards/json/per-tenant.json
Normal file
354
monitoring/grafana/provisioning/dashboards/json/per-tenant.json
Normal 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": ""
|
||||||
|
}
|
||||||
44
monitoring/grafana/provisioning/datasources/datasources.yml
Normal file
44
monitoring/grafana/provisioning/datasources/datasources.yml
Normal 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
|
||||||
61
monitoring/loki/config.yml
Normal file
61
monitoring/loki/config.yml
Normal 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
|
||||||
143
monitoring/prometheus/alerts.yml
Normal file
143
monitoring/prometheus/alerts.yml
Normal 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"
|
||||||
52
monitoring/prometheus/prometheus.yml
Normal file
52
monitoring/prometheus/prometheus.yml
Normal 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'
|
||||||
72
monitoring/promtail/config.yml
Normal file
72
monitoring/promtail/config.yml
Normal 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
17
scripts/hooks/pre-push
Normal 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..."
|
||||||
Reference in New Issue
Block a user