Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions .agents/skills/weekly-404-monitor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Weekly recurring agent that surfaces broken docs.warp.dev URLs by q

# Weekly 404 monitor

Runs every Monday at 9am PT. Identifies new broken URL patterns on docs.warp.dev, surfaces the top uncovered paths, and posts a concise Slack summary so the docs team can prioritize redirect additions.
Runs every Monday at 9am PT. Leads with the overall 404 volume trend, surfaces the uncovered paths that get enough traffic to be worth a redirect, and posts a concise Slack summary so the docs team can prioritize redirect additions without being distracted by long-tail bot/old-link noise.

## Prerequisites

Expand All @@ -26,8 +26,9 @@ Run `python3 .agents/skills/weekly-404-monitor/run_404_report.py` in the docs re
The script:
- Queries `warp-data-357114.prod.stg_website_events` via the Metabase API
- Extracts `broken_url` from `event_properties` for all `event_name = 'docs_404'` events in the past 7 days
- Groups by `broken_url`, sorted by hit count descending
- Returns a ranked list of broken URLs and their hit counts for the current week
- Normalises and groups by path **in SQL** (lowercase, no trailing slash, no scheme/host, no query/fragment) so trailing-slash, case, and host variants of the same page are summed into one row *before* the top-500 cap is applied — a page whose hits are split across variants can't be dropped by the cap and undercounted
- Returns a ranked list of broken pages and their hit counts for the current week, sorted by hit count descending
- Re-applies the same normalisation in Python as an idempotent safety net
- Computes the same for the prior week (days 8–14) for trend comparison
- Total weekly 404 count (current + prior) for the trend line

Expand All @@ -39,7 +40,7 @@ Extract all `source` values from the `redirects` array. Normalise: lowercase, st

### 3. Find uncovered URLs

