mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(query-perf): better error handling and sentry tracking (#41278)
* add retry * handle and capture errors * adds tests * improve Sentry integration * remove SQL logging from query performance error capture * fix error msg prop handling * add max and baseline to alert * undo change meant for other branch oops
This commit is contained in:
1 parent
6b72c8fe19
commit
9685711519
7 files changed
+281
-4
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
export const formatDuration = (milliseconds: number) => {
|
||||
@@ -35,3 +38,47 @@ export const transformLogsToJSON = (log: string) => {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type QueryPerformanceErrorContext = {
|
||||
projectRef?: string
|
||||
databaseIdentifier?: string
|
||||
queryPreset?: string
|
||||
queryType?: 'hitRate' | 'metrics' | 'mainQuery' | 'monitor' | 'slowQueriesCount'
|
||||
sql?: string
|
||||
errorMessage?: string
|
||||
postgresVersion?: string
|
||||
databaseType?: 'primary' | 'read-replica'
|
||||
}
|
||||
|
||||
export function captureQueryPerformanceError(
|
||||
error: unknown,
|
||||
context: QueryPerformanceErrorContext
|
||||
) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('query-performance', 'true')
|
||||
|
||||
scope.setContext('query-performance', {
|
||||
projectRef: context.projectRef,
|
||||
databaseIdentifier: context.databaseIdentifier,
|
||||
queryPreset: context.queryPreset,
|
||||
queryType: context.queryType,
|
||||
postgresVersion: context.postgresVersion,
|
||||
databaseType: context.databaseType,
|
||||
errorMessage: context.errorMessage,
|
||||
})
|
||||
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMessage(error)
|
||||
const errorToCapture = new Error(errorMessage || 'Query performance error')
|
||||
|
||||
if (error !== null && error !== undefined) {
|
||||
errorToCapture.cause = error
|
||||
}
|
||||
|
||||
Sentry.captureException(errorToCapture)
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
|
||||
import { hasIndexRecommendations } from './IndexAdvisor/index-advisor.utils'
|
||||
import { IndexSuggestionIcon } from './IndexAdvisor/IndexSuggestionIcon'
|
||||
@@ -41,8 +42,10 @@ import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFi
|
||||
interface QueryPerformanceGridProps {
|
||||
aggregatedData: QueryPerformanceRow[]
|
||||
isLoading: boolean
|
||||
error?: string | null
|
||||
currentSelectedQuery?: string | null
|
||||
onCurrentSelectQuery?: (query: string) => void
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
const calculateTimeConsumedWidth = (data: QueryPerformanceRow[]) => {
|
||||
@@ -70,8 +73,10 @@ const calculateTimeConsumedWidth = (data: QueryPerformanceRow[]) => {
|
||||
export const QueryPerformanceGrid = ({
|
||||
aggregatedData,
|
||||
isLoading,
|
||||
error,
|
||||
currentSelectedQuery,
|
||||
onCurrentSelectQuery,
|
||||
onRetry,
|
||||
}: QueryPerformanceGridProps) => {
|
||||
const { sort, setSortConfig } = useQueryPerformanceSort()
|
||||
const gridRef = useRef<DataGridHandle>(null)
|
||||
@@ -457,6 +462,28 @@ export const QueryPerformanceGrid = ({
|
||||
}
|
||||
}, [selectedRow, view, reportData])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="relative flex flex-grow bg-alternative min-h-0">
|
||||
<div className="flex-1 min-w-0 p-6">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Failed to load query performance data"
|
||||
description={error}
|
||||
>
|
||||
{onRetry && (
|
||||
<div className="mt-4">
|
||||
<Button type="default" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Admonition>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow bg-alternative min-h-0">
|
||||
<div ref={dataGridContainerRef} className="flex-1 min-w-0 overflow-x-auto">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
|
||||
import { LoadingLine } from 'ui'
|
||||
import { QueryPerformanceChart } from '../QueryPerformanceChart'
|
||||
import { QueryPerformanceFilterBar } from '../QueryPerformanceFilterBar'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from './WithMonitor.utils'
|
||||
import { useParams } from 'common'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
@@ -28,6 +32,8 @@ interface WithMonitorProps {
|
||||
|
||||
export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null)
|
||||
|
||||
// [kemal]: Fetch pg_stat_monitor logs. This will need to change when we move to the actual extension.
|
||||
@@ -79,6 +85,26 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
setSelectedQuery((prev) => (prev === query ? null : query))
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
pgStatMonitorLogs.runQuery()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (logsError) {
|
||||
const errorMessage = getErrorMessage(logsError)
|
||||
captureQueryPerformanceError(logsError, {
|
||||
projectRef: ref,
|
||||
databaseIdentifier: state.selectedDatabaseId,
|
||||
queryPreset: 'pg_stat_monitor',
|
||||
queryType: 'monitor',
|
||||
postgresVersion: project?.dbVersion,
|
||||
databaseType: state.selectedDatabaseId === ref ? 'primary' : 'read-replica',
|
||||
sql: queryWithTimeRange,
|
||||
errorMessage: errorMessage || undefined,
|
||||
})
|
||||
}
|
||||
}, [logsError, ref, state.selectedDatabaseId, project?.dbVersion, queryWithTimeRange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<QueryPerformanceChart
|
||||
@@ -103,8 +129,12 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps)
|
||||
<QueryPerformanceGrid
|
||||
aggregatedData={aggregatedGridData}
|
||||
isLoading={isLogsLoading}
|
||||
error={
|
||||
logsError ? getErrorMessage(logsError) || 'Failed to load query performance data' : null
|
||||
}
|
||||
currentSelectedQuery={selectedQuery}
|
||||
onCurrentSelectQuery={handleSelectQuery}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
+92
-2
@@ -5,6 +5,7 @@ import { Button } from 'ui'
|
||||
import { X, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { Markdown } from '../../Markdown'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
@@ -22,6 +23,8 @@ import { QueryPerformanceGrid } from '../QueryPerformanceGrid'
|
||||
import { transformStatementDataToRows } from './WithStatements.utils'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
|
||||
interface WithStatementsProps {
|
||||
queryHitRate: PresetHookResult
|
||||
@@ -37,10 +40,14 @@ export const WithStatements = ({
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
const { data, isLoading, isRefetching } = queryPerformanceQuery
|
||||
const { data, isLoading, isRefetching, error: queryError } = queryPerformanceQuery
|
||||
const isPrimaryDatabase = state.selectedDatabaseId === ref
|
||||
const formattedDatabaseId = formatDatabaseID(state.selectedDatabaseId ?? '')
|
||||
|
||||
const hitRateError = 'error' in queryHitRate ? queryHitRate.error : null
|
||||
const metricsError = 'error' in queryMetrics ? queryMetrics.error : null
|
||||
const mainQueryError = queryError || null
|
||||
|
||||
const [showResetgPgStatStatements, setShowResetgPgStatStatements] = useState(false)
|
||||
|
||||
const [showBottomSection, setShowBottomSection] = useLocalStorageQuery(
|
||||
@@ -65,8 +72,82 @@ export const WithStatements = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref])
|
||||
|
||||
useEffect(() => {
|
||||
if (mainQueryError) {
|
||||
const errorMessage = getErrorMessage(mainQueryError)
|
||||
captureQueryPerformanceError(mainQueryError, {
|
||||
projectRef: ref,
|
||||
databaseIdentifier: state.selectedDatabaseId,
|
||||
queryPreset: 'unified',
|
||||
queryType: 'mainQuery',
|
||||
postgresVersion: project?.dbVersion,
|
||||
databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
|
||||
sql: queryPerformanceQuery.resolvedSql,
|
||||
errorMessage: errorMessage || undefined,
|
||||
})
|
||||
}
|
||||
}, [
|
||||
mainQueryError,
|
||||
ref,
|
||||
state.selectedDatabaseId,
|
||||
project?.dbVersion,
|
||||
isPrimaryDatabase,
|
||||
queryPerformanceQuery.resolvedSql,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (hitRateError) {
|
||||
const errorMessage = getErrorMessage(hitRateError)
|
||||
captureQueryPerformanceError(hitRateError, {
|
||||
projectRef: ref,
|
||||
databaseIdentifier: state.selectedDatabaseId,
|
||||
queryPreset: 'queryHitRate',
|
||||
queryType: 'hitRate',
|
||||
postgresVersion: project?.dbVersion,
|
||||
databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
|
||||
errorMessage: errorMessage || undefined,
|
||||
})
|
||||
}
|
||||
}, [hitRateError, ref, state.selectedDatabaseId, project?.dbVersion, isPrimaryDatabase])
|
||||
|
||||
useEffect(() => {
|
||||
if (metricsError) {
|
||||
const errorMessage = getErrorMessage(metricsError)
|
||||
captureQueryPerformanceError(metricsError, {
|
||||
projectRef: ref,
|
||||
databaseIdentifier: state.selectedDatabaseId,
|
||||
queryPreset: 'queryMetrics',
|
||||
queryType: 'metrics',
|
||||
postgresVersion: project?.dbVersion,
|
||||
databaseType: isPrimaryDatabase ? 'primary' : 'read-replica',
|
||||
errorMessage: errorMessage || undefined,
|
||||
})
|
||||
}
|
||||
}, [metricsError, ref, state.selectedDatabaseId, project?.dbVersion, isPrimaryDatabase])
|
||||
|
||||
const hasError = mainQueryError || hitRateError || metricsError
|
||||
const errorMessage = mainQueryError
|
||||
? getErrorMessage(mainQueryError) || 'Failed to load query performance data'
|
||||
: hitRateError
|
||||
? getErrorMessage(hitRateError) || 'Failed to load cache hit rate data'
|
||||
: metricsError
|
||||
? getErrorMessage(metricsError) || 'Failed to load query metrics'
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasError && (
|
||||
<div className="px-6 pt-4">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Error loading query performance data"
|
||||
description={
|
||||
errorMessage ||
|
||||
'An error occurred while loading query performance data. Please try refreshing the page.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<QueryPerformanceMetrics />
|
||||
<QueryPerformanceFilterBar
|
||||
showRolesFilter={true}
|
||||
@@ -98,7 +179,16 @@ export const WithStatements = ({
|
||||
}
|
||||
/>
|
||||
<LoadingLine loading={isLoading || isRefetching} />
|
||||
<QueryPerformanceGrid aggregatedData={processedData} isLoading={isLoading} />
|
||||
<QueryPerformanceGrid
|
||||
aggregatedData={processedData}
|
||||
isLoading={isLoading}
|
||||
error={
|
||||
mainQueryError
|
||||
? getErrorMessage(mainQueryError) || 'Failed to load query performance data'
|
||||
: null
|
||||
}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
<div
|
||||
className={cn('px-6 py-6 flex gap-x-4 border-t relative', {
|
||||
hidden: showBottomSection === false,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getErrorMessage } from './get-error-message'
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(getErrorMessage(null)).toBe(null)
|
||||
})
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(getErrorMessage(undefined)).toBe(null)
|
||||
})
|
||||
|
||||
it('returns the string for string errors', () => {
|
||||
expect(getErrorMessage('Something went wrong')).toBe('Something went wrong')
|
||||
expect(getErrorMessage('')).toBe('')
|
||||
})
|
||||
|
||||
it('returns the message for Error instances', () => {
|
||||
expect(getErrorMessage(new Error('Failed to load'))).toBe('Failed to load')
|
||||
expect(getErrorMessage(new TypeError('Invalid type'))).toBe('Invalid type')
|
||||
})
|
||||
|
||||
it('returns the message property for objects with message', () => {
|
||||
expect(getErrorMessage({ message: 'Custom error' })).toBe('Custom error')
|
||||
expect(getErrorMessage({ message: 123 })).toBe('123')
|
||||
expect(getErrorMessage({ message: null })).toBe('null')
|
||||
})
|
||||
|
||||
it('converts other types to string', () => {
|
||||
expect(getErrorMessage(123)).toBe('123')
|
||||
expect(getErrorMessage(true)).toBe('true')
|
||||
expect(getErrorMessage(false)).toBe('false')
|
||||
expect(getErrorMessage({})).toBe('[object Object]')
|
||||
expect(getErrorMessage([])).toBe('')
|
||||
})
|
||||
|
||||
it('handles objects without message property', () => {
|
||||
expect(getErrorMessage({ code: 500 })).toBe('[object Object]')
|
||||
expect(getErrorMessage({ error: 'test' })).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('handles nested error objects', () => {
|
||||
expect(getErrorMessage({ message: { nested: 'error' } })).toBe('[object Object]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Extracts a human-readable error message from various error types.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string | null {
|
||||
if (error === null || error === undefined) return null
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
return String(error.message)
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -20,10 +20,11 @@ import { useReportDateRange } from 'hooks/misc/useReportDateRange'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
|
||||
const { isIndexAdvisorEnabled } = useIndexAdvisorStatus()
|
||||
const { sort: sortConfig } = useQueryPerformanceSort()
|
||||
|
||||
@@ -61,6 +62,30 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
|
||||
|
||||
const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1'
|
||||
|
||||
if (!ref) {
|
||||
return (
|
||||
<div className="h-full flex flex-col p-6">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Invalid project reference"
|
||||
description="Unable to load query performance data. Please ensure you have selected a valid project."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoadingProject && !project) {
|
||||
return (
|
||||
<div className="h-full flex flex-col p-6">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="Project not found"
|
||||
description="Unable to load project data. Please check your project reference and try again."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="w-full mb-0 flex lg:items-center justify-between gap-4 py-4 px-6 lg:flex-row flex-col">
|
||||
|
||||
Reference in new issue
Block a user