Files
supabase/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts

942 lines
30 KiB
TypeScript

import { useMonaco } from '@monaco-editor/react'
import { IS_PLATFORM } from 'common'
import dayjs, { Dayjs } from 'dayjs'
import { get } from 'lodash'
import uniqBy from 'lodash/uniqBy'
import { useEffect } from 'react'
import logConstants from 'shared-data/log-constants'
import {
AUTH_LOG_ERROR_CONDITION,
AUTH_LOG_WARNING_CONDITION,
LogsTableName,
SQL_FILTER_TEMPLATES,
} from './Logs.constants'
import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
import { convertResultsToCSV } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
import BackwardIterator from '@/components/ui/CodeEditor/Providers/BackwardIterator'
import {
analyticsLiteral,
joinSqlFragments,
quotedIdent,
safeSql,
type SafeLogSqlFragment,
} from '@/data/logs/safe-analytics-sql'
/**
* Convert a micro timestamp from number/string to iso timestamp
*/
export const unixMicroToIsoTimestamp = (unix: string | number): string => {
return dayjs.utc(Number(unix) / 1000).toISOString()
}
export const isUnixMicro = (unix: string | number): boolean => {
const digitLength = String(unix).length === 16
const isNum = !Number.isNaN(Number(unix))
return isNum && digitLength
}
/**
* Boolean check to verify that there are 3 columns:
* - id
* - timestamp
* - event_message
*/
export const isDefaultLogPreviewFormat = (log: LogData) =>
log && log.timestamp && log.event_message && log.id
/**
* Recursively retrieve all nested object key paths.
*
* TODO: move to utils
*
* @param obj any object
* @param parent a string representing the parent key
* @returns string[] all dot paths for keys.
*/
const getDotKeys = (obj: { [k: string]: unknown }, parent?: string): string[] => {
const keys = Object.keys(obj).filter((k) => obj[k])
return keys.flatMap((k) => {
const currKey = parent ? `${parent}.${k}` : k
if (typeof obj[k] === 'object') {
return getDotKeys(obj[k] as any, currKey)
} else {
return [currKey]
}
})
}
/**
* Root keys in the filter object are considered to be AND filters.
* Nested keys under a root key are considered to be OR filters.
*
* For example:
* ```
* {my_value: 'something', nested: {id: 123, test: 123 }}
* ```
* This would be converted into `WHERE (my_value = 'something') and (id = 123 or test = 123)
*
* The template of the filter determines the actual filter statement. If no template is provided, a generic equality statement will be used.
* This only applies for root keys of the filter.
* For example:
* ```
* {'my.nested.value': 123}
* ```
* with no template, it will be converted into `WHERE (my.nested.value = 123)
*
* @returns a where statement with WHERE clause.
*/
const buildWhereClauses = (table: LogsTableName, filters: Filters): SafeLogSqlFragment[] => {
const keys = Object.keys(filters)
const filterTemplates = SQL_FILTER_TEMPLATES[table]
const _resolveTemplateToStatement = (dotKey: string): SafeLogSqlFragment | null => {
const template = filterTemplates[dotKey]
const value = get(filters, dotKey)
if (template === undefined) {
// Unknown filter from a filter override — generic equality predicate; drop on bad input.
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return null
}
try {
return safeSql`${quotedIdent(dotKey)} = ${analyticsLiteral(value)}`
} catch {
return null
}
}
if (typeof template === 'function') {
return value !== undefined ? template(value) : null
}
// template is SafeLogSqlFragment — emit unless the filter is explicitly disabled
if (!template || value === false) return null
return template
}
return keys
.map((rootKey): SafeLogSqlFragment | null => {
if (
filters[rootKey] === undefined ||
(typeof filters[rootKey] === 'string' && (filters[rootKey] as string).length === 0)
) {
return null
} else if (typeof filters[rootKey] === 'object') {
// join all statements with an OR
const nestedStatements = getDotKeys(filters[rootKey] as Filters, rootKey)
.map(_resolveTemplateToStatement)
.filter((s) => s !== null)
if (nestedStatements.length > 0) {
return safeSql`(${joinSqlFragments(nestedStatements, ' or ')})`
} else {
return null
}
} else {
const nestedStatement = _resolveTemplateToStatement(rootKey)
if (nestedStatement === null) return null
return safeSql`(${nestedStatement})`
}
})
.filter((s) => s !== null)
}
const genWhereStatement = (table: LogsTableName, filters: Filters): SafeLogSqlFragment => {
const clauses = buildWhereClauses(table, filters)
return clauses.length > 0 ? safeSql`where ${joinSqlFragments(clauses, ' and ')}` : safeSql``
}
export const genDefaultQuery = (
table: LogsTableName,
filters: Filters,
limit: number = 100
): SafeLogSqlFragment => {
const where = genWhereStatement(table, filters)
const joins = genCrossJoinUnnests(table)
const orderBy = safeSql`order by timestamp desc`
const limitLit = analyticsLiteral(limit)
switch (table) {
case 'edge_logs':
if (!IS_PLATFORM) {
return safeSql`
-- local dev edge_logs query
select id, edge_logs.timestamp, event_message, request.method, request.path, request.search, response.status_code
from edge_logs
${joins}
${where}
${orderBy}
limit ${limitLit};
`
}
return safeSql`select id, identifier, timestamp, event_message, request.method, request.path, request.search, response.status_code
from ${LOG_TABLE_SQL[table]}
${joins}
${where}
${orderBy}
limit ${limitLit}
`
case 'postgres_logs':
if (!IS_PLATFORM) {
return safeSql`
select postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint
from postgres_logs
${joins}
${where}
${orderBy}
limit ${limitLit}
`
}
return safeSql`select identifier, postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint from ${LOG_TABLE_SQL[table]}
${joins}
${where}
${orderBy}
limit ${limitLit}
`
case 'function_logs':
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from ${LOG_TABLE_SQL[table]}
${joins}
${where}
${orderBy}
limit ${limitLit}
`
case 'auth_logs':
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message, metadata.level, metadata.status, metadata.path, metadata.msg as msg, metadata.error from ${LOG_TABLE_SQL[table]}
${joins}
${where}
${orderBy}
limit ${limitLit}
`
case 'function_edge_logs':
if (!IS_PLATFORM) {
return safeSql`
select id, function_edge_logs.timestamp, event_message
from function_edge_logs
${orderBy}
limit ${limitLit}
`
}
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_id, m.execution_time_ms, m.deployment_id, m.version from ${LOG_TABLE_SQL[table]}
${joins}
${where}
${orderBy}
limit ${limitLit}
`
case 'supavisor_logs':
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message from ${LOG_TABLE_SQL[table]} ${joins} ${where} ${orderBy} limit ${limitLit}`
case 'pg_upgrade_logs':
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message from ${LOG_TABLE_SQL[table]} ${joins} ${where} ${orderBy} limit ${analyticsLiteral(100)}`
case 'pg_cron_logs': {
const userClauses = buildWhereClauses(table, filters)
const pgCronWhere =
userClauses.length > 0
? safeSql`where ${basePgCronConditions} AND ${joinSqlFragments(userClauses, ' and ')}`
: basePgCronWhere
return safeSql`select id, postgres_logs.timestamp, event_message, parsed.error_severity, parsed.query
from postgres_logs
${joins}
${pgCronWhere}
${orderBy}
limit ${limitLit}
`
}
default:
return safeSql`select id, ${LOG_TABLE_SQL[table]}.timestamp, event_message from ${LOG_TABLE_SQL[table]}
${where}
${orderBy}
limit ${limitLit}
`
}
}
/**
* Hardcoded cross join unnests and aliases for each table.
* Should be used together with the getWhereStatements to allow for filtering on aliases
*/
const genCrossJoinUnnests = (table: LogsTableName): SafeLogSqlFragment => {
switch (table) {
case 'edge_logs':
return safeSql`cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response`
case 'pg_cron_logs':
case 'postgres_logs':
return safeSql`cross join unnest(metadata) as m
cross join unnest(m.parsed) as parsed`
case 'function_logs':
return safeSql`cross join unnest(metadata) as metadata`
case 'auth_logs':
return safeSql`cross join unnest(metadata) as metadata`
case 'function_edge_logs':
return safeSql`cross join unnest(metadata) as m
cross join unnest(m.response) as response
cross join unnest(m.request) as request`
case 'supavisor_logs':
return safeSql`cross join unnest(metadata) as m`
default:
return safeSql``
}
}
/** Brand-safe table name literals for analytics SQL. */
export const LOG_TABLE_SQL: Record<LogsTableName, SafeLogSqlFragment> = {
[LogsTableName.EDGE]: safeSql`edge_logs`,
[LogsTableName.POSTGRES]: safeSql`postgres_logs`,
[LogsTableName.FUNCTIONS]: safeSql`function_logs`,
[LogsTableName.FN_EDGE]: safeSql`function_edge_logs`,
[LogsTableName.AUTH]: safeSql`auth_logs`,
[LogsTableName.AUTH_AUDIT]: safeSql`auth_audit_logs`,
[LogsTableName.REALTIME]: safeSql`realtime_logs`,
[LogsTableName.STORAGE]: safeSql`storage_logs`,
[LogsTableName.POSTGREST]: safeSql`postgrest_logs`,
[LogsTableName.SUPAVISOR]: safeSql`supavisor_logs`,
[LogsTableName.PGBOUNCER]: safeSql`pgbouncer_logs`,
[LogsTableName.PG_UPGRADE]: safeSql`pg_upgrade_logs`,
[LogsTableName.PG_CRON]: safeSql`pg_cron_logs`,
[LogsTableName.ETL]: safeSql`etl_replication_logs`,
[LogsTableName.MULTIGRES]: safeSql`multigres_logs`,
}
/**
* SQL query to retrieve only one log
*/
export const genSingleLogQuery = (table: LogsTableName, id: string): SafeLogSqlFragment => {
// multigres logs have no metadata column
const metadataColumn = table === LogsTableName.MULTIGRES ? safeSql`` : safeSql`, metadata`
return safeSql`select id, timestamp, event_message${metadataColumn} from ${LOG_TABLE_SQL[table]} where id = ${analyticsLiteral(id)} limit 1`
}
/**
* Multigres logs store their structured payload as a JSON string in
* `event_message`. Parse it into a plain object, returning `null` when the
* value is missing, not valid JSON, or not a plain object (e.g. an array).
*/
export const parseMultigresEventMessage = (
eventMessage: unknown
): Record<string, unknown> | null => {
if (typeof eventMessage !== 'string') return null
try {
const parsed = JSON.parse(eventMessage)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
/**
* Determine if we should show the user an upgrade prompt while browsing logs
*/
export const maybeShowUpgradePromptIfNotEntitled = (
from: string | null | undefined,
entitledToDays: number | undefined
) => {
if (!entitledToDays) return false
const day = Math.abs(dayjs().diff(dayjs(from), 'day'))
return day > entitledToDays
}
export const genCountQuery = (table: LogsTableName, filters: Filters): SafeLogSqlFragment => {
let where = genWhereStatement(table, filters)
if (table === LogsTableName.PG_CRON) {
table = LogsTableName.POSTGRES
where = basePgCronWhere
}
const joins = genCrossJoinUnnests(table)
return safeSql`SELECT count(*) as count FROM ${LOG_TABLE_SQL[table]} ${joins} ${where}`
}
/** calculates how much the chart start datetime should be offset given the current datetime filter params */
const calcChartStart = (
params: Partial<LogsEndpointParams>
): [Dayjs, 'minute' | 'hour' | 'day'] => {
const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs()
// todo @TzeYiing needs typing
const its: any = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs()
let trunc: 'minute' | 'hour' | 'day' = 'minute'
let extendValue = 60 * 6
const minuteDiff = ite.diff(its, 'minute')
const hourDiff = ite.diff(its, 'hour')
if (minuteDiff > 60 * 12) {
trunc = 'hour'
extendValue = 24 * 5
} else if (hourDiff > 24 * 3) {
trunc = 'day'
extendValue = 7
}
return [its.add(-extendValue, trunc), trunc]
}
// TODO(qiao): workaround for self-hosted cron logs error until logflare is fixed
const basePgCronConditions: SafeLogSqlFragment = IS_PLATFORM
? safeSql`( parsed.application_name = 'pg_cron' or regexp_contains(event_message, 'cron job') )`
: safeSql`( parsed.application_name = 'pg_cron' or event_message::text LIKE '%cron job%' )`
const basePgCronWhere: SafeLogSqlFragment = safeSql`where ${basePgCronConditions}`
const TRUNC_SQL: Record<'minute' | 'hour' | 'day', SafeLogSqlFragment> = {
minute: safeSql`minute`,
hour: safeSql`hour`,
day: safeSql`day`,
}
/**
*
* generates log event chart query
*/
export const genChartQuery = (
table: LogsTableName,
params: LogsEndpointParams,
filters: Filters
): SafeLogSqlFragment => {
const [startOffset, trunc] = calcChartStart(params)
let where = genWhereStatement(table, filters)
const errorCondition = getErrorCondition(table)
const warningCondition = getWarningCondition(table)
// pg_cron logs are a subset of postgres logs
// to calculate the chart, we need to query postgres logs
if (table === LogsTableName.PG_CRON) {
table = LogsTableName.POSTGRES
where = basePgCronWhere
}
const joins = genCrossJoinUnnests(table)
const tsLit = analyticsLiteral(startOffset.toISOString())
const whereFragment: SafeLogSqlFragment = where
? safeSql`${where} and t.timestamp > ${tsLit}`
: safeSql`where t.timestamp > ${tsLit}`
return safeSql`
SELECT
-- log-event-chart
timestamp_trunc(t.timestamp, ${TRUNC_SQL[trunc]}) as timestamp,
count(CASE WHEN NOT (${errorCondition} OR ${warningCondition}) THEN 1 END) as ok_count,
count(CASE WHEN ${errorCondition} THEN 1 END) as error_count,
count(CASE WHEN ${warningCondition} THEN 1 END) as warning_count,
FROM
${LOG_TABLE_SQL[table]} t
${joins}
${whereFragment}
GROUP BY
timestamp
ORDER BY
timestamp ASC
`
}
type TsPair = [string | '', string | '']
export const ensureNoTimestampConflict = (
[initialStart, initialEnd]: TsPair,
[nextStart, nextEnd]: TsPair
): TsPair => {
if (initialStart && initialEnd && nextEnd && !nextStart) {
const resolvedDiff = dayjs(nextEnd).diff(dayjs(initialStart))
let start = dayjs(initialStart)
if (resolvedDiff <= 0) {
// start ts is definitely before end ts
const currDiff = Math.abs(dayjs(initialEnd).diff(start, 'minute'))
// shift start ts backwards by the current ts difference
start = dayjs(nextEnd).subtract(currDiff, 'minute')
}
return [start.toISOString(), nextEnd]
} else if (!nextEnd && nextStart) {
return [nextStart, initialEnd]
} else {
return [nextStart, nextEnd]
}
}
/**
* Adds SQL code hints to logs explorer code editor
*/
export const useEditorHints = () => {
const monaco = useMonaco()
useEffect(() => {
if (monaco) {
const competionProvider = {
triggerCharacters: ['`', ' ', '.'],
provideCompletionItems: function (model: any, position: any, context: any) {
let iterator = new BackwardIterator(model, position.column - 2, position.lineNumber - 1)
if (iterator.isNextDQuote()) return { suggestions: [] }
let suggestions: { label: string; kind: any; insertText: string }[] = []
let schemasInUse = logConstants.schemas.filter((schema) =>
iterator._text.includes(schema.reference)
)
if (schemasInUse.length === 0) {
schemasInUse = logConstants.schemas
}
if (iterator.isNextPeriod()) {
// should be nested key reference, suggest all tail endings of available fields
const fields = schemasInUse.flatMap((schema) => schema.fields)
const trailingKeys = fields.flatMap((field) => {
const [_head, ...rest] = field.path.split('.')
return rest
})
const trailingToAdd = trailingKeys.map((key) => ({
label: key,
kind: monaco.languages.CompletionItemKind.Property,
insertText: key,
}))
suggestions = suggestions.concat(trailingToAdd)
}
if (context.triggerCharacter === '`' || context.triggerCharacter === ' ') {
// should be reference or start of key
const referencesToAdd = logConstants.schemas.map((schema) => ({
label: schema.reference,
kind: monaco.languages.CompletionItemKind.Class,
insertText: schema.reference,
}))
const fields = schemasInUse.flatMap((schema) => schema.fields)
const leadingKeys = fields.flatMap((field) => {
const splitPath = field.path.split('.')
return splitPath.slice(0, -1)
})
const leadingToAdd = leadingKeys.map((key) => ({
label: key,
kind: monaco.languages.CompletionItemKind.Property,
insertText: key,
}))
suggestions = suggestions.concat(leadingToAdd)
suggestions = suggestions.concat(referencesToAdd)
}
return {
suggestions: uniqBy(suggestions, 'label'),
}
},
} as any
// register completion item provider for pgsql
const completeProvider = monaco.languages.registerCompletionItemProvider(
'pgsql',
competionProvider
)
return () => {
completeProvider.dispose()
}
}
}, [monaco])
}
/**
* Assumes that all timestamps are in ISO-8601 UTC timezone.
*
* min/max are the datetime strings that extend beyond the given timeseries data.
*/
export const fillTimeseries = (
timeseriesData: any[],
timestampKey: string,
valueKey: string | string[],
defaultValue: number,
min?: string,
max?: string,
minPointsToFill: number = 20,
interval?: string
) => {
if (timeseriesData.length === 0 && !(min && max)) {
return []
}
// If we have more points than minPointsToFill, just normalize timestamps and return
if (timeseriesData.length > minPointsToFill) {
return timeseriesData.map((datum) => {
const timestamp = datum[timestampKey]
const iso = isUnixMicro(timestamp)
? unixMicroToIsoTimestamp(timestamp)
: dayjs.utc(timestamp).toISOString()
datum[timestampKey] = iso
return datum
})
}
if (timeseriesData.length <= 1 && !(min && max)) return timeseriesData
const dates: unknown[] = timeseriesData.map((datum) => dayjs.utc(datum[timestampKey]))
const maxDate = max ? dayjs.utc(max) : dayjs.utc(Math.max.apply(null, dates as number[]))
const minDate = min ? dayjs.utc(min) : dayjs.utc(Math.min.apply(null, dates as number[]))
// When no data exists but min/max are provided, we need to determine truncation from the time range
const truncationSamples = timeseriesData.length > 0 ? dates : [minDate, maxDate]
let truncation: 'second' | 'minute' | 'hour' | 'day'
let step = 1
if (interval) {
const match = interval.match(/^(\d+)(m|h|d|s)$/)
if (match) {
step = parseInt(match[1], 10)
const unitChar = match[2] as 'm' | 'h' | 'd' | 's'
const unitMap = { s: 'second', m: 'minute', h: 'hour', d: 'day' } as const
truncation = unitMap[unitChar]
} else {
// Fallback for invalid format
truncation = getTimestampTruncation(truncationSamples as Dayjs[])
}
} else {
truncation = getTimestampTruncation(truncationSamples as Dayjs[])
}
// If no data exists and no interval specified, default to minute precision
if (timeseriesData.length === 0 && !interval) {
truncation = 'minute'
}
const newData = timeseriesData.map((datum) => {
const timestamp = datum[timestampKey]
const iso = isUnixMicro(timestamp)
? unixMicroToIsoTimestamp(timestamp)
: dayjs.utc(timestamp).toISOString()
if (Array.isArray(valueKey) && valueKey.length === 0) {
return { [timestampKey]: iso }
}
datum[timestampKey] = iso
return datum
})
let currentDate = minDate
while (currentDate.isBefore(maxDate) || currentDate.isSame(maxDate)) {
const found = dates.find((d) => {
const d_date = d as Dayjs
return (
d_date.year() === currentDate.year() &&
d_date.month() === currentDate.month() &&
d_date.date() === currentDate.date() &&
d_date.hour() === currentDate.hour() &&
d_date.minute() === currentDate.minute() &&
d_date.second() === currentDate.second()
)
})
if (!found) {
const keys = typeof valueKey === 'string' ? [valueKey] : valueKey
const toMerge = keys.reduce(
(acc, key) => ({
...acc,
[key]: defaultValue,
}),
{}
)
newData.push({
[timestampKey]: currentDate.toISOString(),
...toMerge,
})
}
currentDate = currentDate.add(step, truncation)
}
return newData
}
const getTimestampTruncation = (samples: Dayjs[]): 'second' | 'minute' | 'hour' | 'day' => {
const truncationCounts = samples.reduce(
(acc, sample) => {
const truncation = _getTruncation(sample)
acc[truncation] += 1
return acc
},
{
second: 0,
minute: 0,
hour: 0,
day: 0,
}
)
const mostLikelyTruncation = (
Object.keys(truncationCounts) as (keyof typeof truncationCounts)[]
).reduce((a, b) => (truncationCounts[a] > truncationCounts[b] ? a : b))
return mostLikelyTruncation
}
const _getTruncation = (date: Dayjs) => {
const values = ['second', 'minute', 'hour'].map((key) => date.get(key as dayjs.UnitType))
const zeroCount = values.reduce((acc, value) => {
if (value === 0) {
acc += 1
}
return acc
}, 0)
const truncation = {
0: 'second' as const,
1: 'minute' as const,
2: 'hour' as const,
3: 'day' as const,
}[zeroCount]!
return truncation
}
export function checkForWithClause(query: string) {
const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
const withClauseRegex = /\b(WITH)\b(?=(?:[^']*'[^']*')*[^']*$)/i
return withClauseRegex.test(queryWithoutComments)
}
export function checkForILIKEClause(query: string) {
const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
const ilikeClauseRegex = /\b(ILIKE)\b(?=(?:[^']*'[^']*')*[^']*$)/i
return ilikeClauseRegex.test(queryWithoutComments)
}
export function checkForWildcard(query: string) {
const queryWithoutComments = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//gm, '')
const queryWithoutCount = queryWithoutComments.replace(/count\(\*\)/gi, '')
const wildcardRegex = /\*/
return wildcardRegex.test(queryWithoutCount)
}
function getErrorCondition(table: LogsTableName): SafeLogSqlFragment {
switch (table) {
case 'edge_logs':
return safeSql`response.status_code >= 500`
case 'postgres_logs':
return safeSql`parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')`
case 'auth_logs':
return AUTH_LOG_ERROR_CONDITION
case 'function_edge_logs':
return safeSql`response.status_code >= 500`
case 'function_logs':
return safeSql`metadata.level IN ('error', 'fatal')`
case 'pg_cron_logs':
return safeSql`parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')`
case 'multigres_logs':
return safeSql`JSON_VALUE(event_message, '$.level') IN ('ERROR', 'FATAL', 'PANIC')`
default:
return safeSql`false`
}
}
function getWarningCondition(table: LogsTableName): SafeLogSqlFragment {
switch (table) {
case 'edge_logs':
return safeSql`response.status_code >= 400 AND response.status_code < 500`
case 'postgres_logs':
return safeSql`parsed.error_severity IN ('WARNING')`
case 'auth_logs':
return AUTH_LOG_WARNING_CONDITION
case 'function_edge_logs':
return safeSql`response.status_code >= 400 AND response.status_code < 500`
case 'function_logs':
return safeSql`metadata.level IN ('warning')`
case 'multigres_logs':
return safeSql`JSON_VALUE(event_message, '$.level') IN ('WARN', 'WARNING')`
default:
return safeSql`false`
}
}
export const HTTP_SERVER_ERROR_STATUS = 500
export const HTTP_CLIENT_ERROR_STATUS = 400
/**
* Derives the severity badge shown in the auth log table from the log level and
* HTTP status, matching the chart: 5xx → error, 4xx → warning, otherwise the
* log level. See the AUTH_LOG_*_CONDITION constants in Logs.constants.ts for the
* matching SQL and the reason auth logs need this.
*/
export function getAuthLogSeverity(level?: unknown, status?: unknown): string {
const normalizedLevel = typeof level === 'string' ? level : ''
// Preserve explicit error-class levels so we never downgrade them and so the
// original label (e.g. "fatal") is kept.
if (normalizedLevel === 'error' || normalizedLevel === 'fatal') return normalizedLevel
const statusCode =
typeof status === 'number' ? status : typeof status === 'string' ? Number(status) : NaN
const hasStatus = Number.isFinite(statusCode)
if (hasStatus && statusCode >= HTTP_SERVER_ERROR_STATUS) return 'error'
if (normalizedLevel === 'warning') return 'warning'
if (hasStatus && statusCode >= HTTP_CLIENT_ERROR_STATUS) return 'warning'
return normalizedLevel
}
export function jwtAPIKey(metadata: any) {
const apikeyHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.jwt?.[0]?.apikey?.[0]
if (!apikeyHeader) {
return undefined
}
if (apikeyHeader.invalid) {
return '<invalid>'
}
const payload = apikeyHeader?.payload?.[0]
if (!payload) {
return '<unrecognized>'
}
if (
payload.algorithm === 'HS256' &&
payload.issuer === 'supabase' &&
['anon', 'service_role'].includes(payload.role) &&
!payload.subject
) {
return payload.role
}
return '<unrecognized>'
}
export function apiKey(metadata: any) {
const apikeyHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.apikey?.[0]?.apikey?.[0]
if (!apikeyHeader) {
return undefined
}
if (apikeyHeader.error) {
return `${apikeyHeader.prefix}... <invalid: ${apikeyHeader.error}>`
}
return `${apikeyHeader.prefix}...`
}
export function role(metadata: any) {
const authorizationHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.jwt?.[0]?.authorization?.[0]
if (!authorizationHeader) {
return undefined
}
if (authorizationHeader.invalid) {
return undefined
}
const payload = authorizationHeader?.payload?.[0]
if (!payload || !payload.role) {
return undefined
}
return payload.role
}
export function formatLogsAsJson(rows: LogData[]): string {
return JSON.stringify(rows, null, 2)
}
export function formatLogsAsCsv(rows: LogData[]): string {
return convertResultsToCSV(rows as unknown as Record<string, unknown>[]) ?? ''
}
export function formatLogsAsMarkdown(rows: LogData[]): string {
return rows
.map((row, i) => {
const lines: string[] = [`## Log ${i + 1}`]
if (row.timestamp) {
const numTs = Number(row.timestamp)
let tsString: string
if (isFinite(numTs)) {
tsString = new Date(numTs / 1000).toISOString()
} else if (typeof row.timestamp === 'string') {
const d = new Date(row.timestamp)
tsString = isNaN(d.getTime()) ? row.timestamp : d.toISOString()
} else {
tsString = String(row.timestamp)
}
lines.push(`**Timestamp:** ${tsString}`)
}
if (row.event_message) {
lines.push(`**Message:** ${row.event_message}`)
}
const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row as any
if (Object.keys(rest).length > 0) {
lines.push('', '**Details:**', '```json', JSON.stringify(rest, null, 2), '```')
}
return lines.join('\n')
})
.join('\n\n---\n\n')
}
const QUERY_TYPE_LABELS: Record<QueryType, string> = {
api: 'API Gateway (Edge Network)',
database: 'Postgres Database',
functions: 'Edge Functions',
fn_edge: 'Edge Functions (edge runtime)',
auth: 'Auth',
realtime: 'Realtime',
storage: 'Storage',
supavisor: 'Supavisor (connection pooling)',
postgrest: 'PostgREST',
pg_upgrade: 'Postgres upgrade',
pg_cron: 'pg_cron',
pgbouncer: 'PgBouncer',
etl: 'ETL',
multigres: 'Multigres',
}
const LOG_TABLE_TO_SERVICE_LABEL: Record<LogsTableName, string> = {
edge_logs: 'API Gateway (Edge Network)',
postgres_logs: 'Postgres Database',
function_logs: 'Edge Functions',
function_edge_logs: 'Edge Functions (edge runtime)',
auth_logs: 'Auth',
auth_audit_logs: 'Auth (audit)',
realtime_logs: 'Realtime',
storage_logs: 'Storage',
postgrest_logs: 'PostgREST',
supavisor_logs: 'Supavisor (connection pooling)',
pgbouncer_logs: 'PgBouncer',
pg_upgrade_logs: 'Postgres upgrade',
pg_cron_logs: 'pg_cron',
etl_replication_logs: 'ETL',
multigres_logs: 'Multigres',
}
const isLogsTableName = (value: string): value is LogsTableName =>
value in LOG_TABLE_TO_SERVICE_LABEL
const isQueryType = (value: string): value is QueryType => value in QUERY_TYPE_LABELS
export function extractEdgeFunctionName(pathname: unknown): string {
if (typeof pathname !== 'string' || !pathname) return ''
const parts = pathname.split('/').filter(Boolean)
return parts[parts.length - 1] ?? ''
}
function extractServiceLabelFromSql(sql: string): string | null {
const match = sql.match(/\bfrom\s+(\w+)/i)
const tableName = match?.[1]
return tableName && isLogsTableName(tableName) ? LOG_TABLE_TO_SERVICE_LABEL[tableName] : null
}
export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string {
const serviceLabel =
(queryType && isQueryType(queryType) ? QUERY_TYPE_LABELS[queryType] : null) ??
(sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null)
const serviceContext = serviceLabel ? ` from the **${serviceLabel}** service` : ''
const sqlContext = sqlQuery ? `\n\n**Query used:**\n\`\`\`sql\n${sqlQuery.trim()}\n\`\`\`` : ''
const header = `I have ${rows.length} Supabase log entr${rows.length === 1 ? 'y' : 'ies'}${serviceContext} I'd like help debugging:\n\n`
const body = formatLogsAsMarkdown(rows)
return (
header +
body +
sqlContext +
'\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.'
)
}