15 Commits

Author SHA1 Message Date
adab3be781 fix: register AuthScopeContextListener for Sanctum bearer-token flow
Live HTTP smoke test on the post-architectural-fixes branch surfaced
that captured Sentry events carried only route-scope tags (app,
route_name, http.method) — auth-scope tags (user_id, actor_type,
actor_scope) were absent on every request.

Root cause: Sanctum's Guard fires Laravel\Sanctum\Events\TokenAuthenticated
(vendor/laravel/sanctum/src/Guard.php:77) on bearer-token resolution,
NOT Illuminate\Auth\Events\Authenticated. The Authenticated event only
fires from SessionGuard
(vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:833),
which Crewli does not use — CookieBearerToken middleware injects the
httpOnly cookie as Authorization: Bearer, then auth:sanctum invokes
Sanctum's Guard. So the listener never ran on Crewli's HTTP path.

Offline tests in AuthScopeContextListenerTest passed because they
dispatch event(new Authenticated(...)) directly, bypassing the Guard
layer. Sanctum::actingAs() in tests has the same blind spot — it
short-circuits the Guard via guard('sanctum')->setUser() and fires
neither event.

Fix:
- New handleTokenAuthenticated(TokenAuthenticated $event) method on
  AuthScopeContextListener extracts the user via $event->token->tokenable
  and delegates to a private bindForUser() shared with handle().
