mirror of
https://github.com/supabase/supabase.git
synced 2026-06-22 04:52:48 +08:00
## Problem On self-hosted Supabase instances where the `pg_stat_statements` extension is not installed, the Observability Overview page automatically queries the extension on every page load. This produces "relation pg_stat_statements does not exist" errors in Postgres logs for all projects without the extension. Additionally, if a user navigated to the Query Performance page, they received a generic error with no actionable guidance. A secondary issue allowed malformed sort URL params (e.g. `?sort=created_at:asc&order=asc`) to be interpolated directly into SQL ORDER BY clauses. ## Fix - Wrapped the `useSlowQueriesCount` SQL in a `CASE WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')` guard. The query now returns 0 silently instead of erroring when the extension is absent. - Added a `VALID_SORT_COLUMNS` whitelist in `generateQueryPerformanceSql`. Invalid column names from URL params are rejected and the query falls back to the preset default ORDER BY. - When the Query Performance page fails because `pg_stat_statements` does not exist, a `warning` admonition now appears with "Enable it in Database -> Extensions" guidance instead of a generic destructive error. The Sentry capture is skipped for this expected configuration state. - Extracted `buildSlowQueriesCountSql` as a testable function and added unit tests for both fixes. ## How to test **Extension not installed (self-hosted):** 1. Run a self-hosted Supabase instance without the `pg_stat_statements` extension enabled. 2. Navigate to the Observability Overview page. 3. Check Postgres logs -- no "relation pg_stat_statements does not exist" errors should appear. 4. Navigate to the Query Performance page. 5. Expected: a yellow warning admonition appears saying the extension is not enabled, with a link to Database -> Extensions. No red error. **Extension installed (normal flow):** 1. With `pg_stat_statements` installed, navigate to Observability Overview. 2. Expected: slow queries count loads as normal. 3. Navigate to Query Performance -- data loads as normal. **Invalid sort URL param:** 1. Navigate to `/project/<ref>/observability/query-performance?sort=created_at:asc&order=asc`. 2. Expected: the page loads and falls back to the default sort order (total time descending). No SQL error in logs. **Unit tests:** ``` node apps/studio/node_modules/vitest/dist/cli.js run --no-coverage \ apps/studio/components/interfaces/Observability/useSlowQueriesCount.test.ts \ apps/studio/components/interfaces/QueryPerformance/useQueryPerformanceQuery.test.ts ``` All 28 tests should pass. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
|
import { executeSql } from 'data/sql/execute-sql-query'
|
|
import useDbQuery from 'hooks/analytics/useDbQuery'
|
|
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
|
import { IS_PLATFORM } from 'lib/constants'
|
|
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
|
|
|
import { PRESET_CONFIG } from '../Reports/Reports.constants'
|
|
import { Presets } from '../Reports/Reports.types'
|
|
import {
|
|
QueryPerformanceRow,
|
|
QueryPerformanceSort,
|
|
QueryPerformanceSQLParams,
|
|
} from './QueryPerformance.types'
|
|
|
|
const VALID_SORT_COLUMNS: ReadonlySet<string> = new Set<QueryPerformanceSort['column']>([
|
|
'query',
|
|
'rolname',
|
|
'total_time',
|
|
'prop_total_time',
|
|
'calls',
|
|
'avg_rows',
|
|
'max_time',
|
|
'mean_time',
|
|
'min_time',
|
|
])
|
|
|
|
export function generateQueryPerformanceSql({
|
|
preset,
|
|
orderBy,
|
|
searchQuery = '',
|
|
roles = [],
|
|
sources = [],
|
|
minCalls = 0,
|
|
minTotalTime = 0,
|
|
runIndexAdvisor = false,
|
|
filterIndexAdvisor = false,
|
|
page = 1,
|
|
pageSize = 20,
|
|
}: QueryPerformanceSQLParams) {
|
|
const safePage = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1
|
|
const safePageSize = Number.isFinite(pageSize)
|
|
? Math.min(Math.max(1, Math.floor(pageSize)), 100)
|
|
: 20
|
|
|
|
const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE]
|
|
const baseSQL = queryPerfQueries.queries[preset]
|
|
|
|
const isValidOrderBy =
|
|
orderBy != null &&
|
|
VALID_SORT_COLUMNS.has(orderBy.column) &&
|
|
(orderBy.order === 'asc' || orderBy.order === 'desc')
|
|
|
|
const orderBySql = isValidOrderBy ? `ORDER BY ${orderBy!.column} ${orderBy!.order}` : undefined
|
|
|
|
const whereConditions = []
|
|
if (roles.length > 0) {
|
|
whereConditions.push(`auth.rolname in (${roles.map((r) => `'${r}'`).join(', ')})`)
|
|
}
|
|
if (searchQuery.length > 0) {
|
|
whereConditions.push(`statements.query ~* '${searchQuery}'`)
|
|
}
|
|
if (sources.includes('dashboard') && !sources.includes('non-dashboard')) {
|
|
whereConditions.push(`statements.query ~* 'source: dashboard'`)
|
|
}
|
|
if (sources.includes('non-dashboard') && !sources.includes('dashboard')) {
|
|
whereConditions.push(`statements.query !~* 'source: dashboard'`)
|
|
}
|
|
if (minCalls > 0) {
|
|
whereConditions.push(`statements.calls >= ${minCalls}`)
|
|
}
|
|
if (minTotalTime > 0) {
|
|
whereConditions.push(
|
|
`(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}`
|
|
)
|
|
}
|
|
|
|
const whereSql = whereConditions.join(' AND ')
|
|
|
|
const sql = baseSQL.sql(
|
|
[],
|
|
whereSql.length > 0 ? `WHERE ${whereSql}` : undefined,
|
|
orderBySql,
|
|
runIndexAdvisor,
|
|
filterIndexAdvisor,
|
|
safePage,
|
|
safePageSize
|
|
)
|
|
|
|
return { sql, whereSql, orderBySql }
|
|
}
|
|
|
|
export const useQueryPerformanceQuery = (props: QueryPerformanceSQLParams) => {
|
|
const { sql, whereSql, orderBySql } = generateQueryPerformanceSql(props)
|
|
return useDbQuery({ sql, params: undefined, where: whereSql, orderBy: orderBySql })
|
|
}
|
|
|
|
export interface QueryPerformanceInfiniteHook {
|
|
data: QueryPerformanceRow[] | undefined
|
|
isLoading: boolean
|
|
isRefetching: boolean
|
|
isFetchingNextPage: boolean
|
|
hasNextPage: boolean
|
|
error: unknown
|
|
fetchNextPage: () => void
|
|
refetch: () => void
|
|
resolvedSql: string
|
|
}
|
|
|
|
export const useQueryPerformanceInfiniteQuery = (
|
|
props: Omit<QueryPerformanceSQLParams, 'page'>
|
|
): QueryPerformanceInfiniteHook => {
|
|
const queryClient = useQueryClient()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const state = useDatabaseSelectorStateSnapshot()
|
|
const { data: databases } = useReadReplicasQuery({ projectRef: project?.ref })
|
|
const connectionString = (databases || []).find(
|
|
(db) => db.identifier === state.selectedDatabaseId
|
|
)?.connectionString
|
|
|
|
// Clamp pageSize the same way generateQueryPerformanceSql does so getNextPageParam
|
|
// and the queryKey are always consistent with the SQL actually executed.
|
|
const rawPageSize = props.pageSize
|
|
const safePageSize = Number.isFinite(rawPageSize)
|
|
? Math.min(Math.max(1, Math.floor(rawPageSize!)), 100)
|
|
: 20
|
|
const { sql: page1Sql } = generateQueryPerformanceSql({
|
|
...props,
|
|
page: 1,
|
|
pageSize: safePageSize,
|
|
})
|
|
|
|
// When a read-replica is selected, require its connection string before fetching.
|
|
// Falling back to the primary's connection string would silently query the wrong database.
|
|
const isPrimarySelected = !state.selectedDatabaseId || state.selectedDatabaseId === project?.ref
|
|
const effectiveConnectionString = isPrimarySelected
|
|
? (connectionString ?? project?.connectionString)
|
|
: connectionString
|
|
|
|
const { data, isPending, isRefetching, isFetchingNextPage, hasNextPage, error, fetchNextPage } =
|
|
useInfiniteQuery({
|
|
queryKey: [
|
|
'projects',
|
|
project?.ref,
|
|
'query-performance-infinite',
|
|
{
|
|
...props,
|
|
pageSize: safePageSize,
|
|
identifier: state.selectedDatabaseId,
|
|
connectionString: effectiveConnectionString,
|
|
},
|
|
],
|
|
initialPageParam: 1,
|
|
queryFn: ({ pageParam, signal }) => {
|
|
const { sql } = generateQueryPerformanceSql({
|
|
...props,
|
|
page: pageParam,
|
|
pageSize: safePageSize,
|
|
})
|
|
return executeSql<QueryPerformanceRow[]>(
|
|
{
|
|
projectRef: project?.ref,
|
|
connectionString: effectiveConnectionString,
|
|
sql,
|
|
},
|
|
signal
|
|
).then((res) => res.result)
|
|
},
|
|
getNextPageParam: (lastPage, allPages) => {
|
|
return lastPage.length < safePageSize ? undefined : allPages.length + 1
|
|
},
|
|
// Don't run until we have a connection string for the selected database.
|
|
// For replicas this prevents a silent fallback to the primary before replicas load.
|
|
// In self-hosted mode (IS_PLATFORM=false) there is no real connection string, so we
|
|
// skip the check — executeSql works fine without one on self-hosted deployments.
|
|
enabled: Boolean(project?.ref) && (!IS_PLATFORM || Boolean(effectiveConnectionString)),
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
})
|
|
|
|
return {
|
|
data: data?.pages.flatMap((page) => page) ?? undefined,
|
|
isLoading: isPending,
|
|
isRefetching,
|
|
isFetchingNextPage,
|
|
hasNextPage: hasNextPage ?? false,
|
|
error,
|
|
fetchNextPage,
|
|
// Reset to page 1 instead of re-fetching all loaded pages, avoiding a burst
|
|
// of N requests when the user clicks Refresh after scrolling through multiple pages.
|
|
refetch: () =>
|
|
queryClient.resetQueries({
|
|
queryKey: ['projects', project?.ref, 'query-performance-infinite'],
|
|
exact: false,
|
|
}),
|
|
resolvedSql: page1Sql,
|
|
}
|
|
}
|