mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user