- AppServiceProvider registers the listener for both Authenticated
  (covers SessionGuard / login flow / future authenticators) and
  TokenAuthenticated (covers Crewli's bearer-token Sanctum flow).

Regression coverage: AuthScopeBindingHttpFlowTest exercises the real
Sanctum Guard via $user->createToken() + Authorization: Bearer header.
Three cases:
  - super_admin on a user-scope route: actor_scope=user, all auth tags
    present.
  - super_admin on an admin.* route: actor_scope=platform, no
    organisation_id (correct platform-mode behaviour).
  - org_admin on a route with {organisation} param: actor_scope=
    organisation, organisation_id valid ULID.

Test count 1541 to 1544. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:58:42 +02:00
0379016c7e docs: WS-7 PR-2 follow-up — RFC §3.6 + §3.14 + BACKLOG OBS entries
RFC §3.6 — context tagging tabel volledig vervangen na de PR-2 follow-up
architecturale fixes. Belangrijkste wijzigingen:
- Tag-binding gesplitst in route-scope (BindSentryRouteContext middleware)
  en auth-scope (AuthScopeContextListener op Authenticated event).
- Nieuwe actor_scope tag (organisation/platform/user/anonymous).
- Multi-tenant invariant verfijnd: organisation_id is altijd correct
  gerelateerd aan actor_scope in plaats van "altijd aanwezig". Platform-
  routes zonder org-context worden niet meer gefabriceerd; default
  authenticated user-scope omitt organisation_id (Crewli's User<->Organisation
  is many-to-many, geen reliable single-org hint).
- impersonation.* tags expliciet gedocumenteerd als afkomstig uit
  HandleImpersonation middleware (post-swap), niet uit auth-listener.
- ActorType waarden bijgewerkt na verwijdering van VOLUNTEER case.

RFC §3.14 — status-note toegevoegd dat D-06 indexes al via Spatie's
nullableMorphs default-migratie zijn aangemaakt, met regression-guard
verwijzing.

§6 acceptance criterium 12 markeert D-06 als al voldaan.

BACKLOG.md krijgt vier nieuwe OBS-entries:
- OBS-1: VOLUNTEER actor_type promotion wanneer rol komt
- OBS-4: PHPUnit metadata deprecation cleanup pre-PHPUnit-12
- OBS-6: sentry-laravel install gap awareness + bootstrap test
- OBS-7: custom render handlers report() invariant + coverage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:05:42 +02:00
eb8202584c test: ActivityLogIndexesTest regression guard for D-06
PR-2 verified that Spatie's activitylog default migration creates the
composite indexes RFC-WS-7 §3.14 / addendum D-06 require — via
nullableMorphs('subject') and nullableMorphs('causer'), which emit
indexes named `subject` on (subject_type, subject_id) and `causer` on
(causer_type, causer_id).

This test queries information_schema.STATISTICS and fails if either
composite is missing, regardless of the index name. It guards against
silent regression when:
  - A future Spatie major release changes nullableMorphs semantics.
  - A developer rewrites the activity_log migration without preserving
    the morph indexes.
  - A schema-dump regeneration drops them.

Test count 1539 to 1541. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:00:07 +02:00
49cece3784 feat: actor_scope tag + tenant fallback resolution chain
PR-2 live smoke test surfaced that super_admin platform-route
exceptions arrived without organisation_id, and the original RFC §3.6
invariant (always-present organisation_id on authenticated events)
would force misleading attribution if it tried to fill that gap.

Refined invariant: every authenticated event carries actor_scope
(organisation/platform/user/anonymous), AND when actor_scope is
organisation, organisation_id MUST be a valid ULID. Platform-mode
correctly omits organisation_id rather than fabricate one.

Resolution chain in AuthScopeContextListener:
  1. {organisation} or {event} URI parameter -> actor_scope=organisation
  2. portal_event request attribute -> actor_scope=organisation
  3. super_admin on admin.* named route -> actor_scope=platform
     (Crewli's platform-admin routes use the admin. name prefix)
  4. Default authenticated -> actor_scope=user, no org tag
     (User<->Organisation is many-to-many; no reliable single-org hint)

Eight new test cases in AuthScopeContextListenerTest cover each branch
and the conditional invariant, including ULID validity via
Symfony\Component\Uid\Ulid::isValid.

Test count 1531 to 1539. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:57:12 +02:00
9414d09472 refactor: BindSentryContext to AuthScopeContextListener for auth-scope tags
Sentry-context binding split into two responsibilities:

- Route-scope (app, http.method, route_name) stays in middleware on
  the api group as BindSentryRouteContext — works on every request,
  no auth required.
- Auth-scope (user_id, actor_type) moves to AuthScopeContextListener
  on Illuminate\Auth\Events\Authenticated — works on every
  authentication mechanism (Sanctum, portal-tokens, future
  authenticators) without per-route middleware-attachment. Listener
  also augments Log::withContext with user_id (closes OBS-2).

Architecturally fault-preventing rather than fault-detecting: new
authenticated route groups need no separate sentry.context aliasing,
so silent observability gaps are no longer possible (closes OBS-3).

Impersonation tagging is co-located with HandleImpersonation: after
the user-swap, the middleware re-tags Sentry scope with the target
user_id/actor_type and adds impersonation.active /
impersonation.impersonator_user_id / impersonation.session_id. The
Authenticated event fires for the admin (Sanctum's natural flow),
the listener tags the admin, then HandleImpersonation overwrites
post-swap.

Files renamed:
- BindSentryContext -> BindSentryRouteContext (route-scope only)
- BindSentryContextTest -> BindSentryRouteContextTest (4 cases)

Files added:
- AuthScopeContextListener
- AuthScopeContextListenerTest (6 cases)

bootstrap/app.php drops the sentry.context alias and prepends
BindSentryRouteContext to the api group. routes/api.php drops every
sentry.context middleware string from auth:sanctum groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:53:14 +02:00
42994522eb refactor: drop ActorType::VOLUNTEER pending volunteer role introduction
VOLUNTEER was reserved-but-unused. Resolver mapped non-admin
authenticated users to ORG_MEMBER because Crewli has no dedicated
volunteer Spatie role; volunteer-ness is behaviour (shift assignments),
not identity.

Dead enum cases are YAGNI violations under zero-compromise: a future
developer could use the case without realising no resolution path
leads to it, producing a silent no-op. Re-introduce alongside a real
volunteer role split when that lands (BACKLOG OBS-1).

ActorType keeps ORGANIZER_ADMIN, SUPER_ADMIN, PORTAL_TOKEN, ORG_MEMBER,
UNAUTHENTICATED. Tests at 1537, Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:43:48 +02:00
5980c36ae4 refactor: SentryEventScrubber static + config array notation
The scrubber is fully stateless. Container-resolution per event was
overhead without value, the closure indirection polluted the config
layer with executable logic, and stack traces showed an anonymous
closure frame instead of the class name.

- SentryEventScrubber::scrub() and its private helpers all become
  static methods. No instance fields, so the change is mechanical.
- config/sentry.php before_send switches from a closure that calls
  app() to PHP array-callable notation [Class, method]. Symfony
  OptionsResolver accepts array-callables for static methods.
- PiiScrubbingTest swaps (new SentryEventScrubber)->scrub(...) for
  SentryEventScrubber::scrub(...). Semantics unchanged.

Tests 1537 unchanged. Larastan and Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:42:25 +02:00
48f2a00564 fix: route controller exceptions through sentry-laravel reporter
PR-2 follow-up. The PR-2 backend SDK install passed unit tests because
they exercised the scrubber and the BindSentryContext scope writer in
isolation, but live exceptions from controllers never reached
GlitchTip — they were correctly logged to laravel.log but the report()
call had no Sentry-aware reporter to invoke.

Root cause: sentry-laravel 4.x does NOT auto-register an exception
reporter. The host application is required to wire Integration::handles
inside withExceptions in bootstrap/app.php (per the package README and
Sentry docs). Without it, report and Laravels automatic
report-before-render flow only hit the default log channel.

Fix: add Integration::handles at the top of withExceptions so
sentry-laravel registers a reportable callback that calls
captureUnhandledException for every reported throwable. Filtering
remains downstream:
  - ignore_exceptions in config/sentry.php drops Validation,
    Authentication, Authorization (RFC §3.10).
  - SentryEventScrubber::scrub returns null for sub-500 HttpException
    via the before_send hook (RFC §3.7).

Regression coverage: tests/Feature/Observability/ExceptionReportingTest
installs a real Sentry client with a recording before_send and exercises
the full request to capture pipeline through the auth and sentry.context
middleware. Five cases: RuntimeException IS captured (with §3.6 tags
attached), ValidationException is not, NotFoundHttpException 404 is
not, AuthorizationException 403 is not, request-context tags ride along
on the captured event.

Test count: 1532 to 1537. Larastan clean. Pint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:58:26 +02:00
4a8bb97764 feat: BindRequestLogContext middleware + X-Request-Id round-trip
WS-7 PR-2 commit 3. RFC §3.13.

- app/Http/Middleware/BindRequestLogContext.php: tags every Laravel log
  line written during the request with request_id, organisation_id,
  user_id, and route name. Sets X-Request-Id on the response so the
  SPA can correlate to backend log lines via one click.
- Client-supplied X-Request-Id is honoured only if it parses as a ULID
  via Str::isUlid. Junk input (empty, non-ULID) is rejected and a
  fresh ULID is generated server-side.
- Registered as a global api-group middleware via the prepend list so
  it runs before authentication. Unauthenticated 4xx responses still
  carry the X-Request-Id header.
- Test count: 1523 to 1532. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:28:50 +02:00
b1d5bcda76 feat: BindSentryContext middleware + queue job attempt tagging
WS-7 PR-2 commit 2.

- app/Http/Middleware/BindSentryContext.php: sets RFC §3.6 tags on the
  active Sentry scope (app, http.method, route_name, actor_type,
  user_id, organisation_id, event_id, impersonation). Multi-tenant
  invariant: throws RuntimeException in local/testing when an auth
  request to a tenant-scoped route lacks organisation_id; logs a
  warning in production so the user flow still completes.
- app/Listeners/Observability/TagJobAttemptOnSentry.php: tags
  queue.attempt on the scope from the JobProcessing event. Default
  stack-trace grouping preserved per §3.11.
- ActorType: VOLUNTEER case reserved for a future role split. Current
  resolver maps non-admin authenticated users to ORG_MEMBER.
- bootstrap/app.php: registers sentry.context alias. Applied inside
  auth:sanctum groups in routes/api.php so it runs after auth.
- AppServiceProvider::boot registers the queue listener.

Test count: 1507 to 1523. Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:55 +02:00
bdb89a2479 feat: sentry-laravel install + scrubber + ignored exceptions
WS-7 PR-2 commit 1. Wires sentry-laravel into the app behind a
config-only no-op when SENTRY_DSN_BACKEND is empty (RFC §3.3).

- composer require sentry/sentry-laravel ^4.15 (resolved 4.25.1)
- config/sentry.php: DSN env mapped to SENTRY_DSN_BACKEND, environment
  falls back to APP_ENV, traces/profiles forced to 0.0 (RFC §2
  amendment B), send_default_pii hard-pinned false, before_send to
  SentryEventScrubber, ignore_exceptions covers ValidationException /
  AuthenticationException / AuthorizationException.
- app/Services/Observability/SentryEventScrubber.php: recursive body /
  header / query-string scrubber + form_values wholesale replacement +
  HttpException sub-500 drop (status filter that ignore_exceptions
  cannot do class-only). Max-depth guard against malicious payloads.
- app/Enums/Observability/ActorType.php: enum + resolver for §3.6
  actor_type tag (consumed by BindSentryContext in commit 2).
- tests/Feature/Observability/PiiScrubbingTest.php: 20 cases.
- api/.env.example: SENTRY_DSN_BACKEND + SENTRY_RELEASE entries.

Larastan: clean. Test count: 1487 to 1507.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:55:50 +02:00
d4b785a2c9 chore: add WS-7 observability docs to sync manifest 2026-05-06 08:41:45 +02:00
932788c643 docs: glitchtip runbook + setup + RFC §3.1 dev amendment
Operational docs for the GlitchTip stack landed in the previous two
commits.

- dev-docs/GLITCHTIP.md: new runbook covering local dev, project
  provisioning + DSN-to-vault flow, production deploy on
  monitoring.hausdesign.nl (DNS, DirectAdmin Let's Encrypt, Apache
  reverse proxy with WS upgrade), backup install + restore drill,
  smoke tests, troubleshooting.
- dev-docs/SETUP.md: services table now includes GlitchTip; new
  docker/glitchtip/.env subsection points at the runbook.
- dev-docs/RFC-WS-7-OBSERVABILITY.md §3.1: amended to record that the
  same compose file drives local dev (Mailpit at bm_mailpit:1025), so
  prod and dev cannot drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:15:27 +02:00
5f6fc075ed feat: glitchtip postgres backup script
Daily pg_dump → gzip → retention pipe for the GlitchTip database.
Configurable via env vars (defaults: ./backups/glitchtip, 30-day
retention, glitchtip-postgres container). Streams directly through
gzip so no plaintext dump touches disk; output 0600.

Cron example in the script header. RFC-WS-7-OBSERVABILITY §5
acceptance criterion 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:13:46 +02:00
fc5a2a9156 feat: glitchtip docker stack + local dev integration
WS-7 PR-1 — bring up self-hosted GlitchTip alongside the existing
dev stack. One compose file is portable to the production monitoring
host (RFC-WS-7 §3.1).

- docker-compose.glitchtip.yml: web/worker/postgres/redis pinned, web
  bound to 127.0.0.1:8200, internal network for postgres + valkey.
- docker/glitchtip/.env.example: documented dev defaults + production
  checklist; .env itself ignored.
- Makefile: services / services-stop merge both compose files; new
  services-glitchtip-status tail target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:12:31 +02:00
30 changed files with 3080 additions and 29 deletions

View File

@@ -13,3 +13,5 @@ dev-docs/design-document.md
dev-docs/UX_SPEC_FESTIVAL_HIERARCHY.md
dev-docs/ARCH-BINDINGS.md
dev-docs/ARCH-API-VALIDATION.md
dev-docs/RFC-WS-7-OBSERVABILITY.md
dev-docs/GLITCHTIP.md

4
.gitignore vendored
View File

@@ -60,3 +60,7 @@ docs/.vitepress/cache
# Claude Code runtime state
.claude/*.lock
# GlitchTip
docker/glitchtip/.env
backups/

View File

@@ -1,4 +1,4 @@
.PHONY: help services services-stop api app docs migrate fresh db-shell test test-db-create schema-dump
.PHONY: help services services-stop services-glitchtip-status api app docs migrate fresh db-shell test test-db-create schema-dump
# Colors
GREEN := \033[0;32m
@@ -6,6 +6,10 @@ YELLOW := \033[0;33m
CYAN := \033[0;36m
NC := \033[0m
# Compose files merged for local dev. Both files share one project so
# Mailpit (bm_mailpit) is reachable from the GlitchTip containers.
COMPOSE_FILES := -f docker-compose.yml -f docker-compose.glitchtip.yml
help:
@echo ""
@echo "$(GREEN)╔══════════════════════════════════════════════════════════════╗$(NC)"
@@ -13,8 +17,9 @@ help:
@echo "$(GREEN)╚══════════════════════════════════════════════════════════════╝$(NC)"
@echo ""
@echo " $(YELLOW)Services (Docker):$(NC)"
@echo " make services Start MySQL, Redis, Mailpit"
@echo " make services-stop Stop all Docker services"
@echo " make services Start MySQL, Redis, Mailpit, GlitchTip"
@echo " make services-stop Stop all Docker services"
@echo " make services-glitchtip-status Tail GlitchTip web container logs"
@echo ""
@echo " $(YELLOW)Development Servers:$(NC)"
@echo " make api Laravel API → http://localhost:8000"
@@ -34,21 +39,27 @@ help:
services:
@echo "$(GREEN)Starting Docker services...$(NC)"
@docker compose up -d
@docker compose $(COMPOSE_FILES) up -d
@echo ""
@echo "$(GREEN)Services:$(NC)"
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)"
@echo " $(CYAN)Redis:$(NC) localhost:6379"
@echo " $(CYAN)Mailpit:$(NC) http://localhost:8025"
@echo " $(CYAN)MySQL:$(NC) localhost:3306 (crewli / secret)"
@echo " $(CYAN)Redis:$(NC) localhost:6379"
@echo " $(CYAN)Mailpit:$(NC) http://localhost:8025"
@echo " $(CYAN)GlitchTip:$(NC) http://localhost:8200"
@echo ""
@echo "$(YELLOW)Waiting for MySQL...$(NC)"
@until docker exec bm_mysql mysqladmin ping -h localhost -u root -proot --silent 2>/dev/null; do sleep 1; done
@echo "$(GREEN)✓ Ready!$(NC)"
@echo "$(YELLOW)Note:$(NC) GlitchTip web takes ~60s on first boot (migrations)."
@echo " Tail logs with: $(CYAN)make services-glitchtip-status$(NC)"
services-stop:
@docker compose down
@docker compose $(COMPOSE_FILES) down
@echo "$(GREEN)✓ Services stopped$(NC)"
services-glitchtip-status:
@docker compose $(COMPOSE_FILES) logs -f glitchtip-web
api:
@echo "$(GREEN)Starting Laravel API → http://localhost:8000$(NC)"
@cd api && php artisan serve

View File

@@ -78,3 +78,14 @@ SANCTUM_STATEFUL_DOMAINS=localhost:5174,localhost:5175
# env-gate + this flag) keeps Telescope out even if one layer is
# breached. See /dev-docs/TELESCOPE.md.
TELESCOPE_ENABLED=false
# Sentry / GlitchTip (RFC-WS-7 §3.3, §3.4).
# DSN routes events to the self-hosted GlitchTip project crewli-api.
# Empty = SDK no-op — leave blank in local development. Source the real
# value from the 1Password vault entry "Crewli / GlitchTip / DSNs"
# (key SENTRY_DSN_BACKEND) for staging / production.
SENTRY_DSN_BACKEND=
# Release identifier in the form crewli-api@<short-sha>. The deploy
# pipeline injects this per build; leave blank locally. Empty release
# means events are still captured but won't carry release context.
SENTRY_RELEASE=

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Enums\Observability;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
/**
* Actor classification used as the `actor_type` Sentry tag (RFC-WS-7 §3.6).
*
* Resolution precedence (most specific first):
* 1. Portal-token request PORTAL_TOKEN
* 2. Authenticated super_admin SUPER_ADMIN
* 3. Authenticated org_admin ORGANIZER_ADMIN
* 4. Other authenticated user ORG_MEMBER
* 5. None of the above UNAUTHENTICATED
*
* Crewli has no dedicated `volunteer` Spatie role today; volunteer-ness is
* behaviour (a user has shift assignments) rather than identity. A
* dedicated VOLUNTEER actor_type case will land alongside that role split
* if/when it is introduced (BACKLOG OBS-1).
*/
enum ActorType: string
{
case ORGANIZER_ADMIN = 'organizer_admin';
case SUPER_ADMIN = 'super_admin';
case PORTAL_TOKEN = 'portal_token';
case ORG_MEMBER = 'org_member';
case UNAUTHENTICATED = 'unauthenticated';
public static function resolve(?Authenticatable $user, ?Request $request): self
{
if ($request !== null && $request->attributes->get('portal_context') !== null) {
return self::PORTAL_TOKEN;
}
if (! $user instanceof User) {
return self::UNAUTHENTICATED;
}
if ($user->hasRole('super_admin')) {
return self::SUPER_ADMIN;
}
if ($user->hasRole('org_admin')) {
return self::ORGANIZER_ADMIN;
}
return self::ORG_MEMBER;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Organisation;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Structured-logging context binder (RFC-WS-7 §3.13). Tags every Laravel
* log line written during this request with request_id, organisation_id,
* user_id, and route name. Round-trips X-Request-Id with the response so
* the SPA can correlate to backend log lines via one click.
*/
final class BindRequestLogContext
{
public function handle(Request $request, Closure $next): Response
{
$requestId = $this->resolveRequestId($request);
$request->attributes->set('observability.request_id', $requestId);
Log::withContext(array_filter([
'request_id' => $requestId,
'organisation_id' => $this->resolveOrganisationId($request),
'user_id' => $request->user()?->getAuthIdentifier(),
'route' => $request->route()?->getName(),
], static fn ($v) => $v !== null && $v !== ''));
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
return $response;
}
private function resolveRequestId(Request $request): string
{
$supplied = $request->header('X-Request-Id');
if (is_string($supplied) && Str::isUlid($supplied)) {
return $supplied;
}
return (string) Str::ulid();
}
private function resolveOrganisationId(Request $request): ?string
{
$portalEvent = $request->attributes->get('portal_event');
if ($portalEvent instanceof Event) {
return $portalEvent->organisation_id;
}
$route = $request->route();
if ($route === null) {
return null;
}
$org = $route->parameter('organisation');
if ($org instanceof Organisation) {
return $org->id;
}
if (is_string($org) && $org !== '') {
return $org;
}
$event = $route->parameter('event');
if ($event instanceof Event) {
return $event->organisation_id;
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Sentry\State\Scope;
use Symfony\Component\HttpFoundation\Response;
use function Sentry\configureScope;
/**
* Binds route-scope context to Sentry events on every API request.
*
* Auth-scope tags (user_id, actor_type, organisation_id, impersonation.*,
* actor_scope) live in {@see \App\Listeners\Observability\AuthScopeContextListener}
* so they bind on Authenticated event rather than route entry. That keeps
* the auth-scope binding uniform across Sanctum, portal-tokens, and any
* future authenticator without per-route middleware-attachment.
*
* RFC-WS-7 §3.6.
*/
final class BindSentryRouteContext
{
public function handle(Request $request, Closure $next): Response
{
configureScope(static function (Scope $scope) use ($request): void {
$scope->setTag('app', 'api');
$scope->setTag('http.method', $request->method());
$routeName = $request->route()?->getName();
if (is_string($routeName) && $routeName !== '') {
$scope->setTag('route_name', $routeName);
}
});
return $next($request);
}
}

View File

@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ImpersonationService;
use App\Enums\Observability\ActorType;
use App\Models\User;
use App\Services\ImpersonationService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Sentry\State\Scope;
use Symfony\Component\HttpFoundation\Response;
use function Sentry\configureScope;
class HandleImpersonation
{
/**
@@ -88,6 +92,24 @@ class HandleImpersonation
'impersonation_session_id' => $session->id,
]);
// Re-bind Sentry auth-scope tags after the user swap. The
// Authenticated event already fired with the admin; AuthScopeContextListener
// tagged the admin's user_id/actor_type. We now overwrite both with
// the target's data and add the impersonation.* invariants
// (RFC-WS-7 §3.6) so captured events attribute correctly.
$targetActorType = ActorType::resolve($targetUser, $request);
configureScope(static function (Scope $scope) use ($admin, $targetUser, $session, $targetActorType): void {
$scope->setUser([
'id' => $targetUser->id,
'username' => $targetUser->id,
]);
$scope->setTag('user_id', $targetUser->id);
$scope->setTag('actor_type', $targetActorType->value);
$scope->setTag('impersonation.active', 'true');
$scope->setTag('impersonation.impersonator_user_id', $admin->id);
$scope->setTag('impersonation.session_id', $session->id);
});
// Increment actions count
$this->impersonationService->incrementActionsCount($session);

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Observability;
use App\Enums\Observability\ActorType;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\Events\TokenAuthenticated;
use Sentry\State\Scope;
use function Sentry\configureScope;
/**
* Binds auth-scope context to Sentry and Laravel log context on every
* successful authentication.
*
* Listens to TWO events:
* - {@see Authenticated} fires from SessionGuard (login flow / future
* cookie-session authenticators).
* - {@see TokenAuthenticated} fires from {@see \Laravel\Sanctum\Guard}
* on every bearer-token resolution. Crewli's HTTP flow is
* bearer-token (CookieBearerToken middleware reads the httpOnly
* cookie and injects Authorization: Bearer); without listening to
* TokenAuthenticated, no auth-scope tags would ever bind on live
* requests a regression the offline tests miss because they
* dispatch Authenticated directly.
*
* Auth-scope tags (user_id, actor_type, organisation_id, actor_scope)
* are decoupled from route-scope tags (BindSentryRouteContext middleware)
* so that authentication-mechanism additions don't require touching
* every route-group's middleware stack.
*
* Impersonation re-binding (target user_id + impersonation.* tags) is
* co-located in HandleImpersonation middleware and runs after the user
* swap.
*
* RFC-WS-7 §3.6, §3.13 (Log::withContext OBS-2 fix).
*/
final class AuthScopeContextListener
{
public function handle(Authenticated $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
$this->bindForUser($user);
}
public function handleTokenAuthenticated(TokenAuthenticated $event): void
{
$tokenable = $event->token->tokenable ?? null;
if (! $tokenable instanceof User) {
return;
}
$this->bindForUser($tokenable);
}
private function bindForUser(User $user): void
{
$request = request();
$actorType = ActorType::resolve($user, $request);
[$organisationId, $actorScope] = $this->resolveTenantContext($user, $request);
configureScope(static function (Scope $scope) use ($user, $actorType, $organisationId, $actorScope): void {
$scope->setUser([
'id' => $user->id,
'username' => $user->id, // RFC §3.8: ULID, never email.
]);
$scope->setTag('user_id', $user->id);
$scope->setTag('actor_type', $actorType->value);
$scope->setTag('actor_scope', $actorScope);
if ($organisationId !== null) {
$scope->setTag('organisation_id', $organisationId);
}
});
Log::withContext(array_filter([
'user_id' => $user->id,
'organisation_id' => $organisationId,
'actor_scope' => $actorScope,
], static fn ($v) => $v !== null && $v !== ''));
}
/**
* Resolves organisation_id and actor_scope per RFC §3.6 (refined after
* the PR-2 live smoke test).
*
* Resolution priority:
* 1. Route-scoped: {organisation} or {event} URI parameter resolves
* to an Organisation/Event actor_scope=organisation.
* 2. Portal token: portal_event request attribute populated by
* PortalTokenMiddleware actor_scope=organisation.
* 3. super_admin on admin.* route actor_scope=platform; no
* organisation_id tag (forced current-org fallback would produce
* misleading attribution).
* 4. Default authenticated user actor_scope=user, organisation_id
* is omitted because Crewli's User<->Organisation is many-to-many;
* no reliable single-org hint exists at user level.
*
* @return array{0: ?string, 1: string} [organisation_id|null, actor_scope]
*/
private function resolveTenantContext(User $user, ?Request $request): array
{
if ($request === null) {
return [null, 'user'];
}
// 1a. Explicit {organisation} route parameter.
$route = $request->route();
if ($route !== null) {
$orgParam = $route->parameter('organisation');
if ($orgParam instanceof Organisation) {
return [$orgParam->id, 'organisation'];
}
if (is_string($orgParam) && $orgParam !== '') {
return [$orgParam, 'organisation'];
}
// 1b. {event} parameter — derive org via event.organisation_id.
$eventParam = $route->parameter('event');
if ($eventParam instanceof Event) {
return [$eventParam->organisation_id, 'organisation'];
}
}
// 2. Portal token (artist/supplier/press flows).
$portalEvent = $request->attributes->get('portal_event');
if ($portalEvent instanceof Event) {
return [$portalEvent->organisation_id, 'organisation'];
}
// 3. super_admin on admin.* (Crewli's platform-admin route prefix).
if ($user->hasRole('super_admin') && $route !== null) {
$name = $route->getName();
if (is_string($name) && str_starts_with($name, 'admin.')) {
return [null, 'platform'];
}
}
// 4. Default user-scope: no org attribution (Crewli's User has no
// current_organisation_id; many-to-many membership precludes a
// reliable single-org hint).
return [null, 'user'];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Observability;
use Illuminate\Queue\Events\JobProcessing;
use Sentry\State\Scope;
use function Sentry\configureScope;
/**
* Listener for {@see JobProcessing} that attaches the `queue.attempt` tag
* (RFC-WS-7 §3.6) to the active Sentry scope before the job runs. Default
* stack-trace grouping is preserved RFC §3.11 explicitly forbids
* per-attempt fingerprinting so retries that eventually succeed remain
* grouped with retries that always fail.
*/
final class TagJobAttemptOnSentry
{
public function handle(JobProcessing $event): void
{
$attempt = (string) $event->job->attempts();
configureScope(static function (Scope $scope) use ($attempt): void {
$scope->setTag('queue.attempt', $attempt);
});
}
}

View File

@@ -196,6 +196,31 @@ class AppServiceProvider extends ServiceProvider
ApplyBindingsOnFormSectionSubmitted::class,
);
// RFC-WS-7 §3.6 / §3.11 — tag captured Sentry events with the queue
// attempt count. Default stack-trace grouping is preserved (no
// per-attempt fingerprinting).
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Queue\Events\JobProcessing::class,
\App\Listeners\Observability\TagJobAttemptOnSentry::class,
);
// RFC-WS-7 §3.6 — auth-scope Sentry tags + Log::withContext on
// every successful authentication. Listens to two events:
// - Authenticated covers SessionGuard flows (login etc.).
// - TokenAuthenticated covers Sanctum bearer-token flows; this
// is Crewli's actual SPA auth path because CookieBearerToken
// middleware injects the cookie as an Authorization header.
// Without this, live HTTP events would carry no auth-scope
// tags even though the offline (event-dispatch) tests pass.
\Illuminate\Support\Facades\Event::listen(
\Illuminate\Auth\Events\Authenticated::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handle'],
);
\Illuminate\Support\Facades\Event::listen(
\Laravel\Sanctum\Events\TokenAuthenticated::class,
[\App\Listeners\Observability\AuthScopeContextListener::class, 'handleTokenAuthenticated'],
);
ResetPassword::createUrlUsing(function ($user, string $token) {
return config('crewli.portal_url').'/wachtwoord-resetten?token='.$token.'&email='.urlencode($user->email);
});

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Services\Observability;
use Sentry\Event;
use Sentry\EventHint;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* PII scrubber registered as Sentry's `before_send` hook (RFC-WS-7 §3.7).
*
* Two responsibilities:
* - Drop sub-500 HttpExceptions: ignore_exceptions in config/sentry.php is
* class-only; status-based filtering must happen here.
* - Strip sensitive request data from outgoing events: body keys, headers,
* query string parameters, and form_values payloads (definitionally PII
* in Crewli entire payload is replaced wholesale).
*/
final class SentryEventScrubber
{
private const SENSITIVE_BODY_KEYS = [
'password', 'password_confirmation', 'current_password',
'token', 'api_key', 'secret', 'webhook_secret', 'dsn',
'signature', 'authorization', 'cookie', 'bearer',
'iban', 'bic', 'passport_number', 'bsn',
];
private const SENSITIVE_HEADERS = [
'authorization', 'cookie', 'set-cookie',
'x-api-key', 'x-impersonation-token',
];
private const SENSITIVE_QUERY_KEYS = [
'token', 'api_key',
];
private const SCRUBBED = '[scrubbed]';
private const FORM_VALUES_KEY = 'form_values';
private const FORM_VALUES_REPLACEMENT = '[scrubbed_form_values]';
private const MAX_DEPTH = 10;
public static function scrub(Event $event, ?EventHint $hint = null): ?Event
{
if ($hint?->exception instanceof HttpException && $hint->exception->getStatusCode() < 500) {
return null;
}
$request = $event->getRequest();
if ($request !== []) {
$event->setRequest(array_merge($request, [
'data' => self::scrubBody($request['data'] ?? []),
'headers' => self::scrubHeaders($request['headers'] ?? []),
'query_string' => self::scrubQueryString($request['query_string'] ?? ''),
'cookies' => self::SCRUBBED,
]));
}
return $event;
}
/**
* @param mixed $data
* @return mixed
*/
private static function scrubBody($data, int $depth = 0)
{
if (! is_array($data)) {
return $data;
}
if ($depth > self::MAX_DEPTH) {
return ['[max_depth]'];
}
foreach ($data as $key => $value) {
if (is_string($key) && strtolower($key) === self::FORM_VALUES_KEY) {
$data[$key] = self::FORM_VALUES_REPLACEMENT;
continue;
}
if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_BODY_KEYS, true)) {
$data[$key] = self::SCRUBBED;
continue;
}
if (is_array($value)) {
$data[$key] = self::scrubBody($value, $depth + 1);
}
}
return $data;
}
/**
* @param array<string, mixed>|string $headers
* @return array<string, mixed>|string
*/
private static function scrubHeaders($headers)
{
if (! is_array($headers)) {
return $headers;
}
foreach (array_keys($headers) as $name) {
if (in_array(strtolower((string) $name), self::SENSITIVE_HEADERS, true)) {
$headers[$name] = self::SCRUBBED;
}
}
return $headers;
}
private static function scrubQueryString(string $queryString): string
{
if ($queryString === '') {
return '';
}
parse_str($queryString, $parsed);
foreach ($parsed as $key => $value) {
if (is_string($key) && in_array(strtolower($key), self::SENSITIVE_QUERY_KEYS, true)) {
$parsed[$key] = self::SCRUBBED;
}
}
return http_build_query($parsed);
}
}

View File

@@ -30,6 +30,16 @@ return Application::configure(basePath: dirname(__DIR__))
// Read httpOnly auth cookie and inject as Authorization header (before Sanctum)
$middleware->api(prepend: [
\App\Http\Middleware\CookieBearerToken::class,
// RFC-WS-7 §3.13 — structured logging context + X-Request-Id
// round-trip. Runs early so unauthenticated 4xx responses
// still carry a request_id header.
\App\Http\Middleware\BindRequestLogContext::class,
// RFC-WS-7 §3.6 — route-scope Sentry tags (app/route_name/
// http.method). Auth-scope tags (user_id/actor_type/
// organisation_id/actor_scope/impersonation.*) bind in
// AuthScopeContextListener on the Authenticated event,
// not in middleware. See the listener for rationale.
\App\Http\Middleware\BindSentryRouteContext::class,
]);
$middleware->alias([
@@ -39,6 +49,16 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// RFC-WS-7 §3.10 — bridge Laravel's exception handler into
// sentry-laravel so report($e) and Laravel's automatic
// report-before-render flow reach GlitchTip. sentry-laravel 4.x
// does NOT auto-register this; the README installation snippet
// requires the host application to wire it explicitly.
// Filtering happens downstream of this hook: ignore_exceptions in
// config/sentry.php drops Validation/Auth/AuthZ; SentryEventScrubber
// drops sub-500 HttpExceptions via the before_send hook.
\Sentry\Laravel\Integration::handles($exceptions);
// Public Form Builder standardised error envelope (S2c D6).
$exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) {
$body = [

View File

@@ -13,6 +13,7 @@
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"pragmarx/google2fa": "^9.0",
"sentry/sentry-laravel": "^4.15",
"spatie/laravel-activitylog": "^5.0",
"spatie/laravel-medialibrary": "^11.21",
"spatie/laravel-permission": "^7.2"

483
api/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "78c21fb00a5a2db68ad60afeb62382b9",
"content-hash": "48bb02e9c223eedc61e86fdf91a72552",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -1539,6 +1539,66 @@
],
"time": "2025-08-22T14:27:06+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": "laravel/framework",
"version": "v12.56.0",
@@ -3225,6 +3285,84 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
@@ -4190,6 +4328,191 @@
},
"time": "2026-03-03T17:31:43+00:00"
},
{
"name": "sentry/sentry",
"version": "4.26.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "7597fd10c443929c62489d7cf38d1cb8341d6608"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/7597fd10c443929c62489d7cf38d1cb8341d6608",
"reference": "7597fd10c443929c62489d7cf38d1cb8341d6608",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.0",
"open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/sdk": "^1.0",
"phpbench/phpbench": "^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.26.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-04-30T12:50:22+00:00"
},
{
"name": "sentry/sentry-laravel",
"version": "4.25.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
"reference": "67efbdd74a752fcc1038676986b055a4df7d5084"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084",
"reference": "67efbdd74a752fcc1038676986b055a4df7d5084",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
"sentry/sentry": "^4.23.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/folio": "^1.1",
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"laravel/octane": "^2.15",
"laravel/pennant": "^1.0",
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Sentry": "Sentry\\Laravel\\Facade"
},
"providers": [
"Sentry\\Laravel\\ServiceProvider",
"Sentry\\Laravel\\Tracing\\ServiceProvider"
]
}
},
"autoload": {
"psr-0": {
"Sentry\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Laravel SDK for Sentry (https://sentry.io)",
"homepage": "https://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"laravel",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
"source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-05-05T09:22:46+00:00"
},
{
"name": "spatie/image",
"version": "3.9.4",
@@ -5726,6 +6049,77 @@
],
"time": "2026-03-30T14:11:46+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
"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.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/polyfill-ctype",
"version": "v1.34.0",
@@ -6620,6 +7014,93 @@
],
"time": "2026-03-24T13:12:05+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": "v7.4.8",

159
api/config/sentry.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
/**
* Sentry Laravel SDK configuration file.
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
// Crewli convention: SENTRY_DSN_BACKEND maps to the crewli-api project
// DSN in 1Password vault. Empty = SDK no-op (RFC-WS-7 §3.3).
'dsn' => env('SENTRY_DSN_BACKEND'),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
'release' => env('SENTRY_RELEASE'),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT', env('APP_ENV')),
// RFC-WS-7 §3.7 — stateless static method + array notation.
// Configuration is declarative (a reference, not executable logic);
// container-resolution per event would be overhead without value for
// stateless scrubbing, and stack traces show the class name instead of
// an anonymous closure frame.
'before_send' => [\App\Services\Observability\SentryEventScrubber::class, 'scrub'],
// Errors-only — RFC §2 amendment B explicitly excludes performance tracing.
// Force traces/profiles off regardless of env.
'traces_sample_rate' => 0.0,
'profiles_sample_rate' => 0.0,
// Boundary with existing systems (RFC §3.10): exclude expected business
// outcomes. HTTPException is filtered further by status code in the
// scrubber (sub-500s are dropped there).
'ignore_exceptions' => [
\Illuminate\Validation\ValidationException::class,
\Illuminate\Auth\AuthenticationException::class,
\Illuminate\Auth\Access\AuthorizationException::class,
],
// Override the organization ID used for trace continuation checks.
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
// RFC-WS-7 §3.7 point 5 / §3.8 — strip locals from stack traces and IP
// from user context. Hard-pinned, no env override.
'send_default_pii' => false,
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', false),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', false),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Regression guard for RFC-WS-7 §3.14 / addendum D-06 composite indexes
* on the activity_log table.
*
* Spatie's activitylog default migration calls nullableMorphs('subject')
* and nullableMorphs('causer'), which create composite indexes named
* `subject` on (subject_type, subject_id) and `causer` on
* (causer_type, causer_id). RFC-WS-7 §3.14 / addendum D-06 require
* exactly these indexes for query planner support on activity_log
* lookups by morph subject/causer; PR-2 verified they already exist
* via information_schema.
*
* This test fails when:
* - A future Spatie major release changes nullableMorphs() semantics.
* - A developer rewrites the activity_log migration without keeping
* the morph indexes.
* - A new schema-dump regeneration silently drops them.
*/
final class ActivityLogIndexesTest extends TestCase
{
use RefreshDatabase;
public function test_subject_composite_index_exists(): void
{
$this->assertCompositeIndexExists(
table: 'activity_log',
columns: ['subject_type', 'subject_id'],
description: 'subject composite index (RFC-WS-7 §3.14 / D-06)',
);
}
public function test_causer_composite_index_exists(): void
{
$this->assertCompositeIndexExists(
table: 'activity_log',
columns: ['causer_type', 'causer_id'],
description: 'causer composite index (RFC-WS-7 §3.14 / D-06)',
);
}
/**
* @param list<string> $columns
*/
private function assertCompositeIndexExists(string $table, array $columns, string $description): void
{
$database = config('database.connections.mysql.database');
$rows = DB::select(
'SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
ORDER BY INDEX_NAME, SEQ_IN_INDEX',
[$database, $table],
);
$indexColumns = [];
foreach ($rows as $row) {
$indexColumns[$row->INDEX_NAME][(int) $row->SEQ_IN_INDEX] = $row->COLUMN_NAME;
}
$found = false;
foreach ($indexColumns as $sequence) {
ksort($sequence);
if (array_values($sequence) === $columns) {
$found = true;
break;
}
}
$this->assertTrue(
$found,
sprintf(
'Expected composite index on %s(%s) — %s. Found indexes: %s',
$table,
implode(', ', $columns),
$description,
json_encode($indexColumns, JSON_PRETTY_PRINT),
),
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use RuntimeException;
use Sentry\ClientBuilder;
use Sentry\Event as SentryEvent;
use Sentry\EventHint;
use Sentry\SentrySdk;
use Sentry\State\Hub;
use Symfony\Component\Uid\Ulid;
use Tests\TestCase;
/**
* Verifies that AuthScopeContextListener auth-scope tags actually bind on
* a live HTTP request flow not just when the Authenticated event is
* dispatched directly from a test.
*
* Reproduces the bug surfaced after the WS-7 PR-2 architectural-fixes
* deployment: offline tests passed because they called
* `event(new Authenticated(...))` directly, but Crewli's bearer-token
* Sanctum flow only fires `Laravel\Sanctum\Events\TokenAuthenticated`
* (vendor/laravel/sanctum/src/Guard.php:77), never `Authenticated`. Live
* captured events therefore carried no user_id / actor_type / actor_scope
* tags. The fix listens to both events.
*
* To exercise the real Sanctum Guard, this test creates a personal-access
* token via $user->createToken() and passes Authorization: Bearer in the
* request Sanctum::actingAs() short-circuits the Guard layer and would
* NOT detect the regression.
*/
final class AuthScopeBindingHttpFlowTest extends TestCase
{
use RefreshDatabase;
/**
* Captured events from the recording before_send hook.
*
* @var list<array{event: SentryEvent, hint: ?EventHint}>
*/
private static array $captured = [];
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
self::$captured = [];
$clientBuilder = ClientBuilder::create([
'dsn' => 'https://test@localhost/1',
'environment' => 'testing',
'release' => 'crewli-api@test',
'send_default_pii' => false,
'traces_sample_rate' => 0.0,
'profiles_sample_rate' => 0.0,
'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent {
self::$captured[] = ['event' => $event, 'hint' => $hint];
return null;
},
]);
SentrySdk::setCurrentHub(new Hub($clientBuilder->getClient()));
}
/**
* @return array<string, string>
*/
private function authHeader(User $user): array
{
$token = $user->createToken('regression-test')->plainTextToken;
return ['Authorization' => 'Bearer '.$token];
}
public function test_authenticated_http_request_captures_auth_scope_tags_on_thrown_exception(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void {
Route::get('_obs_authflow_throw', static fn () => throw new RuntimeException('regression-test'))
->name('test.obs.authflow_throw');
});
$response = $this->withHeaders($this->authHeader($user))->getJson('/_obs_authflow_throw');
$response->assertStatus(500);
$this->assertCount(1, self::$captured, 'Sentry event must be captured for thrown RuntimeException');
$tags = self::$captured[0]['event']->getTags();
$this->assertSame($user->id, $tags['user_id'] ?? null, 'user_id tag missing on live HTTP flow');
$this->assertSame('super_admin', $tags['actor_type'] ?? null, 'actor_type tag missing on live HTTP flow');
$this->assertArrayHasKey('actor_scope', $tags, 'actor_scope tag missing on live HTTP flow');
}
public function test_authenticated_http_request_to_admin_route_tags_actor_scope_platform(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])
->name('admin.')
->group(function (): void {
Route::get('_obs_admin_throw', static fn () => throw new RuntimeException('regression-test'))
->name('platform_throw');
});
$this->withHeaders($this->authHeader($user))->getJson('/_obs_admin_throw');
$tags = self::$captured[0]['event']->getTags();
$this->assertSame('platform', $tags['actor_scope'] ?? null,
'super_admin on admin.* route must tag actor_scope=platform');
$this->assertArrayNotHasKey('organisation_id', $tags,
'organisation_id MUST be absent on platform-scoped events');
}
public function test_authenticated_http_request_to_organisation_route_tags_organisation_scope(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$org->users()->attach($user, ['role' => 'org_admin']);
$user->assignRole('org_admin');
Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void {
Route::get('_obs_org_throw/{organisation}', static fn () => throw new RuntimeException('regression-test'))
->name('test.obs.org_throw');
});
$this->withHeaders($this->authHeader($user))->getJson('/_obs_org_throw/'.$org->id);
$tags = self::$captured[0]['event']->getTags();
$this->assertSame('organisation', $tags['actor_scope'] ?? null);
$this->assertSame($org->id, $tags['organisation_id'] ?? null);
$this->assertTrue(Ulid::isValid($tags['organisation_id']));
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\HandleImpersonation;
use App\Models\ImpersonationSession;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
/**
* Auth-scope Sentry tags + Log::withContext applied via the
* {@see \App\Listeners\Observability\AuthScopeContextListener} on every
* Authenticated event.
*
* Impersonation re-binding (target user_id/actor_type plus impersonation.*
* tags) is co-located in {@see HandleImpersonation} and exercised by
* the relevant tests in this file.
*/
final class AuthScopeContextListenerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
SentrySdk::getCurrentHub()->pushScope();
}
/**
* @return array<string, string>
*/
private function captureScopeTags(): array
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getTags();
}
private function captureScopeUserId(): ?string
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getUser()?->getId();
}
public function test_authenticated_event_tags_user_id(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame($user->id, $tags['user_id'] ?? null);
$this->assertSame($user->id, $this->captureScopeUserId());
}
public function test_authenticated_event_tags_actor_type_super_admin(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
event(new Authenticated('web', $user));
$this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_authenticated_event_tags_actor_type_organizer_admin(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$this->assertSame('organizer_admin', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_authenticated_event_tags_actor_type_org_member(): void
{
$user = User::factory()->create();
$user->assignRole('org_member');
event(new Authenticated('web', $user));
$this->assertSame('org_member', $this->captureScopeTags()['actor_type'] ?? null);
}
public function test_actor_scope_user_when_no_route_or_portal_context(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('user', $tags['actor_scope'] ?? null);
$this->assertArrayNotHasKey('organisation_id', $tags);
}
public function test_actor_scope_organisation_when_route_has_organisation_param(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = Request::create('http://localhost/api/v1/organisations/'.$org->id.'/test', 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}/test', static fn () => null);
$route->bind($request);
$route->setParameter('organisation', $org);
$route->name('organisations.test');
$request->setRouteResolver(static fn () => $route);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('organisation', $tags['actor_scope'] ?? null);
$this->assertSame($org->id, $tags['organisation_id'] ?? null);
$this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id']));
}
public function test_actor_scope_organisation_when_route_has_event_param(): void
{
$org = Organisation::factory()->create();
$event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]);
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = Request::create('http://localhost/api/v1/events/'.$event->id, 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'events/{event}', static fn () => null);
$route->bind($request);
$route->setParameter('event', $event);
$route->name('events.show');
$request->setRouteResolver(static fn () => $route);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('organisation', $tags['actor_scope'] ?? null);
$this->assertSame($org->id, $tags['organisation_id'] ?? null);
}
public function test_actor_scope_organisation_when_portal_token_request(): void
{
$org = Organisation::factory()->create();
$event = \App\Models\Event::factory()->create(['organisation_id' => $org->id]);
$user = User::factory()->create();
$user->assignRole('org_member');
$request = Request::create('http://localhost/api/v1/portal/me', 'GET');
$request->attributes->set('portal_context', 'artist');
$request->attributes->set('portal_event', $event);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('organisation', $tags['actor_scope'] ?? null);
$this->assertSame($org->id, $tags['organisation_id'] ?? null);
}
public function test_actor_scope_platform_for_super_admin_on_admin_route(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
$request = Request::create('http://localhost/api/v1/admin/users', 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'admin/users', static fn () => null);
$route->bind($request);
$route->name('admin.users.index');
$request->setRouteResolver(static fn () => $route);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('platform', $tags['actor_scope'] ?? null);
$this->assertArrayNotHasKey('organisation_id', $tags);
}
public function test_actor_scope_user_for_super_admin_on_non_admin_route(): void
{
$user = User::factory()->create();
$user->assignRole('super_admin');
$request = Request::create('http://localhost/api/v1/me/profile', 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null);
$route->bind($request);
$route->name('me.profile');
$request->setRouteResolver(static fn () => $route);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('user', $tags['actor_scope'] ?? null);
$this->assertArrayNotHasKey('organisation_id', $tags);
}
public function test_actor_scope_always_present_on_authenticated_event(): void
{
$user = User::factory()->create();
$user->assignRole('org_member');
event(new Authenticated('web', $user));
$this->assertArrayHasKey('actor_scope', $this->captureScopeTags());
}
public function test_organisation_id_present_when_actor_scope_is_organisation(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$user->assignRole('org_admin');
$request = Request::create('http://localhost/api/v1/organisations/'.$org->id, 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'organisations/{organisation}', static fn () => null);
$route->bind($request);
$route->setParameter('organisation', $org);
$route->name('organisations.show');
$request->setRouteResolver(static fn () => $route);
$this->app->instance('request', $request);
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertSame('organisation', $tags['actor_scope']);
$this->assertArrayHasKey('organisation_id', $tags);
$this->assertTrue(\Symfony\Component\Uid\Ulid::isValid($tags['organisation_id']));
}
public function test_authenticated_event_does_not_set_impersonation_tags(): void
{
$user = User::factory()->create();
$user->assignRole('org_admin');
event(new Authenticated('web', $user));
$tags = $this->captureScopeTags();
$this->assertArrayNotHasKey('impersonation.active', $tags);
$this->assertArrayNotHasKey('impersonation.impersonator_user_id', $tags);
}
public function test_handle_impersonation_rebinds_user_id_and_tags_impersonation_after_swap(): void
{
Organisation::factory()->create(); // tenancy fixture
$admin = User::factory()->create([
'mfa_enabled' => true,
'mfa_method' => \App\Enums\MfaMethod::TOTP->value,
'mfa_secret' => encrypt('JBSWY3DPEHPK3PXP'),
'mfa_confirmed_at' => now(),
]);
$admin->assignRole('super_admin');
$target = User::factory()->create();
$target->assignRole('org_admin');
// Authenticated event for the admin (Sanctum's normal flow).
event(new Authenticated('web', $admin));
$this->assertSame($admin->id, $this->captureScopeTags()['user_id'] ?? null);
$this->assertSame('super_admin', $this->captureScopeTags()['actor_type'] ?? null);
// Manufacture an impersonation session and run HandleImpersonation
// through to the post-swap re-binding logic.
$session = ImpersonationSession::create([
'admin_id' => $admin->id,
'target_user_id' => $target->id,
'reason' => 'test',
'mfa_method' => \App\Enums\MfaMethod::TOTP->value,
'ip_address' => '127.0.0.1',
'started_at' => now(),
'expires_at' => now()->addHour(),
]);
\Illuminate\Support\Facades\Cache::put(
'impersonation:'.$admin->id.':'.$target->id,
$session->id,
now()->addHour(),
);
$request = Request::create('http://localhost/api/v1/me/profile', 'GET');
$request->headers->set('X-Impersonate-User', $target->id);
$request->setUserResolver(static fn () => $admin);
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$middleware = app(HandleImpersonation::class);
$middleware->handle($request, static fn () => response('ok'));
$tags = $this->captureScopeTags();
$this->assertSame($target->id, $tags['user_id'] ?? null);
$this->assertSame('organizer_admin', $tags['actor_type']);
$this->assertSame('true', $tags['impersonation.active'] ?? null);
$this->assertSame($admin->id, $tags['impersonation.impersonator_user_id'] ?? null);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Http\Middleware\BindSentryRouteContext;
use Illuminate\Http\Request;
use Sentry\Event as SentryEvent;
use Sentry\SentrySdk;
use Sentry\State\Scope;
use Tests\TestCase;
use function Sentry\configureScope;
/**
* Route-scope tags (app, http.method, route_name) on every API request.
*
* Auth-scope assertions (user_id, actor_type, organisation_id, etc.) live
* in {@see AuthScopeContextListenerTest} that's the file to look at if
* you're changing what gets tagged on authenticated events.
*/
final class BindSentryRouteContextTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
SentrySdk::getCurrentHub()->pushScope();
}
/**
* @return array<string, string>
*/
private function captureScopeTags(): array
{
$event = SentryEvent::createEvent();
configureScope(static function (Scope $scope) use ($event): void {
$scope->applyToEvent($event);
});
return $event->getTags();
}
private function runMiddleware(Request $request): void
{
(new BindSentryRouteContext())->handle($request, static fn (Request $req) => response('ok'));
}
public function test_app_tag_is_api(): void
{
$request = Request::create('http://localhost/api/v1/_anything', 'GET');
$this->runMiddleware($request);
$this->assertSame('api', $this->captureScopeTags()['app'] ?? null);
}
public function test_http_method_tag_present(): void
{
$request = Request::create('http://localhost/api/v1/me/profile', 'PATCH');
$this->runMiddleware($request);
$this->assertSame('PATCH', $this->captureScopeTags()['http.method'] ?? null);
}
public function test_route_name_tag_present(): void
{
$request = Request::create('http://localhost/api/v1/me/profile', 'GET');
$route = new \Illuminate\Routing\Route(['GET'], 'me/profile', static fn () => null);
$route->name('me.profile');
$route->bind($request);
$request->setRouteResolver(static fn () => $route);
$this->runMiddleware($request);
$this->assertSame('me.profile', $this->captureScopeTags()['route_name'] ?? null);
}
public function test_route_name_tag_omitted_when_route_has_no_name(): void
{
$request = Request::create('http://localhost/api/v1/anonymous', 'GET');
$this->runMiddleware($request);
$this->assertArrayNotHasKey('route_name', $this->captureScopeTags());
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum;
use RuntimeException;
use Sentry\ClientBuilder;
use Sentry\Event as SentryEvent;
use Sentry\EventHint;
use Sentry\SentrySdk;
use Sentry\State\Hub;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
/**
* Regression coverage for the report() sentry-laravel pipeline (PR-2
* follow-up). Captures the bug where unit tests passed (scope tagging
* verified, scrubbing verified) yet live exceptions never reached
* GlitchTip because `\Sentry\Laravel\Integration::handles($exceptions)`
* was missing from `bootstrap/app.php`.
*
* Strategy: install a recording `before_send` hook on a real Sentry
* client. Every exception that traverses the report pipeline lands here
* with its full event payload. Returning null prevents network egress.
*/
final class ExceptionReportingTest extends TestCase
{
use RefreshDatabase;
/**
* Captured events received by the recording before_send hook.
*
* @var list<array{event: SentryEvent, hint: ?EventHint}>
*/
private static array $captured = [];
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
self::$captured = [];
// Wire a real Sentry client whose before_send records events into
// the static buffer and returns null (drops, never networked).
$clientBuilder = ClientBuilder::create([
'dsn' => 'https://test@localhost/1',
'environment' => 'testing',
'release' => 'crewli-api@test',
'send_default_pii' => false,
'traces_sample_rate' => 0.0,
'profiles_sample_rate' => 0.0,
'ignore_exceptions' => [
ValidationException::class,
\Illuminate\Auth\AuthenticationException::class,
AuthorizationException::class,
],
'before_send' => static function (SentryEvent $event, ?EventHint $hint = null): ?SentryEvent {
self::$captured[] = ['event' => $event, 'hint' => $hint];
return null;
},
]);
$hub = new Hub($clientBuilder->getClient());
SentrySdk::setCurrentHub($hub);
// Test-only routes that exercise each branch of the
// ignore_exceptions / before_send / capture pipeline.
Route::middleware(['auth:sanctum', \App\Http\Middleware\BindSentryRouteContext::class])->group(function (): void {
Route::get('_obs_runtime', static fn () => throw new RuntimeException('boom'))
->name('test.obs.runtime');
Route::get('_obs_validation', static function (): never {
throw ValidationException::withMessages(['email' => 'required']);
})->name('test.obs.validation');
Route::get('_obs_404', static fn () => throw new NotFoundHttpException('nope'))
->name('test.obs.404');
Route::get('_obs_403', static fn () => throw new AuthorizationException('denied'))
->name('test.obs.403');
});
}
private function actAsOrgAdmin(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$org->users()->attach($user, ['role' => 'org_admin']);
$user->assignRole('org_admin');
Sanctum::actingAs($user);
}
public function test_runtime_exception_from_controller_is_captured(): void
{
$this->actAsOrgAdmin();
$this->getJson('/_obs_runtime')->assertStatus(500);
$this->assertCount(1, self::$captured, 'expected exactly one captured event');
$event = self::$captured[0]['event'];
$exceptions = $event->getExceptions();
$this->assertNotEmpty($exceptions);
$this->assertSame(RuntimeException::class, $exceptions[0]->getType());
$this->assertSame('boom', $exceptions[0]->getValue());
}
public function test_validation_exception_is_not_captured(): void
{
$this->actAsOrgAdmin();
$this->getJson('/_obs_validation')->assertStatus(422);
$this->assertCount(0, self::$captured);
}
public function test_not_found_http_exception_is_not_captured(): void
{
$this->actAsOrgAdmin();
$this->getJson('/_obs_404')->assertStatus(404);
$this->assertCount(0, self::$captured);
}
public function test_authorization_exception_is_not_captured(): void
{
$this->actAsOrgAdmin();
$this->getJson('/_obs_403')->assertStatus(403);
$this->assertCount(0, self::$captured);
}
public function test_runtime_exception_carries_request_context(): void
{
$this->actAsOrgAdmin();
$this->getJson('/_obs_runtime')->assertStatus(500);
$this->assertCount(1, self::$captured);
$tags = self::$captured[0]['event']->getTags();
// BindSentryRouteContext should have set these on the scope
// before the exception fired in the controller.
$this->assertSame('api', $tags['app'] ?? null);
$this->assertSame('GET', $tags['http.method'] ?? null);
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Services\Observability\SentryEventScrubber;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Sentry\Event;
use Sentry\EventHint;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
final class PiiScrubbingTest extends TestCase
{
/**
* @param array<string, mixed> $request
*/
private function scrubEventWithRequest(array $request, ?EventHint $hint = null): ?Event
{
$event = Event::createEvent();
$event->setRequest($request);
return SentryEventScrubber::scrub($event, $hint);
}
public function test_password_in_request_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['email' => 'a@b.test', 'password' => 'sup3rsecret!'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password']);
$this->assertSame('a@b.test', $event->getRequest()['data']['email']);
}
public function test_password_confirmation_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['password_confirmation' => 'p@ss', 'current_password' => 'oldpass'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['password_confirmation']);
$this->assertSame('[scrubbed]', $event->getRequest()['data']['current_password']);
}
public function test_authorization_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['Authorization' => 'Bearer abc.def.ghi'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Authorization']);
}
public function test_cookie_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['Cookie' => 'crewli_session=abcd'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['Cookie']);
}
public function test_x_impersonation_token_header_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'headers' => ['X-Impersonation-Token' => 'imp_token_xyz'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['headers']['X-Impersonation-Token']);
}
public function test_form_values_payload_is_replaced_wholesale(): void
{
$event = $this->scrubEventWithRequest([
'data' => [
'form_values' => [
'email' => 'sensitive@example.com',
'dietary' => 'vegan',
'phone' => '+31612345678',
],
],
]);
$data = $event->getRequest()['data'];
$this->assertSame('[scrubbed_form_values]', $data['form_values']);
$serialised = json_encode($data, JSON_THROW_ON_ERROR);
$this->assertStringNotContainsString('sensitive@example.com', $serialised);
$this->assertStringNotContainsString('vegan', $serialised);
$this->assertStringNotContainsString('+31612345678', $serialised);
}
public function test_token_query_string_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'query_string' => 'token=abc123&keep=me',
]);
$qs = $event->getRequest()['query_string'];
$this->assertStringContainsString('token=%5Bscrubbed%5D', $qs);
$this->assertStringContainsString('keep=me', $qs);
}
public function test_api_key_query_string_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'query_string' => 'api_key=xyz&page=2',
]);
$qs = $event->getRequest()['query_string'];
$this->assertStringContainsString('api_key=%5Bscrubbed%5D', $qs);
$this->assertStringContainsString('page=2', $qs);
}
public function test_iban_in_nested_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => [
'profile' => [
'address' => [
'iban' => 'NL91ABNA0417164300',
'street' => 'Damrak 1',
],
],
],
]);
$address = $event->getRequest()['data']['profile']['address'];
$this->assertSame('[scrubbed]', $address['iban']);
$this->assertSame('Damrak 1', $address['street']);
}
public function test_bsn_in_nested_body_is_scrubbed(): void
{
$event = $this->scrubEventWithRequest([
'data' => ['kyc' => ['passport_number' => 'NX1234567', 'bsn' => '123456789']],
]);
$kyc = $event->getRequest()['data']['kyc'];
$this->assertSame('[scrubbed]', $kyc['passport_number']);
$this->assertSame('[scrubbed]', $kyc['bsn']);
}
public function test_send_default_pii_is_false(): void
{
$this->assertFalse(config('sentry.send_default_pii'));
}
public function test_validation_exception_is_in_ignore_list(): void
{
$this->assertContains(ValidationException::class, config('sentry.ignore_exceptions'));
}
public function test_authentication_exception_is_in_ignore_list(): void
{
$this->assertContains(AuthenticationException::class, config('sentry.ignore_exceptions'));
}
public function test_authorization_exception_is_in_ignore_list(): void
{
$this->assertContains(AuthorizationException::class, config('sentry.ignore_exceptions'));
}
public function test_http_exception_404_is_dropped_by_scrubber(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new NotFoundHttpException]);
$this->assertNull(SentryEventScrubber::scrub($event, $hint));
}
public function test_http_exception_500_is_captured(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new HttpException(500, 'boom')]);
$this->assertNotNull(SentryEventScrubber::scrub($event, $hint));
}
public function test_throwable_from_controller_is_captured(): void
{
$event = Event::createEvent();
$hint = EventHint::fromArray(['exception' => new RuntimeException('programmer error')]);
$this->assertNotNull(SentryEventScrubber::scrub($event, $hint));
}
public function test_form_values_replacement_blocks_attempts_to_smuggle_pii(): void
{
// form_values is a wholesale replace — even if the payload is deeply
// nested, the entire branch is wiped so individual keys cannot leak.
$event = $this->scrubEventWithRequest([
'data' => [
'submission' => [
'form_values' => [
'medical' => 'celiac',
'children' => [
['name' => 'Bobby', 'allergy' => 'peanuts'],
],
],
],
],
]);
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
$this->assertStringContainsString('[scrubbed_form_values]', $serialised);
$this->assertStringNotContainsString('celiac', $serialised);
$this->assertStringNotContainsString('Bobby', $serialised);
$this->assertStringNotContainsString('peanuts', $serialised);
}
public function test_cookies_request_field_is_replaced(): void
{
$event = $this->scrubEventWithRequest([
'cookies' => ['SESSION' => 'abcd', 'tracking' => 'xyz'],
]);
$this->assertSame('[scrubbed]', $event->getRequest()['cookies']);
}
public function test_max_depth_guard_prevents_unbounded_recursion(): void
{
$deep = ['v' => 'leaf'];
for ($i = 0; $i < 15; $i++) {
$deep = ['nest' => $deep];
}
$event = $this->scrubEventWithRequest(['data' => $deep]);
$serialised = json_encode($event->getRequest()['data'], JSON_THROW_ON_ERROR);
$this->assertStringContainsString('[max_depth]', $serialised);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Observability;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class RequestIdRoundTripTest extends TestCase
{
use RefreshDatabase;
private const VALID_ULID_PATTERN = '/^[0-9A-HJKMNP-TV-Z]{26}$/';
/**
* Captured Log::withContext payload from BindRequestLogContext.
*
* @var array<string, mixed>|null
*/
private static ?array $capturedLogContext = null;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
self::$capturedLogContext = null;
// Spy on Log::withContext so we can assert the structured payload.
Log::swap(new class extends \Illuminate\Log\LogManager
{
public function __construct() {}
/**
* @param array<string, mixed> $context
*/
public function withContext(array $context = []): \Illuminate\Log\Logger
{
RequestIdRoundTripTest::recordContext($context);
return $this->driver();
}
public function driver($driver = null): \Illuminate\Log\Logger
{
return new \Illuminate\Log\Logger(new \Psr\Log\NullLogger);
}
/**
* @param array<int, mixed> $parameters
*/
public function __call($method, $parameters)
{
return null;
}
});
}
/**
* @param array<string, mixed> $context
*/
public static function recordContext(array $context): void
{
self::$capturedLogContext = array_merge(self::$capturedLogContext ?? [], $context);
}
public function test_response_has_x_request_id_header_when_none_supplied(): void
{
$response = $this->getJson('/api/v1/');
$response->assertOk();
$requestId = $response->headers->get('X-Request-Id');
$this->assertNotNull($requestId);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $requestId);
}
public function test_response_has_x_request_id_header_when_client_supplied_valid_ulid(): void
{
$supplied = (string) Str::ulid();
$response = $this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
$this->assertSame($supplied, $response->headers->get('X-Request-Id'));
}
public function test_server_generates_when_client_supplies_invalid_ulid(): void
{
$response = $this->getJson('/api/v1/', ['X-Request-Id' => 'not-a-ulid-at-all']);
$emitted = $response->headers->get('X-Request-Id');
$this->assertNotSame('not-a-ulid-at-all', $emitted);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
}
public function test_server_generates_when_client_supplies_empty_string(): void
{
$response = $this->getJson('/api/v1/', ['X-Request-Id' => '']);
$emitted = $response->headers->get('X-Request-Id');
$this->assertNotNull($emitted);
$this->assertMatchesRegularExpression(self::VALID_ULID_PATTERN, $emitted);
}
public function test_log_context_has_request_id(): void
{
$supplied = (string) Str::ulid();
$this->getJson('/api/v1/', ['X-Request-Id' => $supplied]);
$this->assertSame($supplied, self::$capturedLogContext['request_id'] ?? null);
}
public function test_log_context_has_user_id_and_org_when_authenticated_organisation_route(): void
{
$org = Organisation::factory()->create();
$user = User::factory()->create();
$org->users()->attach($user, ['role' => 'org_admin']);
$user->assignRole('org_admin');
Sanctum::actingAs($user);
$this->getJson('/api/v1/organisations/'.$org->id.'/dashboard-stats');
$this->assertSame($org->id, self::$capturedLogContext['organisation_id'] ?? null);
$this->assertSame($user->id, self::$capturedLogContext['user_id'] ?? null);
}
public function test_log_context_route_matches_named_route(): void
{
$this->getJson('/api/v1/');
// The health-check route at /api/v1/ has no name; expectation is
// simply that the key is absent (filtered out for null) rather
// than carrying a misleading default.
$this->assertArrayNotHasKey('route', self::$capturedLogContext ?? []);
}
public function test_unauthenticated_request_still_gets_request_id(): void
{
// Hitting an authenticated route unauthenticated yields 401 — but
// the request_id middleware still runs.
$response = $this->getJson('/api/v1/auth/me');
$response->assertStatus(401);
$this->assertNotNull($response->headers->get('X-Request-Id'));
}
public function test_request_id_is_valid_ulid_format(): void
{
$response = $this->getJson('/api/v1/');
$emitted = $response->headers->get('X-Request-Id');
$this->assertSame(26, strlen((string) $emitted));
$this->assertTrue(Str::isUlid((string) $emitted));
}
}

