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:
Jordi Enric authored and GitHub committed 2025-12-15 20:39:09 +01:00
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}
/>
</>
)
@@ -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,
+46
View File
@@ -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]')
})
})
+12
View File
@@ -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">