Edge function overview (#44009)

<img width="1575" height="1134" alt="image"
src="https://github.com/user-attachments/assets/994b1113-717f-44a2-89a4-13bc0182db20"
/>

Attempts to improve our edge function overview pages to provide stronger
insights into the health of a function, including reliability (error
rates), performance (execution times) and usage (cpu and memory).

As part of this work it refactors existing charts to use our new chart
components.

main consideration is the collective performance of error queries
https://github.com/supabase/supabase/pull/44009/changes#diff-2a79cf61c5397a8ef363c333229fa7729a2efc90a4d8e0806e49c212d5aa97e7

## To test:
1. Create an edge function that errors out randomly across requests. You
can use cron to poll this function every second.
2. View the edge function and on the overview page confirm that errors
are showing and grouped correctly in recent failed invocations sections.

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
Saxon FletcherandAli Waseem authored and GitHub committed 2026-04-01 14:59:12 +10:00
1 parent 36269df347
commit e21088144d
17 files changed
+2288 -17

No files matched your search

@@ -0,0 +1,20 @@
import { BarChart2 } from 'lucide-react'
import { ChartEmptyState } from 'ui-patterns/Chart'
interface EdgeFunctionChartEmptyStateProps {
title: string
description?: string
}
export const EdgeFunctionChartEmptyState = ({
title,
description,
}: EdgeFunctionChartEmptyStateProps) => {
return (
<ChartEmptyState
icon={<BarChart2 size={16} />}
title={title}
description={description ?? 'It may take up to 24 hours for data to refresh'}
/>
)
}
@@ -0,0 +1,114 @@
import { Rocket } from 'lucide-react'
import { useMemo } from 'react'
import {
Bar,
CartesianGrid,
BarChart as RechartBarChart,
ReferenceLine,
XAxis,
YAxis,
} from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent } from 'ui'
import {
formatChartTimestamp,
getChartTimeRangeLabels,
INVOCATION_CHART_CONFIG,
} from './EdgeFunctionOverview.utils'
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
interface EdgeFunctionInvocationsChartProps {
chartData: InvocationChartDatum[]
dateTimeFormat: string
onChartClick: () => void
updateAnnotation?: InvocationUpdateAnnotation
}
export const EdgeFunctionInvocationsChart = ({
chartData,
dateTimeFormat,
onChartClick,
updateAnnotation,
}: EdgeFunctionInvocationsChartProps) => {
const timeRangeLabels = useMemo(
() => getChartTimeRangeLabels(chartData, dateTimeFormat),
[chartData, dateTimeFormat]
)
return (
<div className="flex flex-col gap-1">
<div className="relative h-40 w-full overflow-visible">
<ChartContainer config={INVOCATION_CHART_CONFIG} className="!aspect-auto !h-full !w-full">
<RechartBarChart
data={chartData}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
onClick={onChartClick}
>
<CartesianGrid vertical={false} />
<YAxis hide width={0} />
<XAxis
dataKey="timestamp"
tickLine={false}
axisLine={false}
tick={false}
minTickGap={32}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
className="text-foreground-light"
labelFormatter={(value) =>
formatChartTimestamp(value as string | number | undefined, dateTimeFormat)
}
indicator="dot"
/>
}
/>
<Bar
dataKey="error_count"
stackId="invocations"
fill="var(--color-error_count)"
maxBarSize={24}
/>
<Bar
dataKey="warning_count"
stackId="invocations"
fill="var(--color-warning_count)"
maxBarSize={24}
/>
<Bar
dataKey="ok_count"
stackId="invocations"
fill="var(--color-ok_count)"
maxBarSize={24}
/>
{updateAnnotation && (
<ReferenceLine
x={updateAnnotation.timestamp}
stroke="hsl(var(--foreground-default))"
strokeDasharray="4 4"
strokeWidth={1.5}
/>
)}
</RechartBarChart>
</ChartContainer>
{updateAnnotation && (
<span
className="pointer-events-none absolute bottom-0 z-10 flex h-6 w-6 -translate-x-1/2 translate-y-1/2 items-center justify-center rounded-full border border-foreground/20 bg-background text-foreground shadow-sm"
style={{ left: `${updateAnnotation.position}%` }}
title={`Updated ${formatChartTimestamp(updateAnnotation.updatedAt, dateTimeFormat)}`}
>
<Rocket size={12} strokeWidth={1.75} />
</span>
)}
</div>
{timeRangeLabels && (
<div className="-mt-6 flex items-center justify-between text-[10px] font-mono text-foreground-lighter">
<span>{timeRangeLabels.start}</span>
<span>{timeRangeLabels.end}</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,156 @@
import AlertError from 'components/ui/AlertError'
import type { ComponentProps } from 'react'
import { useMemo } from 'react'
import type { ChartIntervals } from 'types'
import { Button } from 'ui'
import { Chart, ChartActions, ChartLoadingState, ChartMetric } from 'ui-patterns/Chart'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { EdgeFunctionChartEmptyState } from './EdgeFunctionChartEmptyState'
import { EdgeFunctionInvocationsChart } from './EdgeFunctionInvocationsChart'
import {
EDGE_FUNCTION_CHART_INTERVALS,
formatRate,
getChartEmptyStateCopy,
getSegmentedButtonClassName,
} from './EdgeFunctionOverview.utils'
import { toAlertError } from './EdgeFunctionRecentErrors.utils'
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
interface EdgeFunctionInvocationsSectionProps {
interval: string
onIntervalChange: (interval: string) => void
selectedInterval: ChartIntervals
actions?: ComponentProps<typeof ChartActions>['actions']
totalInvocationCount: number
totalErrorCount: number
totalWarningCount: number
isLoadingFunction: boolean
isErrorFunction: boolean
functionError?: unknown
isLoadingChart: boolean
isErrorChart: boolean
chartErrorMessage?: string
chartData: InvocationChartDatum[]
onChartClick: () => void
updateAnnotation?: InvocationUpdateAnnotation
}
export const EdgeFunctionInvocationsSection = ({
interval,
onIntervalChange,
selectedInterval,
actions,
totalInvocationCount,
totalErrorCount,
totalWarningCount,
isLoadingFunction,
isErrorFunction,
functionError,
isLoadingChart,
isErrorChart,
chartErrorMessage,
chartData,
onChartClick,
updateAnnotation,
}: EdgeFunctionInvocationsSectionProps) => {
const dateTimeFormat = selectedInterval.format ?? 'MMM D, h:mma'
const emptyStateCopy = useMemo(
() => getChartEmptyStateCopy('invocations', isErrorChart, chartErrorMessage),
[chartErrorMessage, isErrorChart]
)
return (
<PageSection className="bg-surface-100/50 border-b pb-10 pt-8">
<PageSectionContent>
<PageContainer size="full">
<div className="flex flex-col gap-5">
<PageSectionMeta className="!items-center">
<PageSectionSummary>
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
<ChartMetric
label="Total Invocations"
value={totalInvocationCount}
status="default"
tooltip="Total number of invocations"
/>
<ChartMetric
label="5xx Rate"
value={formatRate(totalErrorCount, totalInvocationCount)}
status="negative"
tooltip="Share of invocations that returned a 5xx status code"
/>
<ChartMetric
label="4xx Rate"
value={formatRate(totalWarningCount, totalInvocationCount)}
status="warning"
tooltip="Share of invocations that returned a 4xx status code"
/>
</div>
</PageSectionSummary>
<PageSectionAside className="flex-wrap @xl:self-center">
<div className="flex items-center">
{EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => {
return (
<Button
key={`function-filter-${item.key}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => onIntervalChange(item.key)}
className={getSegmentedButtonClassName(
index,
EDGE_FUNCTION_CHART_INTERVALS.length
)}
>
{item.label}
</Button>
)
})}
</div>
<ChartActions actions={actions} />
</PageSectionAside>
</PageSectionMeta>
<div>
{isLoadingFunction && <GenericSkeletonLoader />}
{isErrorFunction && (
<AlertError
error={toAlertError(functionError)}
subject="Failed to retrieve edge function details"
layout="vertical"
/>
)}
</div>
<div>
<Chart isLoading={isLoadingChart}>
{isLoadingChart ? (
<ChartLoadingState />
) : isErrorChart || chartData.length === 0 ? (
<EdgeFunctionChartEmptyState
title={emptyStateCopy.title}
description={emptyStateCopy.description}
/>
) : (
<EdgeFunctionInvocationsChart
chartData={chartData}
dateTimeFormat={dateTimeFormat}
onChartClick={onChartClick}
updateAnnotation={updateAnnotation}
/>
)}
</Chart>
</div>
</div>
</PageContainer>
</PageSectionContent>
</PageSection>
)
}
@@ -0,0 +1,93 @@
import type { ChartLineProps } from 'ui-patterns/Chart'
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
import { formatReferenceDelta, getMemoryTooltipDetail } from './EdgeFunctionOverview.utils'
export const ExecutionTooltipDetail = ({
averageExecutionTime,
metricKey,
value,
}: {
averageExecutionTime: number
metricKey: string
value: unknown
}) => {
if (metricKey !== 'max_execution_time') return null
return (
<span className="text-foreground">
{formatReferenceDelta(Number(value ?? 0), averageExecutionTime)}
</span>
)
}
export const CpuTooltipDetail = ({
averageCpuTime,
value,
}: {
averageCpuTime: number
value: unknown
}) => {
return (
<span className="text-foreground">
{formatReferenceDelta(Number(value ?? 0), averageCpuTime)}
</span>
)
}
export const MemoryTooltipDetail = ({
averageMemoryUsage,
datum,
value,
}: {
averageMemoryUsage: number
datum: EdgeFunctionChartDatum
value: unknown
}) => {
return (
<>
<span className="text-foreground">
{formatReferenceDelta(Number(value ?? 0), averageMemoryUsage)}
</span>
<span className="text-foreground-light">
{getMemoryTooltipDetail(datum.avg_heap_memory_used, datum.avg_external_memory_used)}
</span>
</>
)
}
export const getExecutionTooltipDetails = (
averageExecutionTime: number
): NonNullable<ChartLineProps['tooltipDetails']> => {
return function renderExecutionTooltipDetails(_, key, value) {
return (
<ExecutionTooltipDetail
averageExecutionTime={averageExecutionTime}
metricKey={key}
value={value}
/>
)
}
}
export const getCpuTooltipDetails = (
averageCpuTime: number
): NonNullable<ChartLineProps['tooltipDetails']> => {
return function renderCpuTooltipDetails(_, __, value) {
return <CpuTooltipDetail averageCpuTime={averageCpuTime} value={value} />
}
}
export const getMemoryTooltipDetails = (
averageMemoryUsage: number
): NonNullable<ChartLineProps['tooltipDetails']> => {
return function renderMemoryTooltipDetails(datum, _, value) {
return (
<MemoryTooltipDetail
averageMemoryUsage={averageMemoryUsage}
datum={datum as EdgeFunctionChartDatum}
value={value}
/>
)
}
}
@@ -0,0 +1,237 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
EDGE_FUNCTION_CHART_INTERVALS,
getBucketedTimeRange,
getExecutionMetrics,
getInvocationChartData,
getInvocationTotals,
getInvocationUpdateAnnotation,
getRollingTimeRange,
getUsageMetrics,
toEdgeFunctionChartData,
} from './EdgeFunctionOverview.utils'
import type { EdgeFunctionChartRawDatum } from './EdgeFunctionOverview.utils'
import NoPermission from 'components/ui/NoPermission'
import {
FunctionsCombinedStatsVariables,
useFunctionsCombinedStatsQuery,
} from 'data/analytics/functions-combined-stats-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { ExternalLink } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { EdgeFunctionInvocationsSection } from './EdgeFunctionInvocationsSection'
import { EdgeFunctionPerformanceSection } from './EdgeFunctionPerformanceSection'
import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors'
import { EdgeFunctionUsageSection } from './EdgeFunctionUsageSection'
export const EdgeFunctionOverview = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const [interval, setInterval] = useState<string>('15min')
const selectedInterval =
EDGE_FUNCTION_CHART_INTERVALS.find((item) => item.key === interval) ||
EDGE_FUNCTION_CHART_INTERVALS[1]
const {
data: selectedFunction,
error: functionError,
isPending: isLoadingFunction,
isError: isErrorFunction,
} = useEdgeFunctionQuery({
projectRef,
slug: functionSlug,
})
const id = selectedFunction?.id
const combinedStatsResults = useFunctionsCombinedStatsQuery(
{
projectRef,
functionId: id,
interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'],
},
{
enabled: IS_PLATFORM,
}
)
const combinedStatsData = useMemo(
() => (combinedStatsResults.data?.result as EdgeFunctionChartRawDatum[] | undefined) || [],
[combinedStatsResults.data]
)
const [startDate, endDate] = useMemo(
() => getBucketedTimeRange(selectedInterval),
[selectedInterval]
)
const [selectedWindowStart, selectedWindowEnd] = useMemo(
() => getRollingTimeRange(selectedInterval),
[selectedInterval]
)
const dateTimeFormat = selectedInterval.format ?? 'MMM D, h:mma'
const {
data: combinedStatsChartData,
error: combinedStatsError,
isError: isErrorCombinedStats,
} = useFillTimeseriesSorted({
data: combinedStatsData,
timestampKey: 'timestamp',
valueKey: [
'requests_count',
'log_count',
'log_info_count',
'log_warn_count',
'log_error_count',
'success_count',
'redirect_count',
'client_err_count',
'server_err_count',
'avg_cpu_time_used',
'avg_memory_used',
'avg_execution_time',
'max_execution_time',
'avg_heap_memory_used',
'avg_external_memory_used',
'max_cpu_time_used',
],
defaultValue: 0,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
})
const chartData = useMemo(
() => toEdgeFunctionChartData(combinedStatsChartData),
[combinedStatsChartData]
)
const invocationChartData = useMemo(() => getInvocationChartData(chartData), [chartData])
const { totalInvocationCount, totalWarningCount, totalErrorCount } = useMemo(
() => getInvocationTotals(invocationChartData),
[invocationChartData]
)
const { averageExecutionTime, maxExecutionTime } = useMemo(
() => getExecutionMetrics(chartData),
[chartData]
)
const {
averageCpuTime,
maxCpuTime,
averageMemoryUsage,
totalHeapMemory,
totalExternalMemory,
totalMemoryByType,
} = useMemo(() => getUsageMetrics(chartData), [chartData])
const invocationUpdateAnnotation = useMemo(
() =>
getInvocationUpdateAnnotation({
updatedAt:
selectedFunction?.updated_at === undefined
? undefined
: String(selectedFunction.updated_at),
invocationChartData,
windowStart: selectedWindowStart,
windowEnd: selectedWindowEnd,
}),
[invocationChartData, selectedFunction?.updated_at, selectedWindowEnd, selectedWindowStart]
)
const invocationActions = useMemo(
() => [
{
label: isUnifiedLogsEnabled ? 'Open logs' : 'Open invocations',
href: `/project/${projectRef}/functions/${functionSlug}/${
isUnifiedLogsEnabled ? 'logs' : 'invocations'
}`,
icon: <ExternalLink size={12} />,
},
],
[functionSlug, isUnifiedLogsEnabled, projectRef]
)
const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_READ,
functionSlug as string
)
useEffect(() => {
if (!IS_PLATFORM && projectRef && functionSlug) {
router.replace(`/project/${projectRef}/functions/${functionSlug}/details`)
}
}, [functionSlug, projectRef, router])
if (!canReadFunction && !permissionsLoading) {
return <NoPermission isFullPage resourceText="access this edge function" />
}
if (!IS_PLATFORM) {
return null
}
return (
<>
<EdgeFunctionInvocationsSection
interval={interval}
onIntervalChange={setInterval}
selectedInterval={selectedInterval}
actions={invocationActions}
totalInvocationCount={totalInvocationCount}
totalErrorCount={totalErrorCount}
totalWarningCount={totalWarningCount}
isLoadingFunction={isLoadingFunction}
isErrorFunction={isErrorFunction}
functionError={functionError}
isLoadingChart={combinedStatsResults.isLoading}
isErrorChart={isErrorCombinedStats}
chartErrorMessage={combinedStatsError?.message ?? 'Unknown error'}
chartData={invocationChartData}
onChartClick={() => {
router.push(
`/project/${projectRef}/functions/${functionSlug}/${
isUnifiedLogsEnabled ? 'logs' : 'invocations'
}${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}`
)
}}
updateAnnotation={invocationUpdateAnnotation}
/>
<EdgeFunctionRecentErrors
functionId={id}
functionSlug={functionSlug as string}
projectRef={projectRef as string}
isoTimestampStart={selectedWindowStart.toISOString()}
isoTimestampEnd={selectedWindowEnd.toISOString()}
/>
<EdgeFunctionPerformanceSection
data={chartData}
dateTimeFormat={dateTimeFormat}
isLoading={combinedStatsResults.isLoading}
isError={isErrorCombinedStats}
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
averageExecutionTime={averageExecutionTime}
maxExecutionTime={maxExecutionTime}
/>
<EdgeFunctionUsageSection
data={chartData}
dateTimeFormat={dateTimeFormat}
isLoading={combinedStatsResults.isLoading}
isError={isErrorCombinedStats}
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
averageCpuTime={averageCpuTime}
maxCpuTime={maxCpuTime}
averageMemoryUsage={averageMemoryUsage}
totalHeapMemory={totalHeapMemory}
totalExternalMemory={totalExternalMemory}
totalMemoryByType={totalMemoryByType}
/>
</>
)
}
export default EdgeFunctionOverview
@@ -0,0 +1,219 @@
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import {
EDGE_FUNCTION_CHART_INTERVALS,
type EdgeFunctionChartRawDatum,
formatChartTimestamp,
formatMetric,
formatRate,
formatReferenceDelta,
getBucketedTimeRange,
getChartEmptyStateCopy,
getChartTimeRangeLabels,
getExecutionMetrics,
getInvocationChartData,
getInvocationTotals,
getInvocationUpdateAnnotation,
getMemoryTooltipDetail,
getRollingTimeRange,
getSegmentedButtonClassName,
toEdgeFunctionChartData,
getUsageMetrics,
} from './EdgeFunctionOverview.utils'
describe('EdgeFunctionOverview.utils', () => {
it('uses a full day interval for the 1 day option', () => {
expect(
EDGE_FUNCTION_CHART_INTERVALS.find((interval) => interval.key === '1day')?.startUnit
).toBe('day')
})
it('builds invocation chart data and totals from combined stats', () => {
const chartData = toEdgeFunctionChartData([
{
timestamp: '2026-03-20T10:00:00.000Z',
success_count: 10,
redirect_count: 2,
client_err_count: 1,
server_err_count: 3,
},
{
timestamp: '2026-03-20T10:15:00.000Z',
success_count: '4',
redirect_count: '1',
client_err_count: '0',
server_err_count: '2',
},
] satisfies EdgeFunctionChartRawDatum[])
const data = getInvocationChartData(chartData)
expect(data).toEqual([
{
timestamp: '2026-03-20T10:00:00.000Z',
ok_count: 10,
warning_count: 3,
error_count: 3,
},
{
timestamp: '2026-03-20T10:15:00.000Z',
ok_count: 4,
warning_count: 1,
error_count: 2,
},
])
expect(chartData[1]).toEqual({
timestamp: '2026-03-20T10:15:00.000Z',
success_count: 4,
redirect_count: 1,
client_err_count: 0,
server_err_count: 2,
avg_execution_time: 0,
max_execution_time: 0,
avg_cpu_time_used: 0,
max_cpu_time_used: 0,
avg_memory_used: 0,
avg_heap_memory_used: 0,
avg_external_memory_used: 0,
})
expect(getInvocationTotals(data)).toEqual({
totalInvocationCount: 23,
totalWarningCount: 4,
totalErrorCount: 5,
})
})
it('builds segmented button classes and chart range labels', () => {
expect(getSegmentedButtonClassName(0, 4)).toBe('rounded-tr-none rounded-br-none')
expect(getSegmentedButtonClassName(1, 4)).toBe('rounded-none')
expect(getSegmentedButtonClassName(3, 4)).toBe('rounded-tl-none rounded-bl-none')
expect(
getChartTimeRangeLabels(
[{ timestamp: '2026-03-20T10:00:00.000Z' }, { timestamp: '2026-03-20T11:00:00.000Z' }],
'MMM D, h:mma'
)
).toEqual({
start: dayjs('2026-03-20T10:00:00.000Z').format('MMM D, h:mma'),
end: dayjs('2026-03-20T11:00:00.000Z').format('MMM D, h:mma'),
})
expect(getChartTimeRangeLabels([], 'MMM D')).toBeUndefined()
expect(formatChartTimestamp('2026-03-20T10:00:00.000Z', 'MMM D, h:mma')).toBe(
dayjs('2026-03-20T10:00:00.000Z').format('MMM D, h:mma')
)
})
it('computes execution and usage metrics', () => {
const stats = [
{
timestamp: '2026-03-20T10:00:00.000Z',
success_count: 0,
redirect_count: 0,
client_err_count: 0,
server_err_count: 0,
avg_execution_time: 10,
max_execution_time: 18,
avg_cpu_time_used: 5,
max_cpu_time_used: 8,
avg_memory_used: 100,
avg_heap_memory_used: 75,
avg_external_memory_used: 25,
},
{
timestamp: '2026-03-20T10:15:00.000Z',
success_count: 0,
redirect_count: 0,
client_err_count: 0,
server_err_count: 0,
avg_execution_time: 30,
max_execution_time: 45,
avg_cpu_time_used: 15,
max_cpu_time_used: 20,
avg_memory_used: 200,
avg_heap_memory_used: 150,
avg_external_memory_used: 50,
},
]
expect(getExecutionMetrics(stats)).toEqual({
averageExecutionTime: 20,
maxExecutionTime: 45,
})
expect(getUsageMetrics(stats)).toEqual({
averageCpuTime: 10,
maxCpuTime: 20,
averageMemoryUsage: 150,
totalHeapMemory: 225,
totalExternalMemory: 75,
totalMemoryByType: 300,
})
})
it('returns a snapped deploy annotation when updated_at falls within the selected window', () => {
const annotation = getInvocationUpdateAnnotation({
updatedAt: '2026-03-20T10:16:30.000Z',
invocationChartData: [
{ timestamp: '2026-03-20T10:00:00.000Z', ok_count: 3, warning_count: 0, error_count: 0 },
{ timestamp: '2026-03-20T10:15:00.000Z', ok_count: 5, warning_count: 1, error_count: 1 },
{ timestamp: '2026-03-20T10:30:00.000Z', ok_count: 2, warning_count: 0, error_count: 0 },
],
windowStart: new Date('2026-03-20T09:45:00.000Z'),
windowEnd: new Date('2026-03-20T10:45:00.000Z'),
})
expect(annotation?.timestamp).toBe('2026-03-20T10:15:00.000Z')
expect(annotation?.position).toBeCloseTo(50)
expect(annotation?.updatedAt.toISOString()).toBe('2026-03-20T10:16:30.000Z')
})
it('hides the deploy annotation when updated_at is outside the selected window', () => {
const annotation = getInvocationUpdateAnnotation({
updatedAt: '2026-03-20T11:05:00.000Z',
invocationChartData: [
{ timestamp: '2026-03-20T10:00:00.000Z', ok_count: 3, warning_count: 0, error_count: 0 },
],
windowStart: new Date('2026-03-20T09:45:00.000Z'),
windowEnd: new Date('2026-03-20T10:45:00.000Z'),
})
expect(annotation).toBeUndefined()
})
it('builds bucketed and rolling time windows from the selected interval', () => {
const interval = EDGE_FUNCTION_CHART_INTERVALS.find((item) => item.key === '1hr')
expect(interval).toBeDefined()
const now = new Date('2026-03-20T10:37:00.000Z')
const [bucketedStart, bucketedEnd] = getBucketedTimeRange(interval!, now)
const [rollingStart, rollingEnd] = getRollingTimeRange(interval!, now)
expect(bucketedStart.toISOString()).toBe('2026-03-20T09:00:00.000Z')
expect(bucketedEnd.toISOString()).toBe('2026-03-20T10:00:00.000Z')
expect(rollingStart.toISOString()).toBe('2026-03-20T09:37:00.000Z')
expect(rollingEnd.toISOString()).toBe('2026-03-20T10:37:00.000Z')
})
it('formats metric, rate, and reference deltas consistently', () => {
expect(formatMetric(12.34, 'MB')).toBe('12.3MB')
expect(formatMetric(1234, 'ms')).toBe('1,234ms')
expect(formatRate(1, 4)).toBe('25%')
expect(formatReferenceDelta(110, 100)).toBe('10% above average')
expect(formatReferenceDelta(90, 100)).toBe('10% below average')
expect(formatReferenceDelta(100, 100)).toBe('At average')
})
it('builds empty-state copy and tooltip detail strings', () => {
expect(getChartEmptyStateCopy('invocations', false, 'boom')).toEqual({
title: 'No data to show',
description: undefined,
})
expect(getChartEmptyStateCopy('CPU time', true, 'Request failed')).toEqual({
title: 'Unable to load CPU time',
description: 'Request failed',
})
expect(getMemoryTooltipDetail(12.34, 5.67)).toBe('Heap 12.3MB • External 5.7MB')
})
})
@@ -0,0 +1,297 @@
import dayjs from 'dayjs'
import maxBy from 'lodash/maxBy'
import meanBy from 'lodash/meanBy'
import sumBy from 'lodash/sumBy'
import type { ChartIntervals } from 'types'
import type { ChartConfig } from 'ui'
export type EdgeFunctionChartRawDatum = {
timestamp: string | number
success_count?: string | number
redirect_count?: string | number
client_err_count?: string | number
server_err_count?: string | number
avg_execution_time?: string | number
max_execution_time?: string | number
avg_cpu_time_used?: string | number
max_cpu_time_used?: string | number
avg_memory_used?: string | number
avg_heap_memory_used?: string | number
avg_external_memory_used?: string | number
}
export type EdgeFunctionChartDatum = {
timestamp: string
success_count: number
redirect_count: number
client_err_count: number
server_err_count: number
avg_execution_time: number
max_execution_time: number
avg_cpu_time_used: number
max_cpu_time_used: number
avg_memory_used: number
avg_heap_memory_used: number
avg_external_memory_used: number
}
export type InvocationChartDatum = {
timestamp: string
ok_count: number
warning_count: number
error_count: number
}
export type InvocationUpdateAnnotation = {
timestamp: string
position: number
updatedAt: Date
}
export const EDGE_FUNCTION_CHART_INTERVALS: ChartIntervals[] = [
{
key: '15min',
label: '15 min',
startValue: 15,
startUnit: 'minute',
format: 'MMM D, h:mm:ssa',
},
{
key: '1hr',
label: '1 hour',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '3hr',
label: '3 hours',
startValue: 3,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '1day',
label: '1 day',
startValue: 1,
startUnit: 'day',
format: 'MMM D, h:mma',
},
]
export const INVOCATION_CHART_CONFIG = {
ok_count: {
label: 'Ok',
color: 'hsl(var(--brand-default))',
},
warning_count: {
label: 'Warnings',
color: 'hsl(var(--warning-default))',
},
error_count: {
label: 'Errors',
color: 'hsl(var(--destructive-default))',
},
} satisfies ChartConfig
export const CPU_TIME_CHART_CONFIG = {
max_cpu_time_used: {
label: 'Max CPU Time',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
export const EXECUTION_TIME_CHART_CONFIG = {
avg_execution_time: {
label: 'Average Execution Time',
color: 'hsl(var(--foreground-default))',
},
max_execution_time: {
label: 'Max Execution Time',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
export const MEMORY_CHART_CONFIG = {
avg_memory_used: {
label: 'Memory Usage',
color: 'hsl(var(--brand-default))',
},
} satisfies ChartConfig
const toManipulateUnit = (unit: ChartIntervals['startUnit']) => unit as dayjs.ManipulateType
const toNumber = (value: string | number | undefined) => Number(value ?? 0)
export const getBucketedTimeRange = (
interval: ChartIntervals,
now: Date = new Date()
): [Date, Date] => {
const currentTime = dayjs(now)
const unit = toManipulateUnit(interval.startUnit)
const start = currentTime.subtract(interval.startValue, unit).startOf(unit)
const end = currentTime.startOf(unit)
return [start.toDate(), end.toDate()]
}
export const getRollingTimeRange = (
interval: ChartIntervals,
now: Date = new Date()
): [Date, Date] => {
const currentTime = dayjs(now)
const start = currentTime.subtract(interval.startValue, toManipulateUnit(interval.startUnit))
return [start.toDate(), currentTime.toDate()]
}
export const formatChartTimestamp = (value: Date | string | number | undefined, format: string) => {
return dayjs(value === undefined ? '' : value).format(format)
}
export const getChartTimeRangeLabels = (
data: Array<{ timestamp: string }>,
format: string
): { start: string; end: string } | undefined => {
if (data.length === 0) return undefined
return {
start: formatChartTimestamp(data[0]?.timestamp, format),
end: formatChartTimestamp(data[data.length - 1]?.timestamp, format),
}
}
export const toEdgeFunctionChartData = (
rows: EdgeFunctionChartRawDatum[] = []
): EdgeFunctionChartDatum[] =>
rows.map((row) => ({
timestamp: String(row.timestamp ?? ''),
success_count: toNumber(row.success_count),
redirect_count: toNumber(row.redirect_count),
client_err_count: toNumber(row.client_err_count),
server_err_count: toNumber(row.server_err_count),
avg_execution_time: toNumber(row.avg_execution_time),
max_execution_time: toNumber(row.max_execution_time),
avg_cpu_time_used: toNumber(row.avg_cpu_time_used),
max_cpu_time_used: toNumber(row.max_cpu_time_used),
avg_memory_used: toNumber(row.avg_memory_used),
avg_heap_memory_used: toNumber(row.avg_heap_memory_used),
avg_external_memory_used: toNumber(row.avg_external_memory_used),
}))
export const getInvocationChartData = (data: EdgeFunctionChartDatum[]): InvocationChartDatum[] =>
data.map((datum) => ({
timestamp: datum.timestamp,
ok_count: datum.success_count,
warning_count: datum.redirect_count + datum.client_err_count,
error_count: datum.server_err_count,
}))
export const getInvocationTotals = (data: InvocationChartDatum[]) => {
const totalInvocationCount = sumBy(data, (datum) => {
return datum.ok_count + datum.warning_count + datum.error_count
})
return {
totalInvocationCount,
totalWarningCount: sumBy(data, 'warning_count'),
totalErrorCount: sumBy(data, 'error_count'),
}
}
export const getExecutionMetrics = (data: EdgeFunctionChartDatum[]) => ({
averageExecutionTime: meanBy(data, 'avg_execution_time') ?? 0,
maxExecutionTime: maxBy(data, 'max_execution_time')?.max_execution_time ?? 0,
})
export const getUsageMetrics = (data: EdgeFunctionChartDatum[]) => {
const totalHeapMemory = sumBy(data, 'avg_heap_memory_used')
const totalExternalMemory = sumBy(data, 'avg_external_memory_used')
return {
averageCpuTime: meanBy(data, 'avg_cpu_time_used') ?? 0,
maxCpuTime: maxBy(data, 'max_cpu_time_used')?.max_cpu_time_used ?? 0,
averageMemoryUsage: meanBy(data, 'avg_memory_used') ?? 0,
totalHeapMemory,
totalExternalMemory,
totalMemoryByType: totalHeapMemory + totalExternalMemory,
}
}
export const getInvocationUpdateAnnotation = ({
updatedAt,
invocationChartData,
windowStart,
windowEnd,
}: {
updatedAt?: string
invocationChartData: InvocationChartDatum[]
windowStart: Date
windowEnd: Date
}): InvocationUpdateAnnotation | undefined => {
if (!updatedAt || invocationChartData.length === 0) return undefined
const updatedAtDate = new Date(updatedAt)
const updatedAtValue = updatedAtDate.valueOf()
if (Number.isNaN(updatedAtValue)) return undefined
if (updatedAtValue < windowStart.valueOf() || updatedAtValue > windowEnd.valueOf()) {
return undefined
}
const closestTimestamp = invocationChartData.reduce((closest, datum) => {
const datumDistance = Math.abs(new Date(datum.timestamp).valueOf() - updatedAtValue)
const closestDistance = Math.abs(new Date(closest.timestamp).valueOf() - updatedAtValue)
return datumDistance < closestDistance ? datum : closest
}).timestamp
const markerIndex = invocationChartData.findIndex((datum) => datum.timestamp === closestTimestamp)
if (markerIndex < 0) return undefined
return {
timestamp: closestTimestamp,
position: ((markerIndex + 0.5) / invocationChartData.length) * 100,
updatedAt: updatedAtDate,
}
}
export const getSegmentedButtonClassName = (index: number, total: number) => {
if (index === 0) return 'rounded-tr-none rounded-br-none'
if (index === total - 1) return 'rounded-tl-none rounded-bl-none'
return 'rounded-none'
}
export const getChartEmptyStateCopy = (
subject: string,
isError: boolean,
errorMessage?: string
) => ({
title: isError ? `Unable to load ${subject}` : 'No data to show',
description: isError ? errorMessage : undefined,
})
export const formatMetric = (value?: number, unit?: string) => {
if (value === undefined || Number.isNaN(value)) return unit ? `0${unit}` : '0'
const formatted = unit === 'MB' ? value.toFixed(1) : Math.round(value).toLocaleString('en-US')
return unit ? `${formatted}${unit}` : formatted
}
export const formatRate = (count: number, total: number) =>
new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(total === 0 ? 0 : count / total)
export const formatReferenceDelta = (value: number, reference: number, label = 'average') => {
const difference = value - reference
if (Math.abs(difference) < Number.EPSILON) return `At ${label}`
if (reference === 0) return `${difference > 0 ? 'Above' : 'Below'} ${label}`
const percentDifference = Math.round(Math.abs((difference / reference) * 100))
return `${percentDifference}% ${difference > 0 ? 'above' : 'below'} ${label}`
}
export const getMemoryTooltipDetail = (heapMemory: number, externalMemory: number) => {
return `Heap ${formatMetric(heapMemory, 'MB')} • External ${formatMetric(externalMemory, 'MB')}`
}
@@ -0,0 +1,104 @@
import { useMemo } from 'react'
import { ChartMetric } from 'ui-patterns/Chart'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { getExecutionTooltipDetails } from './EdgeFunctionMetricTooltipDetails'
import {
EXECUTION_TIME_CHART_CONFIG,
formatMetric,
getChartEmptyStateCopy,
} from './EdgeFunctionOverview.utils'
import { EdgeFunctionTimeSeriesChartCard } from './EdgeFunctionTimeSeriesChartCard'
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
interface EdgeFunctionPerformanceSectionProps {
data: EdgeFunctionChartDatum[]
dateTimeFormat: string
isLoading: boolean
isError: boolean
errorMessage?: string
averageExecutionTime: number
maxExecutionTime: number
}
export const EdgeFunctionPerformanceSection = ({
data,
dateTimeFormat,
isLoading,
isError,
errorMessage,
averageExecutionTime,
maxExecutionTime,
}: EdgeFunctionPerformanceSectionProps) => {
const emptyStateCopy = getChartEmptyStateCopy('execution time', isError, errorMessage)
const tooltipDetails = useMemo(
() => getExecutionTooltipDetails(averageExecutionTime),
[averageExecutionTime]
)
const metrics = (
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Execution Time"
value={formatMetric(averageExecutionTime, 'ms')}
tooltip="Average execution time of function invocations"
/>
<ChartMetric
label="Max Execution Time"
value={formatMetric(maxExecutionTime, 'ms')}
tooltip="Maximum execution time of function invocations"
/>
</div>
)
return (
<PageSection>
<PageSectionContent>
<PageContainer size="full">
<div className="flex flex-col gap-6">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Performance</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<EdgeFunctionTimeSeriesChartCard
className="lg:col-span-2"
data={data}
dateTimeFormat={dateTimeFormat}
isLoading={isLoading}
isError={isError}
emptyTitle={emptyStateCopy.title}
emptyDescription={emptyStateCopy.description}
metrics={metrics}
dataKey="max_execution_time"
dataKeys={['avg_execution_time', 'max_execution_time']}
config={EXECUTION_TIME_CHART_CONFIG}
tooltipDetails={tooltipDetails}
referenceLines={[
{
y: averageExecutionTime,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
yAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
</div>
</div>
</PageContainer>
</PageSectionContent>
</PageSection>
)
}
@@ -0,0 +1,263 @@
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
import AlertError from 'components/ui/AlertError'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
import { ExternalLink } from 'lucide-react'
import { useRouter } from 'next/router'
import { Fragment, useMemo } from 'react'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import {
Badge,
Button,
Card,
cn,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
buildGroupAssistantPrompt,
formatLogTimestamp,
getStatusBadgeVariant,
formatSingleLineMessage,
getFunctionRuntimeLogsSql,
getRecentErrorGroups,
getRecentErrorGroupsBase,
getRecentErrorInvocationsSql,
getRelatedExecutionIds,
toAlertError,
type RecentErrorGroup,
} from './EdgeFunctionRecentErrors.utils'
interface EdgeFunctionRecentErrorsProps {
functionId?: string
functionSlug?: string
projectRef?: string
isoTimestampStart?: string
isoTimestampEnd?: string
}
export const EdgeFunctionRecentErrors = ({
functionId,
functionSlug,
projectRef,
isoTimestampStart,
isoTimestampEnd,
}: EdgeFunctionRecentErrorsProps) => {
const router = useRouter()
const { openSidebar } = useSidebarManagerSnapshot()
const aiAssistant = useAiAssistantStateSnapshot()
const isQueryEnabled = Boolean(projectRef && functionId)
const recentErrorInvocationsSql = useMemo(
() => getRecentErrorInvocationsSql(functionId),
[functionId]
)
const {
logData: recentErrorInvocations,
isLoading: isLoadingRecentErrorInvocations,
error: recentErrorInvocationsError,
} = useLogsQuery(
projectRef as string,
{
sql: recentErrorInvocationsSql,
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
},
isQueryEnabled
)
const recentErrorGroupsBase = useMemo(
() => getRecentErrorGroupsBase(recentErrorInvocations),
[recentErrorInvocations]
)
const relatedExecutionIds = useMemo(
() => getRelatedExecutionIds(recentErrorGroupsBase),
[recentErrorGroupsBase]
)
const functionRuntimeLogsSql = useMemo(
() => getFunctionRuntimeLogsSql({ functionId, executionIds: relatedExecutionIds }),
[functionId, relatedExecutionIds]
)
const {
logData: functionRuntimeLogs,
isLoading: isLoadingFunctionRuntimeLogs,
error: functionRuntimeLogsError,
} = useLogsQuery(
projectRef as string,
{
sql: functionRuntimeLogsSql,
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
},
Boolean(projectRef && functionRuntimeLogsSql)
)
const queryError =
toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError)
const recentErrorGroups = useMemo(
() => getRecentErrorGroups({ recentErrorGroupsBase, functionRuntimeLogs }),
[functionRuntimeLogs, recentErrorGroupsBase]
)
const handleOpenAssistant = (group: RecentErrorGroup) => {
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiAssistant.newChat({
name: `Investigate ${functionSlug ?? 'error'}`,
initialMessage: buildGroupAssistantPrompt(group, functionSlug),
})
}
return (
<PageSection>
<PageSectionContent>
<PageContainer size="full">
<div className="flex flex-col gap-6">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Recent Failed Invocations</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<Button
type="default"
size="tiny"
icon={<ExternalLink size={14} />}
onClick={() =>
router.push(`/project/${projectRef}/functions/${functionSlug}/logs`)
}
>
View logs
</Button>
</PageSectionAside>
</PageSectionMeta>
{recentErrorInvocationsError || functionRuntimeLogsError ? (
<AlertError
error={queryError}
subject="Failed to retrieve recent edge function errors"
/>
) : isLoadingRecentErrorInvocations || isLoadingFunctionRuntimeLogs ? (
<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>
) : (
<Card className="p-0 overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Error</TableHead>
<TableHead>Count</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead>Method</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Assistant</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentErrorGroups.map((group) => (
<Fragment key={group.message}>
<TableRow key={`${group.message}-summary`}>
<TableCell className="max-w-[420px]">
<span
className="block truncate whitespace-nowrap text-foreground"
title={formatSingleLineMessage(group.message)}
>
{formatSingleLineMessage(group.message)}
</span>
</TableCell>
<TableCell className="text-foreground-light">{group.count}</TableCell>
<TableCell className="text-foreground-light">
{formatLogTimestamp(group.lastSeen, 'relative')}
</TableCell>
<TableCell className="text-foreground-light">
{group.lastMethod ?? '-'}
</TableCell>
<TableCell>
{group.lastStatusCode ? (
<Badge
variant={getStatusBadgeVariant(group.lastStatusCode)}
className="font-mono"
>
{group.lastStatusCode}
</Badge>
) : (
<Badge variant="destructive" className="font-mono">
Error
</Badge>
)}
</TableCell>
<TableCell className="text-foreground-light">
{group.executionTime ?? '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
<AiAssistantDropdown
label="Ask Assistant"
size="tiny"
buildPrompt={() => buildGroupAssistantPrompt(group, functionSlug)}
onOpenAssistant={() => handleOpenAssistant(group)}
/>
</div>
</TableCell>
</TableRow>
<TableRow key={`${group.message}-logs`} className="bg-surface-100/30">
<TableCell colSpan={7} className="p-0">
<div className="max-h-64 overflow-auto bg-surface-75 py-3 text-foreground-light">
{group.logs.length === 0 ? (
<div className="px-4">
No related runtime logs found for this error group.
</div>
) : (
group.logs.map((log) => (
<div
key={log.key}
className={cn(
'break-words px-4',
log.level === 'error'
? 'bg-destructive-300 font-mono text-destructive'
: 'text-foreground-light'
)}
>
[{formatLogTimestamp(log.lastSeen, 'time')}] [
{log.level.toUpperCase()}] [{log.count}x]{' '}
{formatSingleLineMessage(log.message)}
</div>
))
)}
</div>
</TableCell>
</TableRow>
</Fragment>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</PageContainer>
</PageSectionContent>
</PageSection>
)
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import {
buildGroupAssistantPrompt,
formatLogTimestamp,
formatSingleLineMessage,
getFunctionRuntimeLogsSql,
getRecentErrorGroups,
getRecentErrorGroupsBase,
getRelatedExecutionIds,
getStatusBadgeVariant,
toAlertError,
} from './EdgeFunctionRecentErrors.utils'
describe('EdgeFunctionRecentErrors.utils', () => {
it('normalizes alert errors and single-line messages', () => {
expect(toAlertError('boom')).toEqual({ message: 'boom' })
expect(toAlertError({ message: 'broken' })).toEqual({ message: 'broken' })
expect(toAlertError({ message: 123 })).toBeUndefined()
expect(toAlertError(null)).toBeUndefined()
expect(formatSingleLineMessage(' first line\n second\t\tline ')).toBe(
'first line second line'
)
})
it('builds runtime log SQL and escapes interpolated values', () => {
expect(getFunctionRuntimeLogsSql({ functionId: undefined, executionIds: ['abc'] })).toBe('')
expect(getFunctionRuntimeLogsSql({ functionId: 'fn_123', executionIds: [] })).toBe('')
expect(
getFunctionRuntimeLogsSql({
functionId: "fn_'123",
executionIds: ['exec_1', "exec_'2"],
limit: 25,
})
)
.toBe(`select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
cross join unnest(metadata) as metadata
where metadata.function_id = 'fn_''123' and metadata.execution_id in ('exec_1', 'exec_''2')
order by timestamp desc
limit 25`)
})
it('groups recent failed invocations by parsed error message', () => {
const groups = getRecentErrorGroupsBase([
{
id: 'invocation-1',
event_message: 'POST | 500 | database exploded',
method: 'POST',
status_code: 500,
execution_id: 'exec-1',
execution_time_ms: 123.7,
timestamp: 100,
},
{
id: 'invocation-2',
event_message: 'POST | 500 | database exploded',
method: 'POST',
status_code: 500,
execution_id: 'exec-2',
execution_time_ms: 85.1,
timestamp: 120,
},
{
id: 'invocation-3',
event_message: '',
method: 'GET',
status_code: 503,
execution_id: '',
timestamp: 110,
},
])
expect(groups).toEqual([
{
message: 'database exploded',
count: 2,
lastSeen: 120,
lastExecutionId: 'exec-2',
lastStatusCode: '500',
lastMethod: 'POST',
executionTime: '85ms',
executionIds: ['exec-1', 'exec-2'],
},
{
message: 'Unknown error',
count: 1,
lastSeen: 110,
lastExecutionId: undefined,
lastStatusCode: '503',
lastMethod: 'GET',
executionTime: undefined,
executionIds: [],
},
])
})
it('deduplicates execution ids and attaches grouped runtime logs', () => {
const recentErrorGroupsBase = [
{
message: 'database exploded',
count: 2,
lastSeen: 120,
lastExecutionId: 'exec-2',
lastStatusCode: '500',
lastMethod: 'POST',
executionTime: '85ms',
executionIds: ['exec-1', 'exec-2', 'exec-1'],
},
]
expect(getRelatedExecutionIds(recentErrorGroupsBase)).toEqual(['exec-1', 'exec-2'])
expect(
getRecentErrorGroups({
recentErrorGroupsBase,
functionRuntimeLogs: [
{
id: 'runtime-log-1',
execution_id: 'exec-1',
level: 'error',
event_message: 'stack trace',
timestamp: 101,
},
{
id: 'runtime-log-2',
execution_id: 'exec-2',
level: 'error',
event_message: 'stack trace',
timestamp: 121,
},
{
id: 'runtime-log-3',
execution_id: 'exec-2',
event_type: 'warn',
event_message: 'retrying upstream',
timestamp: 119,
},
{
id: 'runtime-log-4',
execution_id: '',
level: 'info',
event_message: 'ignored',
timestamp: 999,
},
],
})
).toEqual([
{
...recentErrorGroupsBase[0],
logs: [
{
key: 'error:stack trace',
message: 'stack trace',
level: 'error',
count: 2,
lastSeen: 121,
},
{
key: 'warn:retrying upstream',
message: 'retrying upstream',
level: 'warn',
count: 1,
lastSeen: 119,
},
],
},
])
})
it('formats timestamps, prompts, and status variants', () => {
expect(formatLogTimestamp(undefined, 'time')).toBe('-')
expect(formatLogTimestamp('2026-03-20T10:15:00.000Z', 'time')).toBe('10:15:00')
expect(
buildGroupAssistantPrompt(
{
message: 'database exploded',
count: 2,
lastSeen: 1742465700000000,
lastExecutionId: 'exec-2',
lastStatusCode: '500',
lastMethod: 'POST',
executionTime: '85ms',
executionIds: ['exec-1', 'exec-2'],
logs: [
{
key: 'error:stack trace',
message: 'stack trace',
level: 'error',
count: 2,
lastSeen: 1742465700000000,
},
],
},
'my-function'
)
).toContain('Analyze this recurring edge function error for `my-function`.')
expect(getStatusBadgeVariant()).toBe('destructive')
expect(getStatusBadgeVariant('500')).toBe('destructive')
expect(getStatusBadgeVariant('404')).toBe('default')
})
})
@@ -0,0 +1,251 @@
import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants'
import type { LogData } from 'components/interfaces/Settings/Logs/Logs.types'
import {
genDefaultQuery,
isUnixMicro,
unixMicroToIsoTimestamp,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import type { AlertErrorProps } from 'components/ui/AlertError'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.utils'
dayjs.extend(relativeTime)
export const MAX_RECENT_ERROR_GROUPS = 5
export const RECENT_ERROR_INVOCATIONS_LIMIT = 50
export const RELATED_RUNTIME_LOGS_LIMIT = 100
export type GroupedRuntimeLog = {
key: string
message: string
level: string
count: number
lastSeen: number
}
export type RecentErrorGroup = {
message: string
count: number
lastSeen: number
lastExecutionId?: string
lastStatusCode?: string
lastMethod?: string
executionTime?: string
executionIds: string[]
logs: GroupedRuntimeLog[]
}
export type RecentErrorGroupBase = Omit<RecentErrorGroup, 'logs'>
export const escapeSqlString = (value: string) => value.replace(/'/g, "''")
export const formatSingleLineMessage = (message: string) => message.replace(/\s+/g, ' ').trim()
export const toAlertError = (error: unknown): AlertErrorProps['error'] | undefined => {
if (typeof error === 'string') return { message: error }
if (error && typeof error === 'object') {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return { message }
}
return undefined
}
export const formatLogTimestamp = (
value: string | number | undefined,
format: 'relative' | 'time'
) => {
if (value === undefined) return '-'
const timestamp = isUnixMicro(value) ? unixMicroToIsoTimestamp(value) : String(value)
return format === 'relative'
? dayjs.utc(timestamp).fromNow()
: dayjs.utc(timestamp).format('HH:mm:ss')
}
export const buildGroupMarkdown = (group: RecentErrorGroup, functionSlug?: string) => {
const lines = [
`## Recent error for \`${functionSlug ?? 'edge function'}\``,
'',
`### ${group.message}`,
`- Occurrences: ${group.count}`,
`- Last seen: ${formatLogTimestamp(group.lastSeen, 'relative')}`,
]
if (group.lastMethod) lines.push(`- Last method: ${group.lastMethod}`)
if (group.lastStatusCode) lines.push(`- Last status: ${group.lastStatusCode}`)
if (group.executionTime) lines.push(`- Last execution time: ${group.executionTime}`)
lines.push('', '#### Related runtime logs')
if (group.logs.length === 0) {
lines.push('- No related runtime logs found for this error group.')
} else {
for (const log of group.logs) {
lines.push(
`- [${log.level}] ${log.count} occurrence${
log.count === 1 ? '' : 's'
}, last seen ${formatLogTimestamp(log.lastSeen, 'relative')}: ${log.message}`
)
}
}
return lines.join('\n')
}
export const buildGroupAssistantPrompt = (group: RecentErrorGroup, functionSlug?: string) => {
return [
`Analyze this recurring edge function error for \`${functionSlug ?? 'edge function'}\`.`,
'Summarize the likely root cause, what the runtime logs suggest, and the next debugging steps.',
'',
buildGroupMarkdown(group, functionSlug),
].join('\n')
}
export const getStatusBadgeVariant = (statusCode?: string) => {
if (!statusCode) return 'destructive' as const
const status = Number(statusCode)
if (Number.isNaN(status)) return 'destructive' as const
if (status >= 500) return 'destructive' as const
return 'default' as const
}
export const getRecentErrorInvocationsSql = (
functionId?: string,
limit = RECENT_ERROR_INVOCATIONS_LIMIT
) =>
genDefaultQuery(
LOGS_TABLES.fn_edge,
{
function_id: functionId ?? '__pending__',
'status_code.error': true,
},
limit
)
export const getFunctionRuntimeLogsSql = ({
functionId,
executionIds,
limit = RELATED_RUNTIME_LOGS_LIMIT,
}: {
functionId?: string
executionIds: string[]
limit?: number
}) => {
if (!functionId || executionIds.length === 0) return ''
const escapedExecutionIds = executionIds.map((id) => `'${escapeSqlString(id)}'`).join(', ')
return `select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
cross join unnest(metadata) as metadata
where metadata.function_id = '${escapeSqlString(functionId)}' and metadata.execution_id in (${escapedExecutionIds})
order by timestamp desc
limit ${limit}`
}
export const getRecentErrorGroupsBase = (
recentErrorInvocations: LogData[]
): RecentErrorGroupBase[] => {
const grouped: Record<string, RecentErrorGroupBase> = {}
for (const item of recentErrorInvocations) {
const statusCode = String(item.status_code ?? '')
const method = String(item.method ?? '')
const message =
parseEdgeFunctionEventMessage(
String(item.event_message ?? ''),
method || undefined,
statusCode
) || 'Unknown error'
const executionId = String(item.execution_id ?? '')
const timestamp = Number(item.timestamp ?? 0)
const executionTime =
item.execution_time_ms !== undefined
? `${Math.round(Number(item.execution_time_ms))}ms`
: undefined
const current = grouped[message]
if (!current) {
grouped[message] = {
message,
count: 1,
lastSeen: timestamp,
lastExecutionId: executionId || undefined,
lastStatusCode: statusCode || undefined,
lastMethod: method || undefined,
executionTime,
executionIds: executionId ? [executionId] : [],
}
continue
}
current.count += 1
if (executionId && !current.executionIds.includes(executionId)) {
current.executionIds.push(executionId)
}
if (timestamp > current.lastSeen) {
current.lastSeen = timestamp
current.lastExecutionId = executionId || undefined
current.lastStatusCode = statusCode || undefined
current.lastMethod = method || undefined
current.executionTime = executionTime
}
}
return Object.values(grouped)
.sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, MAX_RECENT_ERROR_GROUPS)
}
export const getRelatedExecutionIds = (recentErrorGroupsBase: RecentErrorGroupBase[]) =>
Array.from(new Set(recentErrorGroupsBase.flatMap((group) => group.executionIds).filter(Boolean)))
export const getRecentErrorGroups = ({
recentErrorGroupsBase,
functionRuntimeLogs,
}: {
recentErrorGroupsBase: RecentErrorGroupBase[]
functionRuntimeLogs: LogData[]
}): RecentErrorGroup[] => {
const runtimeLogsByExecutionId = functionRuntimeLogs.reduce<Record<string, LogData[]>>(
(acc, log) => {
const executionId = String(log.execution_id ?? '')
if (!executionId) return acc
acc[executionId] = [...(acc[executionId] ?? []), log]
return acc
},
{}
)
return recentErrorGroupsBase.map((group) => ({
...group,
logs: Array.from(new Set(group.executionIds))
.flatMap((executionId) => runtimeLogsByExecutionId[executionId] ?? [])
.reduce<GroupedRuntimeLog[]>((acc, log) => {
const level = String(log.level ?? log.event_type ?? 'log')
const message = String(log.event_message ?? '')
const key = `${level}:${message}`
const timestamp = Number(log.timestamp ?? 0)
const existing = acc.find((entry) => entry.key === key)
if (existing) {
existing.count += 1
existing.lastSeen = Math.max(existing.lastSeen, timestamp)
return acc
}
acc.push({ key, message, level, count: 1, lastSeen: timestamp })
return acc
}, [])
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
.slice(0, MAX_RECENT_ERROR_GROUPS),
}))
}
@@ -0,0 +1,78 @@
import type { ReactNode } from 'react'
import type { ChartConfig } from 'ui'
import {
Chart,
ChartCard,
ChartContent,
ChartHeader,
ChartLine,
ChartLoadingState,
type ChartLineProps,
} from 'ui-patterns/Chart'
import { EdgeFunctionChartEmptyState } from './EdgeFunctionChartEmptyState'
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
interface EdgeFunctionTimeSeriesChartCardProps {
data: EdgeFunctionChartDatum[]
dateTimeFormat: string
isLoading: boolean
isError: boolean
emptyTitle: string
emptyDescription?: string
metrics: ReactNode
dataKey: string
dataKeys?: string[]
config: ChartConfig
tooltipDetails?: ChartLineProps['tooltipDetails']
referenceLines?: ChartLineProps['referenceLines']
yAxisProps?: ChartLineProps['YAxisProps']
className?: string
}
export const EdgeFunctionTimeSeriesChartCard = ({
data,
dateTimeFormat,
isLoading,
isError,
emptyTitle,
emptyDescription,
metrics,
dataKey,
dataKeys,
config,
tooltipDetails,
referenceLines,
yAxisProps,
className,
}: EdgeFunctionTimeSeriesChartCardProps) => {
return (
<Chart isLoading={isLoading} className={className}>
<ChartCard>
<ChartHeader align="start">{metrics}</ChartHeader>
<ChartContent
isEmpty={isError || data.length === 0}
emptyState={
<EdgeFunctionChartEmptyState title={emptyTitle} description={emptyDescription} />
}
loadingState={<ChartLoadingState />}
>
<div className="h-40">
<ChartLine
data={data}
dataKey={dataKey}
dataKeys={dataKeys}
DateTimeFormat={dateTimeFormat}
isFullHeight
showYAxis
config={config}
tooltipDetails={tooltipDetails}
referenceLines={referenceLines}
YAxisProps={yAxisProps}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
)
}
@@ -0,0 +1,158 @@
import { useMemo } from 'react'
import { ChartMetric } from 'ui-patterns/Chart'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { getCpuTooltipDetails, getMemoryTooltipDetails } from './EdgeFunctionMetricTooltipDetails'
import {
CPU_TIME_CHART_CONFIG,
formatMetric,
formatRate,
getChartEmptyStateCopy,
MEMORY_CHART_CONFIG,
} from './EdgeFunctionOverview.utils'
import { EdgeFunctionTimeSeriesChartCard } from './EdgeFunctionTimeSeriesChartCard'
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
interface EdgeFunctionUsageSectionProps {
data: EdgeFunctionChartDatum[]
dateTimeFormat: string
isLoading: boolean
isError: boolean
errorMessage?: string
averageCpuTime: number
maxCpuTime: number
averageMemoryUsage: number
totalHeapMemory: number
totalExternalMemory: number
totalMemoryByType: number
}
export const EdgeFunctionUsageSection = ({
data,
dateTimeFormat,
isLoading,
isError,
errorMessage,
averageCpuTime,
maxCpuTime,
averageMemoryUsage,
totalHeapMemory,
totalExternalMemory,
totalMemoryByType,
}: EdgeFunctionUsageSectionProps) => {
const cpuEmptyStateCopy = getChartEmptyStateCopy('CPU time', isError, errorMessage)
const memoryEmptyStateCopy = getChartEmptyStateCopy('memory usage', isError, errorMessage)
const cpuTooltipDetails = useMemo(() => getCpuTooltipDetails(averageCpuTime), [averageCpuTime])
const memoryTooltipDetails = useMemo(
() => getMemoryTooltipDetails(averageMemoryUsage),
[averageMemoryUsage]
)
const cpuMetrics = (
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average CPU Time"
value={formatMetric(averageCpuTime, 'ms')}
tooltip="Average CPU time usage for the function"
/>
<ChartMetric
label="Max CPU Time"
value={formatMetric(maxCpuTime, 'ms')}
tooltip="Maximum CPU time usage for the function"
/>
</div>
)
const memoryMetrics = (
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Memory Usage"
value={formatMetric(averageMemoryUsage, 'MB')}
tooltip="Average memory usage for the function"
/>
<ChartMetric
label="Heap"
value={formatRate(totalHeapMemory, totalMemoryByType)}
tooltip="Share of memory attributed to heap usage over the selected interval"
/>
<ChartMetric
label="External"
value={formatRate(totalExternalMemory, totalMemoryByType)}
tooltip="Share of memory attributed to external usage over the selected interval"
/>
</div>
)
return (
<PageSection>
<PageSectionContent>
<PageContainer size="full">
<div className="flex flex-col gap-6">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Usage</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<EdgeFunctionTimeSeriesChartCard
data={data}
dateTimeFormat={dateTimeFormat}
isLoading={isLoading}
isError={isError}
emptyTitle={cpuEmptyStateCopy.title}
emptyDescription={cpuEmptyStateCopy.description}
metrics={cpuMetrics}
dataKey="max_cpu_time_used"
config={CPU_TIME_CHART_CONFIG}
tooltipDetails={cpuTooltipDetails}
referenceLines={[
{
y: averageCpuTime,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
yAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
<EdgeFunctionTimeSeriesChartCard
data={data}
dateTimeFormat={dateTimeFormat}
isLoading={isLoading}
isError={isError}
emptyTitle={memoryEmptyStateCopy.title}
emptyDescription={memoryEmptyStateCopy.description}
metrics={memoryMetrics}
dataKey="avg_memory_used"
config={MEMORY_CHART_CONFIG}
tooltipDetails={memoryTooltipDetails}
referenceLines={[
{
y: averageMemoryUsage,
label: 'average',
stroke: 'hsl(var(--foreground-default))',
strokeWidth: 1.5,
},
]}
yAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
}}
/>
</div>
</div>
</PageContainer>
</PageSectionContent>
</PageSection>
)
}
@@ -1,7 +1,6 @@
import { useMonaco } from '@monaco-editor/react'
import { IS_PLATFORM } from 'common'
import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator'
import type { PlanId } from 'data/subscriptions/types'
import dayjs, { Dayjs } from 'dayjs'
import { get } from 'lodash'
import uniqBy from 'lodash/uniqBy'
@@ -9,7 +8,7 @@ import { useEffect } from 'react'
import logConstants from 'shared-data/log-constants'
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
import type { Filters, LogData, LogsEndpointParams } from './Logs.types'
import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
/**
* Convert a micro timestamp from number/string to iso timestamp
@@ -181,7 +180,7 @@ limit ${limit}
`
case 'function_logs':
return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level from ${table}
return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from ${table}
${joins}
${where}
${orderBy}
@@ -205,7 +204,7 @@ ${orderBy}
limit ${limit}
`
}
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
return `select id, ${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 ${table}
${joins}
${where}
${orderBy}
@@ -303,12 +302,14 @@ export const genCountQuery = (table: LogsTableName, filters: Filters): string =>
}
/** calculates how much the chart start datetime should be offset given the current datetime filter params */
const calcChartStart = (params: Partial<LogsEndpointParams>): [Dayjs, string] => {
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'
let trunc: 'minute' | 'hour' | 'day' = 'minute'
let extendValue = 60 * 6
const minuteDiff = ite.diff(its, 'minute')
const hourDiff = ite.diff(its, 'hour')
@@ -319,8 +320,6 @@ const calcChartStart = (params: Partial<LogsEndpointParams>): [Dayjs, string] =>
trunc = 'day'
extendValue = 7
}
//
// @ts-ignore
return [its.add(-extendValue, trunc), trunc]
}
@@ -775,7 +774,7 @@ export function formatLogsAsMarkdown(rows: LogData[]): string {
.join('\n\n---\n\n')
}
const QUERY_TYPE_LABELS: Record<string, string> = {
const QUERY_TYPE_LABELS: Record<QueryType, string> = {
api: 'API Gateway (Edge Network)',
database: 'Postgres Database',
functions: 'Edge Functions',
@@ -791,7 +790,7 @@ const QUERY_TYPE_LABELS: Record<string, string> = {
etl: 'ETL',
}
const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
const LOG_TABLE_TO_SERVICE_LABEL: Record<LogsTableName, string> = {
edge_logs: 'API Gateway (Edge Network)',
postgres_logs: 'Postgres Database',
function_logs: 'Edge Functions',
@@ -808,6 +807,10 @@ const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
etl_replication_logs: 'ETL',
}
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)
@@ -817,12 +820,12 @@ export function extractEdgeFunctionName(pathname: unknown): string {
function extractServiceLabelFromSql(sql: string): string | null {
const match = sql.match(/\bfrom\s+(\w+)/i)
const tableName = match?.[1]
return tableName ? (LOG_TABLE_TO_SERVICE_LABEL[tableName] ?? null) : null
return tableName && isLogsTableName(tableName) ? LOG_TABLE_TO_SERVICE_LABEL[tableName] : null
}
export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string {
const serviceLabel =
(queryType ? QUERY_TYPE_LABELS[queryType] : null) ??
(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\`\`\`` : ''
@@ -1,5 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { IS_PLATFORM, useFeatureFlags, useFlag, useParams } from 'common'
import { EdgeFunctionOverview } from 'components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview'
import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import DefaultLayout from 'components/layouts/DefaultLayout'
@@ -26,6 +27,7 @@ import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
LogoLoader,
WarningIcon,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
@@ -62,7 +64,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
},
]
const PageLayout: NextPageWithLayout = () => {
const LegacyEdgeFunctionOverview = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
@@ -429,6 +431,21 @@ const PageLayout: NextPageWithLayout = () => {
)
}
const PageLayout: NextPageWithLayout = () => {
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const showNewOverview = useFlag('edgeFunctionsOverview') === true
if (IS_PLATFORM && !flagsLoaded) {
return <LogoLoader />
}
if (showNewOverview) {
return <EdgeFunctionOverview />
}
return <LegacyEdgeFunctionOverview />
}
PageLayout.getLayout = (page) => (
<DefaultLayout>
<EdgeFunctionDetailsLayout title="Overview">{page}</EdgeFunctionDetailsLayout>
@@ -8,6 +8,7 @@ import {
CartesianGrid,
AreaChart as RechartAreaChart,
ReferenceArea,
ReferenceLine,
XAxis,
YAxis,
} from 'recharts'
@@ -42,11 +43,20 @@ export type ChartHighlightAction = {
onSelect: (ctx: { start: string; end: string; clear: () => void }) => void
}
export type ChartReferenceLine = {
y: number
label?: string
stroke?: string
strokeWidth?: number
strokeDasharray?: string
}
export interface ChartLineProps {
data: ChartLineTick[]
dataKey: string
dataKeys?: string[] // Add this line
dataKeys?: string[]
config?: ChartConfig
tooltipDetails?: (datum: ChartLineTick, key: string, value: unknown) => ReactNode
onLineClick?: (datum: ChartLineTick, tooltipData?: CategoricalChartState) => void
DateTimeFormat?: string
isFullHeight?: boolean
@@ -68,13 +78,15 @@ export interface ChartLineProps {
[key: string]: any
}
strokeWidth?: number
referenceLines?: ChartReferenceLine[]
}
export const ChartLine = ({
data,
dataKey,
dataKeys, // Add this line
dataKeys,
config,
tooltipDetails,
onLineClick,
DateTimeFormat = 'MMM D, YYYY, hh:mma',
isFullHeight = false,
@@ -91,6 +103,7 @@ export const ChartLine = ({
showYAxis = false,
YAxisProps,
strokeWidth = 1.5,
referenceLines,
}: ChartLineProps) => {
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
const { resolvedTheme } = useTheme()
@@ -134,6 +147,14 @@ export const ChartLine = ({
bottom: 0,
}
const formatTooltipValue = (value: unknown) => {
if (typeof value === 'number') {
return YAxisProps?.tickFormatter ? YAxisProps.tickFormatter(value) : value.toLocaleString()
}
return String(value)
}
return (
<div
data-testid="chart-line"
@@ -200,6 +221,32 @@ export const ChartLine = ({
<ChartTooltipContent
className="text-foreground-light -mt-5"
labelFormatter={(v: string) => dayjs(v).format(DateTimeFormat)}
formatter={(value, name, item) => {
const key = String(item.dataKey || name || dataKey)
const itemConfig = chartConfig[key]
const indicatorColor = item.payload.fill || item.color || color
const detail = tooltipDetails?.(item.payload as ChartLineTick, key, value)
return (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
style={{ backgroundColor: indicatorColor }}
/>
<div className="flex flex-1 justify-between gap-3 leading-none">
<div className="grid gap-1">
<span className="text-foreground-light">
{itemConfig?.label || name || key}
</span>
{detail}
</div>
<span className="font-mono font-medium tabular-nums text-foreground">
{formatTooltipValue(value)}
</span>
</div>
</>
)
}}
/>
}
/>
@@ -214,6 +261,15 @@ export const ChartLine = ({
fillOpacity={0.2}
/>
)}
{referenceLines?.map((line, index) => (
<ReferenceLine
key={`${line.y}-${index}`}
y={line.y}
stroke={line.stroke ?? CHART_COLORS.TICK}
strokeWidth={line.strokeWidth ?? 1.5}
strokeDasharray={line.strokeDasharray ?? '4 4'}
/>
))}
{keysToRender.map((key, index) => {
const keyConfig = chartConfig[key]
const lineColor =
@@ -50,7 +50,7 @@ const ChartContainer = React.forwardRef<
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-foreground-muted [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-foreground-muted [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-foreground [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}