View File

@@ -1638,5 +1638,105 @@ voeden).
---
_Laatste update: April 2026_
_Voeg nieuwe items toe met prefix: ARCH-, COMM-, OPS-, VOL-, ART-, FORM-, SUP-, DIFF-, APPS-, TECH-, UX-_
_Laatste update: Mei 2026_
_Voeg nieuwe items toe met prefix: ARCH-, COMM-, OPS-, VOL-, ART-, FORM-, SUP-, DIFF-, APPS-, TECH-, UX-, OBS-_
---
## Observability follow-ups (post WS-7 PR-2)
### OBS-1 — Promote ActorType::VOLUNTEER when volunteer role is introduced
**Aanleiding:** WS-7 PR-2 architectural-fix-commit verwijderde de
`ActorType::VOLUNTEER` enum-case omdat Crewli vandaag geen dedicated
`volunteer` Spatie-rol heeft — vrijwilligers zijn behaviorally bepaald
(users met shift-assignments), niet identitair. De resolver mapt
non-admin authenticated users naar `ORG_MEMBER`.
**Wat:** Wanneer Crewli een `volunteer` rol invoert (bijv. via
volunteer-onboarding workflow), her-introduceer dan de `VOLUNTEER`
case in `app/Enums/Observability/ActorType.php` en update
`ActorType::resolve()` om de rol te checken vóór `ORG_MEMBER`. Update
ook `AuthScopeContextListenerTest` met een `actor_type=volunteer`
testcase.
**Prioriteit:** Laag — wachten op een product-besluit over volunteer-rol
modellering. Geen blocker.
**Refs:** `app/Enums/Observability/ActorType.php`,
RFC-WS-7-OBSERVABILITY.md §3.6.
### OBS-4 — PHPUnit metadata-in-doc-comment deprecation cleanup
**Aanleiding:** PHPUnit warnt dat metadata in doc-comments (zoals
`@test`, `@dataProvider`) deprecated is en in PHPUnit 12 verwijderd
wordt. Crewli heeft drie tests met deze pattern:
- `Tests\Unit\Support\Json\JsonCanonicalizerTest::test_scalar_passthrough()`
- `Tests\Feature\FormBuilder\Purposes\PurposeSchemaLifecycleTest::test_create_and_publish_succeeds_for_purpose()`
- `Tests\Feature\Schema\UlidPrimaryKeyTest::test_model_uses_has_ulids_and_generates_crockford_ulid()`
**Wat:** Vervang de doc-comment metadata door PHPUnit attributes
(bijv. `#[Test]`, `#[DataProvider]`). Raak alleen aan vóór de PHPUnit 12
upgrade gepland wordt — nu blokkeert het niets.
**Prioriteit:** Laag — kosmetisch totdat PHPUnit 12 upgrade landt.
**Refs:** PHPUnit changelog, de drie genoemde test-files.
### OBS-6 — sentry-laravel installation gap awareness
**Aanleiding:** WS-7 PR-2 smoke test faalde silent omdat sentry-laravel
4.x de `Integration::handles($exceptions)` registratie niet
auto-registreert in zijn ServiceProvider. De host-app moet de regel
expliciet aan `bootstrap/app.php` toevoegen. README documenteert dit,
maar tijdens `composer require sentry/sentry-laravel` +
`php artisan sentry:publish` workflow is het makkelijk te missen.
**Wat:**
- Voeg een waarschuwing toe in `dev-docs/SETUP.md` onder een nieuwe
sectie "Laravel package installation patterns": bij elke nieuwe
package altijd verifiëren dat het package zijn ServiceProvider-
registraties doet voor exception handlers, queue listeners, en log
channels — niet alleen voor routes/views/migrations.
- Overweeg een `tests/Feature/Bootstrap/ExceptionHandlerRegistrationTest.php`
die `app(\Illuminate\Foundation\Exceptions\Handler::class)->getReportableCallbacks()`
introspecteert en assertert dat sentry-laravel's callback
geregistreerd is. Vangt een toekomstige refactor die per ongeluk
`Integration::handles` uit `bootstrap/app.php` verwijdert.
**Prioriteit:** Laag — fix is gedaan en getest, regression mogelijk
maar onwaarschijnlijk gezien de explicit comment in `bootstrap/app.php`.
**Refs:** `bootstrap/app.php`,
`vendor/sentry/sentry-laravel/src/Sentry/Laravel/Integration.php`,
RFC-WS-7-OBSERVABILITY.md §3.10.
### OBS-7 — Custom $exceptions->render() handlers report() invariant
**Aanleiding:** WS-7 PR-2 smoke-test debugging onthulde dat Crewli's
`bootstrap/app.php` 5 custom render handlers heeft. Met
`Integration::handles($exceptions)` geregistreerd werkt
report-before-render correct. Maar een toekomstige render handler die
een Throwable consumeert zonder `report($e)` aan te roepen vóór return
zou Sentry-capture kunnen overslaan voor die exception class.
**Wat:**
- Documenteer in `bootstrap/app.php` (boven het withExceptions block)
een comment: "Render handlers consume exceptions; Laravel's
ExceptionHandler::handle() doet report() vóór render() zodat capture
automatisch is. NIEUWE render handlers MOGEN NIET short-circuiten
voordat report() bereikt is. Verifieer via
tests/Feature/Observability/ExceptionReportingTest.php."
- Uitbreiden van `ExceptionReportingTest.php` met assertions per
bestaande render handler class: throw die exception, assert event
captured.
**Prioriteit:** Medium — bestaande handlers zijn correct, maar het
invariant is subtiel en silent-failure-prone bij toevoegingen.
**Refs:** `bootstrap/app.php`,
`tests/Feature/Observability/ExceptionReportingTest.php`,
RFC-WS-7-OBSERVABILITY.md §3.10.

