From 105df5291dc29bcc97bb2efbda53a09c6294f957 Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo <31685197+soedirgo@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:11:46 +0800 Subject: [PATCH] feat: initial supamonitor changes (#42313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Query Performance page implementation powered by [supamonitor](https://github.com/supabase/supamonitor). [Context](https://linear.app/supabase/project/build-extension-for-supabase-query-insights-df4fb145352c/overview) This looks largely the same as the pg_stat_monitor implementation: Screenshot 2026-02-12 at 7 35 47 PM Only available on projects on custom AMI - existing users are unaffected ## Summary by CodeRabbit * **New Features** * Supamonitor-based query performance view: charts, aggregated metrics, date-range controls, and export/download. * Added "Application" column for per-application tracking. * Interactive Supamonitor grid: sorting, filtering, keyboard navigation, selection, retry/error handling. * Automatic per-project Supamonitor detection with toggleable UI integration. * **Bug Fixes** * Chart latency calculation prefers histogram data for more accurate p95. * **Documentation** * Minor blog formatting fix. --------- Co-authored-by: kemal Co-authored-by: Ali Waseem --- .../QueryPerformance/QueryCosts.tsx | 56 ---- .../QueryPerformance/QueryDetail.tsx | 5 +- .../QueryPerformance.constants.ts | 75 ++--- .../QueryPerformance/QueryPerformance.tsx | 10 +- .../QueryPerformance.types.ts | 40 +++ .../QueryPerformance.utils.test.ts | 21 -- .../QueryPerformance.utils.ts | 13 +- .../QueryPerformanceChart.tsx | 59 +--- .../QueryPerformance/QueryPerformanceGrid.tsx | 14 +- .../WithMonitor/WithMonitor.utils.ts | 301 ----------------- .../WithStatements.utils.test.ts | 148 +++++++++ .../WithSupamonitor.tsx} | 35 +- .../WithSupamonitor.utils.test.ts | 306 ++++++++++++++++++ .../WithSupamonitor/WithSupamonitor.utils.ts | 147 +++++++++ .../hooks/useSupamonitorStatus.ts | 18 ++ apps/studio/data/database/keys.ts | 2 + .../database/supamonitor-enabled-query.ts | 47 +++ .../[ref]/observability/query-performance.tsx | 8 +- supabase/functions/common/database-types.ts | 0 19 files changed, 800 insertions(+), 505 deletions(-) delete mode 100644 apps/studio/components/interfaces/QueryPerformance/QueryCosts.tsx delete mode 100644 apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.utils.ts create mode 100644 apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.test.ts rename apps/studio/components/interfaces/QueryPerformance/{WithMonitor/WithMonitor.tsx => WithSupamonitor/WithSupamonitor.tsx} (82%) create mode 100644 apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts create mode 100644 apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts create mode 100644 apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts create mode 100644 apps/studio/data/database/supamonitor-enabled-query.ts delete mode 100644 supabase/functions/common/database-types.ts diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryCosts.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryCosts.tsx deleted file mode 100644 index 197ab6ea48..0000000000 --- a/apps/studio/components/interfaces/QueryPerformance/QueryCosts.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { cn } from 'ui' - -interface QueryCostsProps { - currentCost?: number - improvedCost?: number - improvement?: number - className?: string -} - -export const QueryCosts = ({ - currentCost, - improvedCost, - improvement, - className, -}: QueryCostsProps) => { - if (!currentCost) return null - - return ( -
-

Query costs

-
-
-

Total cost of query

-
-
-

Currently:

-

- {typeof currentCost === 'number' && !isNaN(currentCost) && isFinite(currentCost) - ? currentCost.toFixed(2) - : 'N/A'} -

-
- {improvedCost && - typeof improvedCost === 'number' && - !isNaN(improvedCost) && - isFinite(improvedCost) && ( -
-

With index:

-
-

{improvedCost.toFixed(2)}

- {improvement && - typeof improvement === 'number' && - !isNaN(improvement) && - isFinite(improvement) && ( -

↓ {improvement.toFixed(1)}%

- )} -
-
- )} -
-
- -
-
- ) -} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx index 0780903e03..4aa6696c21 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx @@ -12,10 +12,7 @@ import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, cn import { QueryPanelContainer, QueryPanelSection } from './QueryPanel' import { buildQueryExplanationPrompt } from './QueryPerformance.ai' -import { - QUERY_PERFORMANCE_COLUMNS, - QUERY_PERFORMANCE_REPORT_TYPES, -} from './QueryPerformance.constants' +import { QUERY_PERFORMANCE_COLUMNS } from './QueryPerformance.constants' import { QueryPerformanceRow } from './QueryPerformance.types' import { formatDuration } from './QueryPerformance.utils' diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts index 601ef33ca0..ecbaa3ea46 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts @@ -22,6 +22,7 @@ export const QUERY_PERFORMANCE_COLUMNS = [ { id: 'rows_read', name: 'Rows processed', description: undefined, minWidth: 130 }, { id: 'cache_hit_rate', name: 'Cache hit rate', description: undefined, minWidth: 130 }, { id: 'rolname', name: 'Role', description: undefined, minWidth: 200 }, + { id: 'application_name', name: 'Application', description: undefined, minWidth: 150 }, ] as const export const QUERY_PERFORMANCE_ROLE_DESCRIPTION = [ @@ -88,46 +89,40 @@ export const QUERY_PERFORMANCE_CHART_TABS = [ }, ] -export const QUERY_PERFORMANCE_TIME_RANGES = [ - { - id: 'last_60_minutes', - label: 'Last 60 minutes', - }, - { - id: 'last_3_hours', - label: 'Last 3 hours', - }, - { - id: 'last_24_hours', - label: 'Last 24 hours', - }, -] - -export const getPgStatMonitorLogsQuery = (startTime: string, endTime: string) => +export const getSupamonitorLogsQuery = (startTime: string, endTime: string) => ` -select - id, - pgl.timestamp as timestamp, - 'postgres' as log_type, - CAST(pgl_parsed.sql_state_code AS STRING) as status, - CASE - WHEN pgl_parsed.error_severity = 'LOG' THEN 'success' - WHEN pgl_parsed.error_severity = 'WARNING' THEN 'warning' - WHEN pgl_parsed.error_severity = 'FATAL' THEN 'error' - WHEN pgl_parsed.error_severity = 'ERROR' THEN 'error' - ELSE null - END as level, - event_message as event_message -from postgres_logs as pgl -cross join unnest(pgl.metadata) as pgl_metadata -cross join unnest(pgl_metadata.parsed) as pgl_parsed -WHERE pgl.event_message LIKE '%[pg_stat_monitor]%' - AND pgl.timestamp >= CAST('${startTime}' AS TIMESTAMP) - AND pgl.timestamp <= CAST('${endTime}' AS TIMESTAMP) +select + TIMESTAMP_TRUNC(sml.timestamp, MINUTE) as timestamp, + CAST(sml_parsed.application_name AS STRING) as application_name, + SUM(sml_parsed.calls) as calls, + CAST(sml_parsed.database_name AS STRING) as database_name, + CAST(sml_parsed.query AS STRING) as query, + sml_parsed.query_id as query_id, + SUM(sml_parsed.total_exec_time) as total_exec_time, + SUM(sml_parsed.total_plan_time) as total_plan_time, + CAST(sml_parsed.user_name AS STRING) as user_name, + CASE WHEN SUM(sml_parsed.calls) > 0 + THEN SUM(sml_parsed.total_exec_time) / SUM(sml_parsed.calls) + ELSE 0 + END as mean_exec_time, + MIN(NULLIF(sml_parsed.total_exec_time, 0)) as min_exec_time, + MAX(sml_parsed.total_exec_time) as max_exec_time, + CASE WHEN SUM(sml_parsed.calls) > 0 + THEN SUM(sml_parsed.total_plan_time) / SUM(sml_parsed.calls) + ELSE 0 + END as mean_plan_time, + MIN(NULLIF(sml_parsed.total_plan_time, 0)) as min_plan_time, + MAX(sml_parsed.total_plan_time) as max_plan_time, + APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(50)] as p50_exec_time, + APPROX_QUANTILES(sml_parsed.total_exec_time, 100)[OFFSET(95)] as p95_exec_time, + APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(50)] as p50_plan_time, + APPROX_QUANTILES(sml_parsed.total_plan_time, 100)[OFFSET(95)] as p95_plan_time +from supamonitor_logs as sml +cross join unnest(sml.metadata) as sml_metadata +cross join unnest(sml_metadata.supamonitor) as sml_parsed +WHERE sml.event_message = 'log' + AND sml.timestamp >= CAST('${startTime}' AS TIMESTAMP) + AND sml.timestamp <= CAST('${endTime}' AS TIMESTAMP) +GROUP BY timestamp, user_name, database_name, application_name, query_id, query ORDER BY timestamp DESC `.trim() - -export const PG_STAT_MONITOR_LOGS_QUERY = getPgStatMonitorLogsQuery( - new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - new Date().toISOString() -) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index 82844f1ca1..7ff8243739 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' -import { WithMonitor } from './WithMonitor/WithMonitor' import { WithStatements } from './WithStatements/WithStatements' +import { WithSupamonitor } from './WithSupamonitor/WithSupamonitor' import { useParams } from 'common' import { DbQueryHook } from 'hooks/analytics/useDbQuery' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' @@ -11,7 +11,7 @@ interface QueryPerformanceProps { queryHitRate: PresetHookResult queryPerformanceQuery: DbQueryHook queryMetrics: PresetHookResult - isPgStatMonitorEnabled: boolean + isSupamonitorEnabled: boolean dateRange?: { period_start: { date: string; time_period: string } period_end: { date: string; time_period: string } @@ -24,7 +24,7 @@ export const QueryPerformance = ({ queryHitRate, queryPerformanceQuery, queryMetrics, - isPgStatMonitorEnabled, + isSupamonitorEnabled, dateRange, onDateRangeChange, }: QueryPerformanceProps) => { @@ -36,8 +36,8 @@ export const QueryPerformance = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref]) - if (isPgStatMonitorEnabled) { - return + if (isSupamonitorEnabled) { + return } return ( diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.types.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.types.ts index e7499b46d9..a684c9bedd 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.types.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.types.ts @@ -11,8 +11,48 @@ export interface QueryPerformanceRow { rows_read: number cache_hit_rate: number rolname: string + application_name?: string index_advisor_result?: GetIndexAdvisorResultResponse | null _total_cache_hits?: number _total_cache_misses?: number _count?: number } + +export interface ChartDataPoint { + period_start: number + timestamp: string + query_latency: number + mean_time: number + min_time: number + max_time: number + stddev_time: number + p50_time: number + p95_time: number + rows_read: number + calls: number + cache_hits: number + cache_misses: number +} + +export interface ParsedLogEntry { + timestamp?: string + application_name?: string + calls?: number + database_name?: string + query?: string + query_id?: number + total_exec_time?: number + total_plan_time?: number + user_name?: string + mean_exec_time?: number + mean_plan_time?: number + min_exec_time?: number + max_exec_time?: number + min_plan_time?: number + max_plan_time?: number + p50_exec_time?: number + p95_exec_time?: number + p50_plan_time?: number + p95_plan_time?: number + [key: string]: any +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.test.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.test.ts index a2dc1994ab..fe898f94d9 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.test.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' import { formatDuration } from './QueryPerformance.utils' -import { calculatePercentilesFromHistogram } from './WithMonitor/WithMonitor.utils' describe('formatDuration', () => { it('should format seconds', () => { @@ -23,23 +22,3 @@ describe('formatDuration', () => { expect(formatDuration(90061000)).toBe('1d 1h 1m 1s') }) }) - -describe('calculatePercentilesFromHistogram', () => { - it('should return zero for empty histogram', () => { - const result = calculatePercentilesFromHistogram([]) - expect(result.p95).toBe(0) - }) - - it('should return valid p95 for typical distribution', () => { - const result = calculatePercentilesFromHistogram([10, 20, 30, 20, 10, 10]) - expect(result.p95).toBeGreaterThan(0) - expect(result.p95).toBeGreaterThanOrEqual(result.p50) - }) - - it('should return consistent p95 for same input', () => { - const histogram = [10, 20, 30, 20, 10, 10] - const result1 = calculatePercentilesFromHistogram(histogram) - const result2 = calculatePercentilesFromHistogram(histogram) - expect(result1.p95).toBe(result2.p95) - }) -}) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts index 2d4dbff2c1..386c94fa94 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.utils.ts @@ -28,22 +28,11 @@ export const formatDuration = (milliseconds: number) => { return parts.length > 0 ? parts.join(' ') : '0s' } -export const transformLogsToJSON = (log: string) => { - try { - let jsonString = log.replace('[pg_stat_monitor] ', '') - jsonString = jsonString.replace(/""/g, '","') - const jsonObject = JSON.parse(jsonString) - return jsonObject - } catch (error) { - return null - } -} - export type QueryPerformanceErrorContext = { projectRef?: string databaseIdentifier?: string queryPreset?: string - queryType?: 'hitRate' | 'metrics' | 'mainQuery' | 'monitor' | 'slowQueriesCount' + queryType?: 'hitRate' | 'metrics' | 'mainQuery' | 'slowQueriesCount' | 'supamonitor' sql?: string errorMessage?: string postgresVersion?: string diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx index a8dc5314b8..4fcc479a7d 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceChart.tsx @@ -4,8 +4,7 @@ import { QUERY_PERFORMANCE_CHART_TABS } from './QueryPerformance.constants' import { Loader2 } from 'lucide-react' import { ComposedChart } from 'components/ui/Charts/ComposedChart' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import type { ChartDataPoint } from './WithMonitor/WithMonitor.utils' -import { calculatePercentilesFromHistogram } from './WithMonitor/WithMonitor.utils' +import type { ChartDataPoint } from './QueryPerformance.types' interface QueryPerformanceChartProps { dateRange?: { @@ -62,33 +61,11 @@ export const QueryPerformanceChart = ({ switch (selectedMetric) { case 'query_latency': { - let trueP95: number = 0 - - if (parsedLogs && parsedLogs.length > 0) { - const bucketCount = parsedLogs[0]?.resp_calls?.length || 50 - const combinedHistogram = new Array(bucketCount).fill(0) - - parsedLogs.forEach((log) => { - if (log.resp_calls && Array.isArray(log.resp_calls)) { - log.resp_calls.forEach((count: number, index: number) => { - if (index < combinedHistogram.length) { - combinedHistogram[index] += count - } - }) - } - }) - - // [kemal]: this might need a revisit - const percentiles = calculatePercentilesFromHistogram(combinedHistogram) - trueP95 = percentiles.p95 - } else { - // [kemal]: fallback to weighted average - const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0) - trueP95 = - totalCalls > 0 - ? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls - : 0 - } + const totalCalls = chartData.reduce((sum, d) => sum + d.calls, 0) + const trueP95 = + totalCalls > 0 + ? chartData.reduce((sum, d) => sum + d.p95_time * d.calls, 0) / totalCalls + : 0 return [ { @@ -167,12 +144,7 @@ export const QueryPerformanceChart = ({ >() queryLogs.forEach((log) => { - const timestamps = [log.bucket_start_time, log.bucket, log.timestamp, log.ts] - const validTimestamp = timestamps.find((t) => t && !isNaN(new Date(t).getTime())) - - if (!validTimestamp) return - - const time = new Date(validTimestamp).getTime() + const time = new Date(log.timestamp).getTime() const meanTime = log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0 const rowsRead = log.rows_read ?? log.rows ?? 0 const calls = log.calls ?? 0 @@ -258,8 +230,14 @@ export const QueryPerformanceChart = ({ const baseAttributes = attributeMap[selectedMetric] || [] - // Add selected query line based on current metric if (currentSelectedQuery && querySpecificData) { + const dimmedBaseAttributes = baseAttributes.map((attr) => ({ + ...attr, + color: attr.color + ? { light: attr.color.light + '4D', dark: attr.color.dark + '4D' } + : attr.color, + })) + const selectedQueryAttributes: Record = { query_latency: { attribute: 'selected_query_time', @@ -297,7 +275,7 @@ export const QueryPerformanceChart = ({ const selectedQueryAttr = selectedQueryAttributes[selectedMetric] if (selectedQueryAttr) { - return [...baseAttributes, selectedQueryAttr] + return [...dimmedBaseAttributes, selectedQueryAttr] } } @@ -360,12 +338,7 @@ export const QueryPerformanceChart = ({ hideHighlightArea={true} showTooltip={true} showGrid={true} - showLegend={ - selectedMetric === 'query_latency' || - selectedMetric === 'cache_hits' || - selectedMetric === 'rows_read' || - selectedMetric === 'calls' - } + showLegend={true} showTotal={false} showMaxValue={false} updateDateRange={updateDateRange} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 4dbeada04b..2152468809 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -34,7 +34,6 @@ import { QueryDetail } from './QueryDetail' import { QueryIndexes } from './QueryIndexes' import { QUERY_PERFORMANCE_COLUMNS, - QUERY_PERFORMANCE_REPORT_TYPES, QUERY_PERFORMANCE_ROLE_DESCRIPTION, } from './QueryPerformance.constants' import { QueryPerformanceRow } from './QueryPerformance.types' @@ -98,7 +97,6 @@ export const QueryPerformanceGrid = ({ const [view, setView] = useState<'details' | 'suggestion'>('details') const [selectedRow, setSelectedRow] = useState() - const reportType = QUERY_PERFORMANCE_REPORT_TYPES.UNIFIED const columns = QUERY_PERFORMANCE_COLUMNS.map((col) => { const nonSortableColumns = ['query'] @@ -341,6 +339,18 @@ export const QueryPerformanceGrid = ({ ) } + if (col.id === 'application_name') { + return ( +
+ {value ? ( +

{value}

+ ) : ( +

+ )} +
+ ) + } + return (

{formattedValue}

diff --git a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.utils.ts b/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.utils.ts deleted file mode 100644 index f65cfb3093..0000000000 --- a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.utils.ts +++ /dev/null @@ -1,301 +0,0 @@ -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import { transformLogsToJSON } from '../QueryPerformance.utils' -import { QueryPerformanceRow } from '../QueryPerformance.types' - -dayjs.extend(utc) - -export interface ParsedLogEntry { - bucket_start_time?: string - bucket?: string - timestamp?: string - ts?: string - mean_time?: number - mean_exec_time?: number - mean_query_time?: number - min_time?: number - min_exec_time?: number - min_query_time?: number - max_time?: number - max_exec_time?: number - max_query_time?: number - stddev_time?: number - stddev_exec_time?: number - stddev_query_time?: number - rows?: number - calls?: number - shared_blks_hit?: number - shared_blks_read?: number - query?: string - userid?: string - rolname?: string - resp_calls?: number[] - [key: string]: any -} - -export interface ChartDataPoint { - period_start: number - timestamp: string - query_latency: number - mean_time: number - min_time: number - max_time: number - stddev_time: number - p50_time: number - p95_time: number - rows_read: number - calls: number - cache_hits: number - cache_misses: number -} - -export const parsePgStatMonitorLogs = (logData: any[]): ParsedLogEntry[] => { - if (!logData || logData.length === 0) return [] - - const validParsedLogs = logData - .map((log) => ({ - ...log, - parsedEventMessage: transformLogsToJSON(log.event_message), - })) - .filter((log) => log.parsedEventMessage !== null) - .filter((log) => log.parsedEventMessage?.event === 'bucket_query') - - return validParsedLogs.map((log) => log.parsedEventMessage) -} - -export const transformLogsToChartData = (parsedLogs: ParsedLogEntry[]): ChartDataPoint[] => { - if (!parsedLogs || parsedLogs.length === 0) return [] - - // [kemal]: here for debugging - // if (parsedLogs.length > 0) { - // console.log('🟡 Parsed logs:', parsedLogs) - // } - - return parsedLogs - .map((log: ParsedLogEntry) => { - const possibleTimestamps = [log.bucket_start_time, log.bucket, log.timestamp, log.ts] - - let periodStart: number | null = null - - for (const ts of possibleTimestamps) { - if (ts) { - const date = new Date(ts) - const time = date.getTime() - if (!isNaN(time) && time > 0 && time > 946684800000) { - periodStart = time - break - } - } - } - - if (!periodStart) { - return null - } - - const percentiles = - log.resp_calls && Array.isArray(log.resp_calls) - ? calculatePercentilesFromHistogram(log.resp_calls) - : { p50: 0, p95: 0 } - - return { - period_start: periodStart, - timestamp: possibleTimestamps.find((t) => t) || '', - query_latency: parseFloat( - String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0) - ), - mean_time: parseFloat( - String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0) - ), - min_time: parseFloat(String(log.min_time ?? log.min_exec_time ?? log.min_query_time ?? 0)), - max_time: parseFloat(String(log.max_time ?? log.max_exec_time ?? log.max_query_time ?? 0)), - stddev_time: parseFloat( - String(log.stddev_time ?? log.stddev_exec_time ?? log.stddev_query_time ?? 0) - ), - p50_time: percentiles.p50, - p95_time: percentiles.p95, - rows_read: parseInt(String(log.rows ?? 0), 10), - calls: parseInt(String(log.calls ?? 0), 10), - cache_hits: parseFloat(String(log.shared_blks_hit ?? 0)), - cache_misses: parseFloat(String(log.shared_blks_read ?? 0)), - } - }) - .filter((item): item is NonNullable => item !== null) - .sort((a, b) => a.period_start - b.period_start) -} - -const normalizeQuery = (query: string): string => { - return query.replace(/\s+/g, ' ').trim() -} - -export const aggregateLogsByQuery = (parsedLogs: ParsedLogEntry[]): QueryPerformanceRow[] => { - if (!parsedLogs || parsedLogs.length === 0) return [] - - const queryGroups = new Map() - - parsedLogs.forEach((log) => { - const query = normalizeQuery(log.query || '') - if (!query) return - - if (!queryGroups.has(query)) { - queryGroups.set(query, []) - } - queryGroups.get(query)!.push(log) - }) - - const aggregatedData: QueryPerformanceRow[] = [] - let totalExecutionTime = 0 - - const queryStats = Array.from(queryGroups.entries()).map(([query, logs]) => { - const count = logs.length - let totalCalls = 0 - let totalRowsRead = 0 - let totalCacheHits = 0 - let totalCacheMisses = 0 - let rolname = logs[0].username - let minTime = Infinity - let maxTime = -Infinity - let totalExecutionTimeForQuery = 0 - - logs.forEach((log) => { - const logMeanTime = parseFloat( - String(log.mean_time ?? log.mean_exec_time ?? log.mean_query_time ?? 0) - ) - const logMinTime = parseFloat( - String(log.min_time ?? log.min_exec_time ?? log.min_query_time ?? 0) - ) - const logMaxTime = parseFloat( - String(log.max_time ?? log.max_exec_time ?? log.max_query_time ?? 0) - ) - const logCalls = parseInt(String(log.calls ?? 0), 10) - const logRows = parseInt(String(log.rows ?? 0), 10) - const logCacheHits = parseFloat(String(log.shared_blks_hit ?? 0)) - const logCacheMisses = parseFloat(String(log.shared_blks_read ?? 0)) - - minTime = Math.min(minTime, logMinTime) - maxTime = Math.max(maxTime, logMaxTime) - totalCalls += logCalls - totalRowsRead += logRows - totalCacheHits += logCacheHits - totalCacheMisses += logCacheMisses - totalExecutionTimeForQuery += logMeanTime * logCalls - }) - - // Overall mean time is the weighted average - const avgMeanTime = totalCalls > 0 ? totalExecutionTimeForQuery / totalCalls : 0 - const finalMinTime = minTime === Infinity ? 0 : minTime - const finalMaxTime = maxTime === -Infinity ? 0 : maxTime - - totalExecutionTime += totalExecutionTimeForQuery - - return { - query, - rolname, - count, - avgMeanTime, - minTime: finalMinTime, - maxTime: finalMaxTime, - totalCalls, - totalRowsRead, - totalTime: totalExecutionTimeForQuery, - totalCacheHits, - totalCacheMisses, - } - }) - - queryStats.forEach((stats) => { - const totalCacheAccess = stats.totalCacheHits + stats.totalCacheMisses - const cacheHitRate = totalCacheAccess > 0 ? (stats.totalCacheHits / totalCacheAccess) * 100 : 0 - - const propTotalTime = totalExecutionTime > 0 ? (stats.totalTime / totalExecutionTime) * 100 : 0 - - aggregatedData.push({ - query: stats.query, - rolname: stats.rolname, - calls: stats.totalCalls, - mean_time: stats.avgMeanTime, - min_time: stats.minTime, - max_time: stats.maxTime, - total_time: stats.totalTime, - rows_read: stats.totalRowsRead, - cache_hit_rate: cacheHitRate, - prop_total_time: propTotalTime, - index_advisor_result: null, - _total_cache_hits: stats.totalCacheHits, - _total_cache_misses: stats.totalCacheMisses, - _count: stats.count, - }) - }) - - return aggregatedData.sort((a, b) => b.total_time - a.total_time) -} - -export const calculatePercentilesFromHistogram = ( - respCalls: number[] -): { - p50: number - p95: number -} => { - const bucketBoundaries = [ - { min: 0, max: 1 }, - { min: 1, max: 10 }, - { min: 10, max: 100 }, - { min: 100, max: 1000 }, - { min: 1000, max: 10000 }, - { min: 10000, max: 100000 }, - ] - - const totalCalls = respCalls.reduce((sum, count) => sum + count, 0) - - if (totalCalls === 0) { - return { p50: 0, p95: 0 } - } - - const distribution: { - minValue: number - maxValue: number - cumulativeCount: number - count: number - }[] = [] - let cumulativeCount = 0 - - respCalls.forEach((count, index) => { - if (count > 0 && index < bucketBoundaries.length) { - const bucket = bucketBoundaries[index] - cumulativeCount += count - distribution.push({ - minValue: bucket.min, - maxValue: bucket.max, - cumulativeCount, - count, - }) - } - }) - - const getPercentile = (percentile: number): number => { - const targetCount = totalCalls * percentile - - for (let i = 0; i < distribution.length; i++) { - const prevCumulativeCount = i > 0 ? distribution[i - 1].cumulativeCount : 0 - - if (distribution[i].cumulativeCount >= targetCount) { - const positionInBucket = (targetCount - prevCumulativeCount) / distribution[i].count - const bucketMin = distribution[i].minValue - const bucketMax = distribution[i].maxValue - const logMin = Math.log10(Math.max(bucketMin, 0.1)) - const logMax = Math.log10(bucketMax) - const logValue = logMin + positionInBucket * (logMax - logMin) - - return Math.pow(10, logValue) - } - } - - return distribution[distribution.length - 1]?.maxValue || 0 - } - - const result = { - p50: getPercentile(0.5), - p95: getPercentile(0.95), - } - - return result -} diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.test.ts b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.test.ts new file mode 100644 index 0000000000..26e0d4c457 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest' +import { transformStatementDataToRows } from './WithStatements.utils' + +vi.mock('../IndexAdvisor/index-advisor.utils', () => ({ + filterProtectedSchemaIndexAdvisorResult: vi.fn((result) => { + if (result?._mock_filter_null) return null + return result + }), + queryInvolvesProtectedSchemas: vi.fn((query: string) => { + return query?.toLowerCase().includes('auth.') + }), +})) + +const makeRow = (overrides: Record = {}) => ({ + query: 'SELECT 1', + rolname: 'postgres', + calls: 10, + mean_time: 5.0, + min_time: 1.0, + max_time: 20.0, + total_time: 50.0, + rows_read: 100, + cache_hit_rate: 0.95, + index_advisor_result: null, + ...overrides, +}) + +describe('transformStatementDataToRows', () => { + it('returns empty array for null or empty input', () => { + expect(transformStatementDataToRows(null as any)).toEqual([]) + expect(transformStatementDataToRows([])).toEqual([]) + }) + + it('transforms basic rows correctly', () => { + const data = [makeRow()] + const result = transformStatementDataToRows(data) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + query: 'SELECT 1', + rolname: 'postgres', + calls: 10, + mean_time: 5.0, + min_time: 1.0, + max_time: 20.0, + total_time: 50.0, + rows_read: 100, + cache_hit_rate: 0.95, + }) + }) + + it('defaults missing numeric fields to 0', () => { + const data = [{ query: 'SELECT 1' }] + const result = transformStatementDataToRows(data) + + expect(result).toHaveLength(1) + expect(result[0].calls).toBe(0) + expect(result[0].mean_time).toBe(0) + expect(result[0].min_time).toBe(0) + expect(result[0].max_time).toBe(0) + expect(result[0].total_time).toBe(0) + expect(result[0].rows_read).toBe(0) + expect(result[0].cache_hit_rate).toBe(0) + }) + + it('sets rolname to undefined when missing', () => { + const data = [makeRow({ rolname: undefined })] + const result = transformStatementDataToRows(data) + expect(result[0].rolname).toBeUndefined() + }) + + it('calculates prop_total_time as percentage of total time', () => { + const data = [ + makeRow({ query: 'Q1', total_time: 75 }), + makeRow({ query: 'Q2', total_time: 25 }), + ] + const result = transformStatementDataToRows(data) + + expect(result[0].prop_total_time).toBe(75) + expect(result[1].prop_total_time).toBe(25) + }) + + it('handles prop_total_time when total is zero', () => { + const data = [makeRow({ total_time: 0 })] + const result = transformStatementDataToRows(data) + expect(result[0].prop_total_time).toBe(0) + }) + + it('applies index_advisor_result filtering', () => { + const data = [ + makeRow({ + index_advisor_result: { index_statements: ['CREATE INDEX ON public.users (id)'] }, + }), + ] + const result = transformStatementDataToRows(data) + + expect(result[0].index_advisor_result).toEqual({ + index_statements: ['CREATE INDEX ON public.users (id)'], + }) + }) + + it('sets index_advisor_result to null when source is null', () => { + const data = [makeRow({ index_advisor_result: null })] + const result = transformStatementDataToRows(data) + expect(result[0].index_advisor_result).toBeNull() + }) + + describe('filterIndexAdvisor mode', () => { + it('keeps rows for non-protected schema queries', () => { + const data = [makeRow({ query: 'SELECT * FROM public.users' })] + const result = transformStatementDataToRows(data, true) + expect(result).toHaveLength(1) + }) + + it('keeps protected-schema rows that have valid recommendations', () => { + const data = [ + makeRow({ + query: 'SELECT * FROM auth.users', + index_advisor_result: { index_statements: ['CREATE INDEX ON auth.users (id)'] }, + }), + ] + const result = transformStatementDataToRows(data, true) + expect(result).toHaveLength(1) + }) + + it('filters out protected-schema rows with no valid recommendations', () => { + const data = [ + makeRow({ + query: 'SELECT * FROM auth.users', + index_advisor_result: { _mock_filter_null: true }, + }), + ] + const result = transformStatementDataToRows(data, true) + expect(result).toHaveLength(0) + }) + + it('does not filter protected-schema rows when filterIndexAdvisor is false', () => { + const data = [ + makeRow({ + query: 'SELECT * FROM auth.users', + index_advisor_result: { _mock_filter_null: true }, + }), + ] + const result = transformStatementDataToRows(data, false) + expect(result).toHaveLength(1) + }) + }) +}) diff --git a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.tsx similarity index 82% rename from apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx rename to apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.tsx index dce6acb7bb..30bcd69258 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithMonitor/WithMonitor.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.tsx @@ -6,12 +6,12 @@ import { useMemo, useState, useEffect } from 'react' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import useLogsQuery from 'hooks/analytics/useLogsQuery' -import { getPgStatMonitorLogsQuery } from '../QueryPerformance.constants' +import { getSupamonitorLogsQuery } from '../QueryPerformance.constants' import { - parsePgStatMonitorLogs, + parseSupamonitorLogs, transformLogsToChartData, aggregateLogsByQuery, -} from './WithMonitor.utils' +} from './WithSupamonitor.utils' import { useParams } from 'common' import { DownloadResultsButton } from 'components/ui/DownloadResultsButton' import { captureQueryPerformanceError } from '../QueryPerformance.utils' @@ -21,7 +21,7 @@ import { getErrorMessage } from 'lib/get-error-message' dayjs.extend(utc) -interface WithMonitorProps { +interface WithSupamonitorProps { dateRange?: { period_start: { date: string; time_period: string } period_end: { date: string; time_period: string } @@ -30,13 +30,12 @@ interface WithMonitorProps { onDateRangeChange?: (from: string, to: string) => void } -export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) => { +export const WithSupamonitor = ({ dateRange, onDateRangeChange }: WithSupamonitorProps) => { 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. const effectiveDateRange = useMemo(() => { if (dateRange) { return { @@ -45,7 +44,6 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) } } - // [kemal]: Fallback to default 24 hours const end = dayjs.utc() const start = end.subtract(24, 'hours') return { @@ -55,30 +53,33 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) }, [dateRange]) const queryWithTimeRange = useMemo(() => { - return getPgStatMonitorLogsQuery( + return getSupamonitorLogsQuery( effectiveDateRange.iso_timestamp_start, effectiveDateRange.iso_timestamp_end ) }, [effectiveDateRange]) - const pgStatMonitorLogs = useLogsQuery(ref as string, { + const supamonitorLogs = useLogsQuery(ref as string, { sql: queryWithTimeRange, iso_timestamp_start: effectiveDateRange.iso_timestamp_start, iso_timestamp_end: effectiveDateRange.iso_timestamp_end, }) - const { logData, isLoading: isLogsLoading, error: logsError } = pgStatMonitorLogs + const { logData, isLoading: isLogsLoading, error: logsError } = supamonitorLogs const parsedLogs = useMemo(() => { - return parsePgStatMonitorLogs(logData || []) + const result = parseSupamonitorLogs(logData || []) + return result }, [logData]) const chartData = useMemo(() => { - return transformLogsToChartData(parsedLogs) + const result = transformLogsToChartData(parsedLogs) + return result }, [parsedLogs]) const aggregatedGridData = useMemo(() => { - return aggregateLogsByQuery(parsedLogs) + const result = aggregateLogsByQuery(parsedLogs) + return result }, [parsedLogs]) const handleSelectQuery = (query: string) => { @@ -86,7 +87,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) } const handleRetry = () => { - pgStatMonitorLogs.runQuery() + supamonitorLogs.runQuery() } useEffect(() => { @@ -95,8 +96,8 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) captureQueryPerformanceError(logsError, { projectRef: ref, databaseIdentifier: state.selectedDatabaseId, - queryPreset: 'pg_stat_monitor', - queryType: 'monitor', + queryPreset: 'supamonitor', + queryType: 'supamonitor', postgresVersion: project?.dbVersion, databaseType: state.selectedDatabaseId === ref ? 'primary' : 'read-replica', sql: queryWithTimeRange, @@ -120,7 +121,7 @@ export const WithMonitor = ({ dateRange, onDateRangeChange }: WithMonitorProps) actions={ } diff --git a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts new file mode 100644 index 0000000000..802f65f722 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect } from 'vitest' +import { + parseSupamonitorLogs, + transformLogsToChartData, + aggregateLogsByQuery, +} from './WithSupamonitor.utils' +import { ParsedLogEntry } from '../QueryPerformance.types' + +const makeSampleLog = (overrides: Partial = {}): any => ({ + timestamp: '2025-01-01T00:00:00Z', + application_name: 'test_app', + calls: 10, + database_name: 'test_db', + query: 'SELECT 1', + query_id: 1, + total_exec_time: 100, + total_plan_time: 20, + user_name: 'postgres', + mean_exec_time: 10, + mean_plan_time: 2, + min_exec_time: 1, + max_exec_time: 50, + min_plan_time: 0.5, + max_plan_time: 5, + p50_exec_time: 8, + p95_exec_time: 40, + p50_plan_time: 1.5, + p95_plan_time: 4, + ...overrides, +}) + +describe('parseSupamonitorLogs', () => { + it('returns empty array for null or empty input', () => { + expect(parseSupamonitorLogs(null as any)).toEqual([]) + expect(parseSupamonitorLogs([])).toEqual([]) + }) + + it('parses log entries preserving all fields', () => { + const raw = [makeSampleLog()] + const result = parseSupamonitorLogs(raw) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + timestamp: '2025-01-01T00:00:00Z', + application_name: 'test_app', + calls: 10, + database_name: 'test_db', + query: 'SELECT 1', + query_id: 1, + total_exec_time: 100, + total_plan_time: 20, + user_name: 'postgres', + mean_exec_time: 10, + mean_plan_time: 2, + min_exec_time: 1, + max_exec_time: 50, + min_plan_time: 0.5, + max_plan_time: 5, + p50_exec_time: 8, + p95_exec_time: 40, + p50_plan_time: 1.5, + p95_plan_time: 4, + }) + }) + + it('handles multiple log entries', () => { + const raw = [makeSampleLog(), makeSampleLog({ query: 'SELECT 2', query_id: 2 })] + const result = parseSupamonitorLogs(raw) + expect(result).toHaveLength(2) + }) +}) + +describe('transformLogsToChartData', () => { + it('returns empty array for null or empty input', () => { + expect(transformLogsToChartData(null as any)).toEqual([]) + expect(transformLogsToChartData([])).toEqual([]) + }) + + it('filters out entries with no timestamp', () => { + const logs: ParsedLogEntry[] = [{ query: 'SELECT 1', calls: 5 }] + const result = transformLogsToChartData(logs) + expect(result).toEqual([]) + }) + + it('filters out entries with invalid timestamp', () => { + const logs: ParsedLogEntry[] = [{ timestamp: 'not-a-date', calls: 5 }] + const result = transformLogsToChartData(logs) + expect(result).toEqual([]) + }) + + it('transforms a valid log entry into a chart data point', () => { + const logs: ParsedLogEntry[] = [ + { + timestamp: '2025-01-01T00:00:00Z', + mean_exec_time: 10, + mean_plan_time: 2, + min_exec_time: 1, + max_exec_time: 50, + min_plan_time: 0.5, + max_plan_time: 5, + p50_exec_time: 8, + p95_exec_time: 40, + p50_plan_time: 1.5, + p95_plan_time: 4, + calls: 10, + }, + ] + + const result = transformLogsToChartData(logs) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + period_start: new Date('2025-01-01T00:00:00Z').getTime(), + timestamp: '2025-01-01T00:00:00Z', + query_latency: 12, // 10 + 2 + mean_time: 10, + min_time: 1.5, // 1 + 0.5 + max_time: 55, // 50 + 5 + stddev_time: 0, + p50_time: 9.5, // 8 + 1.5 + p95_time: 44, // 40 + 4 + rows_read: 0, + calls: 10, + cache_hits: 0, + cache_misses: 0, + }) + }) + + it('defaults missing numeric fields to 0', () => { + const logs: ParsedLogEntry[] = [ + { + timestamp: '2025-06-01T12:00:00Z', + }, + ] + + const result = transformLogsToChartData(logs) + + expect(result).toHaveLength(1) + expect(result[0].query_latency).toBe(0) + expect(result[0].calls).toBe(0) + expect(result[0].min_time).toBe(0) + expect(result[0].max_time).toBe(0) + }) + + it('sorts results by period_start ascending', () => { + const logs: ParsedLogEntry[] = [ + { timestamp: '2025-01-03T00:00:00Z', mean_exec_time: 1 }, + { timestamp: '2025-01-01T00:00:00Z', mean_exec_time: 2 }, + { timestamp: '2025-01-02T00:00:00Z', mean_exec_time: 3 }, + ] + + const result = transformLogsToChartData(logs) + + expect(result).toHaveLength(3) + expect(result[0].timestamp).toBe('2025-01-01T00:00:00Z') + expect(result[1].timestamp).toBe('2025-01-02T00:00:00Z') + expect(result[2].timestamp).toBe('2025-01-03T00:00:00Z') + }) +}) + +describe('aggregateLogsByQuery', () => { + it('returns empty array for null or empty input', () => { + expect(aggregateLogsByQuery(null as any)).toEqual([]) + expect(aggregateLogsByQuery([])).toEqual([]) + }) + + it('skips entries with empty or whitespace-only queries', () => { + const logs: ParsedLogEntry[] = [ + { query: '', calls: 5 }, + { query: ' ', calls: 3 }, + ] + const result = aggregateLogsByQuery(logs) + expect(result).toEqual([]) + }) + + it('aggregates a single log entry correctly', () => { + const logs: ParsedLogEntry[] = [ + { + query: 'SELECT 1', + user_name: 'postgres', + application_name: 'app', + calls: 10, + total_exec_time: 100, + total_plan_time: 20, + min_exec_time: 1, + max_exec_time: 50, + min_plan_time: 0.5, + max_plan_time: 5, + }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result).toHaveLength(1) + expect(result[0].query).toBe('SELECT 1') + expect(result[0].rolname).toBe('postgres') + expect(result[0].application_name).toBe('app') + expect(result[0].calls).toBe(10) + expect(result[0].total_time).toBe(120) + expect(result[0].mean_time).toBe(12) + expect(result[0].min_time).toBe(1.5) + expect(result[0].max_time).toBe(55) + expect(result[0].prop_total_time).toBe(100) + }) + + it('aggregates multiple entries for the same query', () => { + const logs: ParsedLogEntry[] = [ + { + query: 'SELECT 1', + user_name: 'postgres', + calls: 5, + total_exec_time: 50, + total_plan_time: 10, + min_exec_time: 2, + max_exec_time: 20, + min_plan_time: 1, + max_plan_time: 3, + }, + { + query: 'SELECT 1', + user_name: 'postgres', + calls: 10, + total_exec_time: 100, + total_plan_time: 20, + min_exec_time: 1, + max_exec_time: 50, + min_plan_time: 0.5, + max_plan_time: 5, + }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result).toHaveLength(1) + expect(result[0].calls).toBe(15) // 5 + 10 + expect(result[0].total_time).toBe(180) // (50+10) + (100+20) + expect(result[0].mean_time).toBe(12) // 180 / 15 + expect(result[0].min_time).toBe(1.5) // min(2+1, 1+0.5) = 1.5 + expect(result[0].max_time).toBe(55) // max(20+3, 50+5) = 55 + expect(result[0]._count).toBe(2) // 2 log entries + }) + + it('normalizes whitespace differences in queries', () => { + const logs: ParsedLogEntry[] = [ + { query: 'SELECT 1', calls: 5, total_exec_time: 50, total_plan_time: 0 }, + { query: 'SELECT 1', calls: 3, total_exec_time: 30, total_plan_time: 0 }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result).toHaveLength(1) + expect(result[0].calls).toBe(8) + }) + + it('sorts results by total_time descending', () => { + const logs: ParsedLogEntry[] = [ + { query: 'SELECT 1', calls: 1, total_exec_time: 10, total_plan_time: 0 }, + { query: 'SELECT 2', calls: 1, total_exec_time: 100, total_plan_time: 0 }, + { query: 'SELECT 3', calls: 1, total_exec_time: 50, total_plan_time: 0 }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result).toHaveLength(3) + expect(result[0].query).toBe('SELECT 2') + expect(result[1].query).toBe('SELECT 3') + expect(result[2].query).toBe('SELECT 1') + }) + + it('calculates prop_total_time as percentage of total execution', () => { + const logs: ParsedLogEntry[] = [ + { query: 'SELECT 1', calls: 1, total_exec_time: 75, total_plan_time: 0 }, + { query: 'SELECT 2', calls: 1, total_exec_time: 25, total_plan_time: 0 }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result[0].prop_total_time).toBe(75) + expect(result[1].prop_total_time).toBe(25) + }) + + it('handles zero calls gracefully (mean_time defaults to 0)', () => { + const logs: ParsedLogEntry[] = [ + { query: 'SELECT 1', calls: 0, total_exec_time: 100, total_plan_time: 0 }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result).toHaveLength(1) + expect(result[0].mean_time).toBe(0) + }) + + it('sets static fields correctly', () => { + const logs: ParsedLogEntry[] = [ + { query: 'SELECT 1', calls: 1, total_exec_time: 10, total_plan_time: 0 }, + ] + + const result = aggregateLogsByQuery(logs) + + expect(result[0].rows_read).toBe(0) + expect(result[0].cache_hit_rate).toBe(0) + expect(result[0].index_advisor_result).toBeNull() + expect(result[0]._total_cache_hits).toBe(0) + expect(result[0]._total_cache_misses).toBe(0) + }) +}) diff --git a/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts new file mode 100644 index 0000000000..73711e9be8 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/WithSupamonitor/WithSupamonitor.utils.ts @@ -0,0 +1,147 @@ +import { QueryPerformanceRow, ChartDataPoint, ParsedLogEntry } from '../QueryPerformance.types' + +export function parseSupamonitorLogs(logData: any[]): ParsedLogEntry[] { + if (!logData || logData.length === 0) return [] + + return logData.map((log) => ({ + timestamp: log.timestamp, + application_name: log.application_name, + calls: log.calls, + database_name: log.database_name, + query: log.query, + query_id: log.query_id, + total_exec_time: log.total_exec_time, + total_plan_time: log.total_plan_time, + user_name: log.user_name, + mean_exec_time: log.mean_exec_time, + mean_plan_time: log.mean_plan_time, + min_exec_time: log.min_exec_time, + max_exec_time: log.max_exec_time, + min_plan_time: log.min_plan_time, + max_plan_time: log.max_plan_time, + p50_exec_time: log.p50_exec_time, + p95_exec_time: log.p95_exec_time, + p50_plan_time: log.p50_plan_time, + p95_plan_time: log.p95_plan_time, + })) +} + +export function transformLogsToChartData(parsedLogs: ParsedLogEntry[]): ChartDataPoint[] { + if (!parsedLogs || parsedLogs.length === 0) return [] + + return parsedLogs + .map((log: ParsedLogEntry) => { + if (!log.timestamp) return null + + const periodStart = new Date(log.timestamp).getTime() + if (isNaN(periodStart)) return null + + const meanExecTime = parseFloat(String(log.mean_exec_time ?? 0)) + const meanPlanTime = parseFloat(String(log.mean_plan_time ?? 0)) + const calls = parseInt(String(log.calls ?? 0), 10) + + return { + period_start: periodStart, + timestamp: log.timestamp, + query_latency: meanExecTime + meanPlanTime, + mean_time: meanExecTime, + min_time: (log.min_exec_time ?? 0) + (log.min_plan_time ?? 0), + max_time: (log.max_exec_time ?? 0) + (log.max_plan_time ?? 0), + stddev_time: 0, + p50_time: (log.p50_exec_time ?? 0) + (log.p50_plan_time ?? 0), + p95_time: (log.p95_exec_time ?? 0) + (log.p95_plan_time ?? 0), + rows_read: 0, + calls, + cache_hits: 0, + cache_misses: 0, + } + }) + .filter((item): item is NonNullable => item !== null) + .sort((a, b) => a.period_start - b.period_start) +} + +function normalizeQuery(query: string): string { + return query.replace(/\s+/g, ' ').trim() +} + +export function aggregateLogsByQuery(parsedLogs: ParsedLogEntry[]): QueryPerformanceRow[] { + if (!parsedLogs || parsedLogs.length === 0) return [] + + const queryGroups = new Map() + + parsedLogs.forEach((log) => { + const query = normalizeQuery(log.query || '') + if (!query) return + + if (!queryGroups.has(query)) { + queryGroups.set(query, []) + } + queryGroups.get(query)!.push(log) + }) + + const aggregatedData: QueryPerformanceRow[] = [] + let totalExecutionTime = 0 + + const queryStats = Array.from(queryGroups.entries()).map(([query, logs]) => { + const count = logs.length + let totalCalls = 0 + let totalExecTime = 0 + let totalPlanTime = 0 + let minTime = Infinity + let maxTime = -Infinity + const rolname = logs[0]?.user_name || '' + const applicationName = logs[0]?.application_name || '' + + logs.forEach((log) => { + const logCalls = parseInt(String(log.calls ?? 0), 10) + totalCalls += logCalls + totalExecTime += parseFloat(String(log.total_exec_time ?? 0)) + totalPlanTime += parseFloat(String(log.total_plan_time ?? 0)) + minTime = Math.min(minTime, (log.min_exec_time ?? 0) + (log.min_plan_time ?? 0)) + maxTime = Math.max(maxTime, (log.max_exec_time ?? 0) + (log.max_plan_time ?? 0)) + }) + + const totalTime = totalExecTime + totalPlanTime + const avgMeanTime = totalCalls > 0 ? totalTime / totalCalls : 0 + const finalMinTime = minTime === Infinity ? 0 : minTime + const finalMaxTime = maxTime === -Infinity ? 0 : maxTime + + totalExecutionTime += totalTime + + return { + query, + rolname, + applicationName, + count, + avgMeanTime, + minTime: finalMinTime, + maxTime: finalMaxTime, + totalCalls, + totalTime, + } + }) + + queryStats.forEach((stats) => { + const propTotalTime = totalExecutionTime > 0 ? (stats.totalTime / totalExecutionTime) * 100 : 0 + + aggregatedData.push({ + query: stats.query, + rolname: stats.rolname, + application_name: stats.applicationName, + calls: stats.totalCalls, + mean_time: stats.avgMeanTime, + min_time: stats.minTime, + max_time: stats.maxTime, + total_time: stats.totalTime, + rows_read: 0, + cache_hit_rate: 0, + prop_total_time: propTotalTime, + index_advisor_result: null, + _total_cache_hits: 0, + _total_cache_misses: 0, + _count: stats.count, + }) + }) + + return aggregatedData.sort((a, b) => b.total_time - a.total_time) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts b/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts new file mode 100644 index 0000000000..e525668a31 --- /dev/null +++ b/apps/studio/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus.ts @@ -0,0 +1,18 @@ +import { useSupamonitorEnabledQuery } from 'data/database/supamonitor-enabled-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' + +/** + * Hook to check if supamonitor is enabled in shared_preload_libraries + */ +export function useSupamonitorStatus() { + const { data: project } = useSelectedProjectQuery() + const { data: isSupamonitorEnabled, isLoading } = useSupamonitorEnabledQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + return { + isSupamonitorEnabled: isSupamonitorEnabled ?? false, + isLoading, + } +} diff --git a/apps/studio/data/database/keys.ts b/apps/studio/data/database/keys.ts index 29dc8b52da..941609fe0f 100644 --- a/apps/studio/data/database/keys.ts +++ b/apps/studio/data/database/keys.ts @@ -46,4 +46,6 @@ export const databaseKeys = { schema: string | undefined, table: string | undefined ) => ['projects', projectRef, 'table-index-advisor', schema, table] as const, + supamonitorEnabled: (projectRef: string | undefined) => + ['projects', projectRef, 'supamonitor-enabled'] as const, } diff --git a/apps/studio/data/database/supamonitor-enabled-query.ts b/apps/studio/data/database/supamonitor-enabled-query.ts new file mode 100644 index 0000000000..0d537e449e --- /dev/null +++ b/apps/studio/data/database/supamonitor-enabled-query.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError, UseCustomQueryOptions } from 'types' +import { databaseKeys } from './keys' + +export type SupamonitorEnabledVariables = { + projectRef?: string + connectionString?: string | null +} + +export async function getSupamonitorEnabled({ + projectRef, + connectionString, +}: SupamonitorEnabledVariables) { + const { result } = await executeSql<{ libraries: string }[]>({ + projectRef, + connectionString, + sql: `SELECT current_setting('shared_preload_libraries', true) AS libraries`, + }) + + const libraries = result[0]?.libraries ?? '' + return libraries.split(',').some((lib) => lib.trim() === 'supamonitor') +} + +export type SupamonitorEnabledData = Awaited> +export type SupamonitorEnabledError = ResponseError + +export const useSupamonitorEnabledQuery = ( + { projectRef, connectionString }: SupamonitorEnabledVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + queryKey: databaseKeys.supamonitorEnabled(projectRef), + queryFn: () => getSupamonitorEnabled({ projectRef, connectionString }), + enabled: enabled && typeof projectRef !== 'undefined' && isActive, + ...options, + }) +} diff --git a/apps/studio/pages/project/[ref]/observability/query-performance.tsx b/apps/studio/pages/project/[ref]/observability/query-performance.tsx index e51dec02a3..22a5b54b6b 100644 --- a/apps/studio/pages/project/[ref]/observability/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/observability/query-performance.tsx @@ -3,6 +3,7 @@ import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFi import { useParams } from 'common' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' +import { useSupamonitorStatus } from 'components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' import { useQueryPerformanceSort } from 'components/interfaces/QueryPerformance/hooks/useQueryPerformanceSort' import { QueryPerformance } from 'components/interfaces/QueryPerformance/QueryPerformance' import { @@ -27,6 +28,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { const { ref } = useParams() const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() + const { isSupamonitorEnabled } = useSupamonitorStatus() const { sort: sortConfig } = useQueryPerformanceSort() const { @@ -76,8 +78,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => { filterIndexAdvisor: indexAdvisor === 'true', }) - const isPgStatMonitorEnabled = project?.dbVersion === '17.4.1.076-psml-1' - if (!isLoadingProject && !project) { return (
@@ -99,7 +99,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`} /> - {isPgStatMonitorEnabled && ( + {isSupamonitorEnabled && ( { queryHitRate={queryHitRate} queryPerformanceQuery={queryPerformanceQuery} queryMetrics={queryMetrics} - isPgStatMonitorEnabled={isPgStatMonitorEnabled} + isSupamonitorEnabled={isSupamonitorEnabled} dateRange={selectedDateRange} onDateRangeChange={updateDateRange} /> diff --git a/supabase/functions/common/database-types.ts b/supabase/functions/common/database-types.ts deleted file mode 100644 index e69de29bb2..0000000000