Skip to content

feat(watch): post-cutover monitoring that flags broken redirects and hands back the fix - #33

Merged
itsdylon merged 3 commits into
mainfrom
feat/post-cutover-watch
Aug 20, 2026
Merged

feat(watch): post-cutover monitoring that flags broken redirects and hands back the fix#33
itsdylon merged 3 commits into
mainfrom
feat/post-cutover-watch

Conversation

@itsdylon

Copy link
Copy Markdown
Owner

Closes claims #2 (post-cutover traffic and 404 monitoring with email alerts) and #3 (auto flag and fix).

Exporting a redirect file is a prediction. It gets deployed by a human into a stack Redirx never sees, and every common failure is invisible from our side at export time: the rules never shipped, a CDN rule shadowed them, a trailing-slash normaliser turned one hop into three, someone shipped 302. A watch asks the live site what it actually does with each approved old URL, ranks the failures by the traffic they cost, and hands back a corrective file.

The 026 traffic baseline was built for this — its own migration note says "monitoring and recovery reporting depend on it existing."

A real bug the unit tests could not see

Probing real sites during development caught this, and it would have made the feature useless:

http://github.com and http://google.com were both reported as redirect loops.

Loop detection reused normalize_for_compare, which deliberately ignores scheme and www. so an HSTS hop is not flagged as a wrong target on every URL of a site. Turned on loop detection, that same rule says http://x and https://x are the same URL — so every site doing http→https or bare→www, i.e. most of the web, came back broken.

The fix separates the two questions. normalize_for_compare answers "same page?"; visit_identity answers "same request?" and keeps scheme, host and trailing slash. A genuine /a/a//a oscillation is still caught. Both directions are regression-tested, at the unit level and against a live server.

Probing

  • Redirects followed one hop at a time rather than via allow_redirects — the diagnosis lives in the shape of the chain: hop count, permanent vs temporary, and where it actually landed.
  • Every hop re-validated against the SSRF rules. Following a redirect means letting another server choose our next URL, and Location: http://169.254.169.254/ is a thing a server is allowed to say.
  • HEAD first, retried as GET on the statuses that mean "this server dislikes HEAD" rather than "this URL is broken".
  • Own rate-limiter namespace, paced slower than discovery. Unlike a crawl this recurs forever: a rate a site tolerates once is one it will be asked to tolerate every few hours for months.

Reporting, without becoming noise

Issues are current state keyed by URL, not an event log. A redirect broken for a week is one row and one email, not seven of each. alerted_at gates re-reporting; a different failure at the same URL reopens it, because that is news. Transient types wait for a second consecutive sighting — one failed request is a blip.

Closing an issue requires having re-probed that URL. Absence from a sweep's findings is not evidence of repair: the per-sweep cap leaves a large site's tail unchecked, and treating that as fixed would silently close real breakage on exactly the sites that most need monitoring.

The fix half

Every proposed target resolves to something already justified — the approved mapping, or the destination the site itself proved reachable:

flagged proposed
missing / 404 / wrong target the approved mapping
extra hops where the chain already ends, collapsed to one
302 same target, issued as a 301
loop / outage / 5xx nothing

wrong_target proposes the approved mapping, not the URL the probe observed — proposing where it actually landed would ratify the bug. Rows with no actionable target are omitted rather than emitted blank, since a half-filled rule replaces a broken redirect with a differently-broken one.

fix_rows() shapes issues as old_url/new_url so redirect_export renders the correction in all eight existing formats. A fix the customer cannot deploy the way they deployed the original is not a fix.

Where it runs

The worker, not the API. Probing a few thousand URLs takes tens of minutes and the API is a single sync gunicorn worker where that would block every other request. One sweep at a time, running alongside jobs rather than taking a job slot, since a sweep is almost entirely idle time waiting on the rate limiter. A long lease instead of a lease-extension loop: nothing is waiting on the result, so a stale lease costs monitoring latency rather than a user-visible delay.

Agent surface

POST/GET /api/v1/migrations/{id}/watch and GET .../watch/fixes. The zero-touch story from #31 did not actually end at export — an agent that deploys a redirect file had no way to learn whether the deploy worked, and only the live site can answer that. checked is returned separately from the issue count so an agent polling right after starting a watch can distinguish "clean" from "not looked yet".

Tests

96 new backend, 5 frontend. The classifier's are mostly cases that must not be flagged — a monitor that cries wolf gets muted, and then it reports nothing. Hop following, HEAD fallback, canonical hops and loop termination are tested against a real local aiohttp server, with the SSRF guard asserted separately since it is what makes that server unreachable in production.

Backend 436 tests, frontend 285. Pre-existing backend failure count unchanged (3 failures, 15 errors — all the known local SUPABASE_KEY mismatch).

Migration 029 applied.

