diff --git a/apps/studio/data/empty-body-diagnostics.test.ts b/apps/studio/data/empty-body-diagnostics.test.ts new file mode 100644 index 00000000000..c3ec9168822 --- /dev/null +++ b/apps/studio/data/empty-body-diagnostics.test.ts @@ -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>( + 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' }, + }) + }) +}) diff --git a/apps/studio/data/empty-body-diagnostics.ts b/apps/studio/data/empty-body-diagnostics.ts new file mode 100644 index 00000000000..6731bd035f3 --- /dev/null +++ b/apps/studio/data/empty-body-diagnostics.ts @@ -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() + +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 { + 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) + } +} diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 1ce51f26e80..82d76fee6af 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -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 { - if (response.status === 204 || response.headers.has('Content-Length')) { +export async function normalizeEmptyBodyResponse( + response: Response, + source?: { request: Request; schemaPath: string } +): Promise { + 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