Files
crewli/dev-docs/ARCH-OBSERVABILITY.md
bert.hausmans 754222f74d docs: ARCH-OBSERVABILITY.md (WS-8b)
Replaces the WS-6 skeleton with a full post-implementation reference
for the observability stack. Eleven sections covering scope, component
overview, tag taxonomy (replacing RFC §3.6 as source-of-truth), tag
binding architecture, scrubbing semantics, runtime context split, CSP
whitelist, sourcemap upload, GDPR + privacy, maintenance + extension
guidance, plus cross-references.

Form Builder exception classification from the old skeleton §3 is
preserved in §5.4 — concrete answer for which Crewli exception
classes do or do not go to GlitchTip.

Lengte: 730 regels markdown. Closes WS-7 acceptance criterion 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:46:32 +02:00

731 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ARCH — Observability (v1.0)
> **Source of truth** for Crewli's observability implementation
> (sentry-laravel + @sentry/vue + GlitchTip). This document supersedes
> [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md) for tag
> taxonomy, binding semantics, and operational patterns. The RFC
> remains the historical implementation-spec; ARCH is the
> post-implementation reference for developers maintaining or extending
> the stack.
>
> **Status:** WS-7 implementation complete. Code criteria 3, 4, 5, 6,
> 11, 12, 13 satisfied; documentation criteria 8, 14 satisfied via this
> ARCH plus the runbooks under [`runbooks/`](./runbooks/). Manual
> closure criteria (1, 2, 7, 9, 10) remain on Bert's checklist.
>
> **Version:** 1.0 (initial post-implementation reference, mei 2026,
> after PR-1 → PR-4 landed in `feat/ws-7-observability`).
>
> **Pre-WS-7 skeleton:** earlier versions of this document (v0.1,
> april 2026) carried placeholder sections for log levels, metrics,
> alerting and dashboards. Those decisions were taken during WS-7 and
> implemented as code; this document captures the as-built outcome.
> The historical skeleton sections about metrics (§5), alerting (§6),
> and dashboards (§7) are intentionally not carried forward — Crewli
> has settled on errors-only observability via GlitchTip; no Statsd /
> Prometheus / Grafana stack is planned (RFC §2 amendment B).
---
## §1 Doel & scope
Observability in Crewli levert geautomatiseerde error-detection en
service-availability monitoring. Stack traces, tags, breadcrumbs en
release-correlation worden verzameld via [GlitchTip](https://glitchtip.com/),
self-hosted op `monitoring.hausdesign.nl`. GlitchTip is binary-compatible
met het Sentry event-protocol; we gebruiken `sentry/sentry-laravel` op de
backend en `@sentry/vue` op de frontend ongewijzigd.
**Wel in scope:**
- Programmer errors uit Laravel controllers en queue jobs (`Throwable`,
`RuntimeException`, `TypeError`, `QueryException` etc.)
- Infrastructure failures (database connection drop, redis unavailable,
external HTTP timeouts in Crewli's eigen client-code)
- Frontend runtime errors uit Vue componenten en composables
- Unhandled promise rejections in de SPA
- Vue Router navigatie-context als breadcrumbs
**Niet in scope** (per [RFC §3.10](./RFC-WS-7-OBSERVABILITY.md)):
- **Performance / tracing / profiling.** Hard-pinned op 0.0 sample rate
in zowel `config/sentry.php` als `apps/app/src/observability/sentry.ts`.
- **Verwachte business-uitkomsten.** ValidationException,
AuthenticationException, AuthorizationException, sub-500
HttpExceptions worden bewust niet gecaptured. Die hebben eigen
audit-paden (`form_submission_action_failures`, `activity_log`).
- **Audit trails.** `activity_log`, `impersonation_audit_logs`,
`form_webhook_deliveries` blijven authoritative voor security en
compliance audit. GlitchTip is voor defectdetectie.
- **Replay / Web Vitals / User Feedback.** GlitchTip ondersteunt dit
niet; we gebruiken het ook niet als roadmap-item.
- **Metrics / dashboards / alerting beyond GlitchTip's email.** Geen
Statsd / Prometheus / Grafana. Alerting initieel email-naar-Bert via
GlitchTip's eigen rule-engine; Slack-integratie staat op BACKLOG.
**Boundary met bestaande systemen:**
| Systeem | Wat het doet | Wat GlitchTip NIET overneemt |
|---|---|---|
| Telescope (`/telescope`) | Dev-only debugging dashboard. Local + testing. | GlitchTip is voor production-incidents; Telescope blijft voor lokale debug. |
| `activity_log` (Spatie) | Audit trail van user-acties op tenant data. Authoritative voor "wie deed wat wanneer." | GlitchTip captured nooit business-events zoals `form.submitted`. |
| `form_webhook_deliveries` | Webhook delivery audit met retry / dead-letter. | Bij dead-letter NIET via GlitchTip; alleen als de dispatcher zelf een programmer-error gooit. |
| `form_submission_action_failures` | Apply-pipeline failures per submission, action, en organisatie. Org-admin operational handling via WS-6 admin UI. | GlitchTip ziet runtime apply-pipeline exceptions (zie §5.4) parallel — engineering visibility, niet operational fix UI. |
| `impersonation_audit_logs` | Wie impersoneerde wie wanneer (security audit). | GlitchTip tagt actieve impersonation als context op gecaptured events; vervangt audit niet. |
| Laravel default log channel | Operationele runtime logs (info/warning/error). | Beide systemen krijgen dezelfde events; correlation via `request_id`. |
---
## §2 Componenten-overzicht
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Crewli production / dev │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ Laravel API │ │ apps/app SPA (Vue 3) │ │
│ │ (api.crewli.app) │ │ (crewli.app) │ │
│ │ │ │ │ │
│ │ ┌───────────────┐ │ │ ┌────────────────────┐ │ │
│ │ │sentry-laravel │ │ │ │@sentry/vue 10.x │ │ │
│ │ │4.25 SDK │ │ │ │ │ │ │
│ │ └───────┬───────┘ │ │ └─────────┬──────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌───────▼───────┐ │ │ ┌─────────▼──────────┐ │ │
│ │ │SentryEvent │ │ │ │scrubEvent │ │ │
│ │ │Scrubber (PHP) │ │ │ │(TypeScript) │ │ │
│ │ └───────┬───────┘ │ │ └─────────┬──────────┘ │ │
│ │ │ │ │ │ │ │
│ └──────────┼──────────┘ └────────────┼─────────────┘ │
│ │ │ │
│ │ HTTPS POST /api/<n>/envelope/ │ │
│ └────────────┬────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────┐ │
│ │ GlitchTip │ │
│ │ monitoring.hausdesign.nl (prod) │ │
│ │ localhost:8200 (dev) │ │
│ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌───────┐ │ │
│ │ │ web │ │ worker │ │ pg/ │ │ │
│ │ │ (django)│ │ (celery)│ │ redis │ │ │
│ │ └─────────┘ └─────────┘ └───────┘ │ │
│ └───────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
**Twee projecten in één GlitchTip-instance:**
- `crewli-api` — Laravel events (`app=api` tag)
- `crewli-app` — SPA events (`app=app` tag)
DSN per project; beide keys liggen in 1Password vault onder
`Crewli / GlitchTip / DSNs`. Backend leest `SENTRY_DSN_BACKEND`,
frontend `VITE_SENTRY_DSN_FRONTEND`.
CSP `connect-src` whitelist voor de ingest-host is verplicht — zonder
deze whitelist blokkeert de browser elke `@sentry/vue`-egress stilletjes.
Zie [§7](#7-csp-whitelist-kritisch).
---
## §3 Tag-taxonomie
Deze tabel vervangt de tabel in [`RFC-WS-7-OBSERVABILITY.md §3.6`](./RFC-WS-7-OBSERVABILITY.md)
als source-of-truth. Wanneer een tag wordt toegevoegd of de bron-locatie
wijzigt, wordt deze tabel bijgewerkt; de RFC blijft historisch
document.
### 3.1 Backend tags (sentry-laravel)
| Tag | Locatie | Always / conditional | Bron |
|---|---|---|---|
| `app` | initial scope (`config/sentry.php`) of `BindSentryRouteContext` | always | constant `'api'` |
| `release` | sentry-laravel built-in | when `SENTRY_RELEASE` env set | `crewli-api@<short-sha>` injected by `deploy.sh` |
| `environment` | sentry-laravel built-in | always | `APP_ENV` |
| `route_name` | `BindSentryRouteContext` middleware | conditional (named routes only) | `$request->route()->getName()` |
| `http.method` | `BindSentryRouteContext` middleware | always (HTTP requests) | `$request->method()` |
| `actor_scope` | `AuthScopeContextListener` | always (authenticated events) | resolution chain — see §3.3 |
| `actor_type` | `AuthScopeContextListener` | always (authenticated events) | `ActorType::resolve()` — see §3.4 |
| `user_id` | `AuthScopeContextListener` (overridden by `HandleImpersonation`) | always (authenticated) | ULID |
| `username` | `AuthScopeContextListener` user object | always (authenticated) | ULID — RFC §3.8: never email |
| `organisation_id` | `AuthScopeContextListener` | conditional — only when `actor_scope=organisation` | route param / portal-token resolution — see §3.3 |
| `event_id` | `AuthScopeContextListener` (via `{event}` route param) | conditional | route binding |
| `impersonation.active` | `AuthScopeContextListener` baseline + `HandleImpersonation` override | **always** (authenticated) | binary `'true'`/`'false'` — RFC §3.6 invariant |
| `impersonation.impersonator_user_id` | `HandleImpersonation` middleware | conditional — only when impersonating | ULID |
| `impersonation.session_id` | `HandleImpersonation` middleware | conditional — only when impersonating | ULID |
| `queue.attempt` | `TagJobAttemptOnSentry` listener | conditional — within queue jobs | `$event->job->attempts()` |
### 3.2 Frontend tags (@sentry/vue)
| Tag | Locatie | Always / conditional | Bron |
|---|---|---|---|
| `app` | initial scope (`sentry.ts`) | always | constant `'app'` |
| `release` | sentry-vue built-in | when `VITE_SENTRY_RELEASE` set | `crewli-app@<short-sha>` injected by `deploy.sh` build-time |
| `environment` | sentry-vue built-in | always | `import.meta.env.MODE` |
| `route_name` | `installContextBinding` Vue Router guard | always | `route.name ?? 'unnamed'` |
| `actor_scope` | `installContextBinding` | always | one of `organisation` / `platform` / `user` / `portal` / `anonymous` |
| `actor_type` | `installContextBinding` | always | `super_admin` / `organizer_admin` / `org_member` / `portal_token` / `unauthenticated` |
| `user_id` | `installContextBinding` | conditional — **never** when `actor_scope=portal` | `useAuthStore().user.id` |
| `organisation_id` | `installContextBinding` | conditional — only when `actor_scope=organisation` | `useOrganisationStore().activeOrganisationId` |
`http.method` is afwezig in de frontend-tabel; Vue Router-routes zijn
page-level navigation events, niet HTTP-requests. De backend-tabel
heeft `http.method` per request; de frontend laat die over aan
fetch-breadcrumbs die `@sentry/vue` automatisch attached.
### 3.3 `actor_scope` resolution
Beide implementaties volgen dezelfde priority chain. De backend doet
de resolution in `AuthScopeContextListener::resolveTenantContext()`,
de frontend in `contextBinding.ts::bindScope()`.
| Priority | Backend signaal | Frontend signaal | Resulterende `actor_scope` |
|---|---|---|---|
| 1 | `{organisation}` route-param | route binding op `/organisations/:id` | `organisation` |
| 2 | `{event}` route-param | `{event}` route-param | `organisation` (via event.organisation_id) |
| 3 | `portal_event` request attribute (set by `PortalTokenMiddleware`) | `route.meta.public === true && route.meta.context === 'portal'` | backend: `organisation`, frontend: `portal` |
| 4 | `super_admin` role + route name starts with `admin.` | `super_admin` role + `route.path` starts with `/platform` | `platform` (no `organisation_id`) |
| 5 | Authenticated, no org context | Authenticated, no `activeOrganisationId` | `user` (no `organisation_id`) |
| 6 | Unauthenticated | Unauthenticated | (backend: not bound; frontend: `anonymous`) |
**Belangrijke noot voor frontend portal-zone:** `actor_scope=portal`
kent **geen** `user_id` of `username`. RFC §3.7 frontend-block punt 5
expliciet — token-based flows (artist advance, public form fill)
krijgen geen user-context omdat de identifier (ULID-token) zelf
gevoelig is en de bezoeker niet permanent met Crewli is verbonden.
**Multi-tenant invariant:** wanneer `actor_scope=organisation` MOET
`organisation_id` aanwezig zijn als valide ULID. Wanneer
`actor_scope=platform`, `user`, of `anonymous`, IS `organisation_id`
afwezig. Niet "altijd aanwezig" maar "altijd correct gerelateerd aan
`actor_scope`." Geverifieerd in
`AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation`.
### 3.4 `actor_type` enum
Backend: `App\Enums\Observability\ActorType` (PHP enum). Frontend:
inline string-mapping in `contextBinding.ts::resolveActorType()`. Beide
geven dezelfde waarden:
| Waarde | Wanneer |
|---|---|
| `super_admin` | User has Spatie role `super_admin` |
| `organizer_admin` | User has Spatie role `org_admin` |
| `org_member` | Authenticated user, no admin role (covers volunteers — Crewli has no dedicated `volunteer` role today; see [BACKLOG OBS-1](./BACKLOG.md)) |
| `portal_token` | Token-based portal request (`portal_event` attribute / `route.meta.public` + `context=portal`) |
| `unauthenticated` | No auth (e.g. login page, public form fill) |
---
## §4 Tag-binding architectuur
Drie patronen die we bewust hebben gekozen tijdens WS-7 implementation;
gedocumenteerd hier zodat toekomstige uitbreidingen consistent zijn.
### 4.1 Backend split — middleware × event-listener
Route-scope tags binden **per HTTP request** via middleware. Auth-scope
tags binden **per authenticatie-event** via een listener. Reden:
route-context bestaat alleen tijdens HTTP handling, auth-context wordt
geëmit door élke authenticator (Laravel's `SessionGuard`, Sanctum's
bearer-token Guard, toekomstige authenticators).
| Concern | Implementatie |
|---|---|
| Route-scope (`app`, `route_name`, `http.method`) | `App\Http\Middleware\BindSentryRouteContext` — registered globally on the api group via `$middleware->api(prepend: [...])` in `bootstrap/app.php` |
| Auth-scope (`user_id`, `actor_type`, `actor_scope`, `organisation_id`) | `App\Listeners\Observability\AuthScopeContextListener` — listens to BOTH `Illuminate\Auth\Events\Authenticated` (SessionGuard) AND `Laravel\Sanctum\Events\TokenAuthenticated` (Sanctum) |
| Impersonation override + escalation | `App\Http\Middleware\HandleImpersonation` — re-binds Sentry scope after the user-swap, sets `impersonation.active='true'` plus impersonator/session ids |
| Queue context (`queue.attempt`) | `App\Listeners\Observability\TagJobAttemptOnSentry` — listens to `Illuminate\Queue\Events\JobProcessing` |
**Waarom dual-event listener?** Crewli's HTTP-flow is bearer-token via
`CookieBearerToken` middleware → `auth:sanctum` → Sanctum's `Guard`
fires only `TokenAuthenticated`, NOT `Authenticated`. Listening only
to the Authenticated event would silently miss every authenticated
HTTP request. Discovered by the live smoke test that PR-3 follow-up
fixed (commit `adab3be`).
### 4.2 Frontend split — Vue Router guard + Pinia store reads
Vue heeft geen Sentry-equivalent van Laravel's Authenticated event; de
natuurlijke tag-binding momenten zijn **route-transitions**. Een
`router.beforeEach` guard in `apps/app/src/observability/contextBinding.ts`:
1. Roept `Sentry.getCurrentScope().clear()` aan op elke navigatie.
Voorkomt cross-zone leakage (e.g. user logt uit in portal-zone maar
Sentry houdt user_id van de organizer-context vast).
2. Leest `useAuthStore()` en `useOrganisationStore()` voor identity en
tenant-context.
3. Past dezelfde resolution chain toe als de backend (§3.3).
### 4.3 Default-in-listener / override-in-middleware pattern
Voor binary tags die altijd aanwezig moeten zijn maar door specifieke
middleware-stappen worden geëscaleerd, gebruiken we een twee-fase
pattern:
```
AuthScopeContextListener::bindForUser() → scope.setTag('impersonation.active', 'false')
HandleImpersonation::handle() → scope.setTag('impersonation.active', 'true')
scope.setTag('impersonation.impersonator_user_id', $admin->id)
scope.setTag('impersonation.session_id', $session->id)
```
De listener seedt **altijd** een baseline (`'false'`). Wanneer
impersonation actief is, draait `HandleImpersonation` ná auth en
overschrijft de scope met de target user en de escalation-tags. Als
toekomstige refactors per `actor_scope` branch shortcuts maken die de
baseline overslaan, vangt
`AuthScopeContextListenerTest::test_impersonation_active_default_false_across_every_actor_scope_branch`
de regressie.
Dit pattern is herbruikbaar voor andere binary signals; tot nu toe
alleen toegepast op `impersonation.active`.
### 4.4 Listener registration discipline
Laravel 12's listener auto-discovery is uitgeschakeld in
`bootstrap/app.php` via `->withEvents(discover: false)`. Reden:
auto-discovery + explicit `Event::listen()` veroorzaakt silent
double-registration (vandaag idempotent door scope-tag overwrite
semantics, morgen niet meer wanneer een listener additive operations
doet). Gevangen door
`tests/Feature/Observability/EventListenerRegistrationTest`.
**Voor élke nieuwe observability-listener:**
1. Maak listener-class in `app/Listeners/Observability/`.
2. Registreer **expliciet** in `AppServiceProvider::boot()` met
array-callable form `[Class::class, 'method']`. Class-string vorm
verbergt method-binding in `php artisan event:list`.
3. Voeg een case toe aan
`EventListenerRegistrationTest::test_*_listener_registered_exactly_once`
met de juiste event-class + method-naam.
---
## §5 Scrubbing semantics
### 5.1 Backend — `App\Services\Observability\SentryEventScrubber`
Geregistreerd als `before_send` hook in `config/sentry.php` via
array-callable static-method notation. Stateless; geen
container-resolution per event.
**Wat wordt gescrubt:**
1. **Request body keys** (recursief, key-name match, depth-limited):
`password`, `password_confirmation`, `current_password`, `token`,
`api_key`, `secret`, `webhook_secret`, `dsn`, `signature`,
`authorization`, `cookie`, `bearer`, `iban`, `bic`,
`passport_number`, `bsn`. Replace value met `[scrubbed]`.
2. **Request headers** (case-insensitive): `authorization`, `cookie`,
`set-cookie`, `x-api-key`, `x-impersonation-token`. Replace met
`[scrubbed]`.
3. **Form submissions:** élke payload-key `form_values` wordt
wholesale replaced met `[scrubbed_form_values]`. Reden: Crewli's
form-builder genereert dynamische form-values waar elke key PII
kan zijn (email, telefoon, dietary, medical). Selectief op key
matchen is niet veilig.
4. **URL query string:** `token=`, `api_key=` worden gescrubt.
5. **Cookies wholesale:** `event.request.cookies` wordt vervangen door
`[scrubbed]`.
6. **Max-depth guard** op recursie: na 10 levels wordt subtree
replaced met `['[max_depth]']` om malicious deeply-nested payloads
te beperken.
**Sub-500 HttpException filter:** wanneer
`$hint?->exception instanceof HttpException && $hint->exception->getStatusCode() < 500`,
returnt de scrubber `null` → event wordt niet gestuurd. Reden: 404,
403, 422 etc. zijn verwachte business-uitkomsten (RFC §3.10), niet
programmer-errors. `ignore_exceptions` in `config/sentry.php` doet
class-only filtering; status-based filtering moet hier.
### 5.2 Frontend — `apps/app/src/observability/scrubber.ts`
TypeScript port van de backend-scrubber met identieke semantics. Plus:
7. **Storage context strip:** `event.contexts.storage` wordt gestript.
Sentry doesn't add this by default but defensively. RFC §3.7
frontend point 2 — localStorage / sessionStorage **never** in event
context (Crewli's portal-state in sessionStorage MAG NIET lekken).
8. **`event.user.cookies` strip:** als sentry's BrowserSession
integration `document.cookie` exposure via user-context injecteert,
wordt het weggehaald.
9. **Cookies wholesale (typed shape):** `event.request.cookies` is
typed `Record<string, string>` in `@sentry/vue`. Replace met
`{ scrubbed: '[scrubbed]' }` in plaats van een string — preserves
the typed shape.
### 5.3 Boundary: business outcomes vs programmer/infra errors
| Exception class | Backend behaviour | Reden |
|---|---|---|
| `Throwable`, `RuntimeException`, `TypeError` | Captured | Programmer error |
| `QueryException`, `PDOException` | Captured | Infra error |
| `ValidationException` | NOT captured (`ignore_exceptions`) | Verwacht user-input error |
| `AuthenticationException` | NOT captured (`ignore_exceptions`) | Verwacht user-state error |
| `AuthorizationException` | NOT captured (`ignore_exceptions`) | Verwacht user-permission error |
| `HttpException` status `< 500` | NOT captured (scrubber returns `null`) | Verwacht 4xx outcome |
| `HttpException` status `>= 500` | Captured | Genuine server error |
`Integration::handles($exceptions)` in `bootstrap/app.php` is **niet
auto-registered** door sentry-laravel 4.x. Zonder deze regel runt
`report($e)` alleen door Laravel's default reporter (logs to channel)
en bereikt het Sentry niet. Gedekt door
`tests/Feature/Observability/ExceptionReportingTest`. Zie ook
[BACKLOG OBS-6](./BACKLOG.md).
**Voor élke nieuwe `$exceptions->render(...)` handler in
`bootstrap/app.php`:** Laravel's flow is `report()``render()`. Als
de handler een Throwable consumeert en een Response retourneert, zorgt
de framework-flow voor `report()` automatisch. **Render handlers MOGEN
NIET** `report()` hand-rollen of vroegtijdig short-circuiten — zie
[BACKLOG OBS-7](./BACKLOG.md) voor expansion plan.
### 5.4 Form Builder runtime exceptions (concrete classification)
Form Builder is Crewli's grootste runtime-domein met eigen
exception-hierarchy (zie [`ARCH-FORM-BUILDER.md`](./ARCH-FORM-BUILDER.md)).
De classificatie tussen "expected business outcome" en "programmer /
infra error" voor deze classes is concreet vastgelegd:
**Wel naar GlitchTip (programmer/infra errors):**
- `App\Exceptions\FormBuilder\PersonProvisioningException` — runtime
failure during the apply pipeline. Caught by
`ApplyBindingsOnFormSubmit` and recorded as
`FormSubmissionActionFailure`, but the engineering team needs
visibility into recurring patterns across orgs.
- `App\Exceptions\FormBuilder\PurposeSubjectResolutionException`
runtime resolution failure (no portal token, no auth user, etc.).
Same dual-handling rationale: action-failures table for org-admin
operational handling; GlitchTip for engineering visibility.
- `App\Exceptions\FormBuilder\FormBindingApplicatorException`
runtime applicator failure (`no_transaction`, `no_schema`,
`unknown_purpose`). These should never happen in production; if
they do, they're systemic bugs — GlitchTip is the correct
destination.
**Niet naar GlitchTip (expected business outcomes):**
- `App\Exceptions\FormBuilder\PublishGuardViolationException`
publish-time validation: schema fails a guard. Returned as 422
with field-level errors. Not a system bug.
- `App\Exceptions\FormBuilder\PurposeRequirementsNotMetException`
schema lacks required bindings for its purpose. Returned as 422.
Not a system bug.
- `App\Exceptions\FormBuilder\IdempotencyConflictException`
duplicate idempotency key on submission. Returned as 409. Not a
system bug.
Dual-handling voor de eerste groep is intentional: org-admins fixen
specifieke failures via de WS-6 admin UI; engineering identificeert
systemic issues across all orgs via GlitchTip's aggregation. De
"niet naar GlitchTip" groep is afgedekt door `ignore_exceptions` (voor
`PublishGuardViolationException` etc. die `HttpExceptionInterface`
implementeren via 422 response) en moet bij toevoeging van een nieuwe
expected-outcome class expliciet worden uitgezonderd in
`config/sentry.php`.
---
## §6 Runtime context-split (frontend)
Vie zones, gedecide per `route.path` en `route.meta`:
### 6.1 `actor_scope=organisation`
- Organizer routes met active org context (`useOrganisationStore().activeOrganisationId !== null`)
- Tags: `actor_scope=organisation`, `organisation_id=<ULID>`, plus user-context
- Voorbeelden: `/organisations/:id/dashboard`, `/events/:id`, `/dashboard`
### 6.2 `actor_scope=platform`
- super_admin op `/platform/*` paths
- Tags: `actor_scope=platform`, GEEN `organisation_id`
- **Geforceerde org-attribution zou misleidend zijn.** Platform-mode
events spannen impliciet over alle organisaties.
### 6.3 `actor_scope=user`
- Authenticated user op routes zonder org-scope (`/account-settings`,
`/portal/profiel`)
- Tags: `actor_scope=user`, GEEN `organisation_id`
- Reden: Crewli's User↔Organisation is many-to-many; geen reliable
single-org hint zonder route-context.
### 6.4 `actor_scope=portal`
- Token-based portal flows: `route.meta.public === true && route.meta.context === 'portal'`
- Concrete routes: `/portal/advance/:token` (artist advance),
`/register/:public_token` (public form fill)
- Tags: `actor_scope=portal`, `actor_type=portal_token`
- **Geen `user_id`, geen `username`** — RFC §3.7 frontend point 5.
De ULID-token zelf is gevoelig; de bezoeker is niet permanent met
Crewli verbonden.
- Backend portal-token request resolves de organisation via de
matching artist/event row; frontend events correleren via
`request_id` back naar het backend-event dat wel `organisation_id`
heeft.
### 6.5 `actor_scope=anonymous`
- Public routes zonder auth: `/login`, `/forgot-password`, `/register`,
`/invitations/:token` (acceptance flow)
- Tags: `actor_scope=anonymous`, `actor_type=unauthenticated`
### 6.6 Cross-zone leakage prevention
`Sentry.getCurrentScope().clear()` wordt aangeroepen op élke
route-transitie in `installContextBinding`. Voorbeeld: user logt uit
in organizer-context, navigeert naar `/login`. Zonder clear zou het
volgende anonymous error-event nog `user_id` van de uitgelogde
gebruiker dragen. Met clear wordt het Sentry-scope reset; de
unauthenticated event krijgt alleen de zojuist gebonden anonymous-tags.
Test: `contextBinding.spec.ts::test_cross-zone_leak_guard`.
---
## §7 CSP whitelist (kritisch)
Crewli's strict CSP `connect-src` directive moet de GlitchTip
ingest-host expliciet whitelisten. Zonder deze entry blokkeert de
browser elke `@sentry/vue` POST stilletjes met *"Refused to connect
because it violates the following Content Security Policy directive"*
in DevTools Console — de SDK denkt dat het werkt, maar geen events
bereiken GlitchTip.
| Environment | CSP-locatie | `connect-src` entry |
|---|---|---|
| Dev | `apps/app/index.html` meta tag | `http://localhost:8200` |
| Prod organizer SPA | `deploy/nginx/csp-spa.conf` (Report-Only én Enforce regels) | `https://monitoring.hausdesign.nl` |
| API JSON responses | `api/config/security.php` — geen update | `default-src 'none'`; geen `connect-src` want JSON-context heeft geen fetch-origin |
**Bij introductie van een nieuwe environment** (bijv. staging — zie
[BACKLOG OBS-9](./BACKLOG.md)) MOET:
1. De bijbehorende GlitchTip ingest-host worden toegevoegd aan de
juiste CSP-locatie.
2. `tests/Feature/Security/CspConnectsToObservabilityTest` worden
uitgebreid met een staging-assertion zodat de regression-guard de
nieuwe environment dekt.
---
## §8 Sourcemap upload (frontend)
Vite produceert sourcemaps voor élke chunk (`build.sourcemap=true` in
`vite.config.ts`). `deploy.sh` uploadt ze naar GlitchTip én verwijdert
ze uit `dist/` vóór nginx ze serveert. RFC §3.5: **never**
public-mapped sources op productie.
```
vite build → apps/app/dist/assets/*.js + *.js.map
sentry-cli sourcemaps upload --org $SENTRY_ORG \
--project crewli-app \
--release $VITE_SENTRY_RELEASE \
--url-prefix "~/assets/" \
apps/app/dist/assets
find apps/app/dist -name '*.map' -type f -delete
nginx serves dist/
```
**Required env vars** (deploy host alleen, niet committed):
| Var | Beschrijving |
|---|---|
| `SENTRY_AUTH_TOKEN` | Per-project upload-only token in GlitchTip. Bert provisioned dit handmatig in `crewli-app` project settings. |
| `SENTRY_ORG` | GlitchTip organisation slug. Default in `deploy.sh`: `crewli`. |
| `VITE_SENTRY_DSN_FRONTEND` | Aanwezigheid is conditional — als deze ontbreekt skipt `deploy.sh` upload (soft fail) maar voert alsnog `*.map` strip uit. |
| `VITE_SENTRY_RELEASE` | Build-time injected door `deploy.sh`: `crewli-app@$(git rev-parse --short HEAD)`. |
**Soft-fail:** als upload faalt (GlitchTip unreachable, expired token),
gaat de deploy door en logt een warning. De `find … -delete` stap loopt
**altijd**. Beter unmapped stack traces in GlitchTip dan een
geblokkeerde deploy.
---
## §9 GDPR & privacy
### 9.1 Processing register
Crewli is **controller** voor GlitchTip-data (self-hosted op
Crewli-infra). Geen processor-relatie, geen DPA-uitbreiding nodig.
Processing register entry: zie
[`SECURITY_AUDIT.md`](./SECURITY_AUDIT.md), "WS-7 Observability —
finale audit".
### 9.2 Data na scrubbing
Wat een GlitchTip-event nog kan bevatten:
- ULIDs (user_id, organisation_id, event_id, request_id, session_id)
- Stack traces (zonder locals — `send_default_pii=false`)
- Route names en HTTP methods
- Gecureerde tags (zie §3)
- Breadcrumbs (input-text masked, console-integration off in prod)
Wat **niet**: emails, telefoonnummers, namen, IP-adressen, raw
form_values, raw cookies, raw headers (Authorization etc.).
### 9.3 Retention
90 dagen, daarna purged door GlitchTip's eigen partition-maintenance
loop (zie [`GLITCHTIP.md`](./GLITCHTIP.md) monitoring sectie).
Configurable via GlitchTip admin UI (settings → environment-config).
### 9.4 Right to erasure (Art. 17)
Initieel handmatig. Procedure: zie
[`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md).
Geautomatiseerd erasure-script blijft op BACKLOG (referentie in de
RFC; nog niet als concrete entry in BACKLOG.md).
---
## §10 Onderhoud & uitbreiding
### 10.1 Een nieuwe tag toevoegen
Bepaal eerst de **bron** van de tag. Drie patronen:
| Bron | Pattern | Voorbeeld |
|---|---|---|
| HTTP request context (route, method, headers) | Middleware | `BindSentryRouteContext` |
| Auth context (user, role, org) | Listener op `Authenticated` + `TokenAuthenticated` | `AuthScopeContextListener` |
| Domain event (job processing, custom event) | Listener op het domain event | `TagJobAttemptOnSentry` |
| Static / build-time | `config/sentry.php` initial scope | `app=api` |
Voor élke nieuwe tag:
1. Voeg toe aan §3 tabel hierboven.
2. Implementeer in de gekozen locatie.
3. Bij listeners: registreer expliciet in `AppServiceProvider::boot()`
met array-callable form, en voeg case toe aan
`EventListenerRegistrationTest`.
4. Schrijf een feature-test die de tag op een live HTTP flow asserteert
(volg het pattern van `AuthScopeBindingHttpFlowTest`).
5. Frontend mirror: voeg toe aan
`apps/app/src/observability/contextBinding.ts` en aan
`contextBinding.spec.ts`.
### 10.2 Een nieuwe scrubbing-rule toevoegen
1. Backend: voeg key toe aan `SENSITIVE_BODY_KEYS` of
`SENSITIVE_HEADERS` in
`app/Services/Observability/SentryEventScrubber.php`.
2. Frontend: identieke wijziging in
`apps/app/src/observability/scrubber.ts`.
3. Voeg test-case toe aan beide:
`tests/Feature/Observability/PiiScrubbingTest.php` (PHP) en
`apps/app/src/observability/__tests__/scrubber.spec.ts` (TypeScript).
4. Beide testbestanden moeten de nieuwe key dekken — backend en
frontend zijn semantisch gelijk en moeten dat blijven.
### 10.3 Een nieuwe `$exceptions->render(...)` handler
Per [BACKLOG OBS-7](./BACKLOG.md): nieuwe render handlers MOGEN NIET
short-circuiten zonder `report($e)`. Laravel's flow is `report()`
`render()` automatisch; render handlers die een Response retourneren
hebben report al gehad.
Als de nieuwe handler een Throwable consumeert die niet via
`Integration::handles()` zou gaan (e.g. een eigen `$exception->report()`
methode op een custom exception), voeg een case toe aan
`ExceptionReportingTest` die bewijst dat het event alsnog gecaptured
wordt.
### 10.4 Een nieuwe environment (staging, demo, …)
Zie [BACKLOG OBS-9](./BACKLOG.md). Vereist:
1. GlitchTip-project provisioning + DSN naar 1Password.
2. CSP whitelist update (`apps/app/index.html` voor dev-style env, of
nieuwe nginx-config voor prod-style env).
3. `tests/Feature/Security/CspConnectsToObservabilityTest` uitbreiden
met assertion voor de nieuwe environment.
4. `deploy.sh` aanpassen als de release-tag-vorm verandert (default:
`crewli-app@<short-sha>`).
### 10.5 Een nieuwe Form Builder exception class
Zie §5.4. Bij toevoeging van een nieuwe FormBuilder exception:
- Als het een **expected business outcome** is: voeg toe aan
`ignore_exceptions` in `config/sentry.php` als de class niet via
`HttpException` of `ValidationException` afhandeling al geignored
wordt. Documenteer in §5.4.
- Als het een **programmer/infra error** is: niets toevoegen, de class
flowt automatisch via `Integration::handles($exceptions)`.
---
## §11 Verwijzingen
**Implementatie:**
- [`api/app/Services/Observability/SentryEventScrubber.php`](../api/app/Services/Observability/SentryEventScrubber.php)
- [`api/app/Listeners/Observability/AuthScopeContextListener.php`](../api/app/Listeners/Observability/AuthScopeContextListener.php)
- [`api/app/Listeners/Observability/TagJobAttemptOnSentry.php`](../api/app/Listeners/Observability/TagJobAttemptOnSentry.php)
- [`api/app/Http/Middleware/BindSentryRouteContext.php`](../api/app/Http/Middleware/BindSentryRouteContext.php)
- [`api/app/Http/Middleware/HandleImpersonation.php`](../api/app/Http/Middleware/HandleImpersonation.php)
- [`api/config/sentry.php`](../api/config/sentry.php)
- [`api/bootstrap/app.php`](../api/bootstrap/app.php)
- [`apps/app/src/observability/sentry.ts`](../apps/app/src/observability/sentry.ts)
- [`apps/app/src/observability/scrubber.ts`](../apps/app/src/observability/scrubber.ts)
- [`apps/app/src/observability/contextBinding.ts`](../apps/app/src/observability/contextBinding.ts)
- [`apps/app/index.html`](../apps/app/index.html)
- [`deploy/nginx/csp-spa.conf`](../deploy/nginx/csp-spa.conf)
- [`deploy.sh`](../deploy.sh)
**Tests (regression guards):**
- `tests/Feature/Observability/PiiScrubbingTest.php`
- `tests/Feature/Observability/AuthScopeContextListenerTest.php`
- `tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php`
- `tests/Feature/Observability/BindSentryRouteContextTest.php`
- `tests/Feature/Observability/ExceptionReportingTest.php`
- `tests/Feature/Observability/RequestIdRoundTripTest.php`
- `tests/Feature/Observability/EventListenerRegistrationTest.php`
- `tests/Feature/Database/ActivityLogIndexesTest.php`
- `tests/Feature/Security/CspHeaderTest.php`
- `tests/Feature/Security/CspConnectsToObservabilityTest.php`
- `apps/app/src/observability/__tests__/scrubber.spec.ts`
- `apps/app/src/observability/__tests__/contextBinding.spec.ts`
**Documenten:**
- [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md) —
historische implementation-spec
- [`GLITCHTIP.md`](./GLITCHTIP.md) — operational runbook
- [`runbooks/observability-triage.md`](./runbooks/observability-triage.md) —
incoming-issue triage procedure
- [`runbooks/observability-erasure.md`](./runbooks/observability-erasure.md) —
GDPR Art. 17 procedure
- [`SECURITY_AUDIT.md`](./SECURITY_AUDIT.md) — A13-9 (CSP) + WS-7
finale entry (processing register, security controls)
- [`BACKLOG.md`](./BACKLOG.md) — OBS-* entries (active + resolved)
- [`ARCH-FORM-BUILDER.md`](./ARCH-FORM-BUILDER.md) — Form Builder runtime
(consumer of §5.4 exception classification)
- [`ARCH-BINDINGS.md`](./ARCH-BINDINGS.md) — apply pipeline (origin of
the runtime exceptions captured in §5.4)
- [`RFC-WS-6.md`](./RFC-WS-6.md) — WS-6 binding pipeline design