Files
supabase/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
Saxon FletcherandClaude Opus 5.5 09c3167551 feat(studio): scope unified logs queries to one edge function
Adds an optional `scope` to the unified logs search that narrows every
query (rows, counts, chart and facets, on both the OTEL and BigQuery
builders) to one edge function's invocations and runtime output.

- New `edge function runtime` log type backed by `function_logs`, with
  its level read from the console level and details kept in metadata
- `getLogTypeVisibility` keeps that type out of the project-wide Logs
  page, which already shows runtime logs inside each invocation
- `toQuerySearchParams` attaches the scope

Nothing sets a scope yet, so the Logs page is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:59:51 +10:00

367 lines
12 KiB
TypeScript

import { type Table as TTable } from '@tanstack/react-table'
import { createLoader } from 'nuqs'
import { cn } from 'ui'
import {
EDGE_FUNCTION_LOG_TYPES,
LOG_TYPES,
LOG_TYPES_LABELS,
SEARCH_PARAMS_PARSER,
} from './UnifiedLogs.constants'
import { parseLogsFilterUrlParams } from './UnifiedLogs.filters'
import { ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema'
import type { QuerySearchParamsType, SearchParamsType, UnifiedLogsScope } from './UnifiedLogs.types'
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { Option } from '@/components/ui/DataTable/DataTable.types'
export type UnifiedLogType = keyof typeof LOG_TYPES_LABELS
export function getComputeLogsAvailability({
isPlatform,
flagsLoaded,
computeEnabled,
}: {
isPlatform: boolean
flagsLoaded?: boolean
computeEnabled: boolean
}) {
const flagsReady = flagsLoaded === true
return {
canQueryCompute: isPlatform && flagsReady && computeEnabled,
preserveComputeFilter: isPlatform && (!flagsReady || computeEnabled),
readyToSyncFilters: !isPlatform || flagsReady,
}
}
/**
* Resolves which log types a view may filter on and query. A scoped view only offers its own
* log types; the project-wide view hides edge function runtime rows, which it surfaces inside
* each invocation's detail panel instead.
*/
export function getLogTypeVisibility(
visibility: Partial<Record<UnifiedLogType, boolean>>,
scope?: UnifiedLogsScope
): Partial<Record<UnifiedLogType, boolean>> {
if (!scope) return { ...visibility, 'edge function runtime': false }
const scopeLogTypes: readonly UnifiedLogType[] = EDGE_FUNCTION_LOG_TYPES
return Object.fromEntries(LOG_TYPES.map((logType) => [logType, scopeLogTypes.includes(logType)]))
}
/** Reads unified logs URL params, applying the same defaults as the Logs page. */
export const loadUnifiedLogsSearchParams = createLoader(SEARCH_PARAMS_PARSER)
/**
* Turns URL search state into the search every unified logs query takes. Drops view-only params
* (`id`, `live`) and empty values so they don't churn query keys, and attaches the scope.
*/
export function toQuerySearchParams(
search: SearchParamsType,
scope?: UnifiedLogsScope
): QuerySearchParamsType {
const parameters = Object.entries(search).reduce(
(acc, [key, value]) => {
if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
acc[key] = value
}
return acc
},
{} as Record<string, unknown>
) as QuerySearchParamsType
if (scope) parameters.scope = scope
return parameters
}
/**
* Flattens infinite query pages into rows, keeping the first of any duplicate ids. Pages can
* overlap because the cursor is a timestamp (see unified-logs-infinite-query).
*/
export function getUniqueLogRows<T extends { id: string }>(
pages: { data?: T[] }[] | undefined
): T[] {
const seenIds = new Set<string>()
return (pages ?? [])
.flatMap((page) => page.data ?? [])
.filter((row) => {
if (seenIds.has(row.id)) return false
seenIds.add(row.id)
return true
})
}
/** Row styling for the logs list; rows older than the live mode anchor are dimmed. */
export function getLogRowClassName(
row: Pick<ColumnSchema, 'level' | 'timestamp'>,
liveTimestamp?: number
): string {
const isPast = row.timestamp <= (liveTimestamp || -1)
return cn(getLevelRowClassName(row.level), isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]')
}
/** Serializes a time range into the unified logs `date` URL param. */
export const formatUnifiedLogsDateParam = (start: string | Date, end: string | Date) =>
`${new Date(start).valueOf()}-${new Date(end).valueOf()}`
export const buildUnifiedLogsUrl = ({
projectRef,
logType,
user,
start,
end,
}: {
projectRef: string
logType?: UnifiedLogType
/** Pre-applies the cross-cutting "filter by user" (?user=) — an id or email. */
user?: string
start?: string | Date
end?: string | Date
}) => {
const params = new URLSearchParams()
if (logType) params.append('filter', `log_type:eq:${logType}`)
if (user) params.set('user', user)
if (start && end) params.set('date', formatUnifiedLogsDateParam(start, end))
return `/project/${projectRef}/logs?${params.toString()}`
}
export const getFacetedUniqueValues = <TData>(facets?: Record<string, FacetMetadataSchema>) => {
return (_table: TTable<TData>, columnId: string) => {
return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || [])
}
}
export const getFacetedMinMaxValues = <TData>(facets?: Record<string, FacetMetadataSchema>) => {
return (_table: TTable<TData>, columnId: string) => {
const min = facets?.[columnId]?.min
const max = facets?.[columnId]?.max
if (typeof min === 'number' && typeof max === 'number') return [min, max]
if (typeof min === 'number') return [min, min]
if (typeof max === 'number') return [max, max]
return undefined
}
}
/**
* Returns a unified-logs row's timestamp in epoch milliseconds.
*
* The row mapper attaches a pre-parsed `date` (works for both BigQuery
* microsecond timestamps and OTEL ISO strings); fall back to the raw
* `timestamp` value when it's a number (older BQ-style microseconds).
*/
export function getRowTimestampMs(
row: { date?: Date | null; timestamp?: number | string | null } | null | undefined
): number | null {
if (row?.date instanceof Date) return row.date.getTime()
if (typeof row?.timestamp === 'number') return row.timestamp / 1000
return null
}
type ComputeRawLogData = Pick<ColumnSchema, 'id' | 'event_message' | 'metadata'> & {
timestamp: string | number
}
export function getRawLogData<T extends ComputeRawLogData>(row: T): T | ComputeRawLogData {
if (!('log_type' in row) || row.log_type !== 'compute') return row
return {
id: row.id,
timestamp: row.timestamp,
event_message: row.event_message,
metadata: row.metadata,
}
}
export const getLevelLabel = (value: (typeof LEVELS)[number]): string => {
switch (value) {
case 'success':
return '2xx'
case 'warning':
return '4xx'
case 'error':
return '5xx'
}
}
// Helper function to determine level from HTTP status code
export const getStatusLevel = (status?: number | string): string => {
if (!status) return 'success'
const statusNum = Number(status)
if (statusNum >= 500) return 'error'
if (statusNum >= 400) return 'warning'
if (statusNum >= 300) return 'info' // 3xx redirects are informational
if (statusNum >= 200) return 'success'
if (statusNum >= 100) return 'info'
return 'success'
}
export function getLevelRowClassName(value: (typeof LEVELS)[number] | null | undefined): string {
switch (value) {
case 'success':
return ''
case 'warning':
return cn(
'bg-warning/5 hover:bg-warning/10',
'data-[state=selected]:bg-warning/20 focus-visible:bg-warning/10',
'dark:bg-warning/10 dark:hover:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:focus-visible:bg-warning/20'
)
case 'error':
return cn(
'bg-destructive/5 hover:bg-destructive/10',
'data-[state=selected]:bg-destructive/20 focus-visible:bg-destructive/10',
'dark:bg-error/10 dark:hover:bg-destructive/20 dark:data-[state=selected]:bg-destructive/30 dark:focus-visible:bg-destructive/20'
)
default:
return ''
}
}
/**
* Formats service type strings for display purposes
* Handles special cases like "edge function" -> "Edge Function"
* and applies proper capitalization to other service types
*/
export function formatServiceTypeForDisplay(serviceType: string): string {
if (!serviceType) return ''
// Handle special cases
const specialCases: Record<string, string> = {
'edge function': 'Edge Function',
'edge function runtime': 'Edge Function runtime',
postgrest: 'PostgREST',
postgres: 'Postgres',
auth: 'Auth',
storage: 'Storage',
realtime: 'Realtime',
supavisor: 'Supavisor',
pgbouncer: 'PgBouncer',
multigres: 'Multigres',
}
return specialCases[serviceType.toLowerCase()] || serviceType
}
/**
* Parses an auth log event_message that may be a stringified JSON object.
* Auth log entries store metadata as JSON in event_message (e.g. {"msg":"...","level":"info"}).
* Extracts the human-readable msg field, falling back to error, then the raw string.
* The fallback ensures self-hosted versions with different formats still render correctly.
*/
export function parseAuthLogEventMessage(value: string | undefined): string | undefined {
if (!value) return value
try {
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object') {
const err = parsed.error || parsed.error_code
if (typeof err === 'string' && err.trim()) {
return !/^\d{3}:/.test(err) ? err.replaceAll('_', ' ') : err
}
const msg = parsed.msg
if (typeof msg === 'string' && msg.trim()) {
const action = parsed.action ?? parsed.auth_event?.action
const authEvent = typeof action === 'string' ? action.replaceAll('_', ' ') : undefined
return `${authEvent ? `${authEvent}: ` : ''}${msg}`
}
}
return value
} catch (error) {
return value
}
}
/**
* Parses a Multigres log event_message, which is a stringified JSON object
* (e.g. {"time":"...","level":"INFO","msg":"user pool capacity updated",...}).
* Extracts the human-readable msg field, falling back to the raw string so
* unexpected formats still render.
*/
export function parseMultigresEventMessage(value: string | undefined): string | undefined {
if (!value) return value
try {
const parsed = JSON.parse(value)
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.msg === 'string' &&
parsed.msg.trim()
) {
return parsed.msg
}
return value
} catch (error) {
return value
}
}
/**
* Returns the display text for a log row's event_message alongside whether it
* should be rendered as a capitalized sentence. Keeps the per-service parsing
* and its capitalization rule in one place so callers don't re-derive the list.
*/
export function getEventMessageDisplay(
logType: string,
value: string | undefined
): { message: string | undefined; capitalize: boolean } {
if (logType === 'auth') return { message: parseAuthLogEventMessage(value), capitalize: true }
if (logType === 'multigres')
return { message: parseMultigresEventMessage(value), capitalize: true }
return { message: value, capitalize: false }
}
export function gateLogTypeOptions<T extends { value: string; options?: Option[] }>(
fields: T[],
visibility: Partial<Record<UnifiedLogType, boolean>>
): T[] {
const hiddenLogTypes = new Set(
Object.entries(visibility)
.filter(([, visible]) => !visible)
.map(([logType]) => logType)
)
if (hiddenLogTypes.size === 0) return fields
return fields.map((field) => {
if (field.value !== 'log_type' || !field.options) return field
return {
...field,
options: field.options.filter((option) => !hiddenLogTypes.has(option.value)),
}
})
}
/**
* Wraps a raw ILIKE/NOT ILIKE search term in `%...%` for a "contains" match,
* unless it already includes a `%` or `_` wildcard — in which case the user
* has crafted their own pattern and it's passed through unchanged.
*/
export function wrapIlikePattern(value: string): string {
return value.includes('%') || value.includes('_') ? value : `%${value}%`
}
export function gateLogTypeFilters(
filters: string[] | null | undefined,
visibility: Partial<Record<UnifiedLogType, boolean>>
): string[] | null | undefined {
if (!filters) return filters
const hiddenLogTypes = new Set(
Object.entries(visibility)
.filter(([, visible]) => !visible)
.map(([logType]) => logType)
)
if (hiddenLogTypes.size === 0) return filters
return filters.filter((filter) => {
const parsed = parseLogsFilterUrlParams([filter])[0]
return parsed?.column !== 'log_type' || !hiddenLogTypes.has(parsed.value)
})
}