Not in this PR

  • No plan gating. Pricing V3 (docs: outline the pricing V3 paywall move before building it #29) is still an open outline and Watch is priced there at ~$29/mo; this ships ungated so it can be used and measured first, and the gate is a decision rather than a missing line of code.
  • Content fetching during a sweep still does not consult robots.txt, consistent with Deep Match today.

🤖 Generated with Claude Code

Dylon Shattuck and others added 3 commits August 19, 2026 23:11
Exporting a redirect file is a prediction. It is deployed by a human into a
stack Redirx never sees, and every common failure is invisible from our side
at export time: the rules never shipped, a CDN rule shadowed them, a
trailing-slash normaliser turned one hop into three, someone shipped 302.

A watch closes that loop by asking the live site what it actually does with
each approved old URL, and ranks what it finds by the traffic at stake — the
026 baseline exists for exactly this.

## Probing

Redirects are followed one hop at a time rather than via allow_redirects,
because the diagnosis lives in the shape of the chain: how many hops, whether
they were permanent, and where it actually landed. Every hop is re-validated
against the SSRF rules — following a redirect means letting another server
choose our next URL, and `Location: http://169.254.169.254/` is a thing a
server is allowed to say. HEAD first, retried as GET on the statuses that mean
"this server dislikes HEAD" rather than "this URL is broken".

Sweeps get their own rate-limiter namespace, paced slower than discovery.
Unlike a crawl this recurs forever: a rate a site tolerates once is one it
will be asked to tolerate every few hours for months.

## Two normalisations, deliberately different

Target comparison ignores scheme and `www.`, so an HSTS hop is not reported as
a wrong target on every URL of a site. Loop detection cannot use that: measured
against github.com and google.com, reusing it reported both as redirect loops,
which would have been the verdict on most of the web. `visit_identity` answers
"did we already request this exact URL", and a `/a` -> `/a/` -> `/a`
oscillation is still caught. Both directions are regression-tested.

## Reporting

Issues are current state keyed by URL, not an event log. A redirect broken for
a week is one row and one email, not seven of each; `alerted_at` closes it,
and a *different* failure at the same URL reopens it because that is news.
Transient types wait for a second consecutive sighting before waking anyone —
one failed request is a blip.

Closing an issue requires having actually re-probed the URL. Absence from a
sweep's findings is not evidence of repair: the per-sweep cap leaves a large
site's tail unchecked, and treating that as fixed would silently close real
breakage on exactly the sites that most need monitoring.

## Where it runs

The worker, not the API. Probing a few thousand URLs takes tens of minutes and
the API is a single sync gunicorn worker where that would block every other
request. One sweep at a time, alongside jobs rather than taking a job slot,
since a sweep is almost entirely idle time waiting on the rate limiter. A long
lease instead of a lease-extension loop: nothing is waiting on the result, so
a stale lease costs monitoring latency, not a user-visible delay.

## Tests

90 new. The classifier's are mostly cases that must *not* be flagged — a
monitor that cries wolf gets muted, and then it reports nothing. Hop
following, HEAD fallback and loop termination are tested against a real local
aiohttp server, with the SSRF guard asserted separately since it is what makes
that server unreachable in production.

Backend suite 421 tests; pre-existing failure count unchanged (3 failures,
15 errors, all from the known local SUPABASE_KEY mismatch).

Migration 029 applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The monitoring half says what broke. This closes the loop: turn each flagged
issue into a redirect Redirx would deploy, and hand it back in a file the
customer can ship through the process they already have.

## The fix is never a new guess

Every proposed target resolves to something already justified — the mapping the
user approved, or the destination the site itself proved reachable:

  missing / 404 / wrong target -> the approved mapping
  extra hops                   -> where the chain already ends, collapsed to one
  302                          -> same target, issued as a 301
  loop / outage / 5xx          -> nothing

That last row matters. A loop is not repaired by choosing a better destination,
and emitting a blank target would replace a broken rule with one that breaks
differently. Those rows are omitted from the patch rather than shipped empty.

Notably, `wrong_target` proposes the *approved* mapping, not the URL the probe
observed. Proposing where it actually landed would ratify the bug — that
destination is the symptom.

## Same formatters, on purpose

`fix_rows()` emits old_url/new_url so `redirect_export` renders the correction
in all eight formats unchanged. A fix the customer cannot deploy the way they
deployed the original is not a fix. `paths` stays the default for the same
reason it is elsewhere: an absolute source loads fine and silently redirects
nothing.

## Agent surface

`POST/GET /api/v1/migrations/{id}/watch` and `GET .../watch/fixes`. The
zero-touch claim did not end at export — an agent that deploys a redirect file
had no way to learn whether the deploy worked, and only the live site can
answer that. `checked` is returned separately from the issue count so an agent
polling right after starting a watch can tell "clean" from "not looked yet".

## UI

A watch page leading with clicks at risk, and a prompt on the review page that
links to the existing watch rather than offering a second one — a returning
user must not double the probe traffic aimed at their own origin just by
revisiting.

Backend 436 tests, frontend 285; pre-existing backend failures unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked against production: `old_site_domain` is NULL on all 143 sessions —
the column exists but nothing writes it. A watch that relied on it would never
start, so deriving the host from the mappings is the normal path and the
comment saying otherwise was misleading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@itsdylon
itsdylon merged commit e0dcbb9 into main Aug 20, 2026
1 check passed
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