diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts index fb13634e4b2..2d4dbff2c17 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts @@ -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) + }) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index b96cbb44dab..1d3e8c8bc5f 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -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(null) @@ -457,6 +462,28 @@ export const QueryPerformanceGrid = ({ } }, [selectedRow, view, reportData]) + if (error) { + return ( +
+
+ + {onRetry && ( +
+ +
+ )} +
+
+
+ ) + } + return (
diff --git a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx b/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx index db34a8def4c..dce6acb7bb8 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx @@ -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(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 ( <> ) diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx index 09f2a8a00ca..00bb2b1665e 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx @@ -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 && ( +
+ +
+ )} - +
{ + 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]') + }) +}) diff --git a/apps/studio/lib/get-error-message.ts b/apps/studio/lib/get-error-message.ts new file mode 100644 index 00000000000..87e7b4ace56 --- /dev/null +++ b/apps/studio/lib/get-error-message.ts @@ -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) +} diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index feaa527f55c..43686bb31ad 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -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 ( +
+ +
+ ) + } + + if (!isLoadingProject && !project) { + return ( +
+ +
+ ) + } + return (