Skip to content

feat: Discord bot, audit log, SLO burn-rate alerts, path tracing, support inbox, status-page branding & private pages - #246

Merged
fclairamb merged 479 commits into
mainfrom
batch/2026-08-21
Aug 25, 2026
Merged

fclairamb merged 479 commits into
mainfrom
batch/2026-08-21

Conversation

@fclairamb

@fclairamb fclairamb commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

A large batch: 480 commits, 104 features and 56 fixes, no breaking changes.

Discord bot — a real bot rather than a webhook. Incidents arrive as bot-owned threads that edit in place and carry acknowledge/comment buttons; org members map to Discord identities so on-call mentions reach the right person; thread comments are ingested back onto the incident through a Gateway supervisor. Every inbound interaction is Ed25519-verified, rejecting stale and unsigned requests.

Audit log — every session-minting path, membership change, token operation, config apply and service mutation emits a typed event with actor metadata and a normalized source address. Redaction fails closed on anything it doesn't recognize, failed logins are flood-controlled, retention is swept. Ships with an admin-gated audit page and filters (family, type, actor, target, admin-only IP) with working cursors.

SLO burn-rate alerting — alert policies over an SLO, evaluated multi-window. Burn incidents are a distinct incident kind with their own lifecycle, templates and sweep job.

Path tracing — a pure-Go MTR-style prober with a privilege ladder traces the route when a network failure opens an incident, so you see where packets actually stopped. Pinned never-public.

Support inbox — inbound human messages captured from email, WhatsApp, Telegram, SMS, Slack DMs and Discord DMs into threads, with idempotent capture, abuse ceilings and a mailbox mirror. The JMAP consumer is now safe to run more than once (Message-ID dedup, archive-to-claim, Postgres advisory lock).

Status-page branding & private pages — per-page logo/favicon behind a white-label entitlement, password protection via an unlock cookie, webhook and Slack subscriptions.

Incident screenshots — browser checks can capture a screenshot on failure, on a new generic attachment rail with agent upload, orphan reaping, and attachments kept out of public payloads.

Also: incident acknowledgement actor surfaced everywhere, flapping made visible, check groups handled as groups, custom-domain lifecycle with grace/re-promotion and DNS diagnostics, self-monitoring watchdog, region migration API with ghost-region detection, forced password rotation, UptimeRobot importer, self-healing stale SSO provider links, and a broad pass making result/chart queries seek instead of scan.

Full detail in the ## [Unreleased] section of CHANGELOG.md.

Test plan

  • make build-backend lint-back test — green, 0 lint issues
  • make build-dash0 — green
  • bun run test:unit — 837/837
  • bunx tsc --noEmit — clean
  • dash0 eslint — no new errors vs. the pre-existing baseline
  • Postgres-backed provider-link healing suite verified running (not skipped) against real Postgres

Known pre-existing flake, unrelated to this batch and tracked separately: web/dash0/src/api/use-chart-window.test.tsx can time out under full-suite parallel load (passes in isolation; its waitFor overshoots the 1s default).

🤖 Generated with Claude Code

fclairamb and others added 30 commits August 23, 2026 08:28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The public page view sent no Cache-Control at all while the summary and
badge sent an unconditional public, max-age=60 — so the expensive endpoint
was uncacheable and the cheap ones authorized shared caches to retain a
password-protected page's name, counts and rollup status.

A single helper (internal/statuspagecache) now answers who may cache a
status-page response: public pages keep public, max-age=60; password and
private pages get private, no-store, unlocked or not. The page, summary,
badge, Atom feed and the custom-domain SPA shell all route through it, and
the public 404/401 answers are gated too so they cannot be cached into an
existence oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Walks the page, summary and badge for all three visibilities and asserts the
exact directive, with the public page as the positive control. The assertion
most likely to be missed gets its own test: a request carrying a valid unlock
cookie gets a real 200 and still private, no-store. Vary is pinned exactly so
a new request-derived field cannot ship without extending it, and the private
page's 404 is compared byte-for-byte with a missing page's.

Also covers the feed and the custom-domain SPA shell, including a DB-backed
test that the resolver actually carries the page's visibility — the cache
fixtures alone could not have caught a resolver that dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Control

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g public pages on Cookie

