Client installs send a small, privacy-scrubbed digest of their recent server errors to the mothership with the routine license check-in, so a release that starts throwing fleet-wide is visible on the Fleet page within hours — without SSHing into every site. This complements the §D.4 update health probe (which reports update outcomes); error telemetry covers runtime errors between updates.
What is sent
With each membership check-in (every 6 hours, riding the existing
MembershipClient::check() POST — no new endpoint, cron, or queue worker),
the install attaches an errors block:
- Up to 20 deduplicated error signatures — exception class, file and line (relative to the install root), log level, a truncated + scrubbed message, occurrence count, first/last seen timestamps.
- The install's PHP version (the CMS version already rides the check-in).
Never sent: stack traces, request context, visitor data, IPs, URLs with query strings, or raw log lines. The collector reads only each log entry's header line and scrubs messages of emails, hex/base64-looking tokens, URL query strings, and absolute paths before anything leaves the box. Only ERROR-and-above entries qualify — warnings are noise at fleet scale.
Owner control
Memberships → Settings → Membership client → "Share error reports" —
default on, with a plain-language disclosure. Stored as the
telemetry.share_errors Setting. When off, the errors block is omitted
entirely; nothing is collected or sent.
How the client collects (app/Support/ErrorTelemetry.php)
- Scans
storage/logs/laravel*.logincrementally: per-file byte offsets persisted instorage/app/private/error-telemetry.json(updater-protected), rotation-safe (a shrunken file rescans from the top), and byte-capped (2 MB tail per file per scan) so a huge backlog can never stall the check. - Folds occurrences into signatures —
sha1(class|file|line), falling back to a digit-normalized message hash when there's no exception context — keeping the newest 100 locally with 30-day retention. - Strictly best-effort: any collector failure collapses to "no digest"; it never logs errors itself (that would feed the collector) and never blocks or fails the license check. Updater failures need no special hooks — they log at ERROR and are swept up like any other exception.
- In the
testingenvironment the collector defaults to isolatedstorage/framework/testing/paths (same posture as the page-data sidecars) so test runs can neither scan nor seed live install state.
How the mothership ingests (MembershipCheckController::recordErrorReports())
- Recognized members only — an anonymous writer would be a spam lever (same posture as the health-report endpoint). Keyless/unknown-key check-ins are answered normally but store nothing.
- Upserts into
install_error_reportskeyed by(install_id, signature), every field length-clamped server-side, 20 entries max per report, future-dated timestamps clamped to now. - Bounded per install: newest 100 signatures kept, 30-day retention pruned on each ingest — a hostile key cannot grow the table.
- Best-effort: a malformed block never fails the license check.
Fleet page (license server only)
Dashboard → Fleet gains:
- An "Erroring (7d)" stat tile — installs with any error signature seen in the last 7 days.
- An "Errors (7d)" column — red occurrence-count badge per install; click it for the drill-down modal listing that install's signatures (class, file:line, message, counts, CMS/PHP version, last seen).
Key files
| File | Role |
|---|---|
app/Support/ErrorTelemetry.php |
client-side collector + scrubber |
app/Support/Licensing/MembershipClient.php |
attaches the errors block to the check-in |
app/Features/Memberships/Http/Controllers/MembershipCheckController.php |
mothership ingest + per-install bounds |
app/Features/Memberships/Models/InstallErrorReport.php |
one row per (install, signature) |
app/Features/Memberships/resources/views/dashboard/fleet/⚡index.blade.php |
Fleet page stat, column, drill-down modal |
app/Features/Memberships/resources/views/dashboard/⚡settings.blade.php |
the share-errors toggle |