Compare commits
15 Commits
25888a232b
...
adab3be781
| Author | SHA1 | Date | |
|---|---|---|---|
| adab3be781 | |||
| 0379016c7e | |||
| eb8202584c | |||
| 49cece3784 | |||
| 9414d09472 | |||
| 42994522eb | |||
| 5980c36ae4 | |||
| 48f2a00564 | |||
| 4a8bb97764 | |||
| b1d5bcda76 | |||
| bdb89a2479 | |||
| d4b785a2c9 | |||
| 932788c643 | |||
| 5f6fc075ed | |||
| fc5a2a9156 |
@@ -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
4
.gitignore
vendored
@@ -60,3 +60,7 @@ docs/.vitepress/cache
|
||||
|
||||
# Claude Code runtime state
|
||||
.claude/*.lock
|
||||
|
||||
# GlitchTip
|
||||
docker/glitchtip/.env
|
||||
backups/
|
||||
|
||||
27
Makefile
27
Makefile
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
54
api/app/Enums/Observability/ActorType.php
Normal file
54
api/app/Enums/Observability/ActorType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
80
api/app/Http/Middleware/BindRequestLogContext.php
Normal file
80
api/app/Http/Middleware/BindRequestLogContext.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
api/app/Http/Middleware/BindSentryRouteContext.php
Normal file
41
api/app/Http/Middleware/BindSentryRouteContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
157
api/app/Listeners/Observability/AuthScopeContextListener.php
Normal file
157
api/app/Listeners/Observability/AuthScopeContextListener.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
29
api/app/Listeners/Observability/TagJobAttemptOnSentry.php
Normal file
29
api/app/Listeners/Observability/TagJobAttemptOnSentry.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
137
api/app/Services/Observability/SentryEventScrubber.php
Normal file
137
api/app/Services/Observability/SentryEventScrubber.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
483
api/composer.lock
generated
@@ -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
159
api/config/sentry.php
Normal 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),
|
||||
],
|
||||
|
||||
];
|
||||
92
api/tests/Feature/Database/ActivityLogIndexesTest.php
Normal file
92
api/tests/Feature/Database/ActivityLogIndexesTest.php
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
api/tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php
Normal file
145
api/tests/Feature/Observability/AuthScopeBindingHttpFlowTest.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
317
api/tests/Feature/Observability/AuthScopeContextListenerTest.php
Normal file
317
api/tests/Feature/Observability/AuthScopeContextListenerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
155
api/tests/Feature/Observability/ExceptionReportingTest.php
Normal file
155
api/tests/Feature/Observability/ExceptionReportingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
238
api/tests/Feature/Observability/PiiScrubbingTest.php
Normal file
238
api/tests/Feature/Observability/PiiScrubbingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
161
api/tests/Feature/Observability/RequestIdRoundTripTest.php
Normal file
161
api/tests/Feature/Observability/RequestIdRoundTripTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
283
dev-docs/GLITCHTIP.md
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
85
docker-compose.glitchtip.yml
Normal file
85
docker-compose.glitchtip.yml
Normal 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:
|
||||
62
docker/glitchtip/.env.example
Normal file
62
docker/glitchtip/.env.example
Normal 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
55
scripts/glitchtip-backup.sh
Executable 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."
|
||||
Reference in New Issue
Block a user