chore(studio): report empty-body GET 200s to Sentry with a no-store probe

Studio crashes trace back to successful GET responses with an empty body,
which openapi-fetch turns into `{}`. Report them (GET only, platform only,
once per templated endpoint per page session) with response headers,
browser state, resource timing and a one-off `cache: 'no-store'` refetch,
so we can tell browser cache revalidation apart from the API or edge
sending empty bodies. Response handling is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UefDak8XYLMi9aiEjDPXc5
This commit is contained in:
Claude committed 2026-10-01 09:08:30 +00:00
1 parent 4f0be099e7
commit ee193c5e51
3 files changed
+421 -4

No files matched your search

@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const platform = vi.hoisted(() => ({ isPlatform: true }))
const sentry = vi.hoisted(() => ({ captureMessage: vi.fn(), captureException: vi.fn() }))
vi.mock('@sentry/nextjs', () => sentry)
vi.mock('common', () => ({
get IS_PLATFORM() {
return platform.isPlatform
},
getAccessToken: vi.fn(),
}))
vi.mock('@/lib/constants', () => ({ API_URL: 'http://localhost' }))
vi.mock('@/lib/helpers', () => ({ uuidv4: () => 'probe-uuid' }))
// Import after mocks are set up
const { reportEmptyBodyResponse, templateEndpointPath } = await import('./empty-body-diagnostics')
const { client } = await import('./fetchers')
const SECRET = 'service_role_secret_value'
function emptyGet(path: string, init?: RequestInit) {
const request = new Request(`http://localhost${path}?include=secret`, {
headers: { 'X-Request-Id': 'original-uuid', Authorization: 'Bearer token' },
...init,
})
const response = new Response(null, {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'private' },
})
return { request, response }
}
function lastCapture() {
const [message, context] = sentry.captureMessage.mock.lastCall ?? []
return { message, context }
}
describe('templateEndpointPath', () => {
it.each([
['/platform/projects/abcdefghijklmnopqrst/settings', '/platform/projects/{ref}/settings'],
['/platform/organizations/my-org/members', '/platform/organizations/{slug}/members'],
[
'/v1/projects/abcdefghijklmnopqrst/branches/dev/config',
'/v1/projects/{ref}/branches/{branch}/config',
],
['/platform/pg-meta/abcdefghijklmnopqrst/tables', '/platform/pg-meta/{id}/tables'],
[
'/platform/organizations/{slug}/members/3f2b8c1e-9d4a-4e6b-8c7d-1a2b3c4d5e6f',
'/platform/organizations/{slug}/members/{id}',
],
['/platform/projects/{ref}/backups/12345', '/platform/projects/{ref}/backups/{id}'],
['/platform/projects/{ref}/api-keys?reveal=true', '/platform/projects/{ref}/api-keys'],
['/platform/profile#section', '/platform/profile'],
['/platform/projects/{ref}/custom-hostname', '/platform/projects/{ref}/custom-hostname'],
['/platform/projects', '/platform/projects'],
['/platform/constructor/toString', '/platform/constructor/toString'],
['', ''],
])('templates %s as %s', (path, expected) => {
expect(templateEndpointPath(path)).toBe(expected)
})
})
describe('reportEmptyBodyResponse', () => {
beforeEach(() => {
platform.isPlatform = true
sentry.captureMessage.mockReset()
})
afterEach(() => vi.unstubAllGlobals())
it('reports with probe_has_body "true" when a no-store refetch returns a body', async () => {
const fetchMock = vi.fn<(request: Request) => Promise<Response>>(
async () => new Response(JSON.stringify({ secret: SECRET }))
)
vi.stubGlobal('fetch', fetchMock)
const { request, response } = emptyGet('/platform/projects/abcdefghijklmnopqrst/a')
await reportEmptyBodyResponse({ request, response, schemaPath: '/platform/projects/{ref}/a' })
const probeRequest = fetchMock.mock.lastCall?.[0]
expect(probeRequest).toBeInstanceOf(Request)
expect(probeRequest?.cache).toBe('no-store')
expect(probeRequest?.headers.get('X-Request-Id')).toBe('probe-uuid')
const { message, context } = lastCapture()
expect(message).toBe('Empty response body on successful API request')
expect(context).toMatchObject({
level: 'warning',
fingerprint: ['empty-body-response', '/platform/projects/{ref}/a'],
tags: { endpoint: '/platform/projects/{ref}/a', probe_has_body: 'true' },
extra: {
method: 'GET',
status: 200,
request_id: 'original-uuid',
probe_request_id: 'probe-uuid',
header_content_type: 'application/json',
header_cache_control: 'private',
header_etag: null,
probe_status: 200,
probe_body_length: JSON.stringify({ secret: SECRET }).length,
},
})
expect(JSON.stringify(sentry.captureMessage.mock.calls)).not.toContain(SECRET)
})
it('reports probe_has_body "false" when the refetch is also empty', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null))
)
const { request, response } = emptyGet('/platform/b')
await reportEmptyBodyResponse({ request, response, schemaPath: '/platform/b' })
expect(lastCapture().context).toMatchObject({
tags: { probe_has_body: 'false' },
extra: { probe_body_length: 0 },
})
})
it('reports probe_has_body "error" and does not throw when the refetch rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => Promise.reject(new TypeError('Load failed')))
)
const { request, response } = emptyGet('/platform/c')
await expect(
reportEmptyBodyResponse({ request, response, schemaPath: '/platform/c' })
).resolves.toBeUndefined()
expect(lastCapture().context).toMatchObject({
tags: { probe_has_body: 'error' },
extra: { probe_error: 'TypeError' },
})
})
it('reports at most once per endpoint', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null))
)
for (const ref of ['abcdefghijklmnopqrst', 'tsrqponmlkjihgfedcba']) {
const { request, response } = emptyGet(`/platform/projects/${ref}/d`)
await reportEmptyBodyResponse({ request, response, schemaPath: '/platform/projects/{ref}/d' })
}
expect(sentry.captureMessage).toHaveBeenCalledTimes(1)
})
it('skips non-GET requests', async () => {
const fetchMock = vi.fn(async () => new Response(null))
vi.stubGlobal('fetch', fetchMock)
const { request, response } = emptyGet('/platform/e', { method: 'POST' })
await reportEmptyBodyResponse({ request, response, schemaPath: '/platform/e' })
expect(fetchMock).not.toHaveBeenCalled()
expect(sentry.captureMessage).not.toHaveBeenCalled()
})
it('skips when not on platform', async () => {
platform.isPlatform = false
const fetchMock = vi.fn(async () => new Response(null))
vi.stubGlobal('fetch', fetchMock)
const { request, response } = emptyGet('/platform/f')
await reportEmptyBodyResponse({ request, response, schemaPath: '/platform/f' })
expect(fetchMock).not.toHaveBeenCalled()
expect(sentry.captureMessage).not.toHaveBeenCalled()
})
it('does not throw when Sentry throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null))
)
sentry.captureMessage.mockImplementation(() => {
throw new Error('sentry down')
})
const { request, response } = emptyGet('/platform/g')
await expect(
reportEmptyBodyResponse({ request, response, schemaPath: '/platform/g' })
).resolves.toBeUndefined()
})
})
describe('openapi-fetch client — empty GET 200', () => {
beforeEach(() => {
platform.isPlatform = true
sentry.captureMessage.mockReset()
})
afterEach(() => vi.unstubAllGlobals())
it('still resolves with {} and reports the templated schema path', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 200 }))
)
const { data, error } = await client.GET('/platform/projects/{ref}/settings', {
params: { path: { ref: 'abcdefghijklmnopqrst' } },
})
expect(error).toBeUndefined()
expect(data).toEqual({})
await vi.waitFor(() => expect(sentry.captureMessage).toHaveBeenCalledTimes(1))
expect(lastCapture().context).toMatchObject({
tags: { endpoint: '/platform/projects/{ref}/settings' },
})
})
it('reports an empty GET 200 that carries Content-Length: 0', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 200, headers: { 'Content-Length': '0' } }))
)
const { data } = await client.GET('/platform/organizations/{slug}/members', {
params: { path: { slug: 'my-org' } },
})
expect(data).toEqual({})
await vi.waitFor(() => expect(sentry.captureMessage).toHaveBeenCalledTimes(1))
expect(lastCapture().context).toMatchObject({
tags: { endpoint: '/platform/organizations/{slug}/members' },
extra: { header_content_length: '0' },
})
})
})
+170
View File
@@ -0,0 +1,170 @@
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 },
extra: { ...context, ...probeContext },
})
} catch (error) {
console.error('Failed to report empty response body', error)
}
}
+17 -4
View File
@@ -4,6 +4,7 @@ import { getAccessToken, IS_PLATFORM } from 'common'
import createClient from 'openapi-fetch'
import type { paths } from './api'
import { reportEmptyBodyResponse } from './empty-body-diagnostics'
import { ERROR_PATTERNS } from './error-patterns'
import { API_URL } from '@/lib/constants'
import { uuidv4 } from '@/lib/helpers'
@@ -73,8 +74,18 @@ export async function constructHeaders(headersInit?: HeadersInit | undefined) {
* Normalize empty-body success responses by setting `Content-Length: 0` so the parser
* short-circuits regardless of transport. Non-empty responses are returned untouched.
*/
export async function normalizeEmptyBodyResponse(response: Response): Promise<Response> {
if (response.status === 204 || response.headers.has('Content-Length')) {
export async function normalizeEmptyBodyResponse(
response: Response,
source?: { request: Request; schemaPath: string }
): Promise<Response> {
if (response.status === 204) {
return response
}
if (response.headers.has('Content-Length')) {
if (source && response.headers.get('Content-Length') === '0') {
void reportEmptyBodyResponse({ ...source, response })
}
return response
}
@@ -83,6 +94,8 @@ export async function normalizeEmptyBodyResponse(response: Response): Promise<Re
return response
}
if (source) void reportEmptyBodyResponse({ ...source, response })
const headers = new Headers(response.headers)
headers.set('Content-Length', '0')
return new Response(null, {
@@ -125,9 +138,9 @@ client.use(
},
{
// Middleware to format errors
async onResponse({ request, response }) {
async onResponse({ request, response, schemaPath }) {
if (response.ok) {
return normalizeEmptyBodyResponse(response)
return normalizeEmptyBodyResponse(response, { request, schemaPath })
}
// handle errors