For each broken URL in the current week's data:
Broken URLs are first aggregated by their normalised form, so `/foo`, `/foo/`, and `/Foo` collapse into one page with summed hits (this prevents the same page being reported as several separate gaps). For each aggregated page:
- Normalise (lowercase, strip trailing slash, strip query params and fragments)
- Check if it exists as a `source` in `vercel.json` redirects
- If not covered, it is a **gap**
Expand All @@ -51,6 +52,10 @@ Compare this week's uncovered gaps against last week's uncovered gaps (from step
**New gaps** = uncovered this week AND not seen as uncovered last week.
**Resolved** = uncovered last week AND now either covered (has redirect) or no longer generating 404s.

**Significant vs long-tail.** Split uncovered URLs by the reporting threshold (`REPORT_MIN_HITS`, default 5; must be a positive integer — invalid or non-positive values fall back to 5):
- **Significant gaps** = uncovered URLs with `hits_this_week >= REPORT_MIN_HITS`. These are worth a redirect and belong in the headline.
- **Long-tail noise** = uncovered URLs below the threshold. Because the monitor is only weeks old (low sample), most broken URLs are hit once by bots, crawlers, or stale bookmarks, so the raw uncovered and "new gap" counts churn heavily week-over-week and overstate the problem. Roll these up into a single count — never list them individually or put them in the headline.

### 5. Post Slack summary

Post a Slack message using the Block Kit format defined in the "Slack message format" section below.
Expand All @@ -59,7 +64,7 @@ If `SLACK_BOT_TOKEN` is unavailable, write the full Slack message body to the ru

### 6. Write CSV artifact

Write `404-report-YYYY-MM-DD.csv` to `data/404-reports/` in the docs repo working directory. Format:
Write `404-report-YYYY-MM-DD.csv` to `data/404-reports/` in the docs repo working directory. Each row is one normalised page (hits summed across trailing-slash/case/host variants). Format:

```
broken_url,hits_this_week,hits_last_week,is_covered_by_redirect,is_new_gap
Expand All @@ -76,23 +81,26 @@ Use Slack Block Kit. The message should be scannable in under 30 seconds.
```
📊 *docs.warp.dev 404 Report* — week of {YYYY-MM-DD}

*Total 404s this week:* {N} ({+N / -N vs last week})
*Uncovered broken URLs:* {M} ({+N new this week})
*404 volume:* {trend_summary}
{one-line read, e.g. "Down — redirect coverage is holding." or "Up — check the gaps below."}

*Top 10 uncovered URLs (by hits):*
*Gaps worth fixing (≥{report_min_hits} hits):* {significant_uncovered_count} ({significant_new_gaps_count} new)
{hit_count} `/path` {🆕 if new this week}
...

*{K} resolved since last week* (redirect added or traffic stopped)
_+{long_tail_count} other uncovered URLs under {report_min_hits} hits each (mostly bots/old links) — see CSV._
*{resolved_count} resolved since last week* (redirect added or traffic stopped)

→ Add missing redirects: `vercel.json` › `redirects` array (PR against `main`)
→ Add redirects for the gaps above: `vercel.json` › `redirects` array (PR against `main`)
→ Full breakdown: {oz_run_url}
```

Rules:
- Cap the list at 10 entries. If there are more, note "and N more — see full CSV in the run."
- **Lead with volume trend, not distinct-URL counts.** The first line is always `trend_summary` — the pre-formatted total-404 trend, which reflects real user impact. It already includes the direction arrow (▼ fewer 404s, ▲ more, → no change) and falls back to a "no prior-week baseline yet" message when last week had no data, so the percentage is never rendered as null.
- **Only list significant gaps.** List `top_significant_uncovered` (URLs with `hits_this_week >= report_min_hits`), capped at 10. If there are more, note "and N more — see full CSV in the run." If `significant_uncovered_count` is 0, write "None this week — remaining 404s are all low-hit long-tail traffic." and omit the list.
- **Roll up the long tail.** Never list sub-threshold URLs individually; collapse them into the single `long_tail_count` line so noise doesn't dominate the report.
- Mark new gaps with 🆕.
- If total 404s this week is less than 50, add a brief positive note: "404 volume is low — good signal that redirect coverage is working."
- If `total_404s_this_week` is less than 50, add a brief positive note: "404 volume is low — good signal that redirect coverage is working."
- Never include raw user data (e.g. query strings with user IDs, tokens) in the Slack message. Strip query params from broken_url before displaying.

## Phase 2: Redirect drafter
Expand All @@ -101,7 +109,7 @@ After the Slack summary is posted and the CSV artifact is written, continue with

### Threshold and confidence scoring

Only process gaps where `hits_this_week >= 10`. This threshold reduces noise; review and adjust after the first four weeks of data.
Only process gaps where `hits_this_week >= 10`. This is the **automation** threshold for opening redirect PRs — deliberately higher than the **reporting** threshold (`REPORT_MIN_HITS`, default 5) used for the Phase 1 Slack summary. This threshold reduces noise; review and adjust after the first four weeks of data.

For each qualifying uncovered URL, attempt to find a redirect target using these heuristics in order:

Expand Down Expand Up @@ -165,6 +173,7 @@ Before posting to Slack, verify:
- The `broken_url` field was present in the event properties for at least some rows. If it is consistently null, the `docs_404` tracking implementation has a bug — report it in the Slack message and tag the docs team.
- The vercel.json redirect list was loaded successfully and contains more than 500 entries (sanity check that the file is not truncated).
- The CSV artifact was written before posting to Slack.
- The Slack summary leads with the volume trend and lists only significant gaps (`hits_this_week >= report_min_hits`); long-tail URLs are rolled up into the `long_tail_count` line, never listed individually.

## No-data report

Expand Down
178 changes: 140 additions & 38 deletions .agents/skills/weekly-404-monitor/run_404_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import urllib.request
from datetime import date, timedelta
from pathlib import Path
from typing import Optional


BASE = "https://warp.metabaseapp.com/api"
Expand Down Expand Up @@ -73,23 +74,50 @@ def query_404_events(days_start: int, days_end: int) -> list[dict]:
Return broken_url counts for the window [days_start, days_end) days ago.
days_start=1, days_end=8 → past 7 days (current week)
days_start=8, days_end=15 → 8-14 days ago (prior week)

Normalisation and aggregation happen in SQL so trailing-slash, case, and
scheme/host variants of the same page are summed into a single row BEFORE
the LIMIT is applied. If we grouped by the raw broken_url and capped first,
a page whose hits are split across variants could fall outside the top 500
and be dropped before its hits were summed, undercounting it against the
reporting threshold. The normalisation below mirrors normalise_url().
"""
sql = f"""
SELECT
REGEXP_REPLACE(
SPLIT(JSON_VALUE(event_properties, '$.broken_url'), '?')[OFFSET(0)],
r'#.*$', ''
) AS broken_url,
COUNT(*) AS hits
FROM `warp-data-357114.prod.stg_website_events`
WHERE event_type = 'track'
AND event_name = 'docs_404'
AND JSON_VALUE(event_properties, '$.broken_url') IS NOT NULL
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL {days_end - 1} DAY)
AND event_date < DATE_SUB(CURRENT_DATE(), INTERVAL {days_start - 1} DAY)
GROUP BY 1
HAVING broken_url IS NOT NULL AND broken_url != ''
ORDER BY 2 DESC
WITH normalised AS (
SELECT
COALESCE(
NULLIF(
REGEXP_REPLACE(
LOWER(
REGEXP_REPLACE(
SPLIT(
REGEXP_REPLACE(
JSON_VALUE(event_properties, '$.broken_url'),
r'^https?://[^/]+', ''
),
'?'
)[OFFSET(0)],
r'#.*$', ''
)
),
r'/+$', ''
),
''
),
'/'
) AS broken_url
FROM `warp-data-357114.prod.stg_website_events`
WHERE event_type = 'track'
AND event_name = 'docs_404'
AND JSON_VALUE(event_properties, '$.broken_url') IS NOT NULL
AND JSON_VALUE(event_properties, '$.broken_url') != ''
AND event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL {days_end - 1} DAY)
AND event_date < DATE_SUB(CURRENT_DATE(), INTERVAL {days_start - 1} DAY)
)
SELECT broken_url, COUNT(*) AS hits
FROM normalised
GROUP BY broken_url
ORDER BY hits DESC
LIMIT 500
"""
return run_query(sql)
Expand Down Expand Up @@ -150,20 +178,86 @@ def normalise_url(url: str) -> str:
return url or "/"


def aggregate_by_norm(rows: list[dict]) -> dict[str, int]:
"""Collapse broken_url rows into one entry per normalised path.

query_404_events already normalises and aggregates in SQL (so the LIMIT is
applied to normalised pages, not raw variants). This step re-applies
normalise_url() in Python as an idempotent safety net: it reconciles any
rows that SQL normalisation and normalise_url() would treat differently,
guaranteeing each logical page is counted once with its hits summed.
"""
agg: dict[str, int] = {}
for row in rows:
norm = normalise_url(row.get("broken_url") or "")
if not norm:
continue
agg[norm] = agg.get(norm, 0) + int(row.get("hits") or 0)
return agg


def parse_min_hits(raw: str, default: int = 5) -> int:
"""Parse the REPORT_MIN_HITS env value as a positive integer.

Falls back to `default` (with a warning) for non-numeric, zero, or negative
values, which would otherwise crash or reintroduce long-tail noise.
"""
try:
value = int(raw)
except (TypeError, ValueError):
value = None
if value is None or value < 1:
print(f"WARNING: REPORT_MIN_HITS={raw!r} is not a positive integer; "
f"falling back to {default}.", file=sys.stderr)
return default
return value


def format_trend(total_current: int, total_prior: int) -> tuple[int, Optional[float], str]:
"""Return (trend_delta, trend_pct, trend_summary) for the headline.

trend_pct is None when there is no prior-week baseline (total_prior == 0),
and trend_summary is pre-formatted so the Slack template never has to render
a null percentage.
"""
trend_delta = total_current - total_prior
trend_pct: Optional[float] = None
if total_prior:
trend_pct = round(trend_delta / total_prior * 100, 1)
arrow = "▼" if trend_delta < 0 else ("▲" if trend_delta > 0 else "→")
trend_summary = (
f"{total_current} this week — {arrow} {abs(trend_delta)} "
f"({trend_pct}%) vs {total_prior} last week"
)
else:
trend_summary = f"{total_current} this week (no prior-week baseline yet)"
return trend_delta, trend_pct, trend_summary


def main():
vercel_path = Path(os.environ.get("VERCEL_JSON_PATH", "vercel.json"))
report_dir = Path(os.environ.get("REPORT_DIR", "data/404-reports"))
# Minimum weekly hits for an uncovered URL to count as a "gap worth fixing"
# in the Slack headline. Anything below this is long-tail noise (one-off
# bot/crawler/old-bookmark traffic) and is rolled up into a single count so
# it doesn't dominate the report. Tunable via env; see SKILL.md.
report_min_hits = parse_min_hits(os.environ.get("REPORT_MIN_HITS", "5"))
today = date.today()

print(f"Running weekly 404 report for week ending {today}", file=sys.stderr)

# 1. Query current and prior week
print("Querying current week (past 7 days)...", file=sys.stderr)
current_week = query_404_events(1, 8)
print(f" {len(current_week)} unique broken URLs found", file=sys.stderr)
# query_404_events already normalises + aggregates in SQL; this is an
# idempotent safety net that reconciles any normalisation drift.
current_agg = aggregate_by_norm(current_week)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 [SUGGESTION] Because query_404_events still applies LIMIT 500 before this aggregation, variants outside the raw top 500 are dropped before their hits can be summed. If the goal is accurate thresholding for variant-split pages, aggregate by normalized path in SQL or remove the raw cap before applying any report limit.

print(f" {len(current_week)} pages (SQL) -> {len(current_agg)} pages (normalised)",
file=sys.stderr)

print("Querying prior week (days 8-14)...", file=sys.stderr)
prior_week = query_404_events(8, 15)
prior_agg = aggregate_by_norm(prior_week)

total_current = total_404_count(1, 8)
total_prior = total_404_count(8, 15)
Expand All @@ -173,38 +267,25 @@ def main():
redirect_sources = load_redirect_sources(vercel_path)

# 3. Build prior-week gap set for delta calculation
prior_gaps: set[str] = set()
for row in prior_week:
norm = normalise_url(row["broken_url"])
if norm and norm not in redirect_sources:
prior_gaps.add(norm)
prior_gaps = {u for u in prior_agg if u not in redirect_sources}

# 4. Build current-week report
# 4. Build current-week report (one row per normalised page)
report_rows = []
for row in current_week:
raw_url = row.get("broken_url") or ""
norm = normalise_url(raw_url)
if not norm:
continue
hits_current = int(row.get("hits") or 0)
hits_prior = next(
(int(r["hits"]) for r in prior_week
if normalise_url(r.get("broken_url") or "") == norm),
0
)
for norm, hits_current in current_agg.items():
is_covered = norm in redirect_sources
is_new_gap = (not is_covered) and (norm not in prior_gaps)

report_rows.append({
"broken_url": norm,
"hits_this_week": hits_current,
"hits_last_week": hits_prior,
"hits_last_week": prior_agg.get(norm, 0),
"is_covered_by_redirect": is_covered,
"is_new_gap": is_new_gap,
})
# Summing variants can change ordering, so re-sort by hits descending.
report_rows.sort(key=lambda r: r["hits_this_week"], reverse=True)

# 5. Compute resolved (was a gap last week, is no longer generating hits)
current_urls = {normalise_url(r["broken_url"]) for r in current_week}
current_urls = set(current_agg)
newly_covered = {
g for g in prior_gaps
if g in redirect_sources # redirect was added
Expand All @@ -231,15 +312,36 @@ def main():
uncovered = [r for r in report_rows if not r["is_covered_by_redirect"]]
new_gaps = [r for r in uncovered if r["is_new_gap"]]

# Split uncovered URLs into "signal" (enough hits to be worth a redirect)
# and long-tail "noise" (below the reporting threshold). In a low-sample
# dataset most broken URLs are hit once by bots/old links, so the raw
# uncovered and new-gap counts churn heavily week-over-week and overstate
# the problem. The headline leads with volume trend + significant gaps;
# the long tail is reported only as a single rolled-up count.
significant = [r for r in uncovered if r["hits_this_week"] >= report_min_hits]
significant_new_gaps = [r for r in significant if r["is_new_gap"]]
long_tail_count = len(uncovered) - len(significant)

trend_delta, trend_pct, trend_summary = format_trend(total_current, total_prior)

summary = {
"report_date": today.isoformat(),
# --- Headline: overall 404 volume trend (the metric that matters) ---
"total_404s_this_week": total_current,
"total_404s_last_week": total_prior,
"trend_delta": total_current - total_prior,
"trend_delta": trend_delta,
"trend_pct": trend_pct,
"trend_summary": trend_summary,
# --- Signal: uncovered URLs with enough hits to be worth a redirect ---
"report_min_hits": report_min_hits,
"significant_uncovered_count": len(significant),
"significant_new_gaps_count": len(significant_new_gaps),
"top_significant_uncovered": significant[:10],
# --- Context only: raw/long-tail counts (do NOT headline these) ---
"uncovered_count": len(uncovered),
"new_gaps_count": len(new_gaps),
"long_tail_count": long_tail_count,
"resolved_count": resolved_count,
"top_10_uncovered": uncovered[:10],
"csv_path": str(csv_path),
"has_data": len(current_week) > 0,
}
Expand Down
Loading