Files
supabase/apps/studio/data/empty-body-diagnostics.ts
T
claude[bot]andClaude 6c6b19c6c6 chore(studio): report empty-body GET 200s to Sentry with a no-store probe (#51123)
<!-- ccr-slack-attribution -->
_Requested by **Ivan Vasilov** · [Slack
thread](https://supabase.slack.com/archives/C063LNYJJKS/p1790710416069689?thread_ts=1790710416.069689&cid=C063LNYJJKS)_

**Before:** When a Studio API GET comes back as a 200 with an empty
body, openapi-fetch hands the caller `{}` and we only see the downstream
crash, with no record of the response that caused it.

**After:** The first time this happens for an endpoint in a page
session, Studio sends one Sentry warning, `Empty response body on
successful API request`. It carries the response metadata, browser
state, resource timing, and the result of a single `cache: 'no-store'`
refetch. What the caller receives is unchanged.

## Problem

Studio crashes trace back to GET requests that return 200 with an empty
body, which openapi-fetch turns into `{}`. They are heavily skewed to
Firefox and Safari. The leading hypothesis is browser cache revalidation
(Express weak ETags, no `Cache-Control` on api.supabase.com), but
nothing confirms it yet. The `no-store` probe tells the two cases apart:
if the refetch has a body, the browser cache is the likely culprit; if
it is also empty, the server or the edge is sending empty bodies. This
data should show whether the fix belongs on the API side or the
Cloudflare side.

Context: #51041 (closed) tried to guard the crashing call sites instead.

## Needs API-side change to be fully useful

Cross-origin, Studio can only read CORS-safelisted response headers, and
resource timing sizes read as zero. If api.supabase.com sends
`Access-Control-Expose-Headers: ETag, cf-ray, cf-cache-status,
x-request-id` and `Timing-Allow-Origin: <studio origin>`, this event
will also carry the ETag, cf-ray, and cache status, plus the real
transfer and body sizes and the negotiated protocol. Until then, those
fields read as `null` or `0`.

## Solution

- `data/empty-body-diagnostics.ts` (new): `reportEmptyBodyResponse({
request, response, schemaPath })`.
- Runs only for `GET` and only when `IS_PLATFORM`. Empty POST/201 bodies
are legitimate.
- Reports at most once per templated endpoint per page session
(module-level `Set`).
- Endpoint: openapi-fetch's `schemaPath` (e.g.
`/platform/projects/{ref}/settings`), passed through
`templateEndpointPath`. That function drops the query string and hash,
replaces the segment after `projects`/`organizations`/`branches` with
`{ref}`/`{slug}`/`{branch}`, and replaces UUIDs, numeric IDs, and 20+
character alphanumeric IDs with `{id}`. I used `schemaPath` rather than
the request URL so user-chosen names (bucket names, function slugs)
never end up in tags or fingerprints.
- Probe: one plain `fetch(new Request(request, { cache: 'no-store', ...
}))` with a fresh `X-Request-Id` and a 10s `AbortController` timeout.
`AbortSignal.timeout` isn't available in older Safari. The probe
bypasses the openapi-fetch middleware, so it can't recurse. Only the
body's byte length is recorded, never its contents.
- Event: `level: 'warning'`, `fingerprint: ['empty-body-response',
endpoint]`, `tags: { endpoint, probe_has_body, empty_body_diagnostic:
'true' }`, where `probe_has_body` is `true` / `false` / `error`. `extra`
holds:
- the request: method, status, `response.type`, `redirected`, and the
original `X-Request-Id` (for API log lookup)
- response headers: `content-type`, `cache-control`, `last-modified`,
`expires`, `content-length`, `etag`, `cf-ray`, `cf-cache-status`,
`x-request-id`
- browser state: `visibilityState`, `navigator.onLine`, the navigation
type, ms since navigation start, and whether the page was restored from
bfcache
- the latest `PerformanceResourceTiming` for the URL (transfer, encoded,
and decoded size, `nextHopProtocol`, `responseStatus`)
- the probe: status, request ID, body length, `content-length`,
`content-type`, or the error name
- Fire-and-forget: everything is wrapped in a `try`/`catch`, and the
caller does not await it.
- `data/fetchers.ts`: the `onResponse` middleware passes `{ request,
schemaPath }` to `normalizeEmptyBodyResponse`, which calls the reporter
in its empty-body branch and also for a 200 that carries
`Content-Length: 0`. openapi-fetch short-circuits that case to `{}` the
same way, so it is the same symptom. The return value is unchanged in
every branch.
- `packages/common/sentry.ts`: `filterSentryEvent` normally keeps only
1% of events that aren't page crashes. It now sends events tagged
`empty_body_diagnostic` unsampled, with `codeSampleRate: '1'`. A
once-per-session warning would barely show up at 1%. Consent and
platform gating and the third-party filter still apply. www and docs
also use `filterSentryEvent`, but only Studio's reporter sets this tag,
so sampling for them and for every other Studio event is unchanged.

Sentry config: Studio's `beforeSend` doesn't otherwise drop this
message. It has no exception values, so the no-stack-trace filter
doesn't apply, and it matches no `ignoreErrors` entry.

## Review instructions

1. Check `normalizeEmptyBodyResponse` in `data/fetchers.ts`: the
reporter is `void`-called and its return value is untouched.
2. Check `probe()` in `data/empty-body-diagnostics.ts`: only
`byteLength` is read from the body. The probe reuses the original
request's headers and credentials (same auth as the original GET).
3. Check `filterSentryEvent` in `packages/common/sentry.ts`: only the
`empty_body_diagnostic` tag skips sampling.
4. Tests: `data/empty-body-diagnostics.test.ts` and
`packages/common/sentry.test.ts`.

## Verification

- Unit tests (`data/empty-body-diagnostics.test.ts`, new):
  - path templating cases
  - `probe_has_body` `true` / `false` / `error`
  - the secret body content never appears in the Sentry call
  - one report per endpoint
  - non-GET and non-platform requests are skipped
  - no throw when `fetch` or Sentry throws
- end-to-end through `client.GET`: still resolves `{}` and reports the
`schemaPath`, for both a missing `Content-Length` and `Content-Length:
0`
- `packages/common/sentry.test.ts`: tagged diagnostics are sent
unsampled, untagged or false-tagged ones are still sampled, and they're
still dropped without consent.

These tests, plus the existing `normalizeEmptyBodyResponse.test.ts`,
`handleError.test.ts`, and the rest of `sentry.test.ts`, pass (67 tests)
under vitest 5 + jsdom. I ran them in a minimal harness, not the full
`pnpm install` workspace, because the local checkout is sparse.
- I ran TypeScript 7.0.2 (`--strict`) on the five touched files against
the real `api-types`, with stubbed `common`/Sentry types. No errors in
the touched files.
- Prettier `--check` with the repo config passes, with and without
`SORT_IMPORTS=false`.
- Not run locally: the full studio typecheck, `lint:ratchet`, and knip.
CI covers them. The change adds no `any`, no default exports, and no
deps.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UefDak8XYLMi9aiEjDPXc5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-07 16:15:57 +02:00

171 lines
5.0 KiB
TypeScript

import * as Sentry from '@sentry/nextjs'
import { IS_PLATFORM } from 'common'
import { uuidv4 } from '@/lib/helpers'
const PROBE_TIMEOUT_MS = 10_000
const PARAM_AFTER_SEGMENT = new Map([
['projects', '{ref}'],
['organizations', '{slug}'],
['branches', '{branch}'],
])
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const NUMERIC_ID_PATTERN = /^\d+$/
const LONG_ID_PATTERN = /^[A-Za-z0-9]{20,}$/
const RESPONSE_HEADERS = [
'content-type',
'cache-control',
'last-modified',
'expires',
'content-length',
'etag',
'cf-ray',
'cf-cache-status',
'x-request-id',
]
const reportedEndpoints = new Set<string>()
let isRestoredFromBfcache = false
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', (event) => {
if (event.persisted) isRestoredFromBfcache = true
})
}
export function templateEndpointPath(path: string): string {
const [pathname = ''] = path.split(/[?#]/)
const segments = pathname.split('/')
return segments
.map((segment, index) => {
if (segment.length === 0 || segment.startsWith('{')) return segment
const param = PARAM_AFTER_SEGMENT.get(segments[index - 1] ?? '')
if (param) return param
if (
UUID_PATTERN.test(segment) ||
NUMERIC_ID_PATTERN.test(segment) ||
LONG_ID_PATTERN.test(segment)
) {
return '{id}'
}
return segment
})
.join('/')
}
function readHeaders(headers: Headers) {
return Object.fromEntries(
RESPONSE_HEADERS.map((name) => [`header_${name.replace(/-/g, '_')}`, headers.get(name)])
)
}
function readBrowserContext() {
const navigation = performance.getEntriesByType?.('navigation')[0]
return {
visibility_state: document.visibilityState,
online: navigator.onLine,
navigation_type:
typeof PerformanceNavigationTiming !== 'undefined' &&
navigation instanceof PerformanceNavigationTiming
? navigation.type
: null,
ms_since_navigation_start: Math.round(performance.now()),
restored_from_bfcache: isRestoredFromBfcache,
}
}
function readResourceTiming(url: string) {
const entries = performance.getEntriesByName?.(url, 'resource') ?? []
const entry = entries[entries.length - 1]
if (
typeof PerformanceResourceTiming === 'undefined' ||
!(entry instanceof PerformanceResourceTiming)
) {
return { resource_timing: null }
}
return {
resource_timing: {
transfer_size: entry.transferSize,
encoded_body_size: entry.encodedBodySize,
decoded_body_size: entry.decodedBodySize,
next_hop_protocol: entry.nextHopProtocol,
response_status: 'responseStatus' in entry ? entry.responseStatus : null,
},
}
}
async function probe(request: Request) {
const headers = new Headers(request.headers)
const probeRequestId = uuidv4()
headers.set('X-Request-Id', probeRequestId)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
// Plain fetch so the probe skips the openapi-fetch middleware (and can't recurse)
const response = await fetch(
new Request(request, { cache: 'no-store', headers, signal: controller.signal })
)
// Only the length: these endpoints can return API keys and secrets
const bodyLength = (await response.arrayBuffer()).byteLength
return {
probe_has_body: bodyLength > 0 ? 'true' : 'false',
probe_request_id: probeRequestId,
probe_status: response.status,
probe_body_length: bodyLength,
probe_content_length: response.headers.get('content-length'),
probe_content_type: response.headers.get('content-type'),
}
} catch (error) {
return {
probe_has_body: 'error',
probe_request_id: probeRequestId,
probe_error: error instanceof Error ? error.name : 'unknown',
}
} finally {
clearTimeout(timeout)
}
}
export async function reportEmptyBodyResponse({
request,
response,
schemaPath,
}: {
request: Request
response: Response
schemaPath: string
}): Promise<void> {
try {
if (!IS_PLATFORM || request.method !== 'GET') return
const endpoint = templateEndpointPath(schemaPath)
if (reportedEndpoints.has(endpoint)) return
reportedEndpoints.add(endpoint)
const context = {
method: request.method,
status: response.status,
response_type: response.type,
redirected: response.redirected,
request_id: request.headers.get('X-Request-Id'),
...readHeaders(response.headers),
...readBrowserContext(),
...readResourceTiming(response.url || request.url),
}
const { probe_has_body, ...probeContext } = await probe(request)
Sentry.captureMessage('Empty response body on successful API request', {
level: 'warning',
fingerprint: ['empty-body-response', endpoint],
tags: { endpoint, probe_has_body, empty_body_diagnostic: 'true' },
extra: { ...context, ...probeContext },
})
} catch (error) {
console.error('Failed to report empty response body', error)
}
}