Skip to main content

Observability & Ops

What this covers / who it's for. The operational surfaces of the running system: Horizon and its queue topology, the complete scheduled-work table, failed-job handling, the retention/prune regime, log correlation, and the audit-chain duties. For which async platform to build on, see Async platforms; for the product-facing retention commitments, see Data lifecycle guarantees.

Horizon & the queue topology

Horizon supervises the Redis (Valkey) queues. The dashboard lives at /horizon (HORIZON_PATH), registered by the backoffice module's Modules\Backoffice\Providers\HorizonServiceProvider; its viewHorizon gate ships with an empty email allow-list, so in non-local environments nobody can view it until operators are added (local is open via Horizon's default). Supervisors, from config/horizon.php:

SupervisorQueuetriestimeoutWhy it exists
supervisor-1default160sEverything not listed below
supervisor-webhookswebhooks5120sInbound webhook processing — spatie 200-acks before ProcessInboundWebhookJob runs, so transient failures must be retried, not lost (D58)
supervisor-documentsdocuments5120sForwardDocumentToProviderJob — store-before-forward to the rail; retries before the failed() alert (D43/D58)
supervisor-auditaudit5120sPersistAuditEventJob — the async success audit path; a dropped append leaves no chain gap, so retry first (D58)
supervisor-dlpdlp5120sPersistDataAccessLogJob — classified-read access logging (D44/D57)
supervisor-taskstasks1300sTask-runner fleet sweeps; the deliberately low worker count is the DB-load throttle for cross-tenant batches; failures are contained in the task ledger, not queue-retried (D93)

The non-consumed-queue trap. A Redis queue with no consumer doesn't error — jobs just silently accumulate and the function it serves stalls: no success audit rows, no access logs, no document forwards. Every specialized supervisor must appear in every Horizon environment block, and locally the docker worker service runs plain queue:work on default only — the specialized queues are consumed locally only under make artisan ARGS=horizon. LongWaitDetected thresholds (horizon.waits) are 60s per queue, except tasks at 900s — sweep waits are expected; only a genuinely stuck sweep fires.

Scheduled work — the complete table

Every entry, registered in the owning module's ServiceProvider boot() and driven by the scheduler container (schedule:work). All are onOneServer()/withoutOverlapping() where the comment trail demands it; gates are config keys checked at registration time.

CommandCadenceWhat it doesModule
compliance:scan-sla-breachesevery minuteEscalates every SLA-breached, non-closed compliance case (gated compliance.schedule_sla_scan, default on)compliance
treasury:warm-ratesevery 15 secondsPre-warms the FX rate cache ahead of its 15s TTL (freshness is TTL-bound; this keeps the read path warm)treasury
dlp:evaluate-rulesevery 15 minutesEvaluates DLP tripwire rules over classified-access logs, opening incidentsdlp
dlp:evaluate-egress-rulesevery 15 minutesEvaluates egress rules over download/export eventsdlp
mirrors:reconcile-stalehourlyRe-fetches provider mirrors that have gone staleprovider-mirror
audit:verify-chain --checkpointhourlyRecomputes every audit partition's hash chain, detects tamper, anchors a checkpoint (gated audit.schedule_verification, default on)audit
documents:redrive-forward-failedhourlyRedrives documents stranded by transient forward failures (D2/D59)documents
features:roll-periodshourlyRolls usage-metering periods for entitlement quotasfeatures
tasks:detect-stuckhourlyFlags task-run units stuck in running past stuck_after_minutestask-runner
documents:verify-integritydailyIntegrity sweep of the encrypted document vaultdocuments
documents:prune-accessesdailyClass-aware access-trail retention prune (D78) — safe-by-default: prunes nothing until a signed-off window is configureddocuments
foundation:prune-command-dedup-recordsdailyPrunes expired bus idempotency/dedup recordsfoundation
notifications:prune-logdailyClass-aware notification-log retention prune (D77)notification
rails:prune-vendor-logsdailyPrunes scrubbed vendor request/response logsrails
model:prune (InboundWebhookCall)dailyPrunes stored inbound webhook call rowswebhooks
mirrors:prune-driftdailyPrunes resolved mirror-drift rowsprovider-mirror
audit:export-wormdailyExports closed audit chain segments to the WORM object-storage disk (gated audit.worm_export.enabled, default off)audit
audit:verify-wormdailyReconciles the WORM archive against the DB chain — coverage gaps, HMACs, closing hashes (same gate, default off)audit
async-requests:prunedaily 03:30Prunes terminal async-request rows past their retention windowasync-requests
tasks:prunedaily 03:45Prunes old task_runs/task_run_units ledger rowstask-runner
queue:prune-batches --hours=48 --unfinished=168 --cancelled=168daily 04:00Prunes Laravel job_batches rows underneath the task ledgertask-runner
payments:generate-monthly-statementsmonthly, 1st at 02:00Generates the previous month's tenant statementspayments

Notably not scheduled: documents:shred-document (RTBF crypto-shred) is manual, per-approved-request only.

Failed work — where to look, how to recover

SurfaceTooling
Failed queue jobsmake queue-failed (list), make queue-retry (retry all), make queue-flush (discard); Horizon retains failed jobs 7 days (horizon.trim)
Task-runner runs/unitstasks:status (inspect), tasks:retry (re-dispatch failed units), tasks:cancel; tasks:detect-stuck runs hourly; backoffice read surface at GET /api/v1/backoffice/task-runs
Stranded document forwardsdocuments:redrive-forward-failed (also scheduled hourly); exhausted retries raise a visible alert via the job's failed() handler
Inbound webhooksThe webhooks queue retries (tries 5); the stored InboundWebhookCall row is the replay source

Retention & pruning — the regime at a glance

The prune commands in the schedule table above are the whole regime: async-requests, task ledger + job batches, notification log (D77), document access trail (D78, inert until a window is signed off), vendor logs, webhook calls, mirror drift, bus dedup records. Two deliberate exceptions: the audit chain is append-only and never pruned (growth is handled by the config-gated WORM offload), and documents are never auto-deleted (RTBF is the manual crypto-shred). The product-facing view of these commitments — what users and regulators are promised — is Data lifecycle guarantees.

Log correlation (D74)

Modules\Foundation\Http\Middleware\AssignCorrelationId establishes three ids per request and shares them via Laravel Context, so they dehydrate into queued jobs/listeners and appear on every log line (via CorrelationProcessor; ShareActorContext adds the actor's public uuid + tenant):

  • request_id — always server-minted (UUIDv7), unique to this hop; inbound values are never trusted.
  • correlation_id — stable across the flow: adopted from W3C traceparent, else a sanitized X-Correlation-Id, else seeded from request_id.
  • trace_id — set when a valid traceparent is presented.

Both ids are echoed as response headers and surface in the JSON:API meta block, so a support report can be joined to logs, the CQRS audit trail, and traces on one id.

Audit-chain duties

Ops owns the tamper-evidence loop: the hourly audit:verify-chain --checkpoint recomputes and anchors the chain (a failure is a tamper signal, not noise), and make audit-verify-chain runs it on demand. The WORM export/verify pair (make audit-export-worm / make audit-verify-worm) is config-gated off today — production enables it deliberately against an Object-Lock bucket. Until then there is no off-database copy of the chain; enabling it is a deploy decision, not a code change. Deep dive: the audit-trail skill and app-modules/audit/README.md.


← Engineering wiki index