283
dev-docs/GLITCHTIP.md Normal file
View File

@@ -0,0 +1,283 @@
# GlitchTip — operations runbook
Self-hosted error tracking for Crewli. GlitchTip implements the Sentry
event protocol; the official Sentry SDKs (`sentry-laravel`, `@sentry/vue`,
`@sentry/cli`) work against it without modification.
Reference: [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md).
This file documents how to run the stack — locally and on the production
monitoring host. PR-2 (backend SDK) and PR-3 (frontend SDK) consume DSNs
provisioned via the steps below.
---
## 1. Overview
| Service | Image | Role |
|---------|-------|------|
| `glitchtip-web` | `glitchtip/glitchtip:6.1.6` | Django web UI + ingest API |
| `glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery worker + beat (event processing, alerts, partition maintenance) |
| `glitchtip-postgres` | `postgres:16-alpine` | Primary datastore |
| `glitchtip-redis` | `valkey/valkey:7-alpine` | Celery broker + cache |
The same `docker-compose.glitchtip.yml` runs both locally (merged with
`docker-compose.yml`) and on the production host (standalone). Container
names are identical in both environments to avoid configuration drift.
---
## 2. Local development
```bash
# Once
cp docker/glitchtip/.env.example docker/glitchtip/.env
# Boot the full stack (MySQL, Redis, Mailpit, GlitchTip)
make services
# First boot takes ~60s while migrations run. Tail progress:
make services-glitchtip-status
```
Web UI: <http://localhost:8200>. Outbound mail goes to Mailpit
(`http://localhost:8025`).
Create the first admin user:
```bash
docker exec -it glitchtip-web ./manage.py createsuperuser
```
Stop the stack with `make services-stop`. Volumes (`glitchtip_postgres_data`,
`glitchtip_redis_data`, `glitchtip_uploads`) survive a stop. Wipe with
`docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml down -v`
**never on production**.
---
## 3. Project provisioning
Once the web UI is reachable and the superuser exists:
1. Sign in at `/`.
2. Create an Organization called **Crewli**.
3. Create two projects:
- **`crewli-api`** — platform: Python / Django, alert rules: default.
- **`crewli-app`** — platform: JavaScript / Vue, alert rules: default.
4. For each project, copy the auto-generated DSN from
*Settings → Client Keys (DSN)*.
5. Store both DSNs in 1Password under `Crewli / GlitchTip / DSNs`:
- `SENTRY_DSN_BACKEND``crewli-api` DSN
- `SENTRY_DSN_FRONTEND``crewli-app` DSN
PR-2 wires `SENTRY_DSN_BACKEND` into `api/.env.example`; PR-3 wires
`SENTRY_DSN_FRONTEND` into `apps/app/.env.example`. Empty DSN = SDK no-op
(verified for both `sentry-laravel` and `@sentry/vue`), so dev environments
without a DSN are silent.
---
## 4. Production deployment
GlitchTip runs on a separate host (`monitoring.hausdesign.nl`) and is **not**
deployed via the Crewli `deploy.sh` pipeline.
### 4.1 Prerequisites
- Docker + Docker Compose v2 on the monitoring host.
- DirectAdmin with the Let's Encrypt module enabled.
- DNS A-record `monitoring.hausdesign.nl` pointing at the host IP.
### 4.2 Place the stack
```bash
sudo install -d -o crewli -g crewli /opt/glitchtip
sudo install -d -o crewli -g crewli /opt/glitchtip/docker/glitchtip
# Copy compose file + env example to the host (e.g. via scp or git checkout).
# /opt/glitchtip/docker-compose.glitchtip.yml
# /opt/glitchtip/docker/glitchtip/.env.example
```
### 4.3 Configure `.env`
```bash
cd /opt/glitchtip
cp docker/glitchtip/.env.example docker/glitchtip/.env
chmod 0600 docker/glitchtip/.env
```
Fill in the production values (header of `.env.example` lists the
checklist):
```env
SECRET_KEY=<python -c "import secrets; print(secrets.token_urlsafe(50))">
DATABASE_URL=postgres://postgres:<STRONG>@glitchtip-postgres:5432/glitchtip
POSTGRES_PASSWORD=<STRONG> # MUST match the password in DATABASE_URL
GLITCHTIP_DOMAIN=https://monitoring.hausdesign.nl
DEFAULT_FROM_EMAIL=glitchtip@hausdesign.nl
EMAIL_URL=smtp+tls://USER:PASSWORD@HOST:PORT
```
Source the `<STRONG>` password from the 1Password vault.
### 4.4 DNS + TLS
1. Create the A-record for `monitoring.hausdesign.nl` in DNS.
2. In DirectAdmin: add the subdomain, then enable Let's Encrypt
(Domain Setup → SSL Certificates → "Free & automatic certificate from
Let's Encrypt"). Wait for the cert to issue.
### 4.5 Apache reverse proxy
DirectAdmin generates the vhost. Add a custom config (DirectAdmin →
Custom HTTPD Configurations) for the `monitoring.hausdesign.nl` HTTPS
vhost:
```apache
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:8200/
ProxyPassReverse / http://127.0.0.1:8200/
# WebSocket upgrade — GlitchTip uses WS for live event streaming.
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:8200/$1" [P,L]
```
Reload Apache.
### 4.6 First boot
```bash
cd /opt/glitchtip
docker compose -f docker-compose.glitchtip.yml up -d
# Wait for healthchecks (~60s).
docker compose -f docker-compose.glitchtip.yml ps
# Create the admin user.
docker exec -it glitchtip-web ./manage.py createsuperuser
```
Open <https://monitoring.hausdesign.nl>, sign in, and **enable 2FA** on
the account immediately (acceptance criterion 1). Profile → Security →
Two-Factor Authentication.
Then provision the two projects (§3) and capture DSNs into 1Password.
---
## 5. Backup & restore
### 5.1 Daily backup
`scripts/glitchtip-backup.sh` runs `pg_dump --format=custom`, streams it
through gzip, writes to `./backups/glitchtip/glitchtip-<ts>.dump.gz` with
`0600` permissions, and prunes dumps older than 30 days.
Install the cron entry on the production host:
```cron
# /etc/cron.d/glitchtip-backup
0 3 * * * crewli /opt/crewli/scripts/glitchtip-backup.sh >> /var/log/glitchtip-backup.log 2>&1
```
(Replace `/opt/crewli` with wherever the Crewli repo checkout lives on
the monitoring host. The script is portable — only the `docker exec`
target container needs to exist.)
The script exits non-zero on dump failure so cron's `MAILTO` catches
silent regressions.
### 5.2 Restore drill
```bash
# Pick the dump to restore from.
DUMP=./backups/glitchtip/glitchtip-20260506-030000.dump.gz
# Stream the restore into the postgres container.
gunzip < "$DUMP" \
| docker exec -i glitchtip-postgres pg_restore \
-U postgres -d glitchtip --clean --if-exists
```
`--clean --if-exists` drops existing objects before recreating them, so
the database ends up exactly as it was at dump time. Run after a
`docker compose stop glitchtip-web glitchtip-worker` to avoid concurrent
writes during the restore.
Bert should drill the restore at least once after the production stack
is live (acceptance criterion 11).
---
## 6. Monitoring the monitor
Quick smoke tests:
```bash
# API responds with JSON (not 502).
curl -sS http://localhost:8200/api/0/
# Worker reporting in (look for "celery@... ready").
docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml \
logs --tail=50 glitchtip-worker
# All services healthy.
docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml ps
```
In production, replace `localhost:8200` with `https://monitoring.hausdesign.nl`.
Email-alerting is configured in PR-4; until then alerts surface only in
the GlitchTip web UI (Issues view).
---
## 7. Troubleshooting
### Web container unhealthy on first boot
Migrations take ~60s on a fresh volume. The healthcheck `start_period`
is set accordingly. If the container is still unhealthy after two
minutes, tail logs:
```bash
docker logs glitchtip-web
```
Most common cause: `DATABASE_URL` password ≠ `POSTGRES_PASSWORD`. The
postgres container creates the user with the password it sees, GlitchTip
authenticates with the password embedded in the URL — they MUST match.
### Worker idle / events stuck in queue
Check that `REDIS_URL` resolves and the worker is connected:
```bash
docker logs glitchtip-worker | grep -E "ready|connected|error"
```
### Volume permission errors on Linux hosts
`postgres:16-alpine` runs as UID 70 internally. If `/var/lib/postgresql/data`
is bind-mounted from the host with mismatched ownership, postgres refuses
to start. The default named volume avoids this — only relevant if you
later switch to a host bind-mount.
### Right-to-erasure (Art. 17)
Currently manual. Locate events for a user ULID via the web UI search,
delete via the UI or directly on the postgres container. An automated
erasure script is on the BACKLOG (per RFC §4).
---
## 8. References
- RFC: [`RFC-WS-7-OBSERVABILITY.md`](./RFC-WS-7-OBSERVABILITY.md)
- GlitchTip docs: <https://glitchtip.com/documentation>
- GlitchTip self-hosting: <https://glitchtip.com/documentation/install>

View File

@@ -30,6 +30,8 @@ Twee afwijkingen van charter §3 besluit 8, beide bewust:
Self-hosted GlitchTip op productie VPS via Docker Compose (`glitchtip-web`, `glitchtip-worker`, `glitchtip-postgres`, `glitchtip-redis`). Reverse proxy via DirectAdmin Apache; SSL via DirectAdmin Let's Encrypt op `monitoring.hausdesign.nl` (consistent met bestaande subdomain-pattern).
**Lokale ontwikkeling:** dezelfde `docker-compose.glitchtip.yml` draait lokaal als `make services` (gecombineerd met de bestaande `docker-compose.yml` via `-f`). Web-UI op `http://localhost:8200`, e-mail naar Mailpit op `bm_mailpit:1025`. Dev-stack en prod-stack delen één compose-file zodat configuratie-drift uitgesloten is.
### 3.2 Twee projecten / DSNs
- `crewli-api` — Laravel
@@ -59,24 +61,41 @@ Format `<app>@<short-sha>` (`crewli-api@f41951a`, `crewli-app@f41951a`). Bron: `
### 3.6 Context tagging
| Tag | API | apps/app |
|---|---|---|
| `release` | altijd | altijd |
| `environment` | altijd | altijd |
| `app` | `api` | `app` |
| `route_name` | `Route::currentRouteName()` | `route.name` |
| `http.method` | altijd | n.v.t. |
| `organisation_id` (ULID) | wanneer auth+scope gebound | uit auth store |
| `event_id` (ULID) | wanneer event-scoped | wanneer applicabel |
| `user_id` (ULID) | `auth()?->id()` | uit auth store, alleen session-mode |
| `actor_type` | `organizer_admin` / `super_admin` / `portal_token` / `volunteer` / etc. | mirror |
| `impersonation.active` | bool | n.v.t. |
| `impersonation.impersonator_user_id` | wanneer actief | n.v.t. |
| `queue.attempt` | binnen job-context | n.v.t. |
Tag-binding gebeurt op twee plekken: route-scope tags via `BindSentryRouteContext` middleware (op de api-group), auth-scope tags via `AuthScopeContextListener` op `Illuminate\Auth\Events\Authenticated`. De split volgt de data-bron: route-context is alleen tijdens HTTP-handling beschikbaar, auth-context wordt door elke authenticator (Sanctum, portal-token) ge-emit via het Authenticated event.
| Tag | API | apps/app | Bron / locatie |
|---|---|---|---|
| `release` | altijd | altijd | env, sentry-laravel built-in |
| `environment` | altijd | altijd | env, sentry-laravel built-in |
| `app` | `api` | `app` | route-middleware |
| `route_name` | altijd | altijd | route-middleware |
| `http.method` | altijd | n.v.t. | route-middleware |
| `actor_scope` | `organisation`/`platform`/`user`/`anonymous` | mirror | auth-listener (zie hieronder) |
| `organisation_id` (ULID) | aanwezig wanneer `actor_scope = organisation` | uit auth store | auth-listener |
| `event_id` (ULID) | wanneer event-scoped | wanneer applicabel | auth-listener (via {event} route-param) |
| `user_id` (ULID) | wanneer authenticated | uit auth store, alleen session-mode | auth-listener |
| `actor_type` | `organizer_admin` / `super_admin` / `portal_token` / `org_member` / `unauthenticated` | mirror | auth-listener |
| `impersonation.active` | bool | n.v.t. | HandleImpersonation middleware (post-swap) |
| `impersonation.impersonator_user_id` | wanneer actief | n.v.t. | HandleImpersonation middleware |
| `impersonation.session_id` | wanneer actief | n.v.t. | HandleImpersonation middleware |
| `queue.attempt` | binnen job-context | n.v.t. | TagJobAttemptOnSentry listener |
**Nooit als tag:** email, telefoon, naam, IP-adres, raw form_value content, raw cookie content.
Multi-tenant invariant: élke captured event uit een geauthenticeerde controller MOET `organisation_id` hebben. Een unit-test verifieert dit — als `organisation_id` ontbreekt op een geauthenticeerd path, faalt de test.
**Multi-tenant invariant (verfijnd na PR-2 live smoke test):**
`actor_scope` is altijd aanwezig op authenticated events. Wanneer `actor_scope = organisation`, MOET `organisation_id` aanwezig en valide ULID zijn. Wanneer `actor_scope = platform`, IS `organisation_id` afwezig — dat is correct gedrag voor super_admin platform-routes (geforceerde org-attribution zou misleidend zijn). Wanneer `actor_scope = user` (default authenticated zonder org-route-context), is `organisation_id` ook afwezig: Crewli's User↔Organisation is many-to-many, een single-org "current org" bestaat niet op user-niveau, en attribution aan een willekeurige org zou misleiden. Een unit-test in `AuthScopeContextListenerTest::test_organisation_id_present_when_actor_scope_is_organisation` verifieert deze invariant.
**`actor_scope`-waarden:**
| Waarde | Wanneer | Filtering use-case in GlitchTip |
|---|---|---|
| `organisation` | route met {organisation} of {event} param, of portal-token request | "Issues voor organisatie X" |
| `platform` | super_admin op `admin.*` named routes | "Platform-bugs (niet org-specifiek)" |
| `user` | authenticated user op routes zonder org-scope (`/me/*`, `/portal/my-shifts`, `/uploads/*` etc.) | "Issues op user-routes; geen org-attribution" |
| `anonymous` | unauthenticated requests | "Public-route issues" |
**Wijziging t.o.v. originele RFC:** de oorspronkelijke formulering "élke captured event uit een geauthenticeerde controller MOET `organisation_id` hebben" is verfijnd na bevinding dat super_admin platform-routes geen zinvolle org-context hebben en Crewli's many-to-many user-org model geen reliable single-org hint biedt. Het invariant is nu sterker: niet "altijd aanwezig" maar "altijd correct gerelateerd aan `actor_scope`."
### 3.7 PII scrubbing
@@ -141,6 +160,8 @@ Log::withContext([
`(subject_type, subject_id)` en `(causer_type, causer_id)` composite indexes op `activity_log`. Infrastructure-housekeeping; geen functionele wijziging.
**Status (mei 2026, na PR-2):** Bij implementatie bleek dat de Spatie activitylog default-migratie via `nullableMorphs('subject')` en `nullableMorphs('causer')` deze composite indexes al aanmaakt (`subject` op `(subject_type, subject_id)`, `causer` op `(causer_type, causer_id)`). Geen aparte migratie nodig — geverifieerd via `information_schema.STATISTICS`. Acceptance criterium 12 daarmee al voldaan vóór WS-7 begon. Regression-guard: `tests/Feature/Database/ActivityLogIndexesTest.php` faalt wanneer een toekomstige refactor deze indexes verwijdert.
---
## 4. Privacy / GDPR
@@ -192,7 +213,7 @@ WS-7 is compleet wanneer:
9. Email-alerting geconfigureerd; getest met sample issue.
10. Retention-policy (90 dagen) toegepast.
11. Daily postgres-backup-script in place.
12. Activity_log indexes (addendum D-06) gemigreerd.
12. ~~Activity_log indexes (addendum D-06) gemigreerd.~~ ✓ — al voldaan door Spatie's `nullableMorphs` default in de originele activitylog migratie; zie §3.14 status-note. Regression-guard: `tests/Feature/Database/ActivityLogIndexesTest.php`.
13. Structured logging conventie geïmplementeerd; `X-Request-Id` round-trip getest.
14. SECURITY_AUDIT.md bijgewerkt.

View File

@@ -70,11 +70,18 @@ Three terminal tabs, plus an optional fourth for the queue worker:
| Terminal | Command | Where it runs | Port |
|----------|---------|---------------|------|
| 1. Services | `make services` (from repo root) | Docker | 3306 (MySQL), 6379 (Redis), 8025 (Mailpit) |
| 1. Services | `make services` (from repo root) | Docker | 3306 (MySQL), 6379 (Redis), 8025 (Mailpit), 8200 (GlitchTip) |
| 2. API | `make api` (from repo root) | Laravel dev server | 8000 |
| 3. SPA | `make app` (from repo root) | Vite dev server | 5174 |
| 4. Queue worker (optional) | `cd api && php artisan queue:listen redis --queue=emails` | Local PHP | n/a |
Web UIs available once `make services` is up:
| Service | URL |
|---------|-----|
| Mailpit | <http://localhost:8025> |
| GlitchTip | <http://localhost:8200> (admin UI; first boot ~60s while migrations run) |
The queue worker is only needed when you're triggering email flows (registration, password reset, email change, invitations). Routine UI work doesn't require it.
Stop services when done: `make services-stop`.
@@ -116,6 +123,13 @@ VITE_APP_NAME="Crewli"
For production: `VITE_API_URL=https://api.crewli.app`.
### `docker/glitchtip/.env`
Generated by copying `docker/glitchtip/.env.example`. Dev defaults are
functional out of the box — no edits needed for `make services`. See
[`GLITCHTIP.md`](./GLITCHTIP.md) for first-boot steps (creating the
superuser, creating the two projects, copying DSNs to 1Password).
## Common tasks
### Run tests

View File

@@ -0,0 +1,85 @@
# GlitchTip — self-hosted error tracking (Sentry-protocol compatible).
#
# This file is portable: it runs standalone on the production monitoring
# host AND merges into the local Crewli dev stack via:
#
# docker compose -f docker-compose.yml -f docker-compose.glitchtip.yml up -d
#
# `make services` encapsulates the merge for local development.
#
# All configuration comes from docker/glitchtip/.env via env_file. Copy
# docker/glitchtip/.env.example to docker/glitchtip/.env on first run.
#
# Per RFC-WS-7-OBSERVABILITY §3.1. See dev-docs/GLITCHTIP.md for the
# operations runbook (boot, project provisioning, DSN handling, backup,
# restore).
services:
glitchtip-postgres:
image: postgres:16-alpine
container_name: glitchtip-postgres
env_file:
- ./docker/glitchtip/.env
volumes:
- glitchtip_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d glitchtip"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
glitchtip-redis:
image: valkey/valkey:7-alpine
container_name: glitchtip-redis
volumes:
- glitchtip_redis_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
glitchtip-web:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-web
depends_on:
glitchtip-postgres:
condition: service_healthy
glitchtip-redis:
condition: service_healthy
env_file:
- ./docker/glitchtip/.env
command: ["sh", "-c", "./bin/run-migrate.sh && ./bin/run-web.sh"]
ports:
- "127.0.0.1:8200:8000"
volumes:
- glitchtip_uploads:/code/uploads
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/0/', timeout=4).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
restart: unless-stopped
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
command: ./bin/run-celery-with-beat.sh
depends_on:
glitchtip-postgres:
condition: service_healthy
glitchtip-redis:
condition: service_healthy
env_file:
- ./docker/glitchtip/.env
volumes:
- glitchtip_uploads:/code/uploads
restart: unless-stopped
volumes:
glitchtip_postgres_data:
glitchtip_redis_data:
glitchtip_uploads:

View File

@@ -0,0 +1,62 @@
# GlitchTip — environment configuration
#
# Local development: cp .env.example .env (the dev defaults below are
# functional out of the box — no edits required for `make services`).
#
# ⚠️ PRODUCTION CHECKLIST (deploying to monitoring.hausdesign.nl):
# - Regenerate SECRET_KEY with:
# python -c "import secrets; print(secrets.token_urlsafe(50))"
# - Set POSTGRES_PASSWORD to a strong random value from the 1Password
# vault. The password embedded in DATABASE_URL MUST match.
# - Set EMAIL_URL to the real SMTP relay (smtp+tls://USER:PASS@HOST:PORT).
# - Set GLITCHTIP_DOMAIN=https://monitoring.hausdesign.nl (HTTPS, no
# trailing slash).
# - Set DEFAULT_FROM_EMAIL to a real sender on the hausdesign.nl domain.
# - Never commit the production .env. Keep it on the host only.
# === GlitchTip core ===
# Generate a real value for production: see header for the python one-liner.
SECRET_KEY=dev-only-not-for-production-use
# Postgres connection. Password MUST match POSTGRES_PASSWORD below.
DATABASE_URL=postgres://postgres:devsecret@glitchtip-postgres:5432/glitchtip
# Valkey/Redis connection (Celery broker + result backend).
REDIS_URL=redis://glitchtip-redis:6379/0
# Internal listen port for the GlitchTip web container.
PORT=8000
# Public-facing URL of the GlitchTip web UI.
# Dev: http://localhost:8200
# Prod: https://monitoring.hausdesign.nl
GLITCHTIP_DOMAIN=http://localhost:8200
# Default sender address for outbound mail (alerts, password resets, …).
# Dev: glitchtip@localhost.dev
# Prod: glitchtip@hausdesign.nl
DEFAULT_FROM_EMAIL=glitchtip@localhost.dev
# Outbound SMTP relay.
# Dev: smtp://bm_mailpit:1025 (alerts visible at http://localhost:8025)
# Prod: smtp+tls://USER:PASSWORD@HOST:PORT
EMAIL_URL=smtp://bm_mailpit:1025
# === Registration (locked down — same in dev and prod) ===
# Bert is the only user; first admin is created via:
# docker exec -it glitchtip-web ./manage.py createsuperuser
ENABLE_USER_REGISTRATION=False
ENABLE_OPEN_USER_REGISTRATION=False
# === Worker tuning ===
# Celery autoscale: min,max workers. 1,3 is the production default.
CELERY_WORKER_AUTOSCALE=1,3
# Recycle each worker after N tasks to bound memory growth.
CELERY_WORKER_MAX_TASKS_PER_CHILD=10000
# === Postgres (consumed only by the glitchtip-postgres service) ===
POSTGRES_USER=postgres
# MUST match the password embedded in DATABASE_URL above.
POSTGRES_PASSWORD=devsecret
POSTGRES_DB=glitchtip

55
scripts/glitchtip-backup.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
#
# glitchtip-backup.sh — daily postgres dump for the GlitchTip database.
#
# Usage:
# bash scripts/glitchtip-backup.sh
#
# Cron example (production host):
# # /etc/cron.d/glitchtip-backup
# 0 3 * * * crewli /opt/crewli/scripts/glitchtip-backup.sh >> /var/log/glitchtip-backup.log 2>&1
#
# Configurable via env vars (defaults shown):
# GLITCHTIP_BACKUP_DIR=./backups/glitchtip
# GLITCHTIP_BACKUP_RETENTION_DAYS=30
# GLITCHTIP_DB_CONTAINER=glitchtip-postgres
# GLITCHTIP_DB_USER=postgres
# GLITCHTIP_DB_NAME=glitchtip
#
# Restore (full):
# gunzip < <dump>.gz \
# | docker exec -i glitchtip-postgres pg_restore -U postgres -d glitchtip --clean --if-exists
#
# Per RFC-WS-7-OBSERVABILITY §5 (daily postgres-backup) and acceptance
# criterion 11. See dev-docs/GLITCHTIP.md for the full restore drill.
set -euo pipefail
BACKUP_DIR="${GLITCHTIP_BACKUP_DIR:-./backups/glitchtip}"
RETENTION_DAYS="${GLITCHTIP_BACKUP_RETENTION_DAYS:-30}"
DB_CONTAINER="${GLITCHTIP_DB_CONTAINER:-glitchtip-postgres}"
DB_USER="${GLITCHTIP_DB_USER:-postgres}"
DB_NAME="${GLITCHTIP_DB_NAME:-glitchtip}"
mkdir -p "$BACKUP_DIR"
timestamp="$(date +%Y%m%d-%H%M%S)"
output="$BACKUP_DIR/glitchtip-${timestamp}.dump.gz"
echo "[$(date -Iseconds)] Dumping ${DB_NAME} from ${DB_CONTAINER} to ${output}"
# Stream pg_dump (custom format) directly through gzip — no intermediate file.
# `set -o pipefail` already in effect so a pg_dump failure aborts before retention.
docker exec -i "$DB_CONTAINER" \
pg_dump -U "$DB_USER" -d "$DB_NAME" --format=custom --no-owner --no-privileges \
| gzip -c > "$output"
chmod 0600 "$output"
size="$(wc -c < "$output" | tr -d ' ')"
echo "[$(date -Iseconds)] Wrote ${output} (${size} bytes)"
echo "[$(date -Iseconds)] Pruning dumps older than ${RETENTION_DAYS} days"
find "$BACKUP_DIR" -type f -name 'glitchtip-*.dump.gz' -mtime "+${RETENTION_DAYS}" -print -delete
echo "[$(date -Iseconds)] Done."