mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
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>
367 lines
12 KiB
TypeScript
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)
|
|
})
|
|
}
|