From ec21e68eeee15dfddff891500ceabb8efa65b600 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 14 May 2026 10:29:50 -0400 Subject: [PATCH] studio(logs): use safe sql escaping for new logs queries (#45887) ## Summary by CodeRabbit * **New Features** * Introduced a safe SQL fragment system and helpers to build composable, validated log queries and aggregations. * **Refactor** * Rewrote unified log query builders and inspection flows to use the new safe fragments and identifier/literal validators. * **Bug Fixes** * Improved validation and error handling for filter keys and literal escaping to prevent malformed or injectable queries. * **Tests** * Added tests covering identifier quoting, value escaping, and rejection of invalid filter inputs. [![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/45887) --- .../UnifiedLogs/UnifiedLogs.queries.bq.ts | 267 +++++++++--------- .../UnifiedLogs/UnifiedLogs.queries.test.ts | 49 ++++ .../UnifiedLogs/UnifiedLogs.queries.ts | 226 ++++++++------- apps/studio/data/logs/safe-analytics-sql.ts | 126 +++++++++ .../data/logs/unified-log-inspection-query.ts | 32 ++- .../logs/unified-logs-facet-count-query.ts | 9 +- 6 files changed, 454 insertions(+), 255 deletions(-) create mode 100644 apps/studio/data/logs/safe-analytics-sql.ts diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts index 96feefeac72..375231d47cd 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts @@ -4,11 +4,17 @@ // during the migration. This file should be deleted once the flag is // removed. -import { literal } from '@supabase/pg-meta/src/pg-format' import dayjs from 'dayjs' import { DEFAULT_LOG_TYPES } from './UnifiedLogs.constants' import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types' +import { + bqIdent, + joinSqlFragments, + analyticsLiteral as lit, + safeSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' // Pagination and control parameters const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const @@ -19,55 +25,77 @@ const SPECIAL_FILTER_PARAMS = ['date'] as const // Combined list of all parameters to exclude from standard filtering const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const -// Strips `literal()`'s surrounding quotes so the value can be concatenated -// inside a `LIKE '%...%'` clause without re-escaping; quotes and back- -// slashes are already escaped by literal(). -const likeValue = (value: unknown): string => - literal(String(value)).replace(/^E?'/, '').replace(/'$/, '') - /** - * Builds query conditions from search parameters and returns WHERE clause - * @param search SearchParamsType object containing query parameters - * @returns Object with whereConditions array and formatted WHERE clause + * Builds WHERE-clause fragments from a search-param map. Identifier-position + * keys are validated via `bqIdent()` (regex allowlist) and value-position + * inputs via `analyticsLiteral` — both throw on disallowed input, in which + * case we drop the predicate rather than emit unsafe SQL. + * + * @param search Search params (URL-derived filter values) + * @param excludeKey Optional key to skip — used by facet-count branches that + * need every filter applied *except* the one being faceted + * @returns Array of SafeLogSqlFragment predicates ready to be AND-joined */ -const buildQueryConditions = (search: QuerySearchParamsType) => { - const whereConditions: string[] = [] +const buildConditions = ( + search: QuerySearchParamsType, + excludeKey?: string +): SafeLogSqlFragment[] => { + const conditions: SafeLogSqlFragment[] = [] - // Process all search parameters for filtering Object.entries(search).forEach(([key, value]) => { - // Skip pagination/control parameters - if ((EXCLUDED_QUERY_PARAMS as readonly string[]).includes(key)) { - return - } + if (key === excludeKey) return + if ((EXCLUDED_QUERY_PARAMS as readonly string[]).includes(key)) return - // Handle array filters (IN clause) - if (Array.isArray(value) && value.length > 0) { - whereConditions.push(`${key} IN (${value.map((v) => literal(String(v))).join(',')})`) - return - } + try { + // `key` is interpolated as a column identifier. `bqIdent()` rejects + // anything outside `[A-Za-z_][A-Za-z0-9_]*` (notably no spaces, so a + // crafted URL key like `level OR id IS NOT NULL` is dropped rather + // than emitted into the WHERE clause). + const col = bqIdent(key) - // Handle scalar values - if (value !== null && value !== undefined) { - if (['host', 'pathname'].includes(key)) { - whereConditions.push(`${key} LIKE '%${likeValue(value)}%'`) - } else { - whereConditions.push(`${key} = ${literal(String(value))}`) + if (Array.isArray(value) && value.length > 0) { + const inList = joinSqlFragments( + value.map((v) => lit(String(v))), + ',' + ) + conditions.push(safeSql`${col} IN (${inList})`) + return } + + if (value !== null && value !== undefined) { + if (key === 'host' || key === 'pathname') { + conditions.push(safeSql`${col} LIKE ${lit('%' + String(value) + '%')}`) + } else { + conditions.push(safeSql`${col} = ${lit(String(value))}`) + } + } + } catch { + // bqIdent() or analyticsLiteral() rejected the input — drop the predicate. } }) - // Create final WHERE clause - const finalWhere = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '' - - return { whereConditions, finalWhere } + return conditions } +const whereClause = (conditions: SafeLogSqlFragment[]): SafeLogSqlFragment => + conditions.length > 0 ? safeSql`WHERE ${joinSqlFragments(conditions, ' AND ')}` : safeSql`` + /** * Calculates how much the chart start datetime should be offset given the current datetime filter params * and determines the appropriate bucketing level (minute, hour, day) * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) */ -const calculateChartBucketing = (search: SearchParamsType | Record): string => { +type TruncationLevel = 'MINUTE' | 'HOUR' | 'DAY' + +const TRUNCATION_LEVEL_SQL: Record = { + MINUTE: safeSql`MINUTE`, + HOUR: safeSql`HOUR`, + DAY: safeSql`DAY`, +} + +const calculateChartBucketing = ( + search: SearchParamsType | Record +): TruncationLevel => { // Extract start and end times from the date array if available const dateRange = (search.date as Array) || [] @@ -101,21 +129,12 @@ const calculateChartBucketing = (search: SearchParamsType | Record= 2) { - truncationLevel = 'DAY' - } else if (hourDiff >= 12) { - truncationLevel = 'HOUR' - } else { - truncationLevel = 'MINUTE' - } - - return truncationLevel + if (dayDiff >= 2) return 'DAY' + if (hourDiff >= 12) return 'HOUR' + return 'MINUTE' } /** @@ -123,8 +142,7 @@ const calculateChartBucketing = (search: SearchParamsType | Record { - return ` +const getEdgeLogsQuery = (): SafeLogSqlFragment => safeSql` select id, null as source_id, @@ -150,15 +168,10 @@ const getEdgeLogsQuery = () => { -- ONLY include logs where the path does not include /rest/ WHERE edge_logs_request.path NOT LIKE '%/rest/%' AND edge_logs_request.path NOT LIKE '%/storage/%' - ` -} -// Postgrest logs - -// WHERE pathname includes `/rest/` -const getPostgrestLogsQuery = () => { - return ` +// Postgrest logs — WHERE pathname includes `/rest/` +const getPostgrestLogsQuery = (): SafeLogSqlFragment => safeSql` select id, null as source_id, @@ -184,13 +197,11 @@ const getPostgrestLogsQuery = () => { -- ONLY include logs where the path includes /rest/ WHERE edge_logs_request.path LIKE '%/rest/%' ` -} /** * Postgres logs query fragment */ -const getPostgresLogsQuery = () => { - return ` +const getPostgresLogsQuery = (): SafeLogSqlFragment => safeSql` select id, null as source_id, @@ -213,13 +224,11 @@ const getPostgresLogsQuery = () => { cross join unnest(pgl.metadata) as pgl_metadata cross join unnest(pgl_metadata.parsed) as pgl_parsed ` -} /** * Edge function logs query fragment */ -const getEdgeFunctionLogsQuery = () => { - return ` +const getEdgeFunctionLogsQuery = (): SafeLogSqlFragment => safeSql` select id, null as source_id, @@ -252,13 +261,11 @@ const getEdgeFunctionLogsQuery = () => { GROUP BY fl_metadata.request_id ) as function_logs_agg on fel_metadata.request_id = function_logs_agg.request_id ` -} /** * Auth logs query fragment */ -const getAuthLogsQuery = () => { - return ` +const getAuthLogsQuery = (): SafeLogSqlFragment => safeSql` select el_in_al.id as id, al.id as source_id, @@ -277,24 +284,22 @@ const getAuthLogsQuery = () => { null as log_count, null as logs from auth_logs as al - cross join unnest(metadata) as al_metadata + cross join unnest(metadata) as al_metadata left join ( edge_logs as el_in_al - cross join unnest (metadata) as el_in_al_metadata - cross join unnest (el_in_al_metadata.response) as el_in_al_response - cross join unnest (el_in_al_response.headers) as el_in_al_response_headers + cross join unnest (metadata) as el_in_al_metadata + cross join unnest (el_in_al_metadata.response) as el_in_al_response + cross join unnest (el_in_al_response.headers) as el_in_al_response_headers cross join unnest (el_in_al_metadata.request) as el_in_al_request ) on al_metadata.request_id = el_in_al_response_headers.cf_ray WHERE al_metadata.request_id is not null ` -} /** * Supabase storage logs query fragment */ -const getSupabaseStorageLogsQuery = () => { - return ` +const getSupabaseStorageLogsQuery = (): SafeLogSqlFragment => safeSql` select id, null as source_id, @@ -319,9 +324,8 @@ const getSupabaseStorageLogsQuery = () => { -- ONLY include logs where the path includes /storage/ WHERE edge_logs_request.path LIKE '%/storage/%' ` -} -const LOG_TYPE_QUERIES: Record string> = { +const LOG_TYPE_QUERIES: Record SafeLogSqlFragment> = { edge: getEdgeLogsQuery, postgrest: getPostgrestLogsQuery, postgres: getPostgresLogsQuery, @@ -334,17 +338,19 @@ const LOG_TYPE_QUERIES: Record string> = { * Combine the requested log sources to create the unified logs CTE. * Defaults to postgres + postgrest on first load to reduce query cost. */ -export const getUnifiedLogsCTE = (logTypes: string[] = [...DEFAULT_LOG_TYPES]) => { +export const getUnifiedLogsCTE = ( + logTypes: string[] = [...DEFAULT_LOG_TYPES] +): SafeLogSqlFragment => { const queries = logTypes .filter((type) => type in LOG_TYPE_QUERIES) .map((type) => LOG_TYPE_QUERIES[type]()) - const effectiveQueries = - queries.length > 0 ? queries : DEFAULT_LOG_TYPES.map((type) => LOG_TYPE_QUERIES[type]()) + const effective = + queries.length > 0 ? queries : DEFAULT_LOG_TYPES.map((t) => LOG_TYPE_QUERIES[t]()) - return ` + return safeSql` WITH unified_logs AS ( - ${effectiveQueries.join('\n union all\n ')} + ${joinSqlFragments(effective, ' union all ')} ) ` } @@ -352,11 +358,11 @@ WITH unified_logs AS ( /** * Unified logs SQL query */ -export const getUnifiedLogsQuery = (search: QuerySearchParamsType): string => { - const { finalWhere } = buildQueryConditions(search) +export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { + const conditions = buildConditions(search) const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES] - const sql = ` + return safeSql` ${getUnifiedLogsCTE(effectiveLogTypes)} SELECT id, @@ -371,42 +377,8 @@ SELECT log_count, logs FROM unified_logs -${finalWhere} +${whereClause(conditions)} ` - - return sql -} - -/** - * Get a count query for the total logs within the timeframe - * Uses proper faceted search behavior where facets show "what would I get if I selected ONLY this option" - */ - -// Helper function to build WHERE clause excluding a specific field -const buildFacetWhere = (search: QuerySearchParamsType, excludeField: string): string => { - const conditions: string[] = [] - - Object.entries(search).forEach(([key, value]) => { - if (key === excludeField) return // Skip the field we're getting facets for - if ((EXCLUDED_QUERY_PARAMS as readonly string[]).includes(key)) return // Skip pagination and special params - - // Handle array filters (IN clause) - if (Array.isArray(value) && value.length > 0) { - conditions.push(`${key} IN (${value.map((v) => literal(String(v))).join(',')})`) - return - } - - // Handle scalar values - if (value !== null && value !== undefined) { - if (['host', 'pathname'].includes(key)) { - conditions.push(`${key} LIKE '%${likeValue(value)}%'`) - } else { - conditions.push(`${key} = ${literal(String(value))}`) - } - } - }) - - return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' } export const getFacetCountCTE = ({ @@ -417,24 +389,36 @@ export const getFacetCountCTE = ({ search: QuerySearchParamsType facet: string facetSearch?: string -}) => { +}): SafeLogSqlFragment => { const MAX_FACETS_QUANTITY = 20 - return ` -${facet}_count AS ( - SELECT '${facet}' as dimension, ${facet} as value, COUNT(*) as count + // `facet` is used both as a column reference and to derive a CTE name; + // quote each appropriately with bqIdent() to reject non-identifier inputs. + const facetCol = bqIdent(facet) + const facetCte = bqIdent(facet + '_count') + const baseConditions = buildConditions(search, facet) + const facetSearchClause = facetSearch + ? safeSql`AND ${facetCol} LIKE ${lit('%' + facetSearch + '%')}` + : safeSql`` + + const where = + baseConditions.length > 0 + ? safeSql`WHERE ${joinSqlFragments(baseConditions, ' AND ')} AND ${facetCol} IS NOT NULL` + : safeSql`WHERE ${facetCol} IS NOT NULL` + + return safeSql` +${facetCte} AS ( + SELECT ${lit(facet)} as dimension, ${facetCol} as value, COUNT(*) as count FROM unified_logs - ${buildFacetWhere(search, `${facet}`) || `WHERE ${facet} IS NOT NULL`} - ${buildFacetWhere(search, `${facet}`) ? ` AND ${facet} IS NOT NULL` : ''} - ${!!facetSearch ? `AND ${facet} LIKE '%${likeValue(facetSearch)}%'` : ''} - GROUP BY ${facet} - LIMIT ${MAX_FACETS_QUANTITY} + ${where} + ${facetSearchClause} + GROUP BY ${facetCol} + LIMIT ${lit(MAX_FACETS_QUANTITY)} ) -`.trim() +` } -export const getUnifiedLogsCountCTE = () => { - return ` +export const getUnifiedLogsCountCTE = (): SafeLogSqlFragment => safeSql` WITH unified_logs AS ( -- Single scan of edge_logs covering edge gateway, postgrest, and storage select @@ -526,14 +510,21 @@ WITH unified_logs AS ( WHERE al_metadata.request_id is not null ) ` -} -export const getLogsCountQuery = (search: QuerySearchParamsType): string => { +export const getLogsCountQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES] - const logTypeWhere = buildFacetWhere(search, 'log_type') || 'WHERE log_type IS NOT NULL' - const levelWhere = buildFacetWhere(search, 'level') || 'WHERE level IS NOT NULL' + const logTypeConditions = buildConditions(search, 'log_type') + const levelConditions = buildConditions(search, 'level') + const logTypeWhere: SafeLogSqlFragment = + logTypeConditions.length > 0 + ? safeSql`WHERE ${joinSqlFragments(logTypeConditions, ' AND ')}` + : safeSql`WHERE log_type IS NOT NULL` + const levelWhere: SafeLogSqlFragment = + levelConditions.length > 0 + ? safeSql`WHERE ${joinSqlFragments(levelConditions, ' AND ')}` + : safeSql`WHERE level IS NOT NULL` - const sql = ` + return safeSql` ${getUnifiedLogsCTE(effectiveLogTypes)}, -- Single COUNTIF pass for all log_type buckets + total (no GROUP BY / sort needed) @@ -579,29 +570,27 @@ UNION ALL SELECT dimension, value, count FROM method_count UNION ALL SELECT dimension, value, count FROM status_count UNION ALL SELECT dimension, value, count FROM pathname_count ` - - return sql } /** * Enhanced logs chart query with dynamic bucketing based on time range * Incorporates dynamic bucketing from the older implementation */ -export const getLogsChartQuery = (search: QuerySearchParamsType): string => { - const { finalWhere } = buildQueryConditions(search) +export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { + const conditions = buildConditions(search) const truncationLevel = calculateChartBucketing(search) const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES] - return ` + return safeSql` ${getUnifiedLogsCTE(effectiveLogTypes)} SELECT - TIMESTAMP_TRUNC(timestamp, ${truncationLevel}) as time_bucket, + TIMESTAMP_TRUNC(timestamp, ${TRUNCATION_LEVEL_SQL[truncationLevel]}) as time_bucket, COUNTIF(level = 'success') as success, COUNTIF(level = 'warning') as warning, COUNTIF(level = 'error') as error, COUNT(*) as total_per_bucket FROM unified_logs -${finalWhere} +${whereClause(conditions)} GROUP BY time_bucket ORDER BY time_bucket ASC ` diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts index b1b43e3e36a..3481c054fe8 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts @@ -6,6 +6,7 @@ import { getLogsCountQuery, getUnifiedLogsQuery, } from './UnifiedLogs.queries' +import { getUnifiedLogsQuery as getUnifiedLogsQueryBQ } from './UnifiedLogs.queries.bq' const baseSearch = { date: [new Date('2026-05-08T09:00:00Z'), new Date('2026-05-08T10:00:00Z')], @@ -134,4 +135,52 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(sql).toContain('LIMIT 20') }) }) + + describe('analyticsLiteral escaping', () => { + it('emits ClickHouse / BigQuery escape syntax (doubled `\\\\`, no `E` prefix)', () => { + // pg-meta's literal() would emit `E'a\\b'` for `a\b` — the `E` prefix is + // Postgres-only and rejected by both analytics engines. analyticsLiteral + // doubles the backslash inside plain `'…'` delimiters instead. + const sql = getUnifiedLogsQuery({ ...baseSearch, method: 'a\\b' } as any) + expect(sql).toContain(`log_attributes['request.method'] = 'a\\\\b'`) + expect(sql).not.toContain(`E'a`) + }) + + it("escapes single quotes by doubling them ('' rather than \\')", () => { + const sql = getUnifiedLogsQuery({ ...baseSearch, method: "GET' OR '1'='1" } as any) + expect(sql).toContain(`log_attributes['request.method'] = 'GET'' OR ''1''=''1'`) + }) + }) +}) + +describe('UnifiedLogs.queries.bq', () => { + it('backtick-quotes column identifiers (BigQuery syntax)', () => { + const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: 'GET' } as any) + expect(sql).toContain("`method` = 'GET'") + }) + + it('rejects keys with non-identifier characters', () => { + // A key like "foo; DROP TABLE" fails the bqIdent regex (the `;` is not + // in `[A-Za-z_][A-Za-z0-9_]*`), so the predicate is dropped entirely. + const sql = getUnifiedLogsQueryBQ({ ...baseSearch, 'foo; DROP TABLE x': 'y' } as any) + expect(sql).not.toContain('DROP TABLE') + expect(sql).not.toContain('foo;') + }) + + it('rejects keys containing spaces', () => { + const sql = getUnifiedLogsQueryBQ({ + ...baseSearch, + 'level OR id IS NOT NULL': 'anything', + } as any) + expect(sql).not.toContain('IS NOT NULL') + expect(sql).not.toContain('level OR') + }) + + it('escapes injection attempts in filter values via analyticsLiteral', () => { + const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: "GET' OR '1'='1" } as any) + // The value is single-quote-escaped, so the synthetic OR can't break out + // of the string literal. + expect(sql).toContain("`method` = 'GET'' OR ''1''=''1'") + expect(sql).not.toMatch(/`method` = 'GET' OR '1'='1'/) + }) }) diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 543e36b0b44..db880c26fd1 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -1,19 +1,13 @@ -import { literal } from '@supabase/pg-meta/src/pg-format' import dayjs from 'dayjs' import { DEFAULT_LOG_TYPES } from './UnifiedLogs.constants' import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types' - -// Escapes a substring of a `LIKE '%...%'` clause so a value containing a -// single quote can't terminate the literal early. `literal()` would wrap -// the whole value in quotes, which we don't want when concatenating wild- -// card characters; this strips its quoting back off. -const likeValue = (value: unknown): string => { - const escaped = literal(String(value)) - // literal() wraps strings as 'value' (and prefixes with E for backslash - // values); strip the surrounding quotes since we glue this between %. - return escaped.replace(/^E?'/, '').replace(/'$/, '') -} +import { + joinSqlFragments, + analyticsLiteral as lit, + safeSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' // Pagination and control parameters const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const @@ -23,14 +17,18 @@ const SPECIAL_FILTER_PARAMS = ['date'] as const // Combined list of all parameters to exclude from standard filtering const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const + +// Facets the count query is allowed to be invoked for. Reject anything else +// at the entry point rather than letting an unsupported value reach +// `log_attributes[…]` lookups. const FACET_FIELDS = ['log_type', 'level', 'method', 'status', 'pathname'] as const // OTEL log_attributes keys for HTTP-style fields. Centralized so they can be // adjusted in one place if the backend conventions change. const ATTR = { - method: `log_attributes['request.method']`, - status: `log_attributes['response.status_code']`, - path: `log_attributes['request.path']`, + method: safeSql`log_attributes['request.method']`, + status: safeSql`log_attributes['response.status_code']`, + path: safeSql`log_attributes['request.path']`, } as const /** @@ -43,17 +41,17 @@ const ATTR = { * logs from postgREST / storage-api and are intentionally not part of unified * logs; the UI surfaces gateway HTTP traffic for those buckets. */ -const LOG_TYPE_PREDICATE: Record = { - edge: `source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`, - postgrest: `source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%'`, - storage: `source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%'`, - postgres: `source = 'postgres_logs'`, - 'edge function': `source = 'function_edge_logs'`, - auth: `source = 'auth_logs'`, +const LOG_TYPE_PREDICATE: Record = { + edge: safeSql`source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`, + postgrest: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%'`, + storage: safeSql`source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%'`, + postgres: safeSql`source = 'postgres_logs'`, + 'edge function': safeSql`source = 'function_edge_logs'`, + auth: safeSql`source = 'auth_logs'`, } // Derived `log_type` column for SELECT / GROUP BY / countIf use. -const LOG_TYPE_EXPR = `CASE +const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' WHEN source = 'edge_logs' THEN 'edge' @@ -65,7 +63,7 @@ const LOG_TYPE_EXPR = `CASE // Status code is sourced from the HTTP response for gateway-style rows and // from the Postgres `parsed.sql_state_code` (e.g. `42P01`) for postgres rows. -const STATUS_EXPR = `CASE +const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code']) ELSE toString(${ATTR.status}) END` @@ -78,7 +76,7 @@ const STATUS_EXPR = `CASE // `severity_text` of `INFO` regardless of response code) bucket as // success/warning/error by status. Postgres-style severity is the // fallback for rows without a status code. -const LEVEL_EXPR = `CASE +const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) >= 500 THEN 'error' WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 400 AND 499 THEN 'warning' WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 200 AND 299 THEN 'success' @@ -88,10 +86,11 @@ const LEVEL_EXPR = `CASE ELSE 'success' END` -const logTypeWherePredicate = (logTypes: string[]) => { +const logTypeWherePredicate = (logTypes: string[]): SafeLogSqlFragment => { const effective = logTypes.filter((t) => t in LOG_TYPE_PREDICATE) const types = effective.length ? effective : [...DEFAULT_LOG_TYPES] - return `(${types.map((t) => `(${LOG_TYPE_PREDICATE[t]})`).join(' OR ')})` + const branches = types.map((t) => safeSql`(${LOG_TYPE_PREDICATE[t]})`) + return safeSql`(${joinSqlFragments(branches, ' OR ')})` } /** @@ -100,71 +99,90 @@ const logTypeWherePredicate = (logTypes: string[]) => { * `log_type` or `level` in WHERE for some shapes, so we always emit raw-column * predicates (source/severity_text/log_attributes[…]). */ -const translateFilter = (key: string, value: unknown): string | null => { +const translateFilter = (key: string, value: unknown): SafeLogSqlFragment | null => { if (value === null || value === undefined) return null const arr = Array.isArray(value) ? (value.length > 0 ? value : null) : null if (Array.isArray(value) && !arr) return null - const inList = (values: readonly unknown[]) => - `(${values.map((v) => literal(String(v))).join(',')})` + const inList = (values: readonly unknown[]): SafeLogSqlFragment => + safeSql`(${joinSqlFragments( + values.map((v) => lit(String(v))), + ',' + )})` switch (key) { case 'log_type': { const types = (arr ?? [value]).map((v) => String(v)) - return `(${types - .map((t) => `(${LOG_TYPE_PREDICATE[t] ?? `source = ${literal(t)}`})`) - .join(' OR ')})` + const branches = types.map( + (t) => safeSql`(${LOG_TYPE_PREDICATE[t] ?? safeSql`source = ${lit(t)}`})` + ) + return safeSql`(${joinSqlFragments(branches, ' OR ')})` } case 'level': { // No simple raw column for level; reference the inline CASE expression. const levels = arr ?? [value] - return `(${LEVEL_EXPR}) IN ${inList(levels.map((v) => String(v)))}` + return safeSql`(${LEVEL_EXPR}) IN ${inList(levels.map((v) => String(v)))}` } case 'method': - return arr ? `${ATTR.method} IN ${inList(arr)}` : `${ATTR.method} = ${literal(String(value))}` + return arr + ? safeSql`${ATTR.method} IN ${inList(arr)}` + : safeSql`${ATTR.method} = ${lit(String(value))}` case 'status': - return arr ? `${ATTR.status} IN ${inList(arr)}` : `${ATTR.status} = ${literal(String(value))}` + return arr + ? safeSql`${ATTR.status} IN ${inList(arr)}` + : safeSql`${ATTR.status} = ${lit(String(value))}` case 'pathname': return arr - ? `(${arr.map((v) => `${ATTR.path} LIKE '%${likeValue(v)}%'`).join(' OR ')})` - : `${ATTR.path} LIKE '%${likeValue(value)}%'` + ? safeSql`(${joinSqlFragments( + arr.map((v) => safeSql`${ATTR.path} LIKE ${lit('%' + String(v) + '%')}`), + ' OR ' + )})` + : safeSql`${ATTR.path} LIKE ${lit('%' + String(value) + '%')}` case 'host': // Best-effort: use full request URL since `host` isn't a top-level field. return arr - ? `(${arr.map((v) => `log_attributes['request.url'] LIKE '%${likeValue(v)}%'`).join(' OR ')})` - : `log_attributes['request.url'] LIKE '%${likeValue(value)}%'` + ? safeSql`(${joinSqlFragments( + arr.map( + (v) => safeSql`log_attributes['request.url'] LIKE ${lit('%' + String(v) + '%')}` + ), + ' OR ' + )})` + : safeSql`log_attributes['request.url'] LIKE ${lit('%' + String(value) + '%')}` default: // Unknown filter key — fall back to a generic equality on log_attributes. - // We don't pass `key` through literal() because Map key access in - // ClickHouse uses bracket syntax with a string literal; the existing - // EXCLUDED_QUERY_PARAMS list and the typed search params surface this - // from a static allow-list, not user input. return arr - ? `log_attributes['${key}'] IN ${inList(arr)}` - : `log_attributes['${key}'] = ${literal(String(value))}` + ? safeSql`log_attributes[${lit(key)}] IN ${inList(arr)}` + : safeSql`log_attributes[${lit(key)}] = ${lit(String(value))}` } } /** - * Builds an array of WHERE predicate strings from search params, optionally + * Builds an array of WHERE predicate fragments from search params, optionally * skipping a specific facet field (used when computing faceted counts). * `log_type` is always handled separately (see `logTypeWherePredicate`). */ -const buildPredicates = (search: QuerySearchParamsType, excludeField?: string) => { - const predicates: string[] = [] +const buildPredicates = ( + search: QuerySearchParamsType, + excludeField?: string +): SafeLogSqlFragment[] => { + const predicates: SafeLogSqlFragment[] = [] Object.entries(search).forEach(([key, value]) => { if (key === excludeField) return if (key === 'log_type') return if ((EXCLUDED_QUERY_PARAMS as readonly string[]).includes(key)) return - const predicate = translateFilter(key, value) - if (predicate) predicates.push(predicate) + try { + const predicate = translateFilter(key, value) + if (predicate) predicates.push(predicate) + } catch { + // analyticsLiteral rejected an unsupported input — drop the predicate. + } }) return predicates } -const whereClause = (predicates: string[]) => - predicates.length > 0 ? `WHERE ${predicates.join(' AND ')}` : '' +const whereClause = (predicates: SafeLogSqlFragment[]): SafeLogSqlFragment => + predicates.length > 0 ? safeSql`WHERE ${joinSqlFragments(predicates, ' AND ')}` : safeSql`` /** * Calculates the chart bucketing level (minute/hour/day) given the date range. @@ -203,15 +221,15 @@ const calculateChartBucketing = ( return 'MINUTE' } -const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY') => { +const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY'): SafeLogSqlFragment => { switch (level) { case 'DAY': - return 'toStartOfDay' + return safeSql`toStartOfDay` case 'HOUR': - return 'toStartOfHour' + return safeSql`toStartOfHour` case 'MINUTE': default: - return 'toStartOfMinute' + return safeSql`toStartOfMinute` } } @@ -220,7 +238,7 @@ const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY') => { * inlined so the result can be referenced (or filtered) at the same query * level — the OTEL endpoint rejects subqueries. */ -const rowProjection = () => ` +const rowProjection = (): SafeLogSqlFragment => safeSql` id, null AS source_id, timestamp, @@ -234,9 +252,12 @@ const rowProjection = () => ` null AS logs ` -const buildBaseWhere = (search: QuerySearchParamsType, excludeField?: string) => { +const buildBaseWhere = ( + search: QuerySearchParamsType, + excludeField?: string +): SafeLogSqlFragment[] => { const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES] - const parts: string[] = [] + const parts: SafeLogSqlFragment[] = [] if (excludeField !== 'log_type') { parts.push(logTypeWherePredicate(effectiveLogTypes)) } @@ -247,9 +268,9 @@ const buildBaseWhere = (search: QuerySearchParamsType, excludeField?: string) => /** * Unified logs row query — flat SELECT, no subquery wrapper. */ -export const getUnifiedLogsQuery = (search: QuerySearchParamsType): string => { +export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { const predicates = buildBaseWhere(search) - return ` + return safeSql` SELECT ${rowProjection()} FROM logs ${whereClause(predicates)} @@ -267,14 +288,14 @@ export const getFacetCountQuery = ({ search: QuerySearchParamsType facet: string facetSearch?: string -}) => { +}): SafeLogSqlFragment => { if (!(FACET_FIELDS as readonly string[]).includes(facet)) { throw new Error('Invalid unified logs facet') } const MAX_FACETS_QUANTITY = 20 - const facetExpr = + const facetExpr: SafeLogSqlFragment = facet === 'log_type' ? LOG_TYPE_EXPR : facet === 'level' @@ -285,78 +306,89 @@ export const getFacetCountQuery = ({ ? STATUS_EXPR : facet === 'pathname' ? ATTR.path - : `log_attributes['${facet}']` + : safeSql`log_attributes[${lit(facet)}]` - const predicates = [ + const predicates: SafeLogSqlFragment[] = [ ...buildBaseWhere(search, facet), - `(${facetExpr}) IS NOT NULL AND (${facetExpr}) != ''`, + safeSql`(${facetExpr}) IS NOT NULL AND (${facetExpr}) != ''`, ] if (facetSearch) { - predicates.push(`(${facetExpr}) LIKE '%${likeValue(facetSearch)}%'`) + predicates.push(safeSql`(${facetExpr}) LIKE ${lit('%' + facetSearch + '%')}`) } - return ` -SELECT '${facet}' AS dimension, (${facetExpr}) AS value, count() AS count + return safeSql` +SELECT ${lit(facet)} AS dimension, (${facetExpr}) AS value, count() AS count FROM logs ${whereClause(predicates)} GROUP BY value -LIMIT ${MAX_FACETS_QUANTITY} -`.trim() +LIMIT ${lit(MAX_FACETS_QUANTITY)} +` } /** * Bundled count query — UNION ALL of (dimension, value, count) rows so the * frontend can render facet counts and total in one round trip. */ -export const getLogsCountQuery = (search: QuerySearchParamsType): string => { - const baseFiltersFor = (excludeField?: string) => - buildBaseWhere(search, excludeField).join(' AND ') +export const getLogsCountQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { + // When no predicates remain, fall back to `1` so we emit a valid + // tautology rather than a bare `WHERE`. + const baseFiltersFor = (excludeField?: string): SafeLogSqlFragment => { + const predicates = buildBaseWhere(search, excludeField) + return predicates.length > 0 ? joinSqlFragments(predicates, ' AND ') : safeSql`1` + } // The "total" badge should reflect the user's *current* filter set, // including any active log_type filter. Pass no excludeField so the // log_type predicate is included. - const totalSql = ` + const totalSql = safeSql` SELECT 'total' AS dimension, 'all' AS value, count() AS count FROM logs WHERE ${baseFiltersFor()} -`.trim() +` - const logTypeBranches = Object.entries(LOG_TYPE_PREDICATE) - .map(([logType, predicate]) => - ` -SELECT 'log_type' AS dimension, '${logType}' AS value, countIf(${predicate}) AS count + const logTypeBranches = joinSqlFragments( + Object.entries(LOG_TYPE_PREDICATE).map( + ([logType, predicate]) => + safeSql` +SELECT 'log_type' AS dimension, ${lit(logType)} AS value, countIf(${predicate}) AS count FROM logs WHERE ${baseFiltersFor('log_type')} -`.trim() - ) - .join('\nUNION ALL\n') +` + ), + ' UNION ALL ' + ) - const levelBranches = ['success', 'warning', 'error'] - .map((lvl) => - ` -SELECT 'level' AS dimension, '${lvl}' AS value, countIf((${LEVEL_EXPR}) = '${lvl}') AS count + const levelBranches = joinSqlFragments( + (['success', 'warning', 'error'] as const).map( + (lvl) => + safeSql` +SELECT 'level' AS dimension, ${lit(lvl)} AS value, countIf((${LEVEL_EXPR}) = ${lit(lvl)}) AS count FROM logs WHERE ${baseFiltersFor('level')} -`.trim() - ) - .join('\nUNION ALL\n') +` + ), + ' UNION ALL ' + ) - const facetBranches = ['method', 'status', 'pathname'] - .map((facet) => getFacetCountQuery({ search, facet })) - .join('\nUNION ALL\n') + const facetBranches = joinSqlFragments( + (['method', 'status', 'pathname'] as const).map((facet) => + getFacetCountQuery({ search, facet }) + ), + ' UNION ALL ' + ) - return [totalSql, logTypeBranches, levelBranches, facetBranches].join('\nUNION ALL\n') + return joinSqlFragments([totalSql, logTypeBranches, levelBranches, facetBranches], ' UNION ALL ') } /** * Logs chart query with dynamic bucketing based on time range. */ -export const getLogsChartQuery = (search: QuerySearchParamsType): string => { +export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => { const truncationLevel = calculateChartBucketing(search) const truncFn = truncationFunction(truncationLevel) const predicates = buildBaseWhere(search) - return ` + return safeSql` SELECT ${truncFn}(timestamp) AS time_bucket, countIf((${LEVEL_EXPR}) = 'success') AS success, diff --git a/apps/studio/data/logs/safe-analytics-sql.ts b/apps/studio/data/logs/safe-analytics-sql.ts new file mode 100644 index 00000000000..91c522d325d --- /dev/null +++ b/apps/studio/data/logs/safe-analytics-sql.ts @@ -0,0 +1,126 @@ +// pg-meta's `literal()` and `ident()` are Postgres-specific: `literal()` emits +// `E'…'` for backslash-bearing strings and `::jsonb` casts for objects; +// `ident()` quotes identifiers with double-quotes, which BigQuery rejects +// (double-quoted tokens are string literals there, not identifiers). We add +// analytics-engine-specific helpers here rather than extend pg-meta, which +// would cross-cut unrelated Postgres callers. +// +// The brand `SafeLogSqlFragment` is intentionally distinct from pg-meta's +// `SafeSqlFragment`: escaping that is safe for Postgres (`E'…'` strings, +// `::jsonb` casts, double-quoted identifiers) is not safe for BigQuery or +// ClickHouse, and vice versa. Keeping the brands disjoint prevents a +// Postgres-escaped fragment from being composed into an analytics query +// (or vice versa) and silently emitting unsafe SQL. +// +// String literals: ClickHouse and BigQuery share the same convention — +// double the single quote (`''`) and double the backslash (`\\`), inside +// plain `'…'` delimiters. +// +// Identifiers: BigQuery requires backticks. ClickHouse accepts both +// backticks and double-quotes; we use double-quotes (SQL-standard form). +// In both engines a backslash inside a quoted identifier is an escape +// character, so we reject any non-`[A-Za-z_][A-Za-z0-9_]*` input rather than +// try to escape it — column names never need special characters in practice. + +/** + * A branded string type representing a SQL fragment that is safe to compose + * into BigQuery or ClickHouse queries. Intentionally distinct from pg-meta's + * `SafeSqlFragment` (Postgres-only). + * + * Values of this type are either: + * - Static strings in source code (no interpolation) via `rawSql` + * - Outputs of `analyticsLiteral`, `bqIdent`, or `clickhouseIdent` + * - Compositions via the `safeSql` template tag (which only accepts + * `SafeLogSqlFragment` interpolations) + * - Compositions via `joinSqlFragments` + * + * Never cast arbitrary strings to this type. + */ +export type SafeLogSqlFragment = string & { readonly __safeLogSqlFragmentBrand: never } + +export type LogSqlFragmentSeparator = + | ',' + | ', ' + | ';\n' + | ' and ' + | ' AND ' + | ' or ' + | ' OR ' + | ' union all ' + | ' union ' + | ' UNION ALL ' + | ' UNION ' + | '\n' + | '\n\n' + | ' ' + +/** + * Tagged template literal for composing log-SQL fragments safely. + * Only accepts `SafeLogSqlFragment` interpolations — plain strings and + * Postgres-branded `SafeSqlFragment` values are rejected at compile time. + */ +export function safeSql( + strings: TemplateStringsArray, + ...interpolated: Array +): SafeLogSqlFragment { + return strings.reduce( + (result, string, i) => result + string + (interpolated[i] ?? ''), + '' + ) as SafeLogSqlFragment +} + +/** + * Marks a hand-written log-SQL string as a `SafeLogSqlFragment`. Use only + * for static SQL authored in source code; never call with arbitrary input. + */ +export function rawSql(sql: string): SafeLogSqlFragment { + return sql as SafeLogSqlFragment +} + +/** Joins already-safe log-SQL fragments with a fixed structural separator. */ +export function joinSqlFragments( + fragments: Array, + separator: LogSqlFragmentSeparator +): SafeLogSqlFragment { + return fragments.join(separator) as SafeLogSqlFragment +} + +export function analyticsLiteral(value: string | number | boolean): SafeLogSqlFragment { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('analyticsLiteral: non-finite numbers are not supported') + } + return rawSql(String(value)) + } + if (typeof value === 'boolean') { + return rawSql(value ? 'true' : 'false') + } + if (typeof value !== 'string') { + throw new Error('analyticsLiteral: only string, number, or boolean inputs are supported') + } + let escaped = '' + for (const c of value) { + if (c === "'") escaped += "''" + else if (c === '\\') escaped += '\\\\' + else escaped += c + } + return rawSql(`'${escaped}'`) +} + +const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** Quote an identifier for BigQuery using backticks. */ +export function bqIdent(value: string): SafeLogSqlFragment { + if (typeof value !== 'string' || !SAFE_IDENT_RE.test(value)) { + throw new Error(`bqIdent: invalid BigQuery identifier "${value}"`) + } + return rawSql('`' + value + '`') +} + +/** Quote an identifier for ClickHouse using double-quotes. */ +export function clickhouseIdent(value: string): SafeLogSqlFragment { + if (typeof value !== 'string' || !SAFE_IDENT_RE.test(value)) { + throw new Error(`clickhouseIdent: invalid ClickHouse identifier "${value}"`) + } + return rawSql('"' + value + '"') +} diff --git a/apps/studio/data/logs/unified-log-inspection-query.ts b/apps/studio/data/logs/unified-log-inspection-query.ts index 96553c8aa82..c7698b77554 100644 --- a/apps/studio/data/logs/unified-log-inspection-query.ts +++ b/apps/studio/data/logs/unified-log-inspection-query.ts @@ -1,4 +1,3 @@ -import { literal, safeSql } from '@supabase/pg-meta/src/pg-format' import { useQuery } from '@tanstack/react-query' import { useFlag } from 'common' @@ -8,6 +7,7 @@ import { flattenOtelInspectionRow, type OtelLogRow, } from './otel-inspection.utils' +import { analyticsLiteral as lit, safeSql } from './safe-analytics-sql' import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, @@ -23,7 +23,7 @@ import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/Unifi import { handleError, post } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' -// Service flow types — subset of LOG_TYPES that support service flows. +// Service flow types - subset of LOG_TYPES that support service flows export const SERVICE_FLOW_TYPES = [ 'postgrest', 'auth', @@ -184,17 +184,19 @@ export async function getUnifiedLogInspection( // so existing panel components that read `enrichedData['request.path']` // etc. keep working without per service flow SQL. // - // `logId` ultimately originates from a URL query parameter, so we route - // every interpolation through pg-meta's `literal()` / `safeSql` helpers. - // The analytics endpoint uses ClickHouse SQL string literal escaping - // rules (single quotes doubled), which matches Postgres and what - // `literal()` produces. + // logId comes from the row data we fetched (a uuid-shaped value), but it + // ultimately originates from a URL query parameter. Reject anything that + // isn't a plain uuid before interpolating it into SQL so a crafted id + // can't break out of the string literal. + if (!/^[0-9a-fA-F-]{1,64}$/.test(logId)) { + throw new Error('Invalid logId') + } const sql = safeSql` SELECT id, timestamp, source, event_message, severity_text, log_attributes FROM logs -WHERE id = ${literal(logId)} +WHERE id = ${lit(logId)} LIMIT 1 -`.trim() +` const { data, error } = await post('/platform/projects/{ref}/analytics/endpoints/logs.all.otel', { params: { path: { ref: projectRef } }, @@ -216,21 +218,21 @@ LIMIT 1 return { result: [] } } - const entry = flattenOtelInspectionRow(row) + const entry = flattenOtelInspectionRow(row) as UnifiedLogInspectionEntry & Record // For edge function rows, fetch and aggregate the related `function_logs` // (the per-execution console.log output) — this is the only legitimate // cross-source join in the legacy BigQuery service-flow queries. if (row.source === 'function_edge_logs') { const executionId = row.log_attributes?.['execution_id'] ?? row.log_attributes?.['request_id'] - if (typeof executionId === 'string') { + if (typeof executionId === 'string' && /^[0-9a-fA-F-]{1,64}$/.test(executionId)) { const fnSql = safeSql` SELECT id, timestamp, source, event_message, severity_text, log_attributes FROM logs -WHERE source = 'function_logs' AND log_attributes['execution_id'] = ${literal(executionId)} +WHERE source = 'function_logs' AND log_attributes['execution_id'] = ${lit(executionId)} ORDER BY timestamp ASC LIMIT 100 -`.trim() +` const { data: fnData, error: fnError } = await post( '/platform/projects/{ref}/analytics/endpoints/logs.all.otel', @@ -253,7 +255,7 @@ LIMIT 100 } } - return { result: [entry] } + return { result: [entry as UnifiedLogInspectionEntry] } } export type UnifiedLogInspectionData = Awaited> @@ -266,7 +268,7 @@ export const useUnifiedLogInspectionQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = !!useFlag('otelUnifiedLogs') return useQuery({ queryKey: [...logsKeys.serviceFlow(projectRef, search, logId), { otel: useOtel }], queryFn: ({ signal }) => diff --git a/apps/studio/data/logs/unified-logs-facet-count-query.ts b/apps/studio/data/logs/unified-logs-facet-count-query.ts index 90d13406483..6a219a539c1 100644 --- a/apps/studio/data/logs/unified-logs-facet-count-query.ts +++ b/apps/studio/data/logs/unified-logs-facet-count-query.ts @@ -3,6 +3,7 @@ import { useFlag } from 'common' import { logsKeys } from './keys' import { logsAllEndpointUrl } from './logs-endpoint' +import { bqIdent, safeSql } from './safe-analytics-sql' import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, @@ -35,11 +36,11 @@ export async function getUnifiedLogsFacetCount( const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) const sql = useOtel ? getFacetCountQuery({ search, facet, facetSearch }) - : ` + : safeSql` ${getUnifiedLogsCTE()}, ${getFacetCountCTE({ search, facet, facetSearch })} -SELECT dimension, value, count from ${facet}_count; -`.trim() +SELECT dimension, value, count from ${bqIdent(facet + '_count')}; +` const endpoint = logsAllEndpointUrl(useOtel) const { data, error } = await post(endpoint, { @@ -62,7 +63,7 @@ export const useUnifiedLogsFacetCountQuery = ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = !!useFlag('otelUnifiedLogs') return useQuery({ queryKey: [ ...logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search),