Files
supabase/apps/studio/data/reports/v2/edge-functions.config.ts
Charis fd1f437eca feat(logs): brand remaining analytics SQL callers with SafeLogSqlFragment (#46476)
## Summary

PR 10 of the analytics SQL safety series. Migrates the last surface of
analytics queries that flowed through plain
`get(.../analytics/endpoints/logs.all, { query: { sql } })` or the
`fetchLogs(projectRef, sql: string, ...)` helper over to
`executeAnalyticsSql` with branded `SafeLogSqlFragment` inputs.

After this PR, every analytics SQL call site builds its query through
the safe-analytics-sql helpers and hits the wire through the single
`executeAnalyticsSql` boundary. User-controlled values (filter
operators, numeric thresholds, function IDs, regions, provider names)
all flow through `analyticsLiteral` / branded operator maps; static
fragments are wrapped in `safeSql`. PR 11 (ESLint / vitest rule
forbidding direct analytics-endpoint POST/GET outside
`executeAnalyticsSql`) is the next and final step.

## Changes

- **`hooks/analytics/useProjectUsageStats.tsx`** — route the
already-branded `genChartQuery` output through `executeAnalyticsSql`
(parallels `useLogsPreview`).
- **`data/reports/report.utils.ts`** — tighten `fetchLogs(sql)` from
`string` to `SafeLogSqlFragment`; the wire boundary is now the same
single `executeAnalyticsSql` wrapper used by the rest of the analytics
path. Adds two pre-branded fragment maps reused by the report configs:
- `SAFE_GRANULARITY_SQL` — closed set returned by
`analyticsIntervalToGranularity`.
- `SAFE_COMPARISON_OPERATOR_SQL` — closed set on
`NumericFilter.operator`.
- **`components/interfaces/Auth/Overview/OverviewErrors.constants.ts`**
— wrap the two static `AUTH_TOP_*_SQL` fragments in `safeSql` (no
interpolation, but the type now flows).
- **`data/reports/v2/edge-functions.config.ts`** — `filterToWhereClause`
and every entry in `METRIC_SQL` now return `SafeLogSqlFragment`.
User-controlled values (`status_code.value`, `execution_time.value`,
function IDs, regions) pass through `analyticsLiteral`; operators look
up the branded map; the granularity uses the branded map. The
wire-format strings are unchanged, so the existing
`edge-functions.test.tsx` exact-string expectations still hold.
- **`data/reports/v2/auth.config.ts`** — same shape applied to all ten
`AUTH_REPORT_SQL` entries. The legacy `whereClause.replace(/^WHERE\s+/,
'')` pattern is replaced by two helpers that emit `AND`-prefixed
predicate fragments directly (`authFiltersToAndPredicates`,
`edgeLogsFiltersToAndPredicates`). Static provider SELECT / GROUP BY
fragments are pre-branded.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Enhanced security for analytics and reporting queries by updating
query construction methods across auth, edge functions, and project
usage reports.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46476?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-29 09:26:06 -04:00

384 lines
12 KiB
TypeScript

import dayjs from 'dayjs'
import { ReportConfig } from './reports.types'
import {
extractStatusCodesFromData,
generateStatusCodeAttributes,
transformStatusCodeData,
} from '@/components/interfaces/Reports/Reports.utils'
import { NumericFilter } from '@/components/interfaces/Reports/v2/ReportsNumericFilter'
import { SelectFilters } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
import {
isUnixMicro,
unixMicroToIsoTimestamp,
} from '@/components/interfaces/Settings/Logs/Logs.utils'
import type { AnalyticsInterval } from '@/data/analytics/constants'
import {
analyticsLiteral,
joinSqlFragments,
safeSql,
type SafeLogSqlFragment,
} from '@/data/logs/safe-analytics-sql'
import {
analyticsIntervalToGranularity,
fetchLogs,
SAFE_COMPARISON_OPERATOR_SQL,
SAFE_GRANULARITY_SQL,
} from '@/data/reports/report.utils'
type EdgeFunctionReportFilters = {
status_code: NumericFilter | null
region: SelectFilters
execution_time: NumericFilter | null
functions: SelectFilters
}
export function filterToWhereClause(filters?: EdgeFunctionReportFilters): SafeLogSqlFragment {
const whereClauses: SafeLogSqlFragment[] = []
if (filters?.functions && filters.functions.length > 0) {
const ids = joinSqlFragments(filters.functions.map(analyticsLiteral), ',')
whereClauses.push(safeSql`function_id IN (${ids})`)
}
if (filters?.status_code) {
const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator]
whereClauses.push(
safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}`
)
}
if (filters?.region && filters.region.length > 0) {
const regions = joinSqlFragments(filters.region.map(analyticsLiteral), ',')
whereClauses.push(safeSql`h.x_sb_edge_region IN (${regions})`)
}
if (filters?.execution_time) {
const op = SAFE_COMPARISON_OPERATOR_SQL[filters.execution_time.operator]
whereClauses.push(
safeSql`m.execution_time_ms ${op} ${analyticsLiteral(filters.execution_time.value)}`
)
}
if (whereClauses.length === 0) return safeSql``
return safeSql`WHERE ${joinSqlFragments(whereClauses, ' AND ')}`
}
const METRIC_SQL: Record<
string,
(interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => SafeLogSqlFragment
> = {
TotalInvocations: (interval, filters) => {
const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)]
const whereClause = filterToWhereClause(filters)
return safeSql`
--edgefn-report-invocations
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
function_id,
count(*) as count
from
function_edge_logs
CROSS JOIN UNNEST(metadata) AS m
CROSS JOIN UNNEST(m.request) AS request
CROSS JOIN UNNEST(m.response) AS response
CROSS JOIN UNNEST(response.headers) AS h
${whereClause}
group by
timestamp,
function_id
order by
timestamp desc;
`
},
ExecutionStatusCodes: (interval, filters) => {
const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)]
const whereClause = filterToWhereClause(filters)
return safeSql`
--edgefn-report-execution-status-codes
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
response.status_code as status_code,
count(response.status_code) as count
from
function_edge_logs
cross join unnest(metadata) as m
cross join unnest(m.response) as response
cross join unnest(response.headers) as h
${whereClause}
group by
timestamp,
status_code
order by
timestamp desc
`
},
InvocationsByRegion: (interval, filters) => {
const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)]
const whereClause = filterToWhereClause(filters)
const regionCondition =
whereClause.length > 0
? safeSql`AND h.x_sb_edge_region is not null`
: safeSql`WHERE h.x_sb_edge_region is not null`
return safeSql`
--edgefn-report-invocations-by-region
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
h.x_sb_edge_region as region,
count(*) as count
from
function_edge_logs
cross join unnest(metadata) as m
cross join unnest(m.response) as r
cross join unnest(r.headers) as h
${whereClause}
${regionCondition}
group by
timestamp,
region
order by
timestamp desc
`
},
ExecutionTime: (interval, filters) => {
const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)]
const whereClause = filterToWhereClause(filters)
return safeSql`
--edgefn-report-execution-time
select
timestamp_trunc(timestamp, ${granularity}) as timestamp,
function_id,
avg(m.execution_time_ms) as avg_execution_time
from
function_edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response
cross join unnest(response.headers) as h
${whereClause}
group by
timestamp,
function_id
order by
timestamp desc
`
},
}
/**
* Transforms raw invocation data by normalizing timestamps and adding function names
* @param data - Raw data from the database
* @param functions - Array of function objects with id and name
* @returns Transformed data with normalized timestamps and function names
*/
export function transformInvocationData(data: any[], functions: { id: string; name: string }[]) {
return data.map((log: any) => ({
...log,
timestamp: isUnixMicro(log.timestamp)
? unixMicroToIsoTimestamp(log.timestamp)
: dayjs.utc(log.timestamp).toISOString(),
function_name: functions.find((f) => f.id === log.function_id)?.name ?? log.function_id,
}))
}
/**
* Aggregates invocation data by timestamp, summing counts for each timestamp
* @param data - Transformed invocation data
* @returns Aggregated data with one entry per timestamp
*/
export function aggregateInvocationsByTimestamp(data: any[]) {
const aggregatedData = data.reduce((acc: Record<string, any>, item: any) => {
const timestamp = item.timestamp
if (!acc[timestamp]) {
acc[timestamp] = { timestamp, count: 0 }
}
acc[timestamp].count += item.count
return acc
}, {})
return Object.values(aggregatedData)
}
export const edgeFunctionReports = ({
projectRef,
functions,
startDate,
endDate,
interval,
filters,
}: {
projectRef: string
functions: { id: string; name: string }[]
startDate: string
endDate: string
interval: AnalyticsInterval
filters: EdgeFunctionReportFilters
}): ReportConfig<EdgeFunctionReportFilters>[] => [
{
id: 'total-invocations',
label: 'Total Edge Function Invocations',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of edge function invocations over time.',
dataProvider: async () => {
const sql = METRIC_SQL.TotalInvocations(interval, filters)
const response = await fetchLogs(projectRef, sql, startDate, endDate)
if (!response?.result) return { data: [] }
// Transform and aggregate the data using extracted functions
const transformedData = transformInvocationData(response.result, functions)
const data = aggregateInvocationsByTimestamp(transformedData)
const attributes = [
{
attribute: 'count',
label: 'Count',
},
]
return { data, attributes, query: sql }
},
},
{
id: 'execution-status-codes',
label: 'Edge Function Status Codes',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of edge function executions by status code.',
dataProvider: async () => {
const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
if (!rawData?.result) return { data: [] }
/**
* The query returns { timestamp, status_code: 500, count: 10 }
* and we have to transform it to { timestamp, 500: 10 }
* to be able to render the chart.
*/
const statusCodes = extractStatusCodesFromData(rawData.result)
const attributes = generateStatusCodeAttributes(statusCodes)
const data = transformStatusCodeData(rawData.result, statusCodes)
return { data, attributes, query: sql }
},
},
{
id: 'execution-time',
label: 'Edge Function Execution Time',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'Average execution time for edge functions.',
YAxisProps: {
width: 50,
tickFormatter: (value: number) => `${value}ms`,
},
format: (value: unknown) => `${Number(value).toFixed(0)}ms`,
dataProvider: async () => {
const sql = METRIC_SQL.ExecutionTime(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
if (!rawData?.result) return { data: [] }
// Transform the raw data to ensure one data point per timestamp
const transformedData = rawData.result?.map((point: any) => ({
...point,
timestamp: isUnixMicro(point.timestamp)
? unixMicroToIsoTimestamp(point.timestamp)
: dayjs.utc(point.timestamp).toISOString(),
function_name: functions.find((f) => f.id === point.function_id)?.name ?? point.function_id,
}))
// If we have multiple function IDs, we need to aggregate the execution times per timestamp
const aggregatedData = transformedData.reduce((acc: Record<string, any>, item: any) => {
const timestamp = item.timestamp
if (!acc[timestamp]) {
acc[timestamp] = {
timestamp,
avg_execution_time: item.avg_execution_time,
count: 1,
}
} else {
// Calculate weighted average for multiple functions at the same timestamp
const totalTime =
acc[timestamp].avg_execution_time * acc[timestamp].count + item.avg_execution_time
acc[timestamp].count += 1
acc[timestamp].avg_execution_time = totalTime / acc[timestamp].count
}
return acc
}, {})
const data = Object.values(aggregatedData).map(({ count, ...item }) => item)
const attributes = [
{
attribute: 'avg_execution_time',
label: 'Avg. execution time (ms)',
},
]
return { data, attributes, query: sql }
},
},
{
id: 'invocations-by-region',
label: 'Edge Function Invocations by Region',
valuePrecision: 0,
hide: false,
showTooltip: true,
showLegend: true,
showMaxValue: false,
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'The total number of edge function invocations by region.',
entitlement: 'edge_functions',
requiredPlan: 'Pro',
dataProvider: async () => {
const sql = METRIC_SQL.InvocationsByRegion(interval, filters)
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
const data = rawData.result?.map((point: any) => ({
...point,
timestamp: isUnixMicro(point.timestamp)
? unixMicroToIsoTimestamp(point.timestamp)
: dayjs.utc(point.timestamp).toISOString(),
}))
const attributes = [
{
attribute: 'region',
label: 'Region',
provider: 'logs',
enabled: true,
},
{
attribute: 'count',
label: 'Count',
provider: 'logs',
enabled: true,
},
]
return { data, attributes, query: sql }
},
},
]