Files
supabase/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
Joshen Lim dc211a972c Fix count query for unified logs (#46093)
## Context

The query to retrieve counts in unified logs have been error-ing out
with this
`sql parser error: Expected: end of statement, found: UNION at Line: 71,
Column: 2`

This PR fixes that - can verify visually as the status filters are now
populated correctly
<img width="365" height="148" alt="image"
src="https://github.com/user-attachments/assets/9397cca1-0519-4fe1-9397-c37d399c4b44"
/>



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

* **Refactor**
* Improved status-based log filtering so single-value and multi-value
filters return more accurate results across all log sources.
* Made facet count calculations (method, status, pathname) more robust
for consistent counts.

* **Bug Fixes**
* Standardized log endpoint resolution so log retrieval behaves
consistently when telemetry mode changes.

<!-- 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/46093?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-19 20:53:30 +07:00

407 lines
14 KiB
TypeScript

import dayjs from 'dayjs'
import { DEFAULT_LOG_TYPES } from './UnifiedLogs.constants'
import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types'
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
// Special filter parameters that need custom handling
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: safeSql`log_attributes['request.method']`,
status: safeSql`log_attributes['response.status_code']`,
path: safeSql`log_attributes['request.path']`,
} as const
/**
* Predicate that matches rows belonging to a given log_type. Mirrors the
* shape of the original BigQuery unified-logs CTEs: edge gateway traffic
* (`source = 'edge_logs'`) is split between `edge`, `postgrest` and `storage`
* based on URL path. Other types map straight to a single source.
*
* The OTEL `postgrest_logs` and `storage_logs` sources contain process-level
* 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<string, SafeLogSqlFragment> = {
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: 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'
WHEN source = 'postgres_logs' THEN 'postgres'
WHEN source = 'function_edge_logs' THEN 'edge function'
WHEN source = 'auth_logs' THEN 'auth'
ELSE source
END`
// 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: SafeLogSqlFragment = safeSql`CASE
WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code'])
ELSE toString(${ATTR.status})
END`
// SQL expression for derived `level`. Used inline (not as alias reference)
// because the OTEL endpoint can't resolve aliases inside countIf when the
// alias is not in GROUP BY.
//
// HTTP status is checked first so gateway rows (which always carry an
// `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: 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'
WHEN severity_text IN ('ERROR','FATAL','CRITICAL','ALERT','EMERGENCY') THEN 'error'
WHEN severity_text IN ('WARN','WARNING') THEN 'warning'
WHEN severity_text IN ('TRACE','DEBUG','INFO','LOG','NOTICE') THEN 'success'
ELSE 'success'
END`
const logTypeWherePredicate = (logTypes: string[]): SafeLogSqlFragment => {
const effective = logTypes.filter((t) => t in LOG_TYPE_PREDICATE)
const types = effective.length ? effective : [...DEFAULT_LOG_TYPES]
const branches = types.map((t) => safeSql`(${LOG_TYPE_PREDICATE[t]})`)
return safeSql`(${joinSqlFragments(branches, ' OR ')})`
}
/**
* Translates a frontend filter key/value pair into an underlying SQL predicate.
* The OTEL endpoint won't accept queries that reference derived aliases like
* `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): 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[]): SafeLogSqlFragment =>
safeSql`(${joinSqlFragments(
values.map((v) => lit(String(v))),
','
)})`
switch (key) {
case 'log_type': {
const types = (arr ?? [value]).map((v) => String(v))
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 safeSql`(${LEVEL_EXPR}) IN ${inList(levels.map((v) => String(v)))}`
}
case 'method':
return arr
? safeSql`${ATTR.method} IN ${inList(arr)}`
: safeSql`${ATTR.method} = ${lit(String(value))}`
case 'status': {
// Match the displayed status: HTTP response code for gateway rows,
// Postgres SQLSTATE for postgres rows. Inline STATUS_EXPR so e.g.
// filtering on '00000' picks up postgres success rows.
const statuses = arr ?? [value]
return safeSql`(${STATUS_EXPR}) IN ${inList(statuses.map((v) => String(v)))}`
}
case 'pathname':
return arr
? 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
? 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.
return arr
? safeSql`log_attributes[${lit(key)}] IN ${inList(arr)}`
: safeSql`log_attributes[${lit(key)}] = ${lit(String(value))}`
}
}
/**
* 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
): 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
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: SafeLogSqlFragment[]): SafeLogSqlFragment =>
predicates.length > 0 ? safeSql`WHERE ${joinSqlFragments(predicates, ' AND ')}` : safeSql``
/**
* Calculates the chart bucketing level (minute/hour/day) given the date range.
*/
const calculateChartBucketing = (
search: SearchParamsType | Record<string, unknown>
): 'MINUTE' | 'HOUR' | 'DAY' => {
const dateRange = (search.date as Array<Date | string | number | null | undefined>) || []
const convertToMillis = (timestamp: Date | string | number | null | undefined) => {
if (!timestamp) return null
if (timestamp instanceof Date) return timestamp.getTime()
if (typeof timestamp === 'string') return dayjs(timestamp).valueOf()
if (typeof timestamp === 'number') {
const str = timestamp.toString()
if (str.length >= 16) return Math.floor(timestamp / 1000)
return timestamp
}
return null
}
let startMillis = convertToMillis(dateRange[0])
let endMillis = convertToMillis(dateRange[1])
if (!startMillis) startMillis = dayjs().subtract(1, 'hour').valueOf()
if (!endMillis) endMillis = dayjs().valueOf()
const startTime = dayjs(startMillis)
const endTime = dayjs(endMillis)
const hourDiff = endTime.diff(startTime, 'hour')
const dayDiff = endTime.diff(startTime, 'day')
if (dayDiff >= 2) return 'DAY'
if (hourDiff >= 12) return 'HOUR'
return 'MINUTE'
}
const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY'): SafeLogSqlFragment => {
switch (level) {
case 'DAY':
return safeSql`toStartOfDay`
case 'HOUR':
return safeSql`toStartOfHour`
case 'MINUTE':
default:
return safeSql`toStartOfMinute`
}
}
/**
* Returns the projection list for a unified-logs row. All derivations are
* inlined so the result can be referenced (or filtered) at the same query
* level — the OTEL endpoint rejects subqueries.
*/
const rowProjection = (): SafeLogSqlFragment => safeSql`
id,
null AS source_id,
timestamp,
${LOG_TYPE_EXPR} AS log_type,
${STATUS_EXPR} AS status,
${LEVEL_EXPR} AS level,
${ATTR.path} AS pathname,
event_message,
${ATTR.method} AS method,
null AS log_count,
null AS logs
`
const buildBaseWhere = (
search: QuerySearchParamsType,
excludeField?: string
): SafeLogSqlFragment[] => {
const effectiveLogTypes = search.log_type?.length ? search.log_type : [...DEFAULT_LOG_TYPES]
const parts: SafeLogSqlFragment[] = []
if (excludeField !== 'log_type') {
parts.push(logTypeWherePredicate(effectiveLogTypes))
}
parts.push(...buildPredicates(search, excludeField))
return parts
}
/**
* Unified logs row query — flat SELECT, no subquery wrapper.
*/
export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => {
const predicates = buildBaseWhere(search)
return safeSql`
SELECT ${rowProjection()}
FROM logs
${whereClause(predicates)}
`
}
/**
* Single-facet count query — a complete flat SELECT with GROUP BY.
*/
export const getFacetCountQuery = ({
search,
facet,
facetSearch,
}: {
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: SafeLogSqlFragment =
facet === 'log_type'
? LOG_TYPE_EXPR
: facet === 'level'
? LEVEL_EXPR
: facet === 'method'
? ATTR.method
: facet === 'status'
? STATUS_EXPR
: facet === 'pathname'
? ATTR.path
: safeSql`log_attributes[${lit(facet)}]`
const predicates: SafeLogSqlFragment[] = [
...buildBaseWhere(search, facet),
safeSql`(${facetExpr}) IS NOT NULL AND (${facetExpr}) != ''`,
]
if (facetSearch) {
predicates.push(safeSql`(${facetExpr}) LIKE ${lit('%' + facetSearch + '%')}`)
}
return safeSql`
SELECT ${lit(facet)} AS dimension, (${facetExpr}) AS value, count() AS count
FROM logs
${whereClause(predicates)}
GROUP BY value
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): 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 = safeSql`
SELECT 'total' AS dimension, 'all' AS value, count() AS count
FROM logs
WHERE ${baseFiltersFor()}
`
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')}
`
),
' UNION ALL '
)
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')}
`
),
' UNION ALL '
)
const facetBranches = joinSqlFragments(
(['method', 'status', 'pathname'] as const).map(
(facet) => safeSql`(${getFacetCountQuery({ search, facet })})`
),
' UNION ALL '
)
return joinSqlFragments([totalSql, logTypeBranches, levelBranches, facetBranches], ' UNION ALL ')
}
/**
* Logs chart query with dynamic bucketing based on time range.
*/
export const getLogsChartQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => {
const truncationLevel = calculateChartBucketing(search)
const truncFn = truncationFunction(truncationLevel)
const predicates = buildBaseWhere(search)
return safeSql`
SELECT
${truncFn}(timestamp) AS time_bucket,
countIf((${LEVEL_EXPR}) = 'success') AS success,
countIf((${LEVEL_EXPR}) = 'warning') AS warning,
countIf((${LEVEL_EXPR}) = 'error') AS error,
count() AS total_per_bucket
FROM logs
${whereClause(predicates)}
GROUP BY time_bucket
ORDER BY time_bucket ASC
`
}