Errors since last deploy (#44733)

This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-04-10 20:11:02 +10:00
1 parent ac439d2366
commit b8a246f2d7
4 files changed
+214 -18

No files matched your search

@@ -203,8 +203,7 @@ export const EdgeFunctionOverview = () => {
functionId={id}
functionSlug={functionSlug as string}
projectRef={projectRef as string}
isoTimestampStart={selectedWindowStart.toISOString()}
isoTimestampEnd={selectedWindowEnd.toISOString()}
updatedAt={selectedFunction?.updated_at}
/>
<EdgeFunctionPerformanceSection
@@ -1,4 +1,4 @@
import { ExternalLink } from 'lucide-react'
import { Check, ExternalLink, Eye } from 'lucide-react'
import { useRouter } from 'next/router'
import { Fragment, useMemo } from 'react'
import {
@@ -33,6 +33,10 @@ import {
getRecentErrorGroupsBase,
getRecentErrorInvocationsSql,
getRelatedExecutionIds,
getSinceLastDeployInvocationCount,
getSinceLastDeployInvocationCountSql,
getSinceLastDeployInvocationPhrase,
getSinceLastDeployLogRange,
getStatusBadgeVariant,
toAlertError,
type RecentErrorGroup,
@@ -48,26 +52,34 @@ interface EdgeFunctionRecentErrorsProps {
functionId?: string
functionSlug?: string
projectRef?: string
isoTimestampStart?: string
isoTimestampEnd?: string
updatedAt?: string | number
}
export const EdgeFunctionRecentErrors = ({
functionId,
functionSlug,
projectRef,
isoTimestampStart,
isoTimestampEnd,
updatedAt,
}: EdgeFunctionRecentErrorsProps) => {
const router = useRouter()
const { openSidebar } = useSidebarManagerSnapshot()
const aiAssistant = useAiAssistantStateSnapshot()
const { isoTimestampStart, isoTimestampEnd } = useMemo(
() => getSinceLastDeployLogRange(updatedAt),
[updatedAt]
)
const emptyStateFallback =
'Runtime errors since the last deploy will appear here when this function returns a 5xx response.'
const isQueryEnabled = Boolean(projectRef && functionId)
const isQueryEnabled = Boolean(projectRef && functionId && isoTimestampStart)
const recentErrorInvocationsSql = useMemo(
() => getRecentErrorInvocationsSql(functionId),
[functionId]
)
const sinceLastDeployInvocationCountSql = useMemo(
() => getSinceLastDeployInvocationCountSql(functionId),
[functionId]
)
const {
logData: recentErrorInvocations,
@@ -87,6 +99,19 @@ export const EdgeFunctionRecentErrors = ({
() => getRecentErrorGroupsBase(recentErrorInvocations),
[recentErrorInvocations]
)
const {
logData: sinceLastDeployInvocationCountRows,
isLoading: isLoadingSinceLastDeployInvocationCount,
error: sinceLastDeployInvocationCountError,
} = useLogsQuery(
projectRef as string,
{
sql: sinceLastDeployInvocationCountSql,
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
},
Boolean(projectRef && sinceLastDeployInvocationCountSql && isoTimestampStart)
)
const relatedExecutionIds = useMemo(
() => getRelatedExecutionIds(recentErrorGroupsBase),
@@ -109,7 +134,7 @@ export const EdgeFunctionRecentErrors = ({
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
},
Boolean(projectRef && functionRuntimeLogsSql)
Boolean(projectRef && functionRuntimeLogsSql && isoTimestampStart)
)
const queryError =
toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError)
@@ -118,6 +143,46 @@ export const EdgeFunctionRecentErrors = ({
() => getRecentErrorGroups({ recentErrorGroupsBase, functionRuntimeLogs }),
[functionRuntimeLogs, recentErrorGroupsBase]
)
const sinceLastDeployInvocationCount = useMemo(
() => getSinceLastDeployInvocationCount(sinceLastDeployInvocationCountRows),
[sinceLastDeployInvocationCountRows]
)
const emptyStateMessage = useMemo(() => {
if (!isoTimestampStart || sinceLastDeployInvocationCountError) return emptyStateFallback
const verb = sinceLastDeployInvocationCount === 1 ? 'has' : 'have'
const invocationPhrase = getSinceLastDeployInvocationPhrase(sinceLastDeployInvocationCount)
return (
<>
There {verb} been <span className="text-foreground">{invocationPhrase}</span> since last
deploy and no errors.
</>
)
}, [
emptyStateFallback,
isoTimestampStart,
sinceLastDeployInvocationCount,
sinceLastDeployInvocationCountError,
])
const emptyStateIcon =
isoTimestampStart && !sinceLastDeployInvocationCountError ? (
sinceLastDeployInvocationCount > 0 ? (
<Check
size={16}
strokeWidth={1.5}
className="mt-0.5 shrink-0 text-brand"
aria-hidden="true"
/>
) : (
<Eye
size={16}
strokeWidth={1.5}
className="mt-0.5 shrink-0 text-foreground-muted"
aria-hidden="true"
/>
)
) : null
const handleOpenAssistant = (group: RecentErrorGroup) => {
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
@@ -134,7 +199,7 @@ export const EdgeFunctionRecentErrors = ({
<div className="flex flex-col gap-6">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Recent Failed Invocations</PageSectionTitle>
<PageSectionTitle>Errors since last deploy</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<Button
@@ -153,13 +218,18 @@ export const EdgeFunctionRecentErrors = ({
{recentErrorInvocationsError || functionRuntimeLogsError ? (
<AlertError
error={queryError}
subject="Failed to retrieve recent edge function errors"
subject="Failed to retrieve edge function errors since the last deploy"
/>
) : isLoadingRecentErrorInvocations || isLoadingFunctionRuntimeLogs ? (
) : isLoadingRecentErrorInvocations ||
isLoadingFunctionRuntimeLogs ||
isLoadingSinceLastDeployInvocationCount ? (
<GenericSkeletonLoader />
) : recentErrorGroups.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-6 text-sm text-foreground-light">
Recent runtime errors will appear here when this function returns a 5xx response.
<div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light">
<div className="flex items-start gap-3">
{emptyStateIcon}
<div>{emptyStateMessage}</div>
</div>
</div>
) : (
<Card className="p-0 overflow-hidden">
@@ -1,18 +1,28 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGroupAssistantPrompt,
formatLogTimestamp,
formatSingleLineMessage,
getFunctionRuntimeLogsSql,
getNoErrorsSinceLastDeployMessage,
getRecentErrorGroups,
getRecentErrorGroupsBase,
getRelatedExecutionIds,
getSinceLastDeployInvocationCount,
getSinceLastDeployInvocationCountSql,
getSinceLastDeployInvocationPhrase,
getSinceLastDeployLogRange,
getStatusBadgeVariant,
toAlertError,
toIsoTimestamp,
} from './EdgeFunctionRecentErrors.utils'
describe('EdgeFunctionRecentErrors.utils', () => {
afterEach(() => {
vi.useRealTimers()
})
it('normalizes alert errors and single-line messages', () => {
expect(toAlertError('boom')).toEqual({ message: 'boom' })
expect(toAlertError({ message: 'broken' })).toEqual({ message: 'broken' })
@@ -42,6 +52,61 @@ order by timestamp desc
limit 25`)
})
it('normalizes deploy timestamps and derives the logs query range', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-20T12:00:00.000Z'))
const deployedAt = '2026-03-20T10:15:00.000Z'
const deployedAtMilliseconds = Date.parse(deployedAt)
expect(toIsoTimestamp(deployedAt)).toBe(deployedAt)
expect(toIsoTimestamp(String(deployedAtMilliseconds))).toBe(deployedAt)
expect(toIsoTimestamp(String(deployedAtMilliseconds * 1000))).toBe(deployedAt)
expect(toIsoTimestamp('')).toBeUndefined()
expect(toIsoTimestamp('not-a-date')).toBeUndefined()
expect(getSinceLastDeployLogRange(deployedAt)).toEqual({
isoTimestampStart: deployedAt,
isoTimestampEnd: '2026-03-20T12:00:00.000Z',
})
expect(getSinceLastDeployLogRange('2026-03-20T13:00:00.000Z')).toEqual({
isoTimestampStart: '2026-03-20T13:00:00.000Z',
isoTimestampEnd: '2026-03-20T13:00:00.000Z',
})
expect(getSinceLastDeployLogRange()).toEqual({})
})
it('builds the since-deploy invocation count query and empty-state message', () => {
expect(getSinceLastDeployInvocationCountSql()).toContain(
'SELECT count(*) as count FROM function_edge_logs'
)
expect(getSinceLastDeployInvocationCountSql()).toContain("(function_id = '__pending__')")
expect(
getSinceLastDeployInvocationCount([
{
count: '12',
},
] as unknown as Parameters<typeof getSinceLastDeployInvocationCount>[0])
).toBe(12)
expect(getSinceLastDeployInvocationCount([])).toBe(0)
expect(getSinceLastDeployInvocationPhrase(1)).toBe('1 invocation')
expect(getSinceLastDeployInvocationPhrase(1200)).toBe('1,200 invocations')
expect(getNoErrorsSinceLastDeployMessage(0)).toBe(
'There have been 0 invocations since last deploy and no errors.'
)
expect(getNoErrorsSinceLastDeployMessage(1)).toBe(
'There has been 1 invocation since last deploy and no errors.'
)
expect(getNoErrorsSinceLastDeployMessage(1200)).toBe(
'There have been 1,200 invocations since last deploy and no errors.'
)
})
it('groups recent failed invocations by parsed error message', () => {
const groups = getRecentErrorGroupsBase([
{
@@ -196,7 +261,7 @@ limit 25`)
},
'my-function'
)
).toContain('Analyze this recurring edge function error for `my-function`.')
).toContain('Analyze this edge function error since the last deploy for `my-function`.')
expect(getStatusBadgeVariant()).toBe('destructive')
expect(getStatusBadgeVariant('500')).toBe('destructive')
@@ -5,6 +5,7 @@ import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.
import { LOGS_TABLES } from '@/components/interfaces/Settings/Logs/Logs.constants'
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
import {
genCountQuery,
genDefaultQuery,
isUnixMicro,
unixMicroToIsoTimestamp,
@@ -16,6 +17,7 @@ dayjs.extend(relativeTime)
export const MAX_RECENT_ERROR_GROUPS = 5
export const RECENT_ERROR_INVOCATIONS_LIMIT = 50
export const RELATED_RUNTIME_LOGS_LIMIT = 100
const NUMERIC_TIMESTAMP_PATTERN = /^\d+(?:\.\d+)?$/
export type GroupedRuntimeLog = {
key: string
@@ -66,9 +68,45 @@ export const formatLogTimestamp = (
: dayjs.utc(timestamp).format('HH:mm:ss')
}
export const toIsoTimestamp = (value?: string | number) => {
if (value === undefined) return undefined
const normalizedValue = typeof value === 'string' ? value.trim() : value
if (normalizedValue === '') return undefined
const stringValue = String(normalizedValue)
const isNumericTimestamp = NUMERIC_TIMESTAMP_PATTERN.test(stringValue)
const date = (() => {
if (!isNumericTimestamp) return new Date(stringValue)
const numericValue = Number(stringValue)
if (!Number.isFinite(numericValue)) return new Date(NaN)
if (stringValue.length >= 16) return new Date(numericValue / 1000)
if (stringValue.length <= 10) return new Date(numericValue * 1000)
return new Date(numericValue)
})()
return Number.isNaN(date.valueOf()) ? undefined : date.toISOString()
}
export const getSinceLastDeployLogRange = (updatedAt?: string | number, now: Date = new Date()) => {
const isoTimestampStart = toIsoTimestamp(updatedAt)
if (!isoTimestampStart) return {}
const startDate = new Date(isoTimestampStart)
const normalizedNow = new Date(now)
const endDate = Number.isNaN(normalizedNow.valueOf()) ? new Date() : normalizedNow
return {
isoTimestampStart,
isoTimestampEnd: new Date(Math.max(startDate.valueOf(), endDate.valueOf())).toISOString(),
}
}
export const buildGroupMarkdown = (group: RecentErrorGroup, functionSlug?: string) => {
const lines = [
`## Recent error for \`${functionSlug ?? 'edge function'}\``,
`## Error since last deploy for \`${functionSlug ?? 'edge function'}\``,
'',
`### ${group.message}`,
`- Occurrences: ${group.count}`,
@@ -98,7 +136,7 @@ export const buildGroupMarkdown = (group: RecentErrorGroup, functionSlug?: strin
export const buildGroupAssistantPrompt = (group: RecentErrorGroup, functionSlug?: string) => {
return [
`Analyze this recurring edge function error for \`${functionSlug ?? 'edge function'}\`.`,
`Analyze this edge function error since the last deploy for \`${functionSlug ?? 'edge function'}\`.`,
'Summarize the likely root cause, what the runtime logs suggest, and the next debugging steps.',
'',
buildGroupMarkdown(group, functionSlug),
@@ -128,6 +166,30 @@ export const getRecentErrorInvocationsSql = (
limit
)
export const getSinceLastDeployInvocationCountSql = (functionId?: string) =>
genCountQuery(LOGS_TABLES.fn_edge, {
function_id: functionId ?? '__pending__',
})
export const getSinceLastDeployInvocationCount = (invocationCountRows: LogData[]) => {
const count = Number(invocationCountRows[0]?.count ?? 0)
return Number.isFinite(count) ? count : 0
}
export const getSinceLastDeployInvocationPhrase = (invocationCount: number) => {
const formattedCount = invocationCount.toLocaleString('en-US')
const invocationLabel = invocationCount === 1 ? 'invocation' : 'invocations'
return `${formattedCount} ${invocationLabel}`
}
export const getNoErrorsSinceLastDeployMessage = (invocationCount: number) => {
const verb = invocationCount === 1 ? 'has' : 'have'
const invocationPhrase = getSinceLastDeployInvocationPhrase(invocationCount)
return `There ${verb} been ${invocationPhrase} since last deploy and no errors.`
}
export const getFunctionRuntimeLogsSql = ({
functionId,
executionIds,