From fd1f437eca71837c8d17de83ad28296fdec5c4ce Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 29 May 2026 09:26:06 -0400 Subject: [PATCH] feat(logs): brand remaining analytics SQL callers with SafeLogSqlFragment (#46476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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 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) --- .../Auth/Overview/OverviewErrors.constants.ts | 5 +- apps/studio/data/reports/report.utils.ts | 50 +++- apps/studio/data/reports/v2/auth.config.ts | 259 ++++++++++-------- .../data/reports/v2/edge-functions.config.ts | 62 +++-- .../hooks/analytics/useProjectUsageStats.tsx | 21 +- 5 files changed, 237 insertions(+), 160 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts index ac326906145..616b14e8ca9 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts @@ -1,5 +1,6 @@ import dayjs from 'dayjs' +import { safeSql } from '@/data/logs/safe-analytics-sql' import { fetchLogs } from '@/data/reports/report.utils' export type ResponseErrorRow = { @@ -22,7 +23,7 @@ export const getDateRange = () => { } // Top API response errors for /auth/v1 endpoints (path/method/status) -export const AUTH_TOP_RESPONSE_ERRORS_SQL = ` +export const AUTH_TOP_RESPONSE_ERRORS_SQL = safeSql` select request.method as method, request.path as path, @@ -40,7 +41,7 @@ export const AUTH_TOP_RESPONSE_ERRORS_SQL = ` ` // Top Auth service error codes from x_sb_error_code header for /auth/v1 endpoints -export const AUTH_TOP_ERROR_CODES_SQL = ` +export const AUTH_TOP_ERROR_CODES_SQL = safeSql` select h.x_sb_error_code as error_code, count(*) as count diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts index 409d233b4c8..9c52a8759d4 100644 --- a/apps/studio/data/reports/report.utils.ts +++ b/apps/studio/data/reports/report.utils.ts @@ -1,8 +1,36 @@ +import { type ComparisonOperator } from '@/components/interfaces/Reports/v2/ReportsNumericFilter' import { AnalyticsInterval } from '@/data/analytics/constants' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { get } from '@/data/fetchers' +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { safeSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' export type Granularity = 'minute' | 'hour' | 'day' + +/** + * Pre-branded SQL fragments for the closed set of granularity tokens that + * `analyticsIntervalToGranularity` may return. Use to splice a granularity into + * a `safeSql` template without re-validating at the call site. + */ +export const SAFE_GRANULARITY_SQL: Record = { + minute: safeSql`minute`, + hour: safeSql`hour`, + day: safeSql`day`, +} + +/** + * Pre-branded SQL fragments for the closed set of numeric comparison operators + * accepted by `ReportsNumericFilter`. Use to splice an operator into a + * `safeSql` template without re-validating at the call site. + */ +export const SAFE_COMPARISON_OPERATOR_SQL: Record = { + '=': safeSql`=`, + '>=': safeSql`>=`, + '<=': safeSql`<=`, + '>': safeSql`>`, + '<': safeSql`<`, + '!=': safeSql`!=`, +} + export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { switch (interval) { case '1m': @@ -55,22 +83,18 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => { export async function fetchLogs( projectRef: string, - sql: string, + sql: SafeLogSqlFragment, startDate: string, endDate: string ) { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, + return await executeAnalyticsSql({ + projectRef, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + method: 'get', }) - if (error) throw error - return data } export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { diff --git a/apps/studio/data/reports/v2/auth.config.ts b/apps/studio/data/reports/v2/auth.config.ts index 11e933bbf2d..82fcf2490ba 100644 --- a/apps/studio/data/reports/v2/auth.config.ts +++ b/apps/studio/data/reports/v2/auth.config.ts @@ -10,7 +10,18 @@ import { } from '@/components/interfaces/Reports/Reports.utils' import { NumericFilter } from '@/components/interfaces/Reports/v2/ReportsNumericFilter' import type { AnalyticsInterval } from '@/data/analytics/constants' -import { analyticsIntervalToGranularity, fetchLogs } from '@/data/reports/report.utils' +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' const AUTH_ERROR_CODE_LIST = Object.entries(AUTH_ERROR_CODES).map(([key, value]) => ({ key, @@ -32,39 +43,103 @@ const METRIC_KEYS = [ type MetricKey = (typeof METRIC_KEYS)[number] +type AuthReportFilters = { + status_code?: NumericFilter | null + provider?: string[] | null +} + +// Static SELECT-clause fragment for the `auth_logs` table. +const PROVIDER_SELECT_FRAGMENT = safeSql`COALESCE(JSON_VALUE(event_message, "$.provider"), 'unknown') as provider,` + +// Static SELECT-clause fragment for the aliased `auth_logs f` form. +const PROVIDER_SELECT_FRAGMENT_F_ALIAS = safeSql`COALESCE(JSON_VALUE(f.event_message, "$.provider"), 'unknown') as provider,` + +const PROVIDER_GROUP_BY_FRAGMENT = safeSql`, provider` +const EMPTY = safeSql`` + +function providerSelectFragment(groupByProvider: boolean, aliased: boolean): SafeLogSqlFragment { + if (!groupByProvider) return EMPTY + return aliased ? PROVIDER_SELECT_FRAGMENT_F_ALIAS : PROVIDER_SELECT_FRAGMENT +} + +function providerGroupBy(groupByProvider: boolean): SafeLogSqlFragment { + return groupByProvider ? PROVIDER_GROUP_BY_FRAGMENT : EMPTY +} + +/** + * Builds an `AND`-prefixed predicate fragment for `auth_logs`-shaped queries. + * Returns the empty fragment when no filters apply. The returned value can be + * spliced directly after a query's existing `WHERE` clause. + */ +function authFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { + const predicates: SafeLogSqlFragment[] = [] + + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + predicates.push( + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` + ) + } + + if (filters?.provider && filters.provider.length > 0) { + const list = joinSqlFragments(filters.provider.map(analyticsLiteral), ', ') + predicates.push(safeSql`JSON_VALUE(event_message, "$.provider") IN (${list})`) + } + + if (predicates.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` +} + +/** + * Builds an `AND`-prefixed predicate fragment for `edge_logs`-shaped queries. + */ +function edgeLogsFiltersToAndPredicates(filters?: AuthReportFilters): SafeLogSqlFragment { + const predicates: SafeLogSqlFragment[] = [] + + if (filters?.status_code) { + const op = SAFE_COMPARISON_OPERATOR_SQL[filters.status_code.operator] + predicates.push( + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` + ) + } + + if (predicates.length === 0) return EMPTY + return safeSql`AND ${joinSqlFragments(predicates, ' AND ')}` +} + const AUTH_REPORT_SQL: Record< MetricKey, - (interval: AnalyticsInterval, filters?: AuthReportFilters) => string + (interval: AnalyticsInterval, filters?: AuthReportFilters) => SafeLogSqlFragment > = { ActiveUsers: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --active-users - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(f.event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, true)} count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count from auth_logs f where json_value(f.event_message, "$.auth_event.action") in ( 'login', 'user_signedup', 'token_refreshed', 'user_modified', 'user_recovery_requested', 'user_reauthenticate_requested' ) - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInAttempts: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --sign-in-attempts SELECT timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} CASE WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL AND JSON_VALUE(event_message, "$.provider") != '' @@ -82,133 +157,133 @@ const AUTH_REPORT_SQL: Record< WHERE JSON_VALUE(event_message, "$.action") = 'login' AND JSON_VALUE(event_message, "$.metering") = "true" - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} GROUP BY - timestamp, login_type_provider${groupByProvider ? ', provider' : ''} + timestamp, login_type_provider${providerGroupBy(groupByProvider)} ORDER BY - timestamp desc, login_type_provider${groupByProvider ? ', provider' : ''} + timestamp desc, login_type_provider${providerGroupBy(groupByProvider)} ` }, PasswordResetRequests: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --password-reset-requests - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(f.event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, true)} count(*) as count from auth_logs f where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, TotalSignUps: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --total-signups - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimeBasic: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signin-processing-time-basic - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_processing_time_ms, round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_processing_time_ms, round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignInProcessingTimePercentiles: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signin-processing-time-percentiles - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'login' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimeBasic: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signup-processing-time-basic - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_processing_time_ms, round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_processing_time_ms, round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, SignUpProcessingTimePercentiles: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = filterToWhereClause(filters) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = authFiltersToAndPredicates(filters) const groupByProvider = Boolean(filters?.provider && filters.provider.length > 0) - return ` + return safeSql` --signup-processing-time-percentiles - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${groupByProvider ? 'COALESCE(JSON_VALUE(event_message, "$.provider"), \'unknown\') as provider,' : ''} + ${providerSelectFragment(groupByProvider, false)} count(*) as count, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_processing_time_ms, round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_processing_time_ms from auth_logs where json_value(event_message, "$.auth_event.action") = 'user_signedup' - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} - group by timestamp${groupByProvider ? ', provider' : ''} - order by timestamp desc${groupByProvider ? ', provider' : ''} + ${andPredicates} + group by timestamp${providerGroupBy(groupByProvider)} + order by timestamp desc${providerGroupBy(groupByProvider)} ` }, ErrorsByStatus: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = edgeLogsFilterToWhereClause(filters) - return ` + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = edgeLogsFiltersToAndPredicates(filters) + return safeSql` --auth-errors-by-status - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, count(*) as count, response.status_code @@ -219,17 +294,17 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} group by timestamp, status_code order by timestamp desc ` }, ErrorsByAuthCode: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) - const whereClause = edgeLogsFilterToWhereClause(filters) - return ` + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] + const andPredicates = edgeLogsFiltersToAndPredicates(filters) + return safeSql` --auth-errors-by-code - select + select timestamp_trunc(timestamp, ${granularity}) as timestamp, count(*) as count, h.x_sb_error_code as error_code @@ -240,47 +315,13 @@ const AUTH_REPORT_SQL: Record< cross join unnest(response.headers) as h where path like '%auth/v1%' and response.status_code >= 400 and response.status_code <= 599 - ${whereClause ? `AND ${whereClause.replace(/^WHERE\s+/, '')}` : ''} + ${andPredicates} group by timestamp, error_code order by timestamp desc ` }, } -type AuthReportFilters = { - status_code?: NumericFilter | null - provider?: string[] | null -} - -function filterToWhereClause(filters?: AuthReportFilters): string { - const whereClauses: string[] = [] - - if (filters?.status_code) { - whereClauses.push( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` - ) - } - - if (filters?.provider && filters.provider.length > 0) { - const providerList = filters.provider.map((p) => `'${p}'`).join(', ') - whereClauses.push(`JSON_VALUE(event_message, "$.provider") IN (${providerList})`) - } - - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' -} - -function edgeLogsFilterToWhereClause(filters?: AuthReportFilters): string { - const whereClauses: string[] = [] - - if (filters?.status_code) { - whereClauses.push( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` - ) - } - - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' -} - export const AUTH_ERROR_CODE_VALUES: string[] = [ 'anonymous_provider_disabled', 'bad_code_verifier', diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index efe1cabfcd8..a4c72987276 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -13,7 +13,18 @@ import { unixMicroToIsoTimestamp, } from '@/components/interfaces/Settings/Logs/Logs.utils' import type { AnalyticsInterval } from '@/data/analytics/constants' -import { analyticsIntervalToGranularity, fetchLogs } from '@/data/reports/report.utils' +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 @@ -22,44 +33,48 @@ type EdgeFunctionReportFilters = { functions: SelectFilters } -export function filterToWhereClause(filters?: EdgeFunctionReportFilters): string { - const whereClauses: string[] = [] +export function filterToWhereClause(filters?: EdgeFunctionReportFilters): SafeLogSqlFragment { + const whereClauses: SafeLogSqlFragment[] = [] if (filters?.functions && filters.functions.length > 0) { - whereClauses.push(`function_id IN (${filters.functions.map((id) => `'${id}'`).join(',')})`) + 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( - `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` + safeSql`response.status_code ${op} ${analyticsLiteral(filters.status_code.value)}` ) } if (filters?.region && filters.region.length > 0) { - whereClauses.push( - `h.x_sb_edge_region IN (${filters.region.map((region) => `'${region}'`).join(',')})` - ) + 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( - `m.execution_time_ms ${filters.execution_time.operator} ${filters.execution_time.value}` + safeSql`m.execution_time_ms ${op} ${analyticsLiteral(filters.execution_time.value)}` ) } - return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' + if (whereClauses.length === 0) return safeSql`` + return safeSql`WHERE ${joinSqlFragments(whereClauses, ' AND ')}` } const METRIC_SQL: Record< string, - (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => string + (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => SafeLogSqlFragment > = { TotalInvocations: (interval, filters) => { + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-invocations select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + timestamp_trunc(timestamp, ${granularity}) as timestamp, function_id, count(*) as count from @@ -77,11 +92,12 @@ order by ` }, ExecutionStatusCodes: (interval, filters) => { + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-execution-status-codes select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + timestamp_trunc(timestamp, ${granularity}) as timestamp, response.status_code as status_code, count(response.status_code) as count from @@ -98,14 +114,14 @@ order by ` }, InvocationsByRegion: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - const hasWhere = whereClause.includes('WHERE') - const regionCondition = hasWhere - ? 'AND h.x_sb_edge_region is not null' - : 'WHERE h.x_sb_edge_region is not null' + 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 ` + return safeSql` --edgefn-report-invocations-by-region select timestamp_trunc(timestamp, ${granularity}) as timestamp, @@ -126,10 +142,10 @@ order by ` }, ExecutionTime: (interval, filters) => { - const granularity = analyticsIntervalToGranularity(interval) + const granularity = SAFE_GRANULARITY_SQL[analyticsIntervalToGranularity(interval)] const whereClause = filterToWhereClause(filters) - return ` + return safeSql` --edgefn-report-execution-time select timestamp_trunc(timestamp, ${granularity}) as timestamp, diff --git a/apps/studio/hooks/analytics/useProjectUsageStats.tsx b/apps/studio/hooks/analytics/useProjectUsageStats.tsx index c4638726ef7..e09b4d27dd2 100644 --- a/apps/studio/hooks/analytics/useProjectUsageStats.tsx +++ b/apps/studio/hooks/analytics/useProjectUsageStats.tsx @@ -11,7 +11,7 @@ import type { LogsEndpointParams, } from '@/components/interfaces/Settings/Logs/Logs.types' import { genChartQuery } from '@/components/interfaces/Settings/Logs/Logs.utils' -import { get } from '@/data/fetchers' +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' interface ProjectUsageStatsHookResult { error: string | Object | null @@ -71,20 +71,15 @@ function useProjectUsageStats({ const { data: eventChartResponse, refetch: refreshEventChart } = useQuery({ queryKey: chartQueryKey, queryFn: async ({ signal }) => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - iso_timestamp_start: timestampStart, - iso_timestamp_end: timestampEnd, - sql: chartQuery, - }, - }, + const data = await executeAnalyticsSql({ + projectRef, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql: chartQuery, + iso_timestamp_start: timestampStart, + iso_timestamp_end: timestampEnd, + method: 'get', signal, }) - if (error) { - throw error - } return data as unknown as EventChart },