EdgeFunctionRecentErrors.utils.ts353 lines · main
1import dayjs from 'dayjs'
2import relativeTime from 'dayjs/plugin/relativeTime'
3
4import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.utils'
5import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants'
6import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
7import {
8 genCountQuery,
9 genDefaultQuery,
10 isUnixMicro,
11 unixMicroToIsoTimestamp,
12} from '@/components/interfaces/Settings/Logs/Logs.utils'
13import type { AlertErrorProps } from '@/components/ui/AlertError'
14
15dayjs.extend(relativeTime)
16
17export const MAX_RECENT_ERROR_GROUPS = 5
18export const RECENT_ERROR_INVOCATIONS_LIMIT = 50
19export const RELATED_RUNTIME_LOGS_LIMIT = 100
20const NUMERIC_TIMESTAMP_PATTERN = /^\d+(?:\.\d+)?$/
21
22export type GroupedRuntimeLog = {
23 key: string
24 message: string
25 level: string
26 count: number
27 lastSeen: number
28}
29
30export type RecentErrorGroup = {
31 message: string
32 count: number
33 lastSeen: number
34 lastExecutionId?: string
35 lastStatusCode?: string
36 lastMethod?: string
37 executionTime?: string
38 executionIds: string[]
39 logs: GroupedRuntimeLog[]
40}
41
42export type RecentErrorGroupBase = Omit<RecentErrorGroup, 'logs'>
43
44export const escapeSqlString = (value: string) => value.replace(/'/g, "''")
45
46export const formatSingleLineMessage = (message: string) => message.replace(/\s+/g, ' ').trim()
47
48/**
49 * Trims a runtime error message down to the meaningful summary, dropping the
50 * stack trace that follows the first ` at ` frame so we can show it inline in
51 * a table cell.
52 */
53export const summarizeErrorMessage = (message: string): string => {
54 const collapsed = formatSingleLineMessage(message)
55 if (!collapsed) return collapsed
56
57 const stackFrameMatch = collapsed.match(/\s+at\s+\S+\s+\(/)
58 if (stackFrameMatch && stackFrameMatch.index !== undefined) {
59 return collapsed.slice(0, stackFrameMatch.index).trim()
60 }
61 return collapsed
62}
63
64/**
65 * Picks the most useful error description for a group. The invocation
66 * `event_message` only contains the request URL, so when we have a related
67 * runtime error log we surface its summary instead.
68 */
69export const getDisplayErrorMessage = (group: RecentErrorGroup): string => {
70 const errorLog = group.logs.find((log) => log.level === 'error')
71 if (errorLog) {
72 const summary = summarizeErrorMessage(errorLog.message)
73 if (summary) return summary
74 }
75 return summarizeErrorMessage(group.message)
76}
77
78const TROUBLESHOOTING_DOCS_BASE = 'https://supabase.com/docs/guides/troubleshooting'
79
80export const buildTroubleshootingDocsUrl = ({ statusCode }: { statusCode?: string }): string => {
81 const numericStatusCode = Number(statusCode)
82 if (Number.isFinite(numericStatusCode) && numericStatusCode >= 100) {
83 return `${TROUBLESHOOTING_DOCS_BASE}/edge-function-${numericStatusCode}-response`
84 }
85 return `${TROUBLESHOOTING_DOCS_BASE}?search=${encodeURIComponent('edge function')}`
86}
87
88export const toAlertError = (error: unknown): AlertErrorProps['error'] | undefined => {
89 if (typeof error === 'string') return { message: error }
90
91 if (error && typeof error === 'object') {
92 const message = (error as { message?: unknown }).message
93 if (typeof message === 'string') return { message }
94 }
95
96 return undefined
97}
98
99export const formatLogTimestamp = (
100 value: string | number | undefined,
101 format: 'relative' | 'time'
102) => {
103 if (value === undefined) return '-'
104
105 const timestamp = isUnixMicro(value) ? unixMicroToIsoTimestamp(value) : String(value)
106 return format === 'relative'
107 ? dayjs.utc(timestamp).fromNow()
108 : dayjs.utc(timestamp).format('HH:mm:ss')
109}
110
111export const toIsoTimestamp = (value?: string | number) => {
112 if (value === undefined) return undefined
113
114 const normalizedValue = typeof value === 'string' ? value.trim() : value
115 if (normalizedValue === '') return undefined
116
117 const stringValue = String(normalizedValue)
118 const isNumericTimestamp = NUMERIC_TIMESTAMP_PATTERN.test(stringValue)
119 const date = (() => {
120 if (!isNumericTimestamp) return new Date(stringValue)
121
122 const numericValue = Number(stringValue)
123 if (!Number.isFinite(numericValue)) return new Date(NaN)
124
125 if (stringValue.length >= 16) return new Date(numericValue / 1000)
126 if (stringValue.length <= 10) return new Date(numericValue * 1000)
127 return new Date(numericValue)
128 })()
129
130 return Number.isNaN(date.valueOf()) ? undefined : date.toISOString()
131}
132
133export const getSinceLastDeployLogRange = (updatedAt?: string | number, now: Date = new Date()) => {
134 const isoTimestampStart = toIsoTimestamp(updatedAt)
135 if (!isoTimestampStart) return {}
136
137 const startDate = new Date(isoTimestampStart)
138 const normalizedNow = new Date(now)
139 const endDate = Number.isNaN(normalizedNow.valueOf()) ? new Date() : normalizedNow
140
141 return {
142 isoTimestampStart,
143 isoTimestampEnd: new Date(Math.max(startDate.valueOf(), endDate.valueOf())).toISOString(),
144 }
145}
146
147export const buildGroupMarkdown = (group: RecentErrorGroup, functionSlug?: string) => {
148 const lines = [
149 `## Error since last deploy for \`${functionSlug ?? 'edge function'}\``,
150 '',
151 `### ${group.message}`,
152 `- Occurrences: ${group.count}`,
153 `- Last seen: ${formatLogTimestamp(group.lastSeen, 'relative')}`,
154 ]
155
156 if (group.lastMethod) lines.push(`- Last method: ${group.lastMethod}`)
157 if (group.lastStatusCode) lines.push(`- Last status: ${group.lastStatusCode}`)
158 if (group.executionTime) lines.push(`- Last execution time: ${group.executionTime}`)
159
160 lines.push('', '#### Related runtime logs')
161
162 if (group.logs.length === 0) {
163 lines.push('- No related runtime logs found for this error group.')
164 } else {
165 for (const log of group.logs) {
166 lines.push(
167 `- [${log.level}] ${log.count} occurrence${
168 log.count === 1 ? '' : 's'
169 }, last seen ${formatLogTimestamp(log.lastSeen, 'relative')}: ${log.message}`
170 )
171 }
172 }
173
174 return lines.join('\n')
175}
176
177export const buildGroupAssistantPrompt = (group: RecentErrorGroup, functionSlug?: string) => {
178 return [
179 `Analyze this edge function error since the last deploy for \`${functionSlug ?? 'edge function'}\`.`,
180 'Summarize the likely root cause, what the runtime logs suggest, and the next debugging steps.',
181 '',
182 buildGroupMarkdown(group, functionSlug),
183 ].join('\n')
184}
185
186export const getStatusBadgeVariant = (statusCode?: string) => {
187 if (!statusCode) return 'destructive' as const
188
189 const status = Number(statusCode)
190 if (Number.isNaN(status)) return 'destructive' as const
191 if (status >= 500) return 'destructive' as const
192
193 return 'default' as const
194}
195
196export const getRecentErrorInvocationsSql = (
197 functionId?: string,
198 limit = RECENT_ERROR_INVOCATIONS_LIMIT
199) =>
200 genDefaultQuery(
201 LOGS_TABLES.fn_edge,
202 {
203 function_id: functionId ?? '__pending__',
204 'status_code.error': true,
205 },
206 limit
207 )
208
209export const getSinceLastDeployInvocationCountSql = (functionId?: string) =>
210 genCountQuery(LOGS_TABLES.fn_edge, {
211 function_id: functionId ?? '__pending__',
212 })
213
214export const getSinceLastDeployInvocationCount = (invocationCountRows: LogData[]) => {
215 const count = Number(invocationCountRows[0]?.count ?? 0)
216 return Number.isFinite(count) ? count : 0
217}
218
219export const getSinceLastDeployInvocationPhrase = (invocationCount: number) => {
220 const formattedCount = invocationCount.toLocaleString('en-US')
221 const invocationLabel = invocationCount === 1 ? 'invocation' : 'invocations'
222
223 return `${formattedCount} ${invocationLabel}`
224}
225
226export const getNoErrorsSinceLastDeployMessage = (invocationCount: number) => {
227 const verb = invocationCount === 1 ? 'has' : 'have'
228 const invocationPhrase = getSinceLastDeployInvocationPhrase(invocationCount)
229
230 return `There ${verb} been ${invocationPhrase} since last deploy and no errors.`
231}
232
233export const getFunctionRuntimeLogsSql = ({
234 functionId,
235 executionIds,
236 limit = RELATED_RUNTIME_LOGS_LIMIT,
237}: {
238 functionId?: string
239 executionIds: string[]
240 limit?: number
241}) => {
242 if (!functionId || executionIds.length === 0) return ''
243
244 const escapedExecutionIds = executionIds.map((id) => `'${escapeSqlString(id)}'`).join(', ')
245
246 return `select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
247cross join unnest(metadata) as metadata
248where metadata.function_id = '${escapeSqlString(functionId)}' and metadata.execution_id in (${escapedExecutionIds})
249order by timestamp desc
250limit ${limit}`
251}
252
253export const getRecentErrorGroupsBase = (
254 recentErrorInvocations: LogData[]
255): RecentErrorGroupBase[] => {
256 const grouped: Record<string, RecentErrorGroupBase> = {}
257
258 for (const item of recentErrorInvocations) {
259 const statusCode = String(item.status_code ?? '')
260 const method = String(item.method ?? '')
261 const message =
262 parseEdgeFunctionEventMessage(
263 String(item.event_message ?? ''),
264 method || undefined,
265 statusCode
266 ) || 'Unknown error'
267 const executionId = String(item.execution_id ?? '')
268 const timestamp = Number(item.timestamp ?? 0)
269 const executionTime =
270 item.execution_time_ms !== undefined
271 ? `${Math.round(Number(item.execution_time_ms))}ms`
272 : undefined
273 const current = grouped[message]
274
275 if (!current) {
276 grouped[message] = {
277 message,
278 count: 1,
279 lastSeen: timestamp,
280 lastExecutionId: executionId || undefined,
281 lastStatusCode: statusCode || undefined,
282 lastMethod: method || undefined,
283 executionTime,
284 executionIds: executionId ? [executionId] : [],
285 }
286 continue
287 }
288
289 current.count += 1
290
291 if (executionId && !current.executionIds.includes(executionId)) {
292 current.executionIds.push(executionId)
293 }
294
295 if (timestamp > current.lastSeen) {
296 current.lastSeen = timestamp
297 current.lastExecutionId = executionId || undefined
298 current.lastStatusCode = statusCode || undefined
299 current.lastMethod = method || undefined
300 current.executionTime = executionTime
301 }
302 }
303
304 return Object.values(grouped)
305 .sort((a, b) => b.lastSeen - a.lastSeen)
306 .slice(0, MAX_RECENT_ERROR_GROUPS)
307}
308
309export const getRelatedExecutionIds = (recentErrorGroupsBase: RecentErrorGroupBase[]) =>
310 Array.from(new Set(recentErrorGroupsBase.flatMap((group) => group.executionIds).filter(Boolean)))
311
312export const getRecentErrorGroups = ({
313 recentErrorGroupsBase,
314 functionRuntimeLogs,
315}: {
316 recentErrorGroupsBase: RecentErrorGroupBase[]
317 functionRuntimeLogs: LogData[]
318}): RecentErrorGroup[] => {
319 const runtimeLogsByExecutionId = functionRuntimeLogs.reduce<Record<string, LogData[]>>(
320 (acc, log) => {
321 const executionId = String(log.execution_id ?? '')
322 if (!executionId) return acc
323
324 acc[executionId] = [...(acc[executionId] ?? []), log]
325 return acc
326 },
327 {}
328 )
329
330 return recentErrorGroupsBase.map((group) => ({
331 ...group,
332 logs: Array.from(new Set(group.executionIds))
333 .flatMap((executionId) => runtimeLogsByExecutionId[executionId] ?? [])
334 .reduce<GroupedRuntimeLog[]>((acc, log) => {
335 const level = String(log.level ?? log.event_type ?? 'log')
336 const message = String(log.event_message ?? '')
337 const key = `${level}:${message}`
338 const timestamp = Number(log.timestamp ?? 0)
339 const existing = acc.find((entry) => entry.key === key)
340
341 if (existing) {
342 existing.count += 1
343 existing.lastSeen = Math.max(existing.lastSeen, timestamp)
344 return acc
345 }
346
347 acc.push({ key, message, level, count: 1, lastSeen: timestamp })
348 return acc
349 }, [])
350 .sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
351 .slice(0, MAX_RECENT_ERROR_GROUPS),
352 }))
353}