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.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 | 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 ): [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 '' } const payload = apikeyHeader?.payload?.[0] if (!payload) { return '' } if ( payload.algorithm === 'HS256' && payload.issuer === 'supabase' && ['anon', 'service_role'].includes(payload.role) && !payload.subject ) { return payload.role } return '' } 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}... ` } 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[]) ?? '' } 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 = { 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 = { 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.' ) }