Three audit findings.

The public incident history was the sixth surface and the one that still sent
no Cache-Control at all — a header-less 200 is heuristically storable, and the
route is on the custom-host allowlist, so the cache in question is the CDN in
front of a customer's status domain. The body quotes incident titles and update
bodies verbatim: exactly what made the Atom feed a leak. The service now
surfaces the page's visibility the way the summary does, and the handler
applies the shared helper, gating its 401/404 too.

Vary is now split. Public responses carry only X-Forwarded-Proto, which
genuinely changes the body; Cookie moves to the gated branch. The old
everywhere-Cookie rationale was wrong — Vary does not invalidate anything when
a page's visibility flips, so it bought nothing, while Cloudflare, Fastly and
Varnish all decline to cache a response carrying it, which would have left this
spec's whole point unbuilt. The comment now argues something true.

The path-based status0 shell now varies on X-Forwarded-Proto like its
custom-domain twin, and carries a comment explaining why it is safe to serve
unconditionally cacheable: it resolves page metadata without the unlock grant,
so a gated page's name never reaches it. That claim is now pinned by a
password-page case in the no-existence-leak test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Control

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fclairamb and others added 22 commits August 24, 2026 18:21
The consolidated "RabbitMQ — 2/6 down" view that group incidents used to
bake into the row is rebuilt where it belongs — at display time (spec
2026-08-24-14).

New `lib/incident-grouping.ts`: a pure `groupIncidentsByCheckGroup(incidents,
checks, groups)` returning ordered rows. Ungrouped incidents keep their
original position as bare rows; a group takes the position of its first
member. Nothing is re-sorted — the server chose the order.

Rendered as a PLAIN, non-interactive header row in the incidents list and in
the dashboard's Active-incidents card, per the spec's resolved question: no
expand/collapse, because there is no per-group open/closed state worth
persisting across filters, sorts and pagination. Catalogued in the design
reference alongside the collapsible checks-index header it deliberately
differs from.

Grouped client-side, so `GET /incidents` stays a flat list with no groupBy
verb, no second response shape and no group-count field. Two consequences
are handled rather than papered over: the count reflects the incidents
LOADED, so `groupHeaderCounts` drops the denominator instead of asserting a
fleet-wide total it cannot see; and past the checks endpoint's 100-row clamp
an incident whose check did not load renders bare rather than under a header
with a wrong count.

i18n keys in all four locales. Unit tests cover bucketing, position
preservation, active-only counting, both unknown-mapping fallbacks, and the
denominator-dropping rule.

Docs: `wiki/database-model/results-incidents.md` and
`wiki/features/notifications-and-escalation.md` now describe group incidents
as historical, with the three defects that retired them. The published docs
(`features/incidents.md`, `features/check-groups.md`, `intro.md`) described
a live correlation mechanism to users — rewritten to what actually happens
now, with a v0.18.0 changed-behavior note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fully route-mocked, so it asserts the rendering rule rather than whatever
the seeded fixture happens to hold: one plain "RabbitMQ — 2/6 down" header
with no expand/collapse affordance, both members rendering as their OWN
incident rows beneath it, and the ungrouped incident staying bare.

The fixture interleaves the ungrouped incident BETWEEN the two group
members, so a grouping pass that reordered the list instead of pulling the
second member up to its group's position would fail the row-order assertion.

