From d3c6773be5e70aa6a2452d9d4cffd229f756a210 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 4 Feb 2026 11:47:01 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Observabilit=C3=A9=20et=20monitoring=20?= =?UTF-8?q?complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + Makefile | 76 +- README.md | 44 +- backend/.env | 8 + backend/composer.json | 2 + backend/composer.lock | 675 ++++++++- backend/config/bundles.php | 1 + backend/config/packages/security.yaml | 5 + backend/config/packages/sentry.yaml | 24 + backend/config/services.yaml | 56 + .../Security/LoginFailureHandler.php | 5 + .../Monitoring/CorrelationIdLogProcessor.php | 45 + .../Monitoring/HealthCheckController.php | 49 + .../Monitoring/HealthMetricsCollector.php | 51 + .../HealthMetricsCollectorInterface.php | 20 + .../InfrastructureHealthChecker.php | 97 ++ .../InfrastructureHealthCheckerInterface.php | 27 + .../Monitoring/MetricsCollector.php | 129 ++ .../Monitoring/MetricsController.php | 92 ++ .../Infrastructure/Monitoring/PiiPatterns.php | 95 ++ .../Monitoring/PiiScrubberLogProcessor.php | 86 ++ .../Monitoring/PrometheusStorageFactory.php | 23 + .../Monitoring/SentryBeforeSendCallback.php | 106 ++ .../Monitoring/SentryContextEnricher.php | 67 + .../Tenant/TenantMiddleware.php | 18 +- backend/symfony.lock | 12 + .../CorrelationIdLogProcessorTest.php | 138 ++ .../Monitoring/HealthCheckControllerTest.php | 160 +++ .../Monitoring/MetricsCollectorTest.php | 199 +++ .../Monitoring/MetricsControllerTest.php | 172 +++ .../PiiScrubberLogProcessorTest.php | 186 +++ .../SentryBeforeSendCallbackTest.php | 192 +++ compose.monitoring.yaml | 206 +++ frontend/package.json | 2 + frontend/pnpm-lock.yaml | 1223 ++++++++++++++++- frontend/src/lib/monitoring/index.ts | 21 + frontend/src/lib/monitoring/sentry.ts | 130 ++ frontend/src/lib/monitoring/webVitals.ts | 115 ++ monitoring/alertmanager/alertmanager.yml | 95 ++ .../provisioning/dashboards/dashboards.yml | 16 + .../provisioning/dashboards/json/main.json | 466 +++++++ .../dashboards/json/per-tenant.json | 354 +++++ .../provisioning/datasources/datasources.yml | 44 + monitoring/loki/config.yml | 61 + monitoring/prometheus/alerts.yml | 143 ++ monitoring/prometheus/prometheus.yml | 52 + monitoring/promtail/config.yml | 72 + scripts/hooks/pre-push | 17 + 48 files changed, 5846 insertions(+), 32 deletions(-) create mode 100644 backend/config/packages/sentry.yaml create mode 100644 backend/src/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessor.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollector.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollectorInterface.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/InfrastructureHealthChecker.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/InfrastructureHealthCheckerInterface.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/MetricsCollector.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/MetricsController.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/PiiScrubberLogProcessor.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/PrometheusStorageFactory.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/SentryBeforeSendCallback.php create mode 100644 backend/src/Shared/Infrastructure/Monitoring/SentryContextEnricher.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessorTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/HealthCheckControllerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsCollectorTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/PiiScrubberLogProcessorTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Monitoring/SentryBeforeSendCallbackTest.php create mode 100644 compose.monitoring.yaml create mode 100644 frontend/src/lib/monitoring/index.ts create mode 100644 frontend/src/lib/monitoring/sentry.ts create mode 100644 frontend/src/lib/monitoring/webVitals.ts create mode 100644 monitoring/alertmanager/alertmanager.yml create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/dashboards/json/main.json create mode 100644 monitoring/grafana/provisioning/dashboards/json/per-tenant.json create mode 100644 monitoring/grafana/provisioning/datasources/datasources.yml create mode 100644 monitoring/loki/config.yml create mode 100644 monitoring/prometheus/alerts.yml create mode 100644 monitoring/prometheus/prometheus.yml create mode 100644 monitoring/promtail/config.yml create mode 100644 scripts/hooks/pre-push diff --git a/.gitignore b/.gitignore index b49ba46..091f45e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ pnpm-debug.log* correlation_id tenant_id test-results/ +compose.override.yaml diff --git a/Makefile b/Makefile index 3b00bc7..738c433 100644 --- a/Makefile +++ b/Makefile @@ -4,40 +4,81 @@ # Docker # ============================================================================= +# Fichiers compose - ajouter monitoring si MONITORING=1 +COMPOSE_FILES := -f compose.yaml +ifdef MONITORING +COMPOSE_FILES += -f compose.monitoring.yaml +endif + .PHONY: up -up: ## Lancer tous les services - docker compose up -d +up: ## Lancer les services (ajouter MONITORING=1 pour inclure observabilité) + docker compose $(COMPOSE_FILES) up -d + +.PHONY: up-full +up-full: ## Lancer TOUS les services (app + monitoring) + docker compose -f compose.yaml -f compose.monitoring.yaml up -d .PHONY: down -down: ## Arrêter tous les services - docker compose down +down: ## Arrêter tous les services (app + monitoring) + docker compose -f compose.yaml -f compose.monitoring.yaml down --remove-orphans .PHONY: restart -restart: ## Redémarrer tous les services - docker compose down - docker compose up -d +restart: ## Redémarrer les services + docker compose $(COMPOSE_FILES) down + docker compose $(COMPOSE_FILES) up -d .PHONY: rebuild rebuild: ## Reconstruire et relancer les services (sans cache) - docker compose down - docker compose build --no-cache - docker compose up -d + docker compose $(COMPOSE_FILES) down + docker compose $(COMPOSE_FILES) build --no-cache + docker compose $(COMPOSE_FILES) up -d .PHONY: build build: ## Reconstruire les images Docker (sans cache) - docker compose build --no-cache + docker compose $(COMPOSE_FILES) build --no-cache .PHONY: logs logs: ## Voir les logs de tous les services (Ctrl+C pour quitter) - docker compose logs -f + docker compose $(COMPOSE_FILES) logs -f .PHONY: ps ps: ## Afficher le statut des services - docker compose ps + docker compose $(COMPOSE_FILES) ps .PHONY: clean clean: ## Supprimer volumes et images locales - docker compose down -v --rmi local + docker compose -f compose.yaml -f compose.monitoring.yaml down -v --rmi local + +# ============================================================================= +# Monitoring +# ============================================================================= + +.PHONY: monitoring-up +monitoring-up: ## Lancer uniquement les services de monitoring + docker compose -f compose.monitoring.yaml up -d + +.PHONY: monitoring-down +monitoring-down: ## Arrêter les services de monitoring + docker compose -f compose.monitoring.yaml down + +.PHONY: monitoring-logs +monitoring-logs: ## Voir les logs du monitoring + docker compose -f compose.monitoring.yaml logs -f + +.PHONY: grafana +grafana: ## Ouvrir Grafana dans le navigateur (http://localhost:3001) + @echo "Grafana: http://localhost:3001 (admin/admin)" + @command -v xdg-open >/dev/null && xdg-open http://localhost:3001 || echo "Ouvrir manuellement: http://localhost:3001" + +.PHONY: prometheus +prometheus: ## Ouvrir Prometheus dans le navigateur (http://localhost:9090) + @echo "Prometheus: http://localhost:9090" + @command -v xdg-open >/dev/null && xdg-open http://localhost:9090 || echo "Ouvrir manuellement: http://localhost:9090" + +.PHONY: glitchtip +glitchtip: ## Ouvrir GlitchTip dans le navigateur (http://localhost:8081) + @echo "GlitchTip: http://localhost:8081" + @command -v xdg-open >/dev/null && xdg-open http://localhost:8081 || echo "Ouvrir manuellement: http://localhost:8081" # ============================================================================= # Shell @@ -172,6 +213,13 @@ ci: ## Lancer TOUS les tests et checks (comme en CI) # Scripts # ============================================================================= +.PHONY: setup-hooks +setup-hooks: ## Installer les git hooks (pre-push: make ci && make e2e) + @echo "Installation des git hooks..." + @cp scripts/hooks/pre-push .git/hooks/pre-push + @chmod +x .git/hooks/pre-push + @echo "✅ Git hooks installés (pre-push)" + .PHONY: check-bc check-bc: ## Vérifier l'isolation des Bounded Contexts ./scripts/check-bc-isolation.sh diff --git a/README.md b/README.md index 1a951cc..d1f123c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,46 @@ make token-beta role=ROLE_PROF email=prof@test.com | Mailpit | http://localhost:8025 | Emails de test | | Mercure | http://localhost:3000/.well-known/mercure | SSE Hub | +#### Monitoring (optionnel) + +Lancer avec `make up-full` pour activer le stack de monitoring : + +| Service | URL | Description | +|---------|-----|-------------| +| Grafana | http://localhost:3001 | Dashboards (admin/admin) | +| Prometheus | http://localhost:9090 | Métriques | +| GlitchTip | http://localhost:8081 | Error tracking | +| Loki | http://localhost:3100 | Logs centralisés | +| Alertmanager | http://localhost:9093 | Gestion alertes | + +##### Stack de monitoring expliquée + +**GlitchTip** - Error tracking (compatible Sentry) +- Capture automatiquement les exceptions PHP et les envoie avec leur stack trace +- Regroupe les erreurs similaires pour éviter le bruit +- Configuration : ajouter `SENTRY_DSN` dans `compose.override.yaml` + +**Prometheus** - Métriques & alertes +- Collecte les métriques applicatives (latence, requêtes, erreurs) toutes les 15s +- Déclenche des alertes si les SLAs sont menacés (P95 > 200ms, error rate > 1%) +- Requêtes PromQL : http://localhost:9090/graph + +**Grafana** - Dashboards visuels +- Dashboard principal : vue globale des métriques applicatives +- Dashboard per-tenant : métriques filtrées par établissement +- Credentials : admin/admin + +**Loki + Promtail** - Logs centralisés +- Promtail collecte les logs de tous les conteneurs Docker +- Loki les stocke et permet les requêtes LogQL +- Accès via Grafana → Explore → Loki +- Exemple : `{container_name="classeo_php"} |= "error"` + +**Alertmanager** - Notification des alertes +- Reçoit les alertes de Prometheus et les route vers les bons canaux +- En dev : envoie les emails à Mailpit (http://localhost:8025) +- En prod : configurable pour Slack, PagerDuty, email, etc. + ## Stack Technique ### Backend @@ -138,7 +178,9 @@ Chaque Bounded Context suit la même structure : ### Workflow quotidien ```bash -make up # Démarrer les services +make up # Démarrer les services (app uniquement) +make up-full # Démarrer avec monitoring (Grafana, Prometheus, Loki...) +make down # Arrêter tous les services make logs # Suivre les logs make test # Lancer les tests avant commit make check # Vérifier la qualité du code diff --git a/backend/.env b/backend/.env index 63d1096..1b2b090 100644 --- a/backend/.env +++ b/backend/.env @@ -89,3 +89,11 @@ TURNSTILE_FAIL_OPEN=true # postgresql+advisory://db_user:db_password@localhost/db_name LOCK_DSN=flock ###< symfony/lock ### + +###> sentry/sentry-symfony ### +# GlitchTip DSN for error tracking (Sentry-compatible) +# Set this after creating a project in GlitchTip UI at http://localhost:8081 +SENTRY_DSN= +# Environment label for error reports +SENTRY_ENVIRONMENT=development +###< sentry/sentry-symfony ### diff --git a/backend/composer.json b/backend/composer.json index 0cd7f15..6bc6c49 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -17,7 +17,9 @@ "doctrine/orm": "^3.3", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", + "promphp/prometheus_client_php": "^2.14", "ramsey/uuid": "^4.7", + "sentry/sentry-symfony": "^5.8", "symfony/amqp-messenger": "^8.0", "symfony/asset": "^8.0", "symfony/console": "^8.0", diff --git a/backend/composer.lock b/backend/composer.lock index 0037045..659f1bd 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff0834d39a673e5aea0d0d8fde04c9b0", + "content-hash": "fb9fd4887621a91ef8635fd6092e53b2", "packages": [ { "name": "api-platform/core", @@ -1457,6 +1457,182 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -1814,6 +1990,74 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "promphp/prometheus_client_php", + "version": "v2.14.1", + "source": { + "type": "git", + "url": "https://github.com/PromPHP/prometheus_client_php.git", + "reference": "a283aea8269287dc35313a0055480d950c59ac1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PromPHP/prometheus_client_php/zipball/a283aea8269287dc35313a0055480d950c59ac1f", + "reference": "a283aea8269287dc35313a0055480d950c59ac1f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "endclothing/prometheus_client_php": "*", + "jimdo/prometheus_client_php": "*", + "lkaemmerling/prometheus_client_php": "*" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5.4", + "phpstan/phpstan-phpunit": "^1.1.0", + "phpstan/phpstan-strict-rules": "^1.1.0", + "phpunit/phpunit": "^9.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/polyfill-apcu": "^1.6" + }, + "suggest": { + "ext-apc": "Required if using APCu.", + "ext-pdo": "Required if using PDO.", + "ext-redis": "Required if using Redis.", + "promphp/prometheus_push_gateway_php": "An easy client for using Prometheus PushGateway.", + "symfony/polyfill-apcu": "Required if you use APCu on PHP8.0+" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Prometheus\\": "src/Prometheus/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Lukas Kämmerling", + "email": "kontakt@lukas-kaemmerling.de" + } + ], + "description": "Prometheus instrumentation library for PHP applications.", + "support": { + "issues": "https://github.com/PromPHP/prometheus_client_php/issues", + "source": "https://github.com/PromPHP/prometheus_client_php/tree/v2.14.1" + }, + "time": "2025-04-14T07:59:43+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -2014,6 +2258,114 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/link", "version": "2.0.1", @@ -2120,6 +2472,50 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "ramsey/collection", "version": "2.1.1", @@ -2274,6 +2670,196 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sentry/sentry", + "version": "4.19.1", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1c21d60bebe67c0122335bd3fe977990435af0a3", + "reference": "1c21d60bebe67c0122335bd3fe977990435af0a3", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5|^9.6", + "vimeo/psalm": "^4.17" + }, + "suggest": { + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.19.1" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2025-12-02T15:57:41+00:00" + }, + { + "name": "sentry/sentry-symfony", + "version": "5.8.3", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-symfony.git", + "reference": "e82559a078b26c8f8592289e98a25b203527a9c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/e82559a078b26c8f8592289e98a25b203527a9c6", + "reference": "e82559a078b26c8f8592289e98a25b203527a9c6", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.1.1", + "jean85/pretty-package-versions": "^1.5||^2.0", + "php": "^7.2||^8.0", + "sentry/sentry": "^4.19.1", + "symfony/cache-contracts": "^1.1||^2.4||^3.0", + "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/polyfill-php80": "^1.22", + "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0", + "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0" + }, + "require-dev": { + "doctrine/dbal": "^2.13||^3.3||^4.0", + "doctrine/doctrine-bundle": "^2.6||^3.0", + "friendsofphp/php-cs-fixer": "^2.19||^3.40", + "masterminds/html5": "^2.8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-symfony": "1.4.10", + "phpunit/phpunit": "^8.5.40||^9.6.21", + "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/monolog-bundle": "^3.4||^4.0", + "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0", + "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "vimeo/psalm": "^4.3||^5.16.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "symfony/cache": "Allow distributed tracing of cache pools using Sentry.", + "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry." + }, + "type": "symfony-bundle", + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Sentry\\SentryBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Symfony integration for Sentry (http://getsentry.com)", + "homepage": "http://getsentry.com", + "keywords": [ + "errors", + "logging", + "sentry", + "symfony" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-symfony/issues", + "source": "https://github.com/getsentry/sentry-symfony/tree/5.8.3" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2025-12-18T09:26:49+00:00" + }, { "name": "symfony/amqp-messenger", "version": "v8.0.4", @@ -5496,6 +6082,93 @@ ], "time": "2026-01-27T16:18:07+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531", + "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:40:55+00:00" + }, { "name": "symfony/rate-limiter", "version": "v8.0.5", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 50df428..45afee5 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -14,4 +14,5 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + Sentry\SentryBundle\SentryBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index c95f33d..dc3e186 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -16,6 +16,11 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + # Monitoring endpoints - no authentication, restricted by IP in production + monitoring: + pattern: ^/(health|metrics)$ + stateless: true + security: false api_login: pattern: ^/api/login$ stateless: true diff --git a/backend/config/packages/sentry.yaml b/backend/config/packages/sentry.yaml new file mode 100644 index 0000000..e3851d2 --- /dev/null +++ b/backend/config/packages/sentry.yaml @@ -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: '' diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 4662edf..1394a53 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -167,6 +167,62 @@ services: App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface: alias: App\Shared\Infrastructure\Captcha\TurnstileValidator + # ============================================================================= + # Monitoring & Observability (Story 1.8) + # ============================================================================= + + # Prometheus CollectorRegistry - uses Redis for persistence between requests + Prometheus\Storage\Redis: + factory: ['App\Shared\Infrastructure\Monitoring\PrometheusStorageFactory', 'createRedisStorage'] + arguments: + $redisUrl: '%env(REDIS_URL)%' + + Prometheus\CollectorRegistry: + arguments: + $storageAdapter: '@Prometheus\Storage\Redis' + + # Sentry/GlitchTip PII scrubber callback + App\Shared\Infrastructure\Monitoring\SentryBeforeSendCallback: ~ + + # Infrastructure Health Checker - shared service for health checks (DRY) + App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker: + arguments: + $redisUrl: '%env(REDIS_URL)%' + + # Interface alias for InfrastructureHealthChecker (allows testing with stubs) + App\Shared\Infrastructure\Monitoring\InfrastructureHealthCheckerInterface: + alias: App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker + + # Health Check Controller - uses shared InfrastructureHealthChecker + App\Shared\Infrastructure\Monitoring\HealthCheckController: ~ + + # Metrics Controller - restricted to internal networks in production + App\Shared\Infrastructure\Monitoring\MetricsController: + arguments: + $appEnv: '%kernel.environment%' + + # Health Metrics Collector - exposes health_check_status gauge + App\Shared\Infrastructure\Monitoring\HealthMetricsCollector: ~ + + # Interface alias for HealthMetricsCollector (allows testing with stubs) + App\Shared\Infrastructure\Monitoring\HealthMetricsCollectorInterface: + alias: App\Shared\Infrastructure\Monitoring\HealthMetricsCollector + + # Sentry context enricher - adds tenant/user/correlation_id to error reports + # Explicitly registered to ensure HubInterface dependency is resolved + App\Shared\Infrastructure\Monitoring\SentryContextEnricher: + arguments: + $sentryHub: '@Sentry\State\HubInterface' + + # Monolog processors for structured logging + App\Shared\Infrastructure\Monitoring\CorrelationIdLogProcessor: + tags: + - { name: monolog.processor } + + App\Shared\Infrastructure\Monitoring\PiiScrubberLogProcessor: + tags: + - { name: monolog.processor } + # ============================================================================= # Test environment overrides # ============================================================================= diff --git a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php index cddd254..30605ac 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php @@ -8,6 +8,7 @@ use App\Administration\Domain\Event\CompteBloqueTemporairement; use App\Administration\Domain\Event\ConnexionEchouee; use App\Shared\Domain\Clock; use App\Shared\Domain\Tenant\TenantId; +use App\Shared\Infrastructure\Monitoring\MetricsCollector; use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface; use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult; use App\Shared\Infrastructure\Tenant\TenantResolver; @@ -41,6 +42,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler private MessageBusInterface $eventBus, private Clock $clock, private TenantResolver $tenantResolver, + private MetricsCollector $metricsCollector, ) { } @@ -68,6 +70,9 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler occurredOn: $this->clock->now(), )); + // Record metric for Prometheus alerting + $this->metricsCollector->recordLoginFailure('invalid_credentials'); + // If the IP was just blocked if ($result->ipBlocked) { $this->eventBus->dispatch(new CompteBloqueTemporairement( diff --git a/backend/src/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessor.php b/backend/src/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessor.php new file mode 100644 index 0000000..a77bd41 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessor.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php b/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php new file mode 100644 index 0000000..bd3e17c --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/HealthCheckController.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollector.php b/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollector.php new file mode 100644 index 0000000..67f6ca6 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollector.php @@ -0,0 +1,51 @@ +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]); + } + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollectorInterface.php b/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollectorInterface.php new file mode 100644 index 0000000..210bf4e --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/HealthMetricsCollectorInterface.php @@ -0,0 +1,20 @@ +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(), + ]; + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/InfrastructureHealthCheckerInterface.php b/backend/src/Shared/Infrastructure/Monitoring/InfrastructureHealthCheckerInterface.php new file mode 100644 index 0000000..ea4e8bd --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/InfrastructureHealthCheckerInterface.php @@ -0,0 +1,27 @@ +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]); + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/MetricsController.php b/backend/src/Shared/Infrastructure/Monitoring/MetricsController.php new file mode 100644 index 0000000..940925f --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/MetricsController.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php b/backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php new file mode 100644 index 0000000..714f6e2 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/PiiPatterns.php @@ -0,0 +1,95 @@ +scrubArray($record->context); + $extra = $this->scrubArray($record->extra); + + return $record->with(context: $context, extra: $extra); + } + + /** + * @param array $data + * + * @return array + */ + 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; + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/PrometheusStorageFactory.php b/backend/src/Shared/Infrastructure/Monitoring/PrometheusStorageFactory.php new file mode 100644 index 0000000..ed7696e --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/PrometheusStorageFactory.php @@ -0,0 +1,23 @@ + $parsed['host'] ?? 'redis', + 'port' => $parsed['port'] ?? 6379, + ]); + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/SentryBeforeSendCallback.php b/backend/src/Shared/Infrastructure/Monitoring/SentryBeforeSendCallback.php new file mode 100644 index 0000000..4f21046 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/SentryBeforeSendCallback.php @@ -0,0 +1,106 @@ +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 $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 $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; + } +} diff --git a/backend/src/Shared/Infrastructure/Monitoring/SentryContextEnricher.php b/backend/src/Shared/Infrastructure/Monitoring/SentryContextEnricher.php new file mode 100644 index 0000000..433a284 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Monitoring/SentryContextEnricher.php @@ -0,0 +1,67 @@ +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, + )); + } + }); + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php index ddfa7ac..f5ad2e4 100644 --- a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php +++ b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php @@ -39,6 +39,8 @@ final readonly class TenantMiddleware implements EventSubscriberInterface '/_profiler', '/_wdt', '/_error', + '/health', + '/metrics', ]; public function onKernelRequest(RequestEvent $event): void @@ -49,16 +51,17 @@ final readonly class TenantMiddleware implements EventSubscriberInterface $request = $event->getRequest(); $path = $request->getPathInfo(); + $host = $request->getHost(); - // Skip tenant resolution for public paths (docs, profiler, etc.) + // Check if this is a public path (docs, profiler, login, etc.) + $isPublicPath = false; foreach (self::PUBLIC_PATHS as $publicPath) { if (str_starts_with($path, $publicPath)) { - return; + $isPublicPath = true; + break; } } - $host = $request->getHost(); - try { $config = $this->resolver->resolve($host); $this->context->setCurrentTenant($config); @@ -66,7 +69,12 @@ final readonly class TenantMiddleware implements EventSubscriberInterface // Store tenant config in request for easy access $request->attributes->set('_tenant', $config); } catch (TenantNotFoundException) { - // Return 404 with generic message - DO NOT reveal tenant existence + // For public paths, allow requests without tenant context (metrics will show "none") + if ($isPublicPath) { + return; + } + + // For protected paths, return 404 with generic message - DO NOT reveal tenant existence $response = new JsonResponse( [ 'status' => Response::HTTP_NOT_FOUND, diff --git a/backend/symfony.lock b/backend/symfony.lock index 14ac008..f134b44 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -121,6 +121,18 @@ "bin/phpunit" ] }, + "sentry/sentry-symfony": { + "version": "5.8", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "5.0", + "ref": "12f504985eb24e3b20a9e41e0ec7e398798d18f0" + }, + "files": [ + "config/packages/sentry.yaml" + ] + }, "symfony/console": { "version": "8.0", "recipe": { diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessorTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessorTest.php new file mode 100644 index 0000000..19c5b92 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/CorrelationIdLogProcessorTest.php @@ -0,0 +1,138 @@ +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 $extra + */ + private function createLogRecord(array $extra = []): LogRecord + { + return new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Info, + message: 'Test message', + context: [], + extra: $extra, + ); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/HealthCheckControllerTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/HealthCheckControllerTest.php new file mode 100644 index 0000000..a9400c8 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/HealthCheckControllerTest.php @@ -0,0 +1,160 @@ + 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']); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsCollectorTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsCollectorTest.php new file mode 100644 index 0000000..c193a59 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsCollectorTest.php @@ -0,0 +1,199 @@ +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); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php new file mode 100644 index 0000000..f3d8382 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/MetricsControllerTest.php @@ -0,0 +1,172 @@ +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()); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/PiiScrubberLogProcessorTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/PiiScrubberLogProcessorTest.php new file mode 100644 index 0000000..8c8a252 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/PiiScrubberLogProcessorTest.php @@ -0,0 +1,186 @@ +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 + */ + 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 $context + * @param array $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, + ); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Monitoring/SentryBeforeSendCallbackTest.php b/backend/tests/Unit/Shared/Infrastructure/Monitoring/SentryBeforeSendCallbackTest.php new file mode 100644 index 0000000..713eb0e --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Monitoring/SentryBeforeSendCallbackTest.php @@ -0,0 +1,192 @@ +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 + */ + 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']); + } +} diff --git a/compose.monitoring.yaml b/compose.monitoring.yaml new file mode 100644 index 0000000..9b06305 --- /dev/null +++ b/compose.monitoring.yaml @@ -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: diff --git a/frontend/package.json b/frontend/package.json index 51195ea..db75058 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,8 +49,10 @@ "vitest": "^2.1.0" }, "dependencies": { + "@sentry/sveltekit": "^8.50.0", "@tanstack/svelte-query": "^5.66.0", "@vite-pwa/sveltekit": "^0.6.8", + "web-vitals": "^4.2.0", "workbox-window": "^7.3.0" }, "packageManager": "pnpm@10.28.2" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1885669..1b09973 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: dependencies: + '@sentry/sveltekit': + specifier: ^8.50.0 + version: 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) '@tanstack/svelte-query': specifier: ^5.66.0 version: 5.90.2(svelte@5.49.1) '@vite-pwa/sveltekit': specifier: ^0.6.8 - version: 0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0)) + version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0)) + web-vitals: + specifier: ^4.2.0 + version: 4.2.4 workbox-window: specifier: ^7.3.0 version: 7.4.0 @@ -26,13 +32,13 @@ importers: version: 1.58.0 '@sveltejs/adapter-auto': specifier: ^4.0.0 - version: 4.0.0(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))) + version: 4.0.0(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))) '@sveltejs/adapter-node': specifier: ^5.0.0 - version: 5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))) + version: 5.5.2(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))) '@sveltejs/kit': specifier: ^2.50.0 - version: 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) + version: 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) '@sveltejs/vite-plugin-svelte': specifier: ^5.0.0 version: 5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) @@ -1070,6 +1076,230 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.53.0': + resolution: {integrity: sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==} + engines: {node: '>=14'} + + '@opentelemetry/api-logs@0.57.1': + resolution: {integrity: sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==} + engines: {node: '>=14'} + + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation-amqplib@0.46.1': + resolution: {integrity: sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.43.0': + resolution: {integrity: sha512-Q57JGpH6T4dkYHo9tKXONgLtxzsh1ZEW5M9A/OwKrZFyEpLqWgjhcZ3hIuVvDlhb426iDF1f9FPToV/mi5rpeA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dataloader@0.16.0': + resolution: {integrity: sha512-88+qCHZC02up8PwKHk0UQKLLqGGURzS3hFQBZC7PnGwReuoKjHXS1o29H58S+QkXJpkTr2GACbx8j6mUoGjNPA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.47.0': + resolution: {integrity: sha512-XFWVx6k0XlU8lu6cBlCa29ONtVt6ADEjmxtyAyeF2+rifk8uBJbk1La0yIVfI0DoKURGbaEDTNelaXG9l/lNNQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.44.1': + resolution: {integrity: sha512-RoVeMGKcNttNfXMSl6W4fsYoCAYP1vi6ZAWIGhBY+o7R9Y0afA7f9JJL0j8LHbyb0P0QhSYk+6O56OwI2k4iRQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.19.0': + resolution: {integrity: sha512-JGwmHhBkRT2G/BYNV1aGI+bBjJu4fJUD/5/Jat0EWZa2ftrLV3YE8z84Fiij/wK32oMZ88eS8DI4ecLGZhpqsQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.43.0': + resolution: {integrity: sha512-at8GceTtNxD1NfFKGAuwtqM41ot/TpcLh+YsGe4dhf7gvv1HW/ZWdq6nfRtS6UjIvZJOokViqLPJ3GVtZItAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.47.0': + resolution: {integrity: sha512-Cc8SMf+nLqp0fi8oAnooNEfwZWFnzMiBHCGmDFYqmgjPylyLmi83b+NiTns/rKGwlErpW0AGPt0sMpkbNlzn8w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.45.1': + resolution: {integrity: sha512-VH6mU3YqAKTePPfUPwfq4/xr049774qWtfTuJqVHoVspCLiT3bW+fCQ1toZxt6cxRPYASoYaBsMA3CWo8B8rcw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.57.1': + resolution: {integrity: sha512-ThLmzAQDs7b/tdKI3BV2+yawuF09jF111OFsovqT1Qj3D8vjwKBwhi/rDE5xethwn4tSXtZcJ9hBsVAlWFQZ7g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.47.0': + resolution: {integrity: sha512-4HqP9IBC8e7pW9p90P3q4ox0XlbLGme65YTrA3UTLvqvo4Z6b0puqZQP203YFu8m9rE/luLfaG7/xrwwqMUpJw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.7.0': + resolution: {integrity: sha512-LB+3xiNzc034zHfCtgs4ITWhq6Xvdo8bsq7amR058jZlf2aXXDrN9SV4si4z2ya9QX4tz6r4eZJwDkXOp14/AQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.44.0': + resolution: {integrity: sha512-SlT0+bLA0Lg3VthGje+bSZatlGHw/vwgQywx0R/5u9QC59FddTQSPJeWNw29M6f8ScORMeUOOTwihlQAn4GkJQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.47.0': + resolution: {integrity: sha512-HFdvqf2+w8sWOuwtEXayGzdZ2vWpCKEQv5F7+2DSA74Te/Cv4rvb2E5So5/lh+ok4/RAIPuvCbCb/SHQFzMmbw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.44.0': + resolution: {integrity: sha512-Tn7emHAlvYDFik3vGU0mdwvWJDwtITtkJ+5eT2cUquct6nIs+H8M47sqMJkCpyPe5QIBJoTOHxmc6mj9lz6zDw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.51.0': + resolution: {integrity: sha512-cMKASxCX4aFxesoj3WK8uoQ0YUrRvnfxaO72QWI2xLu5ZtgX/QvdGBlU3Ehdond5eb74c2s1cqRQUIptBnKz1g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.46.0': + resolution: {integrity: sha512-mtVv6UeaaSaWTeZtLo4cx4P5/ING2obSqfWGItIFSunQBrYROfhuVe7wdIrFUs2RH1tn2YYpAJyMaRe/bnTTIQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.45.0': + resolution: {integrity: sha512-qLslv/EPuLj0IXFvcE3b0EqhWI8LKmrgRPIa4gUd8DllbBpqJAvLNJSv3cC6vWwovpbSI3bagNO/3Q2SuXv2xA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.45.0': + resolution: {integrity: sha512-tWWyymgwYcTwZ4t8/rLDfPYbOTF3oYB8SxnYMtIQ1zEf5uDm90Ku3i6U/vhaMyfHNlIHvDhvJh+qx5Nc4Z3Acg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.44.0': + resolution: {integrity: sha512-t16pQ7A4WYu1yyQJZhRKIfUNvl5PAaF2pEteLvgJb/BWdd1oNuU1rOYt4S825kMy+0q4ngiX281Ss9qiwHfxFQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.50.0': + resolution: {integrity: sha512-TtLxDdYZmBhFswm8UIsrDjh/HFBeDXd4BLmE8h2MxirNHewLJ0VS9UUddKKEverb5Sm2qFVjqRjcU+8Iw4FJ3w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis-4@0.46.0': + resolution: {integrity: sha512-aTUWbzbFMFeRODn3720TZO0tsh/49T8H3h8vVnVKJ+yE36AeW38Uj/8zykQ/9nO8Vrtjr5yKuX3uMiG/W8FKNw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.18.0': + resolution: {integrity: sha512-9zhjDpUDOtD+coeADnYEJQ0IeLVCj7w/hqzIutdp5NqS1VqTAanaEfsEcSypyvYv5DX3YOsTUoF+nr2wDXPETA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.10.0': + resolution: {integrity: sha512-vm+V255NGw9gaSsPD6CP0oGo8L55BffBc8KnxqsMuc6XiAD1L8SFNzsW0RHhxJFqy9CJaJh+YiJ5EHXuZ5rZBw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.53.0': + resolution: {integrity: sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.57.1': + resolution: {integrity: sha512-SgHEKXoVxOjc20ZYusPG3Fh+RLIZTSa4x8QtD3NfgAUDyqdFFS9W1F2ZVbZkqDCdyMcQG02Ok4duUGLHJXHgbA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/redis-common@0.36.2': + resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==} + engines: {node: '>=14'} + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.27.0': + resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.39.0': + resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.40.1': + resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1082,6 +1312,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prisma/instrumentation@5.22.0': + resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==} + '@rollup/plugin-babel@5.3.1': resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -1296,6 +1529,125 @@ packages: cpu: [x64] os: [win32] + '@sentry-internal/browser-utils@8.55.0': + resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} + engines: {node: '>=14.18'} + + '@sentry-internal/feedback@8.55.0': + resolution: {integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==} + engines: {node: '>=14.18'} + + '@sentry-internal/replay-canvas@8.55.0': + resolution: {integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==} + engines: {node: '>=14.18'} + + '@sentry-internal/replay@8.55.0': + resolution: {integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==} + engines: {node: '>=14.18'} + + '@sentry/babel-plugin-component-annotate@2.22.6': + resolution: {integrity: sha512-V2g1Y1I5eSe7dtUVMBvAJr8BaLRr4CLrgNgtPaZyMT4Rnps82SrZ5zqmEkLXPumlXhLUWR6qzoMNN2u+RXVXfQ==} + engines: {node: '>= 14'} + + '@sentry/browser@8.55.0': + resolution: {integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==} + engines: {node: '>=14.18'} + + '@sentry/bundler-plugin-core@2.22.6': + resolution: {integrity: sha512-1esQdgSUCww9XAntO4pr7uAM5cfGhLsgTK9MEwAKNfvpMYJi9NUTYa3A7AZmdA8V6107Lo4OD7peIPrDRbaDCg==} + engines: {node: '>= 14'} + + '@sentry/cli-darwin@2.58.4': + resolution: {integrity: sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.4': + resolution: {integrity: sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.4': + resolution: {integrity: sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.4': + resolution: {integrity: sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.4': + resolution: {integrity: sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.4': + resolution: {integrity: sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.4': + resolution: {integrity: sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.4': + resolution: {integrity: sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.4': + resolution: {integrity: sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==} + engines: {node: '>= 10'} + hasBin: true + + '@sentry/core@8.55.0': + resolution: {integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==} + engines: {node: '>=14.18'} + + '@sentry/node@8.55.0': + resolution: {integrity: sha512-h10LJLDTRAzYgay60Oy7moMookqqSZSviCWkkmHZyaDn+4WURnPp5SKhhfrzPRQcXKrweiOwDSHBgn1tweDssg==} + engines: {node: '>=14.18'} + + '@sentry/opentelemetry@8.55.0': + resolution: {integrity: sha512-UvatdmSr3Xf+4PLBzJNLZ2JjG1yAPWGe/VrJlJAqyTJ2gKeTzgXJJw8rp4pbvNZO8NaTGEYhhO+scLUj0UtLAQ==} + engines: {node: '>=14.18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^1.30.1 + '@opentelemetry/core': ^1.30.1 + '@opentelemetry/instrumentation': ^0.57.1 + '@opentelemetry/sdk-trace-base': ^1.30.1 + '@opentelemetry/semantic-conventions': ^1.28.0 + + '@sentry/svelte@8.55.0': + resolution: {integrity: sha512-8xQ3RHOUq21f40LWn5eJEgg6rLQfZ+8oBdKLkg03b3SwvfdBs9CrlPkvhhmxdZZslmcGr6ewl0t5WT9ea8Ydlw==} + engines: {node: '>=14.18'} + peerDependencies: + svelte: 3.x || 4.x || 5.x + + '@sentry/sveltekit@8.55.0': + resolution: {integrity: sha512-fhjv4hn/y/4olSuZLBzQZbD20EcguIzgSYmarc8P/kn9ZVkO5onNDIqgDP0wmFrGVs5ihCPl/gGn9gXV0cXUjQ==} + engines: {node: '>=16'} + peerDependencies: + '@sveltejs/kit': 1.x || 2.x + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + '@sentry/vite-plugin@2.22.6': + resolution: {integrity: sha512-zIieP1VLWQb3wUjFJlwOAoaaJygJhXeUoGd0e/Ha2RLb2eW2S+4gjf6y6NqyY71tZ74LYVZKg/4prB6FAZSMXQ==} + engines: {node: '>= 14'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1392,6 +1744,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/connect@3.4.36': + resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -1404,12 +1759,27 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mysql@2.15.26': + resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + '@types/pg-pool@2.0.6': + resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + + '@types/pg@8.6.1': + resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1521,6 +1891,11 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1531,6 +1906,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1593,6 +1972,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1713,6 +2096,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1835,6 +2221,10 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1950,6 +2340,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2040,6 +2435,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2047,6 +2445,9 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2105,8 +2506,14 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2119,6 +2526,12 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2164,6 +2577,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2183,6 +2600,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -2486,6 +2906,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@0.30.7: + resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} + engines: {node: '>=12'} + + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + + magicast@0.2.8: + resolution: {integrity: sha512-zEnqeb3E6TfMKYXGyHv3utbuHNixr04o3/gVGviSzVQkbFiU46VZUd+Ea/1npKfvEsEWxBYuIksKzoztTDPg0A==} + magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -2523,14 +2954,28 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2553,6 +2998,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2632,6 +3086,17 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2744,6 +3209,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2832,6 +3313,13 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2856,6 +3344,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2886,6 +3378,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2970,6 +3466,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -3000,6 +3499,10 @@ packages: smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} + sorcery@1.0.0: + resolution: {integrity: sha512-5ay9oJE+7sNmhzl3YNG18jEEEf4AOQCM/FAqR5wMmzqd1FtRorFbJXn3w3SKOhbiQaVgHM+Q1lszZspjri7bpA==} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3140,6 +3643,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3181,6 +3690,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -3197,6 +3709,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3264,6 +3779,9 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unplugin@1.0.1: + resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} + upath@1.2.0: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} @@ -3405,6 +3923,12 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -3412,6 +3936,13 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webpack-sources@3.3.3: + resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.5.0: + resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -3424,6 +3955,9 @@ packages: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -3533,6 +4067,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4520,6 +5058,299 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api-logs@0.53.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.57.1': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation-amqplib@0.46.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.43.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@types/connect': 3.4.36 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.16.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.44.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.19.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.43.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.45.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.57.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + forwarded-parse: 2.1.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.7.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.44.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.47.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.44.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.51.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.46.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.45.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.45.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@types/mysql': 2.15.26 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.44.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.50.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.27.0 + '@opentelemetry/sql-common': 0.40.1(@opentelemetry/api@1.9.0) + '@types/pg': 8.6.1 + '@types/pg-pool': 2.0.6 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis-4@0.46.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.36.2 + '@opentelemetry/semantic-conventions': 1.39.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.18.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.10.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.7.3 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.57.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.1 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.7.3 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.7.3 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/redis-common@0.36.2': {} + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/semantic-conventions@1.27.0': {} + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.39.0': {} + + '@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@pkgjs/parseargs@0.11.0': optional: true @@ -4529,6 +5360,14 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@prisma/instrumentation@5.22.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + '@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)': dependencies: '@babel/core': 7.28.6 @@ -4688,6 +5527,183 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.0': optional: true + '@sentry-internal/browser-utils@8.55.0': + dependencies: + '@sentry/core': 8.55.0 + + '@sentry-internal/feedback@8.55.0': + dependencies: + '@sentry/core': 8.55.0 + + '@sentry-internal/replay-canvas@8.55.0': + dependencies: + '@sentry-internal/replay': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry-internal/replay@8.55.0': + dependencies: + '@sentry-internal/browser-utils': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry/babel-plugin-component-annotate@2.22.6': {} + + '@sentry/browser@8.55.0': + dependencies: + '@sentry-internal/browser-utils': 8.55.0 + '@sentry-internal/feedback': 8.55.0 + '@sentry-internal/replay': 8.55.0 + '@sentry-internal/replay-canvas': 8.55.0 + '@sentry/core': 8.55.0 + + '@sentry/bundler-plugin-core@2.22.6': + dependencies: + '@babel/core': 7.28.6 + '@sentry/babel-plugin-component-annotate': 2.22.6 + '@sentry/cli': 2.58.4 + dotenv: 16.6.1 + find-up: 5.0.0 + glob: 9.3.5 + magic-string: 0.30.8 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.4': + optional: true + + '@sentry/cli-linux-arm64@2.58.4': + optional: true + + '@sentry/cli-linux-arm@2.58.4': + optional: true + + '@sentry/cli-linux-i686@2.58.4': + optional: true + + '@sentry/cli-linux-x64@2.58.4': + optional: true + + '@sentry/cli-win32-arm64@2.58.4': + optional: true + + '@sentry/cli-win32-i686@2.58.4': + optional: true + + '@sentry/cli-win32-x64@2.58.4': + optional: true + + '@sentry/cli@2.58.4': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.4 + '@sentry/cli-linux-arm': 2.58.4 + '@sentry/cli-linux-arm64': 2.58.4 + '@sentry/cli-linux-i686': 2.58.4 + '@sentry/cli-linux-x64': 2.58.4 + '@sentry/cli-win32-arm64': 2.58.4 + '@sentry/cli-win32-i686': 2.58.4 + '@sentry/cli-win32-x64': 2.58.4 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/core@8.55.0': {} + + '@sentry/node@8.55.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.46.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.43.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.16.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fastify': 0.44.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.19.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.43.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.45.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.57.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.7.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.44.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.44.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.51.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.46.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.45.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.45.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': 0.44.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.50.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis-4': 0.46.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.18.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.10.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@prisma/instrumentation': 5.22.0 + '@sentry/core': 8.55.0 + '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) + import-in-the-middle: 1.15.0 + transitivePeerDependencies: + - supports-color + + '@sentry/opentelemetry@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@sentry/core': 8.55.0 + + '@sentry/svelte@8.55.0(svelte@5.49.1)': + dependencies: + '@sentry/browser': 8.55.0 + '@sentry/core': 8.55.0 + magic-string: 0.30.21 + svelte: 5.49.1 + + '@sentry/sveltekit@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))': + dependencies: + '@sentry/core': 8.55.0 + '@sentry/node': 8.55.0 + '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0) + '@sentry/svelte': 8.55.0(svelte@5.49.1) + '@sentry/vite-plugin': 2.22.6 + '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) + magic-string: 0.30.7 + magicast: 0.2.8 + sorcery: 1.0.0 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/context-async-hooks' + - '@opentelemetry/core' + - '@opentelemetry/instrumentation' + - '@opentelemetry/sdk-trace-base' + - '@opentelemetry/semantic-conventions' + - encoding + - supports-color + - svelte + + '@sentry/vite-plugin@2.22.6': + dependencies: + '@sentry/bundler-plugin-core': 2.22.6 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + '@standard-schema/spec@1.1.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -4701,20 +5717,20 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))': + '@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))': dependencies: - '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) + '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) import-meta-resolve: 4.2.0 - '@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))': + '@sveltejs/adapter-node@5.5.2(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))': dependencies: '@rollup/plugin-commonjs': 28.0.9(rollup@4.57.0) '@rollup/plugin-json': 6.1.0(rollup@4.57.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.57.0) - '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) + '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) rollup: 4.57.0 - '@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))': + '@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0) @@ -4733,6 +5749,7 @@ snapshots: svelte: 5.49.1 vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0) optionalDependencies: + '@opentelemetry/api': 1.9.0 typescript: 5.9.3 '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))': @@ -4800,6 +5817,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/connect@3.4.36': + dependencies: + '@types/node': 22.19.7 + '@types/cookie@0.6.0': {} '@types/estree@0.0.39': {} @@ -4808,12 +5829,32 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/mysql@2.15.26': + dependencies: + '@types/node': 22.19.7 + '@types/node@22.19.7': dependencies: undici-types: 6.21.0 + '@types/pg-pool@2.0.6': + dependencies: + '@types/pg': 8.6.1 + + '@types/pg@8.6.1': + dependencies: + '@types/node': 22.19.7 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/resolve@1.20.2': {} + '@types/shimmer@1.2.0': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 22.19.7 + '@types/trusted-types@2.0.7': {} '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': @@ -4907,9 +5948,9 @@ snapshots: '@typescript-eslint/types': 8.54.0 eslint-visitor-keys: 4.2.1 - '@vite-pwa/sveltekit@0.6.8(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))': + '@vite-pwa/sveltekit@0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))': dependencies: - '@sveltejs/kit': 2.50.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) + '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)) kolorist: 1.8.0 tinyglobby: 0.2.15 vite-plugin-pwa: 0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0) @@ -4972,12 +6013,22 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv@6.12.6: @@ -5040,6 +6091,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + async-function@1.0.0: {} async@3.2.6: {} @@ -5174,6 +6229,8 @@ snapshots: dependencies: readdirp: 4.1.2 + cjs-module-lexer@1.4.3: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -5279,6 +6336,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5518,6 +6577,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -5601,6 +6662,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forwarded-parse@2.1.2: {} + fraction.js@5.3.4: {} fs-extra@9.1.0: @@ -5610,6 +6673,8 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -5685,6 +6750,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.4 + minipass: 4.2.8 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@16.5.0: {} @@ -5694,6 +6766,10 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globalyzer@0.1.0: {} + + globrex@0.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5735,6 +6811,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -5753,6 +6836,13 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -6051,6 +7141,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.7: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.2.8: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + recast: 0.23.11 + magicast@0.3.5: dependencies: '@babel/parser': 7.28.6 @@ -6086,12 +7190,22 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@8.0.4: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + + minipass@4.2.8: {} + minipass@7.1.2: {} + module-details-from-path@1.0.4: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -6108,6 +7222,10 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -6182,6 +7300,18 @@ snapshots: pathval@2.0.1: {} + pg-int8@1.0.1: {} + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6264,6 +7394,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.49.1): @@ -6289,6 +7429,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + progress@2.0.3: {} + + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -6309,6 +7453,14 @@ snapshots: readdirp@4.1.2: {} + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -6352,6 +7504,14 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve@1.22.11: @@ -6468,6 +7628,8 @@ snapshots: shebang-regex@3.0.0: {} + shimmer@1.2.1: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -6508,6 +7670,12 @@ snapshots: smob@1.5.0: {} + sorcery@1.0.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + minimist: 1.2.8 + tiny-glob: 0.2.9 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6718,6 +7886,13 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6749,6 +7924,8 @@ snapshots: dependencies: tldts: 7.0.19 + tr46@0.0.3: {} + tr46@1.0.1: dependencies: punycode: 2.3.1 @@ -6763,6 +7940,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6841,6 +8020,13 @@ snapshots: universalify@2.0.1: {} + unplugin@1.0.1: + dependencies: + acorn: 8.15.0 + chokidar: 3.6.0 + webpack-sources: 3.3.3 + webpack-virtual-modules: 0.5.0 + upath@1.2.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -6952,10 +8138,18 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-vitals@4.2.4: {} + + webidl-conversions@3.0.1: {} + webidl-conversions@4.0.2: {} webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} + + webpack-virtual-modules@0.5.0: {} + whatwg-mimetype@4.0.0: {} whatwg-mimetype@5.0.0: {} @@ -6965,6 +8159,11 @@ snapshots: tr46: 6.0.0 webidl-conversions: 8.0.1 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -7154,6 +8353,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yaml@1.10.2: {} diff --git a/frontend/src/lib/monitoring/index.ts b/frontend/src/lib/monitoring/index.ts new file mode 100644 index 0000000..8216eea --- /dev/null +++ b/frontend/src/lib/monitoring/index.ts @@ -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'; diff --git a/frontend/src/lib/monitoring/sentry.ts b/frontend/src/lib/monitoring/sentry.ts new file mode 100644 index 0000000..44eb74f --- /dev/null +++ b/frontend/src/lib/monitoring/sentry.ts @@ -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): 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 +): void { + Sentry.addBreadcrumb({ + category, + message, + level: 'info', + ...(data && { data }) + }); +} diff --git a/frontend/src/lib/monitoring/webVitals.ts b/frontend/src/lib/monitoring/webVitals.ts new file mode 100644 index 0000000..e3c1250 --- /dev/null +++ b/frontend/src/lib/monitoring/webVitals.ts @@ -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 + }); + } + } + }; +} diff --git a/monitoring/alertmanager/alertmanager.yml b/monitoring/alertmanager/alertmanager.yml new file mode 100644 index 0000000..de4eb52 --- /dev/null +++ b/monitoring/alertmanager/alertmanager.yml @@ -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 }}' diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..cd5b011 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -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 diff --git a/monitoring/grafana/provisioning/dashboards/json/main.json b/monitoring/grafana/provisioning/dashboards/json/main.json new file mode 100644 index 0000000..1861f06 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/json/main.json @@ -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": "" +} diff --git a/monitoring/grafana/provisioning/dashboards/json/per-tenant.json b/monitoring/grafana/provisioning/dashboards/json/per-tenant.json new file mode 100644 index 0000000..5587e97 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/json/per-tenant.json @@ -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": "" +} diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..950830f --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasources.yml @@ -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 diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000..b39da48 --- /dev/null +++ b/monitoring/loki/config.yml @@ -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 diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 0000000..1f0d4f1 --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -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" diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..d4f5ec5 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -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' diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000..5aa9a23 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -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 diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100644 index 0000000..af6e946 --- /dev/null +++ b/scripts/hooks/pre-push @@ -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..."