diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx new file mode 100644 index 00000000000..77171a44291 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx @@ -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 ( + } + title={title} + description={description ?? 'It may take up to 24 hours for data to refresh'} + /> + ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx new file mode 100644 index 00000000000..2b578bb4f57 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx @@ -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 ( +
+
+ + + + + + + formatChartTimestamp(value as string | number | undefined, dateTimeFormat) + } + indicator="dot" + /> + } + /> + + + + {updateAnnotation && ( + + )} + + + {updateAnnotation && ( + + + + )} +
+ {timeRangeLabels && ( +
+ {timeRangeLabels.start} + {timeRangeLabels.end} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx new file mode 100644 index 00000000000..9d43ec7a877 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx @@ -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['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 ( + + + +
+ + +
+ + + +
+
+ +
+ {EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => { + return ( + + ) + })} +
+ +
+
+ +
+ {isLoadingFunction && } + {isErrorFunction && ( + + )} +
+ +
+ + {isLoadingChart ? ( + + ) : isErrorChart || chartData.length === 0 ? ( + + ) : ( + + )} + +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx new file mode 100644 index 00000000000..17e10d0a148 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx @@ -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 ( + + {formatReferenceDelta(Number(value ?? 0), averageExecutionTime)} + + ) +} + +export const CpuTooltipDetail = ({ + averageCpuTime, + value, +}: { + averageCpuTime: number + value: unknown +}) => { + return ( + + {formatReferenceDelta(Number(value ?? 0), averageCpuTime)} + + ) +} + +export const MemoryTooltipDetail = ({ + averageMemoryUsage, + datum, + value, +}: { + averageMemoryUsage: number + datum: EdgeFunctionChartDatum + value: unknown +}) => { + return ( + <> + + {formatReferenceDelta(Number(value ?? 0), averageMemoryUsage)} + + + {getMemoryTooltipDetail(datum.avg_heap_memory_used, datum.avg_external_memory_used)} + + + ) +} + +export const getExecutionTooltipDetails = ( + averageExecutionTime: number +): NonNullable => { + return function renderExecutionTooltipDetails(_, key, value) { + return ( + + ) + } +} + +export const getCpuTooltipDetails = ( + averageCpuTime: number +): NonNullable => { + return function renderCpuTooltipDetails(_, __, value) { + return + } +} + +export const getMemoryTooltipDetails = ( + averageMemoryUsage: number +): NonNullable => { + return function renderMemoryTooltipDetails(datum, _, value) { + return ( + + ) + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx new file mode 100644 index 00000000000..c7c5e049a2b --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx @@ -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('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: , + }, + ], + [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 + } + + if (!IS_PLATFORM) { + return null + } + + return ( + <> + { + router.push( + `/project/${projectRef}/functions/${functionSlug}/${ + isUnifiedLogsEnabled ? 'logs' : 'invocations' + }${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}` + ) + }} + updateAnnotation={invocationUpdateAnnotation} + /> + + + + + + + + ) +} + +export default EdgeFunctionOverview diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts new file mode 100644 index 00000000000..d05fff7a749 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts new file mode 100644 index 00000000000..0bbc99017fa --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts @@ -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')}` +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx new file mode 100644 index 00000000000..1a037405d63 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx @@ -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 = ( +
+ + +
+ ) + + return ( + + + +
+ + + Performance + + + +
+ `${Math.round(value)}ms`, + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx new file mode 100644 index 00000000000..34a6de6f936 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx @@ -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 ( + + + +
+ + + Recent Failed Invocations + + + + + + + {recentErrorInvocationsError || functionRuntimeLogsError ? ( + + ) : isLoadingRecentErrorInvocations || isLoadingFunctionRuntimeLogs ? ( + + ) : recentErrorGroups.length === 0 ? ( +
+ Recent runtime errors will appear here when this function returns a 5xx response. +
+ ) : ( + + + + + Error + Count + Last Seen + Method + Status + Duration + Assistant + + + + {recentErrorGroups.map((group) => ( + + + + + {formatSingleLineMessage(group.message)} + + + {group.count} + + {formatLogTimestamp(group.lastSeen, 'relative')} + + + {group.lastMethod ?? '-'} + + + {group.lastStatusCode ? ( + + {group.lastStatusCode} + + ) : ( + + Error + + )} + + + {group.executionTime ?? '-'} + + +
+ buildGroupAssistantPrompt(group, functionSlug)} + onOpenAssistant={() => handleOpenAssistant(group)} + /> +
+
+
+ + +
+ {group.logs.length === 0 ? ( +
+ No related runtime logs found for this error group. +
+ ) : ( + group.logs.map((log) => ( +
+ [{formatLogTimestamp(log.lastSeen, 'time')}] [ + {log.level.toUpperCase()}] [{log.count}x]{' '} + {formatSingleLineMessage(log.message)} +
+ )) + )} +
+
+
+
+ ))} +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts new file mode 100644 index 00000000000..a874e18eee4 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts new file mode 100644 index 00000000000..f12c9245736 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts @@ -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 + +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 = {} + + 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>( + (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((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), + })) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx new file mode 100644 index 00000000000..7eb53d345e7 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx @@ -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 ( + + + {metrics} + + } + loadingState={} + > +
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx new file mode 100644 index 00000000000..a434abe7781 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx @@ -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 = ( +
+ + +
+ ) + const memoryMetrics = ( +
+ + + +
+ ) + + return ( + + + +
+ + + Usage + + + +
+ `${Math.round(value)}ms`, + }} + /> + + `${Number(value).toFixed(1)}MB`, + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 130abc22088..383d1e7abb8 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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): [Dayjs, string] => { +const calcChartStart = ( + params: Partial +): [Dayjs, 'minute' | 'hour' | 'day'] => { const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs() // todo @TzeYiing needs typing const its: any = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs() - let trunc = 'minute' + 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): [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 = { +const QUERY_TYPE_LABELS: Record = { api: 'API Gateway (Edge Network)', database: 'Postgres Database', functions: 'Edge Functions', @@ -791,7 +790,7 @@ const QUERY_TYPE_LABELS: Record = { etl: 'ETL', } -const LOG_TABLE_TO_SERVICE_LABEL: Record = { +const LOG_TABLE_TO_SERVICE_LABEL: Record = { 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 = { 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\`\`\`` : '' diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index dc2d55a6d0f..29dad4538ea 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -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 + } + + if (showNewOverview) { + return + } + + return +} + PageLayout.getLayout = (page) => ( {page} diff --git a/packages/ui-patterns/src/Chart/charts/chart-line.tsx b/packages/ui-patterns/src/Chart/charts/chart-line.tsx index 2fc044ab7fe..a7a111f5779 100644 --- a/packages/ui-patterns/src/Chart/charts/chart-line.tsx +++ b/packages/ui-patterns/src/Chart/charts/chart-line.tsx @@ -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(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 (
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 ( + <> +
+
+
+ + {itemConfig?.label || name || key} + + {detail} +
+ + {formatTooltipValue(value)} + +
+ + ) + }} /> } /> @@ -214,6 +261,15 @@ export const ChartLine = ({ fillOpacity={0.2} /> )} + {referenceLines?.map((line, index) => ( + + ))} {keysToRender.map((key, index) => { const keyConfig = chartConfig[key] const lineColor = diff --git a/packages/ui/src/components/shadcn/ui/chart.tsx b/packages/ui/src/components/shadcn/ui/chart.tsx index e8ab237e744..335cb47e16c 100644 --- a/packages/ui/src/components/shadcn/ui/chart.tsx +++ b/packages/ui/src/components/shadcn/ui/chart.tsx @@ -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}