Ran green against a side-car test server, alongside the existing incidents,
incident-timestamps, dashboard and check-incidents-slug suites (34 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FindActiveIncidentByGroupUID and FindRecentlyResolvedIncidentByGroupUID
existed for the group create/reopen path, which is gone (spec
2026-08-24-14). Nothing reads them any more, and leaving them is not
neutral: they are precisely the lookups a future caller would reach for to
"reuse the group incident", reintroducing the binding of one check's failure
to another check's incident that this spec removed.

The member-check operations stay — the readers serve historical rows and
the migration test writes them — but the interface now says out loud that
they are historical and gain no new production callers.

Also retires two comments naming queueGroupNotifications, a function that no
longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d defeat dependency rollup — replace them with per-check incidents aggregated at read time

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… defeat dependency rollup — replace them with per-check incidents aggregated at read time
…ere the parent confirms after its dependents — re-evaluate children when a parent incident opens
Cascade rollup only ever looked backward, at child-incident open, so the
ordering that actually dominates in production — a core service's own probe
confirming AFTER the consumers that depend on it — left every dependent
paging for its whole lifetime.

An incident open or reopen now also walks hard descendants downward (same BFS
shape, same depth cap, same per-child correlation window, same query as the
backward path) and attaches every active, non-suppressed child incident whose
window still contains the parent's onset. The attachment is a compare-and-set
guarded on paging_suppressed = FALSE, so the forward and backward evaluations
converge on one attachment and one event even when they race from two workers.

Escalation steps already queued for a retroactively suppressed child now
stop firing: incidentNeedsPaging re-checks paging_suppressed at fire time,
closing a gap the original dependencies spec had already called for.

Rollup success is logged at INFO instead of DEBUG, and a retroactive
attachment emits incident.rolled_up on the child's timeline.
Eight cases driven through the real state machine on in-memory SQLite with an
injected clock, so every correlation-window assertion is deterministic:
child-before-parent (the headline — it fails against the backward-only
implementation), parent-before-child, same-instant race, a depth-2 chain whose
intermediate node never fails, soft edges ignored, a child outside its own
window left alone, and both branches of the parent-resolve re-evaluation.

Plus the escalation fire-time gate, with a positive control that an ordinary
open incident still pages.
…e where the parent confirms after its dependents — re-evaluate children when a parent incident opens
… where the parent confirms after its dependents — re-evaluate children when a parent incident opens
…no rows in result set" when the guild's linked organization no longer resolves
…ogin

A provider link outliving the soft-deleted org or user it points at used to
brick every subsequent SSO login for that workspace/guild permanently. The
shared resolveLinkedOrganization/resolveLinkedUser helpers now clear the stale
link (WARN-logged) and let the caller fall through to its normal create path.

Wired into discord, slack, github, gitlab, google, microsoft, oidc, saml and
ldap, all of which also gained the missing non-ErrNoRows guard on the provider
lookup. DeleteOrg releases the org's provider links so the class cannot recur,
and unexpected callback failures now redirect with a generic description while
the real error goes to the server log.
…ct_uri

A failed provider callback lands on ?error=…&error_description=…; the 401
bounce captures that URL as returnTo and it was handed straight back as
redirect_uri, so every retry nested the previous failure one URL-encoding
deeper.
Reproduces the production Discord failure end-to-end through the real
HandleCallback (guild path, personal-org discord-user-<id> path, user link),
the Slack twin, a live-link positive control, the per-connector sweep, the
generic error_description, and DeleteOrg releasing its provider links.
… guild's linked organization no longer resolves

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit page kept its pagination cursor in component state, so a shared
link reproduced the filters but always landed back on page one. Move the
cursor into the URL search params (reset on any filter change, so a stale
cursor can't page into a result set that no longer exists) and drop the
now-redundant refresh button in favour of a DocsLink.

Also add DocsLink to the check detail and report-schedules pages, and move
the send-test-digest action out of the report-schedules list row into the
edit form, where it can act on the saved schedule.

E2E coverage extended for all three surfaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g, SLO burn alerts, traceroute diagnostics and support inbox

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fclairamb fclairamb changed the title feat: audit log, SLO burn-rate alerts, traceroute diagnostics, support inbox, status-page branding & private pages, Discord bot, UptimeRobot importer feat: Discord bot, audit log, SLO burn-rate alerts, path tracing, support inbox, status-page branding & private pages Aug 24, 2026
@fclairamb
fclairamb enabled auto-merge (squash) August 24, 2026 23:30
@fclairamb
fclairamb merged commit 87b33d7 into main Aug 25, 2026
11 checks passed
@fclairamb
fclairamb deleted the batch/2026-08-21 branch August 25, 2026 00:00
fclairamb added a commit that referenced this pull request Aug 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.18.0](v0.17.0...v0.18.0)
(2026-08-25)


### Features

* Discord bot, audit log, SLO burn-rate alerts, path tracing, support
inbox, status-page branding & private pages
([#246](#246))
([87b33d7](87b33d7))


### Bug Fixes

* **slack:** register the login callback in the prod manifest
([#251](#251))
([32fae86](32fae86))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant