EdgeFunctionRecentErrors.utils.ts 12 KB

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