Skip to content

SvelteKit SSR gateway 504 errors missed by Sentry warnings and default alerts

TL;DR.

A self-emitted SSR 504 was captured at Sentry warning level, matched no alert rule (defaults only fire on first-seen/regression, never on rate), and still rendered inside the root layout so PostHog logged it as a normal $pageview. The failure was not missing from the KPIs, it was counted as a success.

Stack: SvelteKit SSR fronting a FastAPI backend, Sentry on both, PostHog for product analytics.

We deliberately map upstream SSR fetch failures to honest gateway statuses in handleFetch:

const name = err?.name;
if (name === 'TimeoutError' || name === 'AbortError') {
  Sentry.captureException(err, { level: 'warning', fingerprint: ['ssr-upstream-timeout'] });
  error(504, 'Backend timeout');
}

A 6-hourly crawl gate caught 6 user-facing 504s that no human-facing system would ever have surfaced. Three independent blind spots stacked:

  1. level: 'warning' was chosen to de-noise a known transient family. It also removed the events from every error-level view. Reasonable locally, wrong globally.

  2. Sentry's default project alert rule only has FirstSeenEventCondition + RegressionEventCondition. Verify yours: GET /api/0/projects/<org>/<proj>/rules/. A long-lived unresolved issue can absorb an unbounded event spike and notify nobody, because neither condition is about rate. We also had zero org-wide metric alerts (GET /api/0/organizations/<org>/alert-rules/ returned []), so no rate trigger existed anywhere. 'We have Sentry' is not 'we are alerted'.

  3. The error page is still an app page. In SvelteKit, error() thrown from a nested layout/page load renders +error.svelte inside the surviving root layout. Our root layout mounts the PostHog component with capture_pageview: 'history_change', so a 504 emits a perfectly normal $pageview. The failure is not missing from the KPIs, it is counted as a success — strictly worse than a gap, because it inflates the very numerator you would use to notice the problem. Check this by asking which layout owns the failing load: if the root layout survives, your analytics fire.

Leading indicator worth adding, and nearly free: log a structured line whenever the upstream fetch exceeds a fraction of the timeout budget.

finally {
  const ms = Date.now() - start;
  if (ms > 2000) console.log(JSON.stringify({ slow_ssr_fetch: true, url, ms }));
}

Ours fired 881 times in 22h against 10 actual timeouts, with p99 pinned exactly at the 10s AbortSignal.timeout ceiling. That clamp in the ms distribution is how you confirm the timeout budget is the binding constraint rather than a coincidence. It also localised blame: 879 of 881 slow fetches fell inside our own crawler's four windows, so the incident was self-inflicted synthetic load, not user traffic. Nothing consumed the line for days, because it shipped as debug output and was never wired to anything.

General rule: for any deliberately-degraded status you emit yourself, record at capture time which system is expected to page a human. If the answer is 'the dashboard', the answer is nobody.

No signals yet