feat: initial supamonitor changes (#42313)

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:

<img width="2556" height="960" alt="Screenshot 2026-02-12 at 7 35 47 PM"
src="https://github.com/user-attachments/assets/bf37466e-f7af-41f2-b4f2-cf8eb6a8c76f"
/>

Only available on projects on custom AMI - existing users are unaffected

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: kemal <hello@kemal.earth>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-17 15:11:46 +08:00
1 parent 46053398de
commit 105df5291d
19 files changed
+800 -505

No files matched your search

@@ -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 (
<div className={cn('flex flex-col gap-y-4', className)}>
<h3 className="text-sm">Query costs</h3>
<div className="flex flex-col gap-y-2 rounded bg-surface-100 px-4 py-3">
<div className="flex items-center justify-between">
<p className="text-sm text-foreground-light">Total cost of query</p>
<div className="flex flex-col items-end gap-y-1">
<div className="flex items-center gap-x-4">
<p className="text-sm text-foreground-light">Currently:</p>
<p className="font-mono text-sm">
{typeof currentCost === 'number' && !isNaN(currentCost) && isFinite(currentCost)
? currentCost.toFixed(2)
: 'N/A'}
</p>
</div>
{improvedCost &&
typeof improvedCost === 'number' &&
!isNaN(improvedCost) &&
isFinite(improvedCost) && (
<div className="flex items-center gap-x-4">
<p className="text-sm text-foreground-light">With index:</p>
<div className="flex items-center gap-x-2">
<p className="font-mono text-sm">{improvedCost.toFixed(2)}</p>
{improvement &&
typeof improvement === 'number' &&
!isNaN(improvement) &&
isFinite(improvement) && (
<p className="text-sm text-brand">↓ {improvement.toFixed(1)}%</p>
)}
</div>
</div>
)}
</div>
</div>
<button className="text-sm text-brand hover:text-brand-600 transition">View more</button>
</div>
</div>
)
}
@@ -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'
@@ -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()
)
@@ -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<any>
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 <WithMonitor dateRange={dateRange} onDateRangeChange={onDateRangeChange} />
if (isSupamonitorEnabled) {
return <WithSupamonitor dateRange={dateRange} onDateRangeChange={onDateRangeChange} />
}
return (
@@ -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
}
@@ -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)
})
})
@@ -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
@@ -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<string, MultiAttribute> = {
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}
@@ -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<number>()
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 (
<div className="w-full flex flex-col justify-center">
{value ? (
<p className="font-mono text-xs">{value}</p>
) : (
<p className="text-muted">&ndash;</p>
)}
</div>
)
}
return (
<div className="w-full flex flex-col gap-y-0.5 justify-center text-xs">
<p>{formattedValue}</p>
@@ -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<typeof item> => 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<string, ParsedLogEntry[]>()
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
}
@@ -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<string, any> = {}) => ({
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)
})
})
})
@@ -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<string | null>(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={
<DownloadResultsButton
results={aggregatedGridData}
fileName={`Supabase Query Performance Monitor (${ref})`}
fileName={`Supabase Query Performance Supamonitor (${ref})`}
align="end"
/>
}
@@ -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<ParsedLogEntry> = {}): 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)
})
})
@@ -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<typeof item> => 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<string, ParsedLogEntry[]>()
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)
}
@@ -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,
}
}
+2
View File
@@ -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,
}
@@ -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<ReturnType<typeof getSupamonitorEnabled>>
export type SupamonitorEnabledError = ResponseError
export const useSupamonitorEnabledQuery = <TData = SupamonitorEnabledData>(
{ projectRef, connectionString }: SupamonitorEnabledVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<SupamonitorEnabledData, SupamonitorEnabledError, TData> = {}
) => {
const { data: project } = useSelectedProjectQuery()
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
return useQuery<SupamonitorEnabledData, SupamonitorEnabledError, TData>({
queryKey: databaseKeys.supamonitorEnabled(projectRef),
queryFn: () => getSupamonitorEnabled({ projectRef, connectionString }),
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
...options,
})
}
@@ -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 (
<div className="h-full flex flex-col p-6">
@@ -99,7 +99,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
href={`${DOCS_URL}/guides/platform/performance#examining-query-performance`}
/>
<DatabaseSelector />
{isPgStatMonitorEnabled && (
{isSupamonitorEnabled && (
<LogsDatePicker
value={datePickerValue}
helpers={datePickerHelpers.filter(
@@ -117,7 +117,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => {
queryHitRate={queryHitRate}
queryPerformanceQuery={queryPerformanceQuery}
queryMetrics={queryMetrics}
isPgStatMonitorEnabled={isPgStatMonitorEnabled}
isSupamonitorEnabled={isSupamonitorEnabled}
dateRange={selectedDateRange}
onDateRangeChange={updateDateRange}
/>
Whitespace-only changes.