Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions 25f3ac7316 chore: bump version to v0.1.128
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m41s
2026-06-28 11:46:46 +00:00
matthieu 42b02a8148 feat : error tracking backend vers GlitchTip (via Tailscale) (#37)
Auto Tag Develop / tag (push) Successful in 9s
## Objectif
Remonter les erreurs **backend** Symfony vers **GlitchTip** (SDK Sentry), **prod uniquement**, **inerte sans `SENTRY_DSN`**. Transport réseau via **Tailscale** sur le host de prod (infra, hors repo). Frontend hors périmètre.

## Contenu
- `sentry/sentry-symfony:^5.10` (+ `symfony.lock` recipe)
- `config/bundles.php` → `SentryBundle ['prod' => true]`
- `config/packages/sentry.yaml` (nouveau) : DSN runtime, release `%app.version%`, 4xx ignorés, pas de tracing, handler Monolog ERROR+
- `config/packages/monolog.yaml` : handler `sentry` en `when@prod`
- `.env` : bloc `SENTRY_DSN` documenté (vide → inerte)
- `doc/error-tracking.md` (runbook Tailscale) + section `CLAUDE.md`
- Spec + plan sous `docs/superpowers/`

## Vérifications
- Prod `cache:clear` OK, service `Sentry\Monolog\Handler` chargé
- **267/267 tests verts**, dev/test inchangés (bundle non chargé hors prod)
- Aucun changement `frontend/` / `.gitea/` / `deploy/docker/`
- Revue multi-agents : **READY TO MERGE** (aucun Critical/Important)

## Activation prod (hors code, cf. `doc/error-tracking.md`)
1. Tailscale sur l'hôte GlitchTip **et** sur le VPS OVH (prod)
2. Créer le projet `sirh-api` dans GlitchTip → récupérer le DSN
3. `SENTRY_DSN=http://<clé>@<IP-tailnet>:<port>/<id>` dans l'env_file serveur + redéploiement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #37
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
2026-06-28 11:46:35 +00:00
gitea-actions fe317f37b4 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 50s
2026-06-25 07:29:13 +00:00
tristan d66288d061 fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite) (#36)
Auto Tag Develop / tag (push) Successful in 13s
## Contexte (incident prod)
Le correctif #31 (dirty-tracking front) ne protège que les sessions chargeant le nouveau bundle. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS et envoie toute la grille périmée. Hier soir, un onglet ouvert le matin a **supprimé ~10 lignes d'heures** saisies dans la journée par d'autres utilisateurs (journal BDD à l'appui : 1 save = 2 updates + 8 deletes de lignes intactes).

Cause : le backend traitait toute **entrée vide comme une suppression**, sans aucune garde indépendante du client.

## Correctif — suppression sur intention explicite (`delete: true`)
`WorkHourBulkUpsertProcessor` ne supprime une ligne existante sur entrée vide **que si l'entrée porte `delete: true`**. Sinon → **no-op** (ligne préservée). Aucune grille périmée, quel que soit le client (vieil onglet inclus), ne peut plus détruire une saisie concurrente. La création de ligne technique de validation reste limitée à `null === $existing`.

Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (helper `isEntryEmpty`, appliqué après le filtre dirty-tracking) → suppression métier conservée. Flag optionnel ajouté au DTO front (`WorkHourEntryPayload`) et back (`WorkHourBulkUpsert`), défaut `false`.

## Testabilité
Le processor dépend désormais des interfaces repo (`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface`, repos concrets `final` non mockables) → nouveau test unitaire `WorkHourBulkUpsertProcessorTest` (no-op sans flag / suppression avec flag / update normal).

## Limite résiduelle (choix : suppression explicite, pas verrou optimiste)
L'**édition explicite** d'une ligne sur données périmées peut encore écraser une saisie concurrente sur cette même ligne. Seule la **suppression** est blindée.

## Vérification
- **267 tests PHPUnit OK** (dont 3 nouveaux), via le pre-commit hook.
- Front : revue de code (pas de harnais de tests front).

## Doc
- `doc/hours-save-dirty-tracking.md`, `CLAUDE.md`, doc in-app (`documentation-content.ts`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #36
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 07:29:02 +00:00
24 changed files with 1626 additions and 91 deletions
+7
View File
@@ -56,3 +56,10 @@ JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
###< lexik/jwt-authentication-bundle ###
###> sentry/sentry-symfony ###
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
# À définir dans l'env_file du serveur, PAS ici. Format :
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
# SENTRY_DSN=
###< sentry/sentry-symfony ###
+9 -1
View File
@@ -68,7 +68,8 @@
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`.
- **Garde backend : suppression sur intention explicite (`delete: true`)** — la protection front ci-dessus **ne couvre que les sessions chargeant le nouveau bundle**. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS (sans dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (onglet ouvert le matin → ~10 lignes saisies dans la journée supprimées). Donc `WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si l'entrée porte `delete: true`** (`$deleteRequested = true === ($entry['delete'] ?? false)`). Sinon : **no-op** (ligne préservée) — aucune grille périmée, quel que soit le client, ne peut plus détruire une saisie concurrente. Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** via `isEntryEmpty(...)` appliqué après le filtre dirty-tracking (les deux composables). Flag **optionnel** (`WorkHourEntryPayload` front + DTO `WorkHourBulkUpsert` back, défaut `false`). Branche de **création de ligne technique de validation** (toggle quand aucune ligne) inchangée : `null === $existing && (absence || contrat 4h)`. Le processor dépend des **interfaces** `EmployeeScopedRepositoryInterface`/`WorkHourReadRepositoryInterface` (repos concrets `final` non mockables) → testé dans `tests/State/WorkHourBulkUpsertProcessorTest.php`. **Limite résiduelle** : pas de verrou optimiste — l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (seule la **suppression** est protégée). Doc : `doc/hours-save-dirty-tracking.md`.
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
@@ -220,6 +221,13 @@
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`, via `getHolidaysDayByYears` → applique `EXCLUDED_PUBLIC_HOLIDAYS`, donc **Lundi de Pentecôte traité comme jour ouvré**, cohérent avec le reste de l'app). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
## Error tracking (GlitchTip)
- Backend Symfony → GlitchTip (SDK Sentry), org `malio`, projet `sirh-api`. **Prod only**, **inerte sans `SENTRY_DSN`**.
- Config : `config/packages/sentry.yaml` (DSN runtime `%env(SENTRY_DSN)%`, release `%app.version%`, 4xx ignorés, `traces_sample_rate: 0`, handler Monolog ERROR+) ; `SentryBundle` enregistré `['prod' => true]` (`config/bundles.php`) ; handler `sentry` en `when@prod` (`config/packages/monolog.yaml`). DSN runtime via l'env_file serveur, jamais committé/baké.
- **Transport réseau** : GlitchTip est interne (bloqué Sophos), servi HTTPS sur `logs.malio-dev.fr` (cert auto-signé) ; SIRH sur VPS OVH → tunnel **Tailscale** entre les deux hôtes (GlitchTip `100.111.223.34`, OVH `100.93.52.45`). Topologie retenue (Option A) : DSN **inchangé** (`https://…@logs.malio-dev.fr/3`), hostname résolu vers l'IP tailnet via `extra_hosts` dans le `docker-compose` serveur, et **CA racine MALIO bakée** dans l'image (`deploy/docker/Dockerfile.prod` + `deploy/docker/malio-dev-root-ca.crt`). Frontend hors périmètre (ajout futur via proxy nginx `/ingest`).
- Test d'envoi : `php bin/console sentry:test` (prod-only, `SENTRY_DSN` requis) → Issue dans `sirh-api`.
- Doc : `doc/error-tracking.md`.
## Backend Conventions
- Prefer explicit DTOs over associative arrays
- Business rules in backend (providers/processors/services), frontend is display/interaction only
+1
View File
@@ -17,6 +17,7 @@
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"sentry/sentry-symfony": "^5.10",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",
Generated
+685 -72
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bdc04f5145303388bac52809ea3f4b05",
"content-hash": "5fa560dba1bae2997c8f71afbbbfb4ab",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2515,6 +2515,185 @@
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"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": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"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.12.3"
},
"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": "2026-06-23T15:21:08+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",
@@ -3361,6 +3540,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",
@@ -3467,6 +3754,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": "sabberworm/php-css-parser",
"version": "v9.1.0",
@@ -3541,6 +3872,201 @@
},
"time": "2025-09-14T07:37:21+00:00"
},
{
"name": "sentry/sentry",
"version": "4.28.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
"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": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.0",
"open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/sdk": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"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.28.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-06-11T12:22:38+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.23.0",
"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.10.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-04-01T14:50:32+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.4",
@@ -5616,6 +6142,77 @@
],
"time": "2025-12-08T08:00:13+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"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": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"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": "2025-11-12T15:55:31+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.4",
@@ -6358,6 +6955,93 @@
],
"time": "2026-01-27T16:18:07+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19",
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19",
"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.8"
},
"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-03-30T15:14:47+00:00"
},
{
"name": "symfony/routing",
"version": "v8.0.4",
@@ -11220,77 +11904,6 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"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": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"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": "2025-11-12T15:55:31+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.5",
+2
View File
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Sentry\SentryBundle\SentryBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -24,4 +25,5 @@ return [
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SentryBundle::class => ['prod' => true],
];
+6
View File
@@ -36,3 +36,9 @@ when@prod:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini
# dans sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
sentry:
type: service
id: Sentry\Monolog\Handler
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
+30
View File
@@ -0,0 +1,30 @@
# Error tracking → GlitchTip (compatible SDK Sentry).
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
when@prod:
parameters:
env(SENTRY_DSN): ''
sentry:
dsn: '%env(SENTRY_DSN)%'
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du
# kernel sont déjà logguées par Symfony → remontées via Monolog).
register_error_listener: false
register_error_handler: true
options:
environment: '%env(APP_ENV)%'
release: '%app.version%'
traces_sample_rate: 0.0
ignore_exceptions:
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
- Symfony\Component\Security\Core\Exception\AccessDeniedException
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
services:
Sentry\Monolog\Handler:
arguments:
$hub: '@Sentry\State\HubInterface'
$level: !php/const Monolog\Level::Error
$bubble: true
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.126'
app.version: '0.1.128'
+8 -1
View File
@@ -39,10 +39,17 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
nginx supervisor ca-certificates \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# CA racine interne MALIO (auto-signée) — permet au SDK Sentry/HttpClient de joindre
# GlitchTip en HTTPS sur logs.malio-dev.fr (cert *.malio-dev.fr). Le host est résolu vers
# l'IP tailnet via `extra_hosts` dans le docker-compose du serveur (cf. doc/error-tracking.md).
# Sans cette CA approuvée, le SDK logue « Message not sent » et rien ne remonte.
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL
BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX
TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy
MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD
VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02
QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM
3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr
R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT
lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ
NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5
0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj
89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy
tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo
saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo
FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB
AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW
gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m
AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81
Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr
W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ
3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/
gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt
KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE
2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI
iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f
atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe
zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg==
-----END CERTIFICATE-----
+114
View File
@@ -0,0 +1,114 @@
# Error tracking (GlitchTip)
Les erreurs **backend** (Symfony) sont remontées vers **GlitchTip** (instance interne MALIO,
compatible SDK Sentry), org `malio`, projet `sirh-api`. **Prod uniquement**, **inerte sans DSN**.
> Frontend hors périmètre (les erreurs front partent du navigateur RH ; ajout futur possible via
> un proxy nginx `/ingest`).
## Contrainte réseau & transport
GlitchTip est sur le réseau interne (bloqué par Sophos), servi en **HTTPS sur
`logs.malio-dev.fr`** (cert auto-signé par la CA interne « MALIO-DEV Local Root CA »). SIRH tourne
sur un **VPS OVH** public. Le lien passe par un **tunnel Tailscale** entre les deux hôtes.
**Topologie retenue (Option A — HTTPS + hostname mappé sur le tailnet) :**
- Tailscale est installé **sur l'hôte GlitchTip** (IP tailnet `100.111.223.34`) **et sur le VPS
OVH** (IP tailnet `100.93.52.45`).
- Le **DSN reste inchangé** : `https://<clé>@logs.malio-dev.fr/<id>` (même endpoint que le
navigateur → pas de souci `ALLOWED_HOSTS`, Host header et cert cohérents).
- Côté SIRH, le nom `logs.malio-dev.fr` est résolu vers l'**IP tailnet de GlitchTip** via
`extra_hosts` dans le `docker-compose` du serveur.
- La **CA racine MALIO** est bakée dans l'image SIRH (`deploy/docker/Dockerfile.prod`) pour que le
SDK accepte le TLS auto-signé.
> Pré-requis : le nginx qui sert `logs.malio-dev.fr` en 443 doit écouter sur une interface
> joignable via le tailnet (typiquement `0.0.0.0:443` → joignable sur `100.111.223.34:443`).
## Fichiers concernés
| Fichier | Rôle |
|---|---|
| `config/packages/sentry.yaml` | conf backend (prod-only, DSN runtime, 4xx ignorés, release = `app.version`, handler Monolog ERROR+) |
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
| `config/packages/monolog.yaml` | handler `sentry` (service) en `when@prod` |
| `.env` | bloc documenté `SENTRY_DSN` (vide → inerte) |
| `deploy/docker/Dockerfile.prod` | bake la CA racine MALIO (`update-ca-certificates`) pour le TLS interne |
| `deploy/docker/malio-dev-root-ca.crt` | certificat **public** de la CA interne (aucune clé privée) |
## Activation (runbook)
1. **Tailscale sur les deux hôtes** (GlitchTip **et** VPS OVH) :
```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # ou --authkey tskey-auth-XXXX (headless)
tailscale ip -4 # GlitchTip → 100.111.223.34 ; OVH → 100.93.52.45
```
2. **Vérifier l'accès** depuis le VPS OVH (tunnel + nginx 443 de GlitchTip) :
```bash
tailscale ping 100.111.223.34
curl -sSk -o /dev/null -w "%{http_code}\n" https://100.111.223.34/ # réponse HTTP = tunnel OK
```
3. **Mapper le hostname vers l'IP tailnet** dans le `docker-compose` du serveur OVH (service `php`),
pour que le container résolve `logs.malio-dev.fr` :
```yaml
extra_hosts:
- "logs.malio-dev.fr:100.111.223.34"
```
4. **Projet GlitchTip** : déjà créé (org `malio`, projet `sirh-api`, id `3`). DSN de base affiché
dans *Settings → Client Keys* : `https://<clé>@logs.malio-dev.fr/3`.
5. **Injecter le DSN tel quel** (hostname conservé) dans l'env_file serveur (pas dans l'image),
puis rebuild/redéployer l'image (la CA est bakée au build) :
```env
SENTRY_DSN=https://<clé>@logs.malio-dev.fr/3
```
```bash
docker compose up -d
docker compose exec php php bin/console cache:clear --env=prod
```
## Tester l'envoi
Le bundle `sentry/sentry-symfony` fournit une commande qui envoie un événement de test et
confirme s'il est bien parti vers GlitchTip. Elle n'existe qu'en **prod** (bundle prod-only) et
nécessite `SENTRY_DSN` défini.
```bash
# Sur le serveur, dans le container PHP (SENTRY_DSN doit être dans l'env) :
docker compose exec php sh -lc "APP_ENV=prod php bin/console sentry:test"
```
Sortie attendue : `Sending test message... done.` → une **Issue de test** apparaît dans le projet
`sirh-api` côté GlitchTip. Si l'envoi échoue (`Message not sent`), le problème est réseau
(Tailscale/route/port) ou DSN, pas applicatif.
Pré-check connectivité depuis le VPS OVH (`-k` ignore le cert juste pour ce test) :
```bash
tailscale ping 100.111.223.34
curl -sSk -o /dev/null -w "%{http_code}\n" https://100.111.223.34/ # réponse HTTP = tunnel OK
# Avec résolution du hostname (comme le container) + validation par la CA :
curl --resolve logs.malio-dev.fr:443:100.111.223.34 \
--cacert deploy/docker/malio-dev-root-ca.crt \
-sS -o /dev/null -w "%{http_code}\n" https://logs.malio-dev.fr/
```
Alternative sans commande dédiée : déclencher un `throw new \RuntimeException('glitchtip test')`
temporaire dans un endpoint, ou un `$logger->error('glitchtip test')` (niveau ERROR+ → Issue).
## CA HTTPS interne (bakée dans l'image)
GlitchTip est en HTTPS avec un cert auto-signé par la **CA interne MALIO**. Le SDK refuse un TLS
non approuvé (« Message not sent »). La CA publique (`deploy/docker/malio-dev-root-ca.crt`, aucune
clé privée) est donc installée dans le trust store de l'image au build (`deploy/docker/Dockerfile.prod`,
stage production) :
```dockerfile
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
```
Combinée à l'`extra_hosts` (hostname → IP tailnet), le container fait confiance à
`logs.malio-dev.fr` et l'atteint via le tunnel.
Design détaillé : `docs/superpowers/specs/2026-06-28-glitchtip-backend-error-tracking-design.md`.
+47 -6
View File
@@ -45,10 +45,51 @@ Conséquences :
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
## Limite connue (hors périmètre de ce correctif)
## Garde backend : suppression sur intention explicite (`delete: true`)
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil
onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans
dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet
ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres
utilisateurs). La protection front est donc **insuffisante** : il faut une garde **côté
backend**, indépendante de la version du client.
`WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si
la suppression est explicitement demandée** par le flag `delete: true` sur l'entrée :
```php
$deleteRequested = true === ($entry['delete'] ?? false);
if ($existing && $deleteRequested) {
// suppression (audit 'delete' + remove)
} elseif (null === $existing && ($absence || $is4hContract)) {
// création d'une ligne technique (validation d'une journée d'absence / contrat 4h)
}
// existing && !deleteRequested → NO-OP : la ligne existante est préservée
```
Conséquences :
- Une entrée vide **sans flag** sur une ligne existante est un **no-op** → une grille périmée
(n'importe quel client, vieil onglet inclus) **ne peut plus détruire** une saisie concurrente.
- Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (entrée vide qui
diffère de l'instantané chargé, donc transmise) → la suppression métier reste possible.
Helper `isEntryEmpty(...)` dans les deux composables, appliqué après le filtre dirty-tracking.
Le `delete` est **optionnel** (`WorkHourEntryPayload` front, DTO `WorkHourBulkUpsert` back) et
vaut `false` par défaut. Les appels de **création de ligne technique de validation** (toggles de
validation quand aucune ligne n'existe) envoient une entrée vide sans flag → inchangés (branche
`null === $existing`).
Tests : `tests/State/WorkHourBulkUpsertProcessorTest.php` (no-op sans flag / suppression avec
flag / mise à jour d'une entrée non vide). Le processor dépend des interfaces
`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface` (repos concrets `final`,
non mockables) pour permettre ces tests unitaires.
## Limite connue résiduelle (hors périmètre)
Reste le cas où l'admin **édite explicitement** une ligne sur des données périmées (il voit la
ligne vide, tape une valeur, écrasant une saisie concurrente sur cette même ligne). Ce cas
relèverait d'un **verrou optimiste** (comparaison d'`updatedAt`/version côté backend), non
implémenté ici — choix métier (option « suppression explicite » retenue plutôt que verrou
optimiste). Le backend n'a aucune détection de conflit concurrent sur l'**édition** (seule la
**suppression** est désormais protégée par l'intention explicite).
@@ -0,0 +1,255 @@
# GlitchTip Backend Error Tracking Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remonter les erreurs backend Symfony de SIRH vers GlitchTip (SDK Sentry), en prod uniquement, inerte sans DSN, le transport réseau étant assuré par Tailscale (infra, hors repo).
**Architecture:** On installe `sentry/sentry-symfony`, enregistré **prod-only** dans `bundles.php`. Un fichier `config/packages/sentry.yaml` (bloc `when@prod`) configure le SDK (DSN runtime `%env(SENTRY_DSN)%`, release = `%app.version%`, 4xx ignorés, pas de tracing) et déclare un handler Monolog qui remonte les logs `ERROR+` comme Issues. Sans `SENTRY_DSN`, le SDK est un no-op. Aucun changement frontend ni CI.
**Tech Stack:** PHP 8.4, Symfony 8, API Platform, `sentry/sentry-symfony` ^5.10, Monolog, Docker (prod), Tailscale (transport infra).
## Global Constraints
- **Prod only** : `SentryBundle` enregistré `['prod' => true]`, config sous `when@prod`. Dev/test : zéro impact.
- **Inerte sans DSN** : `env(SENTRY_DSN)` défaut `''` → SDK no-op si vide.
- **DSN runtime** : lu depuis l'env_file serveur, jamais baké dans l'image, jamais committé.
- **Version dépendance** : `sentry/sentry-symfony:^5.10` (identique au projet Lesstime, même stack).
- **Release** : `%app.version%` (déjà fourni par `config/version.yaml`, ex. `0.1.127`).
- **Pas de tracing/APM** : `traces_sample_rate: 0.0`.
- **Format commit** (hook SIRH) : Conventional Commits FR, ex. `feat : …`, `docs : …`. Terminer par la ligne `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- **Pas de modif frontend** (`frontend/**`), **pas de modif CI** (`.gitea/workflows/**`), **pas de modif Dockerfile** (DSN runtime ; CA HTTPS hors périmètre).
- **Branche** : `feat/glitchtip-backend-error-tracking` (déjà créée).
---
### Task 1: Câblage backend Sentry/GlitchTip (inerte sans DSN)
**Files:**
- Modify: `composer.json`, `composer.lock` (via `composer require`)
- Modify: `config/bundles.php`
- Create: `config/packages/sentry.yaml`
- Modify: `config/packages/monolog.yaml`
- Modify: `.env`
**Interfaces:**
- Consumes: paramètre `%app.version%` (`config/version.yaml`), variable d'env `SENTRY_DSN`, `%env(APP_ENV)%`.
- Produces: service `Sentry\Monolog\Handler` (handler Monolog niveau `Error`), variable d'env attendue `SENTRY_DSN` (runtime). Aucune API PHP consommée par d'autres tâches.
- [ ] **Step 1: Installer la dépendance**
Depuis le container PHP (`make shell` ou `docker compose exec php sh`) :
```bash
composer require sentry/sentry-symfony:^5.10
```
Expected : `composer.json` + `composer.lock` modifiés, paquet `sentry/sentry-symfony` 5.x installé.
- [ ] **Step 2: Forcer l'enregistrement prod-only du bundle**
Le recipe Flex peut écrire `['all' => true]` dans `config/bundles.php`. Corriger en prod-only. Contenu attendu de la ligne ajoutée :
```php
use Sentry\SentryBundle\SentryBundle;
// ... dans le tableau de retour :
SentryBundle::class => ['prod' => true],
```
Si Flex a aussi généré un `config/packages/sentry.yaml`, il sera remplacé à l'étape suivante.
- [ ] **Step 3: Écrire `config/packages/sentry.yaml`**
Remplacer intégralement le contenu du fichier par :
```yaml
# Error tracking → GlitchTip (compatible SDK Sentry).
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
when@prod:
parameters:
env(SENTRY_DSN): ''
sentry:
dsn: '%env(SENTRY_DSN)%'
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du
# kernel sont déjà logguées par Symfony → remontées via Monolog).
register_error_listener: false
register_error_handler: true
options:
environment: '%env(APP_ENV)%'
release: '%app.version%'
traces_sample_rate: 0.0
ignore_exceptions:
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
- Symfony\Component\Security\Core\Exception\AccessDeniedException
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
services:
Sentry\Monolog\Handler:
arguments:
$hub: '@Sentry\State\HubInterface'
$level: !php/const Monolog\Level::Error
$bubble: true
```
- [ ] **Step 4: Brancher le handler Monolog en prod**
Dans `config/packages/monolog.yaml`, sous `when@prod:``monolog:``handlers:`, ajouter le handler `sentry` (à côté des handlers `cron`, `main`, `deprecation` existants, sans les modifier) :
```yaml
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini
# dans sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
sentry:
type: service
id: Sentry\Monolog\Handler
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
```
- [ ] **Step 5: Documenter la variable dans `.env`**
Ajouter à la fin de `.env` le bloc commenté (valeur réelle injectée côté serveur uniquement) :
```env
###> sentry/sentry-symfony ###
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
# À définir dans l'env_file du serveur, PAS ici. Format :
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
# SENTRY_DSN=
###< sentry/sentry-symfony ###
```
> Si Flex a déjà inséré un bloc `###> sentry/sentry-symfony ###`, le remplacer par celui-ci (ne pas dupliquer).
- [ ] **Step 6: Vérifier que la conf prod compile (DSN vide → inerte)**
Depuis le container :
```bash
APP_ENV=prod php bin/console cache:clear --no-warmup
APP_ENV=prod php bin/console debug:container --env=prod 2>/dev/null | grep -i sentry | head
```
Expected :
- `cache:clear` : `[OK] Cleared the cache.` (aucune erreur de config/DI).
- `debug:container` : au moins une ligne `Sentry\...` (services chargés). DSN vide → SDK inerte, aucun envoi.
- [ ] **Step 7: Vérifier que le dev n'est pas impacté**
```bash
php bin/console cache:clear # APP_ENV=dev par défaut
make test
```
Expected : cache dev OK ; suite de tests verte (le bundle n'est pas chargé hors prod).
- [ ] **Step 8: Commit**
```bash
git add composer.json composer.lock config/bundles.php config/packages/sentry.yaml config/packages/monolog.yaml .env
git commit -m "$(cat <<'EOF'
feat : error tracking backend vers GlitchTip (prod-only, inerte sans DSN)
Ajout du SDK sentry/sentry-symfony enregistré prod-only, config sentry.yaml
(DSN runtime, release app.version, 4xx ignorés, pas de tracing) et handler
Monolog ERROR+. Sans SENTRY_DSN le SDK est no-op. Transport réseau via
Tailscale (infra, hors repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Documentation (doc/ + CLAUDE.md)
**Files:**
- Create: `doc/error-tracking.md`
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: néant (documentation).
- Produces: néant.
- [ ] **Step 1: Créer `doc/error-tracking.md`**
```markdown
# Error tracking (GlitchTip)
Les erreurs **backend** (Symfony) sont remontées vers **GlitchTip** (instance interne MALIO,
compatible SDK Sentry), org `malio`, projet `sirh-api`. **Prod uniquement**, **inerte sans DSN**.
> Frontend hors périmètre (les erreurs front partent du navigateur RH ; ajout futur possible via
> un proxy nginx `/ingest`).
## Contrainte réseau & transport
GlitchTip est sur le réseau interne (bloqué par Sophos). SIRH tourne sur un VPS OVH public. Le
container PHP joint GlitchTip via un **tunnel Tailscale** monté sur le host de prod.
## Fichiers concernés
| Fichier | Rôle |
|---|---|
| `config/packages/sentry.yaml` | conf backend (prod-only, DSN runtime, 4xx ignorés, release = `app.version`, handler Monolog ERROR+) |
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
| `config/packages/monolog.yaml` | handler `sentry` (service) en `when@prod` |
| `.env` | bloc documenté `SENTRY_DSN` (vide → inerte) |
## Activation (runbook)
1. **Tailscale sur le host prod OVH** :
\`\`\`bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # ou --authkey tskey-auth-XXXX (headless)
tailscale status && tailscale ip -4
\`\`\`
2. **Vérifier l'accès à GlitchTip** depuis le host :
\`\`\`bash
tailscale ping <glitchtip-tailnet>
curl -sS -o /dev/null -w "%{http_code}\n" http://<glitchtip-IP-tailnet>:<port>/_health/
\`\`\`
3. **Routage container → tailnet** : pointer `SENTRY_DSN` sur l'**IP tailnet** de GlitchTip
(le container ne résout pas MagicDNS). Repli si non routé : sidecar `tailscale/tailscale`
+ `network_mode: service:tailscale`.
4. **Créer le projet GlitchTip** `sirh-api` (plateforme `php-symfony`) dans l'org `malio`,
récupérer le DSN (Settings → Client Keys).
5. **Injecter le DSN** dans l'env_file serveur (pas dans l'image), puis redéployer :
\`\`\`env
SENTRY_DSN=http://<clé>@100.x.y.z:<port>/<id-sirh-api>
\`\`\`
\`\`\`bash
docker compose up -d
docker compose exec php php bin/console cache:clear --env=prod
\`\`\`
## CA HTTPS (conditionnel)
Uniquement si le DSN cible l'HTTPS interne `logs.malio-dev.fr` (cert auto-signé) : baker la CA
racine MALIO dans `deploy/docker/Dockerfile.prod` (stage production). Recommandé : préférer
l'endpoint **HTTP** via le tailnet (déjà chiffré par WireGuard) → pas de CA.
Design détaillé : `docs/superpowers/specs/2026-06-28-glitchtip-backend-error-tracking-design.md`.
```
- [ ] **Step 2: Ajouter une section à `CLAUDE.md`**
Insérer une section (après « ## Notifications ») :
```markdown
## Error tracking (GlitchTip)
- Backend Symfony → GlitchTip (SDK Sentry), org `malio`, projet `sirh-api`. **Prod only**, **inerte sans `SENTRY_DSN`**.
- Config : `config/packages/sentry.yaml` (DSN runtime `%env(SENTRY_DSN)%`, release `%app.version%`, 4xx ignorés, `traces_sample_rate: 0`, handler Monolog ERROR+) ; `SentryBundle` enregistré `['prod' => true]` (`config/bundles.php`) ; handler `sentry` en `when@prod` (`config/packages/monolog.yaml`). DSN runtime via l'env_file serveur, jamais committé/baké.
- **Transport réseau** : GlitchTip est interne (bloqué Sophos), SIRH sur VPS OVH → tunnel **Tailscale** sur le host prod. Frontend hors périmètre (ajout futur via proxy nginx `/ingest`).
- Doc : `doc/error-tracking.md`.
```
- [ ] **Step 3: Commit**
```bash
git add doc/error-tracking.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs : documentation error tracking GlitchTip (doc/ + CLAUDE.md)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Notes d'exécution
- **TDD non applicable** ici : ce sont des changements de **configuration** (pas de logique métier unitairement testable). La vérification se fait par commandes console (`cache:clear` prod, `debug:container`) + non-régression de la suite existante (`make test`). Ne pas ajouter de test PHPUnit factice.
- Les valeurs `<…>` du runbook (IP tailnet, port GlitchTip, clé DSN) sont renseignées au **déploiement**, hors repo.
- Après les deux tasks : pousser la branche et ouvrir la MR (`git push -u origin feat/glitchtip-backend-error-tracking`).
@@ -0,0 +1,223 @@
# Error tracking backend SIRH → GlitchTip (via Tailscale)
> Date : 2026-06-28
> Périmètre : **backend Symfony uniquement**, **prod only**, transport **Tailscale**.
> Référence pattern : projet **Lesstime** (`config/packages/sentry.yaml`, `README.md` § Error tracking).
## 1. Contexte & contrainte
GlitchTip (instance auto-hébergée MALIO, compatible SDK Sentry) vit sur le **réseau interne**,
bloqué par **Sophos**, sur le domaine interne `logs.malio-dev.fr` (DNS local, CA auto-signée).
SIRH tourne sur un **VPS OVH** (Internet public) → le container PHP ne peut pas joindre l'interne.
**Décision** : on monte un **tunnel Tailscale** sur le host de prod OVH. Le container PHP atteint
GlitchTip par le tailnet. **Backend seulement** pour l'instant (les erreurs front partent du
navigateur RH, hors périmètre — pourra être ajouté plus tard via un proxy nginx `/ingest`).
Flux retenu :
| Flux | Source | Chemin vers GlitchTip |
|---|---|---|
| **Backend** Symfony | container PHP sur le VPS OVH | → host Tailscale → tailnet → GlitchTip ✅ |
| Frontend SPA | navigateur RH | **hors périmètre** (pas de SDK front) |
## 2. Principes
- **Prod only** : le bundle n'est enregistré que pour `prod`. En dev/test : zéro impact.
- **Inerte sans DSN** : si `SENTRY_DSN` est vide/absent, le SDK ne fait rien (no-op).
- **Runtime DSN** : le DSN est lu à l'exécution depuis l'`env_file` du serveur, jamais baké dans
l'image (pas de secret dans le repo ni dans l'image Docker).
- **Pas d'APM/tracing** (`traces_sample_rate: 0`) : on ne remonte que les erreurs.
- **Bruit filtré** : 4xx HTTP (404/405/AccessDenied) ignorés ; channels `event/doctrine/
deprecation/cron` exclus du handler Monolog.
## 3. Changements de code (repo SIRH)
### 3.1 Dépendance
```bash
make shell # ou docker exec dans le container php
composer require sentry/sentry-symfony:^5.10
```
Met à jour `composer.json` + `composer.lock`. Version identique à Lesstime (stack PHP 8.4 /
Symfony 8 commune).
### 3.2 `config/bundles.php`
Ajouter l'enregistrement **prod-only** :
```php
use Sentry\SentryBundle\SentryBundle;
// ...
SentryBundle::class => ['prod' => true],
```
> `composer require` ajoute généralement la ligne `['all' => true]` via Flex — la corriger en
> `['prod' => true]`.
### 3.3 `config/packages/sentry.yaml` *(nouveau fichier)*
```yaml
# Error tracking → GlitchTip (compatible SDK Sentry).
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
when@prod:
parameters:
env(SENTRY_DSN): ''
sentry:
dsn: '%env(SENTRY_DSN)%'
# On capture les erreurs fatales PHP via le handler, mais on DÉSACTIVE le listener
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du kernel
# sont déjà logguées par Symfony → remontées via Monolog).
register_error_listener: false
register_error_handler: true
options:
environment: '%env(APP_ENV)%'
release: '%app.version%'
traces_sample_rate: 0.0
ignore_exceptions:
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
- Symfony\Component\Security\Core\Exception\AccessDeniedException
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
services:
Sentry\Monolog\Handler:
arguments:
$hub: '@Sentry\State\HubInterface'
$level: !php/const Monolog\Level::Error
$bubble: true
```
> `release: '%app.version%'` réutilise `config/version.yaml` (`app.version`, ex. `0.1.127`).
### 3.4 `config/packages/monolog.yaml`
Dans le bloc `when@prod.monolog.handlers`, ajouter :
```yaml
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini dans
# sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
sentry:
type: service
id: Sentry\Monolog\Handler
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
```
> Les autres handlers (`main`, `cron`, `deprecation`) restent inchangés.
### 3.5 `.env` (+ `.env.example` si présent)
Bloc documenté (valeur réelle injectée côté serveur uniquement) :
```env
###> sentry/sentry-symfony ###
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
# À définir dans l'env_file du serveur, PAS ici. Format :
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
# SENTRY_DSN=
###< sentry/sentry-symfony ###
```
### 3.6 CI / Dockerfile
**Aucun changement requis** pour le backend : le DSN est runtime (env_file). La CI
(`.gitea/workflows/build-docker.yml`) ne build/push que l'image — rien à toucher.
**CA TLS (conditionnel)** — voir §4.4 : nécessaire **uniquement** si le DSN cible l'HTTPS interne
`logs.malio-dev.fr`. Si on tape l'endpoint **HTTP** GlitchTip via le tailnet (recommandé), pas de
modif Dockerfile.
## 4. Runbook infra (hors repo) — toutes les étapes & commandes
### 4.1 Installer Tailscale sur le host de prod OVH
```bash
# Sur le serveur OVH (Debian/Ubuntu), en root/sudo :
curl -fsSL https://tailscale.com/install.sh | sh
# Jointure du tailnet (ouvre une URL d'auth, ou utiliser une auth key headless) :
sudo tailscale up
# --- headless (CI/scripté) :
# sudo tailscale up --authkey tskey-auth-XXXXXXXXXXXX
# Vérifier l'état et récupérer l'IP tailnet du serveur :
tailscale status
tailscale ip -4
```
> **Si GlitchTip est sur une autre machine du tailnet** : noter son IP tailnet (`100.x.y.z`) ou son
> nom MagicDNS. **Si GlitchTip est derrière un subnet router** (LAN interne non tailnet) : ajouter
> `--accept-routes` au `tailscale up`, et s'assurer qu'un subnet router annonce le sous-réseau.
### 4.2 Vérifier la connectivité host → GlitchTip via le tailnet
```bash
# Depuis le host OVH :
tailscale ping <glitchtip-tailnet-name-ou-IP>
curl -sS -o /dev/null -w "%{http_code}\n" http://<glitchtip-IP-tailnet>:<port>/_health/ # → 200 attendu
```
### 4.3 Rendre le tailnet joignable depuis le container PHP
Le container PHP est sur le réseau bridge Docker, pas directement sur le tailnet. Deux options :
**Option A — Host Tailscale + IP tailnet dans le DSN (recommandé, simple).**
L'egress du container est masqueradé par le host, qui route `100.x.y.z` via `tailscale0`.
→ Pointer `SENTRY_DSN` directement sur l'**IP tailnet** de GlitchTip (pas MagicDNS, que le
container ne résout pas). Optionnellement figer le nom via `extra_hosts` dans le compose :
```yaml
# docker-compose.yml (serveur)
extra_hosts:
- "glitchtip.tailnet:100.x.y.z"
```
Prérequis : IP forwarding actif sur le host (`net.ipv4.ip_forward=1`, déjà posé par l'install
Tailscale).
**Option B — Sidecar Tailscale (robuste, si A ne route pas).**
Service `tailscale/tailscale` dans le compose, et le container app en
`network_mode: service:tailscale` → l'app partage l'interface tailnet (MagicDNS dispo).
À retenir seulement si l'option A ne fonctionne pas.
### 4.4 (Conditionnel) CA racine MALIO — uniquement si DSN = HTTPS interne
Si le DSN cible `https://logs.malio-dev.fr` (cert auto-signé), baker la CA dans l'image
(`deploy/docker/Dockerfile.prod`, stage `production`) — `ca-certificates` est déjà installé :
```dockerfile
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
```
(Le `.crt` public est récupérable depuis le repo Lesstime : `infra/prod/malio-dev-root-ca.crt`.)
Vérification :
```bash
curl --cacert deploy/docker/malio-dev-root-ca.crt https://logs.malio-dev.fr/api/<id>/store/
```
> **Recommandation** : préférer l'endpoint **HTTP** via le tailnet (déjà chiffré par WireGuard) →
> on évite complètement la CA et cette modif Dockerfile.
### 4.5 Créer le projet GlitchTip `sirh-api`
Dans l'UI GlitchTip (org `malio`) : **New Project** → plateforme `php-symfony` → nom `sirh-api`.
Récupérer le **DSN** dans *Settings → Client Keys (DSN)*. Adapter le host du DSN à l'IP/nom tailnet
si nécessaire.
> Le MCP GlitchTip est en lecture seule (pas de `create_project`) → création manuelle UI.
### 4.6 Injecter le DSN sur le serveur
Ajouter à l'`env_file` du docker-compose serveur (PAS dans l'image), puis redéployer :
```env
SENTRY_DSN=http://<clé>@100.x.y.z:<port>/<id-sirh-api>
```
```bash
docker compose up -d # recharge l'env_file
docker compose exec php php bin/console cache:clear --env=prod
```
## 5. Documentation (règles SIRH)
- **`doc/error-tracking.md`** *(nouveau)* : pattern back, activation, runbook Tailscale, CA, lien
vers ce spec.
- **`CLAUDE.md`** : nouvelle section « Error tracking (GlitchTip) » résumant le pattern + le fait
que c'est prod-only / inerte sans DSN / transport Tailscale.
- **In-app documentation** (`frontend/data/documentation-content.ts`) : **non concernée** —
infra invisible pour les utilisateurs RH (employé/chef de site/admin), aucun changement
fonctionnel UI.
## 6. Vérification
| Niveau | Test | Attendu |
|---|---|---|
| Dev (sans DSN) | `make test`, boot dev | aucune régression, SDK absent en dev |
| Prod config | build image + `APP_ENV=prod cache:clear` (DSN bidon) | bundle chargé, pas d'erreur de conf |
| Inerte | prod sans `SENTRY_DSN` | aucun envoi, no-op |
| End-to-end | une fois Tailscale + projet OK : déclencher une erreur ERROR+ | Issue visible dans GlitchTip `sirh-api` |
## 7. Hors périmètre (explicite)
- Frontend (SDK Nuxt, source maps, build-args CI) — ajout futur via proxy nginx `/ingest`.
- APM / tracing / performance (DuckDB-like) — non.
- Exposition publique de GlitchTip — non (tout passe par Tailscale).
+9 -1
View File
@@ -959,6 +959,12 @@ export const useDriverHoursPage = () => {
}
}
// Une entrée conducteur est vide quand aucune minute (jour/nuit/atelier) ni repas/nuitée
// n'est renseignée. Sert à marquer une suppression explicite (`delete: true`).
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
!entry.dayHoursMinutes && !entry.nightHoursMinutes && !entry.workshopHoursMinutes
&& !entry.hasBreakfast && !entry.hasLunch && !entry.hasDinner && !entry.hasOvernight
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
@@ -977,7 +983,9 @@ export const useDriverHoursPage = () => {
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
.map(({ current }) => current)
// Une ligne vidée par l'utilisateur porte le flag `delete` : le backend n'autorise la
// suppression d'une ligne existante que sur intention explicite (anti-grille périmée).
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
if (entries.length === 0) return
+12 -1
View File
@@ -1175,6 +1175,14 @@ export const useHoursPage = () => {
}
}
// Une entrée est vide quand aucune plage horaire ni présence n'est renseignée.
// Sert à marquer une suppression explicite (`delete: true`) côté bulk-upsert.
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
!entry.morningFrom && !entry.morningTo
&& !entry.afternoonFrom && !entry.afternoonTo
&& !entry.eveningFrom && !entry.eveningTo
&& !entry.isPresentMorning && !entry.isPresentAfternoon
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
@@ -1190,7 +1198,10 @@ export const useHoursPage = () => {
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
.map(({ current }) => current)
// Une ligne vidée par l'utilisateur (donc différente de l'instantané chargé) porte le
// flag `delete` : le backend n'autorise la suppression d'une ligne existante que sur
// intention explicite. Sans ce flag, une grille périmée ne peut rien détruire.
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
if (entries.length === 0) {
return
+1
View File
@@ -363,6 +363,7 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
{ type: 'note', content: 'Sécurité anti-écrasement : « Enregistrer » ne touche que les lignes que vous avez réellement modifiées ; les lignes auxquelles vous n\'avez pas touché ne sont jamais envoyées, donc jamais écrasées même si un autre utilisateur les a saisies pendant que votre écran était ouvert. Pour supprimer les heures d\'un salarié, videz explicitement sa ligne puis Enregistrer. Conseil : si votre écran est resté ouvert longtemps, rechargez la page avant de saisir pour repartir des données à jour.' },
],
},
],
+4
View File
@@ -42,6 +42,10 @@ export type WorkHourEntryPayload = {
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
// Autorise la suppression d'une ligne existante quand l'entrée est vide.
// Sans ce flag, le backend ignore une entrée vide sur une ligne existante
// (garde anti-perte de données contre les grilles périmées).
delete?: boolean
}
export type WeeklyWorkHourDailySummary = {
+6 -1
View File
@@ -37,8 +37,13 @@ final class WorkHourBulkUpsert
* nightHoursMinutes?:?int,
* hasBreakfast?:bool,
* hasLunch?:bool,
* hasOvernight?:bool
* hasOvernight?:bool,
* delete?:bool
* }>
*
* Le flag `delete` (défaut false) autorise la suppression d'une ligne existante
* quand l'entrée est vide. Sans lui, une entrée vide sur une ligne existante est
* un no-op (garde anti-perte de données contre les grilles périmées).
*/
public array $entries = [];
}
@@ -13,4 +13,11 @@ interface EmployeeScopedRepositoryInterface
* @return list<Employee>
*/
public function findScoped(User $user): array;
/**
* @param list<int> $employeeIds
*
* @return array<int, Employee>
*/
public function findAccessibleByIds(array $employeeIds, User $user): array;
}
@@ -18,6 +18,13 @@ interface WorkHourReadRepositoryInterface
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return array<int, WorkHour>
*/
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array;
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
+13 -7
View File
@@ -12,8 +12,8 @@ use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable;
@@ -28,8 +28,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private EmployeeScopedRepositoryInterface $employeeRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AuditLogger $auditLogger,
@@ -142,8 +142,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
// Garde anti-perte de données : une ligne vide ne supprime l'enregistrement
// existant QUE si la suppression est explicitement demandée (`delete: true`).
// Sans ce flag, une grille périmée (ex. vieil onglet sans dirty-tracking front)
// ne peut plus détruire une ligne saisie entre-temps par un autre utilisateur :
// l'entrée vide est traitée comme un no-op.
$deleteRequested = true === ($entry['delete'] ?? false);
if ($existing && $deleteRequested) {
$this->auditLogger->log(
$employee,
'delete',
@@ -155,7 +161,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
);
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
} elseif (null === $existing && (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract)) {
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
+9
View File
@@ -112,6 +112,15 @@
"bin/phpunit"
]
},
"sentry/sentry-symfony": {
"version": "5.10",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
}
},
"symfony/console": {
"version": "8.0",
"recipe": {
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use App\ApiResource\WorkHourBulkUpsert;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use App\State\WorkHourBulkUpsertProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Garde anti-perte de données : une entrée vide ne supprime une ligne existante
* QUE si la suppression est explicitement demandée (flag `delete: true`).
* Sans ce flag, une grille périmée (vieil onglet) ne peut plus rien détruire.
*
* @internal
*/
final class WorkHourBulkUpsertProcessorTest extends TestCase
{
public function testEmptyEntryWithoutDeleteFlagPreservesExistingRow(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::never())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload(['employeeId' => 7]),
new Post(),
);
self::assertSame(0, $result->deleted);
self::assertSame(1, $result->processed);
}
public function testEmptyEntryWithDeleteFlagRemovesExistingRow(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::once())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload(['employeeId' => 7, 'delete' => true]),
new Post(),
);
self::assertSame(1, $result->deleted);
}
public function testNonEmptyEntryStillUpdatesRegardlessOfFlag(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::never())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload([
'employeeId' => 7,
'morningFrom' => '08:00',
'morningTo' => '12:30',
'afternoonFrom' => '14:00',
'afternoonTo' => '18:00',
]),
new Post(),
);
self::assertSame(1, $result->updated);
}
/**
* @param array<string, mixed> $entry
*/
private function payload(array $entry): WorkHourBulkUpsert
{
$payload = new WorkHourBulkUpsert();
$payload->workDate = '2026-06-24';
$payload->entries = [$entry];
return $payload;
}
private function buildProcessor(EntityManagerInterface $em): WorkHourBulkUpsertProcessor
{
$user = new User()->setUsername('Elodie')->setRoles(['ROLE_ADMIN']);
$employee = new Employee()->setFirstName('Delphine')->setLastName('BACHELIER');
// Ligne existante NON vide (journée complète saisie entre-temps par un autre utilisateur).
$existing = new WorkHour()
->setEmployee($employee)
->setWorkDate(new DateTimeImmutable('2026-06-24'))
->setMorningFrom('08:00')
->setMorningTo('12:00')
->setAfternoonFrom('14:00')
->setAfternoonTo('18:00')
;
$contract = new Contract()->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
$employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$employeeRepository->method('findAccessibleByIds')->willReturn([7 => $employee]);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$workHourRepository->method('findByDateAndEmployeesIndexedByEmployeeId')->willReturn([7 => $existing]);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$contractResolver = $this->createStub(EmployeeContractResolver::class);
$contractResolver->method('resolveForEmployeeAndDate')->willReturn($contract);
$contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false);
return new WorkHourBulkUpsertProcessor(
$em,
$security,
$employeeRepository,
$workHourRepository,
$absenceRepository,
$contractResolver,
$this->createStub(AuditLogger::class),
);
}
}