error rates in functions list (#44006)

<img width="1835" height="886" alt="image"
src="https://github.com/user-attachments/assets/c4ab5be6-cddc-49b3-8d09-169acb706a20"
/>

Adds total request and error rate columns to edge functions table behind
a feature flag

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SaxonF <1072756+SaxonF@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-25 17:23:46 +08:00
1 parent b625f3502c
commit 3f6cd1f188
7 files changed
+373 -15

No files matched your search

@@ -3,12 +3,19 @@ import { useParams } from 'common/hooks'
import dayjs from 'dayjs'
import { Check, Copy } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { copyToClipboard, TableCell, TableRow } from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { useMemo, useState, type MouseEvent } from 'react'
import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
import { ShimmeringLoader, TimestampInfo } from 'ui-patterns'
import { formatErrorRate } from './EdgeFunctionsListItem.utils'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import type { EdgeFunctionsResponse } from '@/data/edge-functions/edge-functions-query'
import { useEdgeFunctionsLastHourStatsQuery } from '@/data/edge-functions/edge-functions-last-hour-stats-query'
import {
useEdgeFunctionsQuery,
type EdgeFunctionsResponse,
} from '@/data/edge-functions/edge-functions-query'
import { normalizeFunctionIds } from '@/data/edge-functions/keys'
import { usePHFlag } from '@/hooks/ui/useFlag'
import { createNavigationHandler } from '@/lib/navigation'
interface EdgeFunctionsListItemProps {
@@ -20,6 +27,9 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
const { ref } = useParams()
const [isCopied, setIsCopied] = useState(false)
const showEdgeFunctionsRequestMetrics = usePHFlag<boolean>('edgeFunctionsRequestMetrics') === true
const showLastHourStats = IS_PLATFORM && showEdgeFunctionsRequestMetrics
const { data: endpoint } = useProjectApiUrl({ projectRef: ref })
const functionUrl = `${endpoint}/functions/v1/${item.slug}`
@@ -28,6 +38,24 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
router
)
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const functionIds = useMemo(() => {
if (!showLastHourStats || !functions) return []
return normalizeFunctionIds(functions.map((item) => item.id))
}, [functions, showLastHourStats])
// [Joshen] We may be paginating the edge functions query in the future
// So this will eventually need to be a list of visibleFunctionIds instead + debounced
const {
data: lastHourStatsAll,
isPending: isStatsPending,
isError: isStatsError,
} = useEdgeFunctionsLastHourStatsQuery(
{ projectRef: ref, functionIds },
{ enabled: showLastHourStats }
)
const lastHourStats = lastHourStatsAll?.[item.id]
return (
<TableRow
key={item.id}
@@ -48,8 +76,8 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
<button
type="button"
className="text-foreground-lighter hover:text-foreground transition"
onClick={(event: any) => {
function onCopy(value: any) {
onClick={(event: MouseEvent<HTMLButtonElement>) => {
function onCopy(value: string) {
setIsCopied(true)
copyToClipboard(value)
setTimeout(() => setIsCopied(false), 3000)
@@ -86,7 +114,48 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
label={dayjs(item.updated_at).fromNow()}
/>
</TableCell>
<TableCell className="lg:table-cell">
{showLastHourStats && (
<>
<TableCell className="lg:table-cell whitespace-nowrap">
{isStatsPending ? (
<ShimmeringLoader className="w-12" />
) : isStatsError ? (
<p className="text-foreground-lighter" title="Failed to load stats">
-
</p>
) : (
<p className="text-foreground-light">
{lastHourStats !== undefined ? lastHourStats.requestsCount.toLocaleString() : '-'}
</p>
)}
</TableCell>
<TableCell className="lg:table-cell whitespace-nowrap">
{isStatsPending ? (
<ShimmeringLoader className="w-12" />
) : isStatsError ? (
<p className="text-foreground-lighter" title="Failed to load stats">
-
</p>
) : lastHourStats !== undefined ? (
<span
className={cn(
'text-sm',
lastHourStats.errorRate >= 1
? 'text-destructive'
: lastHourStats.errorRate > 0.1
? 'text-warning'
: 'text-foreground-light'
)}
>
{formatErrorRate(lastHourStats.errorRate)}
</span>
) : (
<p className="text-foreground-lighter">-</p>
)}
</TableCell>
</>
)}
<TableCell className="hidden 2xl:table-cell">
<p className="text-foreground-light">{item.version}</p>
<button tabIndex={-1} className="sr-only">
Go to function details
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { formatErrorRate } from './EdgeFunctionsListItem.utils'
describe('formatErrorRate', () => {
it.each([
{ value: 0, expected: '0%' },
{ value: 100, expected: '100%' },
{ value: 101, expected: '100%' },
{ value: 200, expected: '100%' },
{ value: 0.05, expected: '<0.1%' },
{ value: 0.001, expected: '<0.1%' },
{ value: 0.099, expected: '<0.1%' },
{ value: 0.1, expected: '0.1%' },
{ value: 1.0, expected: '1.0%' },
{ value: 1.567, expected: '1.6%' },
{ value: 50, expected: '50.0%' },
{ value: 99.9, expected: '99.9%' },
{ value: 99.99, expected: '100%' },
{ value: 1.55, expected: '1.6%' },
{ value: 1.54, expected: '1.5%' },
])('formats $value as $expected', ({ value, expected }) => {
expect(formatErrorRate(value)).toBe(expected)
})
})
@@ -0,0 +1,20 @@
/**
* Formats a numeric error rate (0–100) into a display string.
*
* Examples:
* 0 → "0%"
* 0.05 → "<0.1%"
* 0.1 → "0.1%"
* 1.567 → "1.6%"
* 100 → "100%"
*/
export function formatErrorRate(value: number): string {
if (value === 0) return '0%'
if (value >= 100) return '100%'
if (value < 0.1) return '<0.1%'
const rounded = Number(value.toFixed(1))
const clamped = Math.min(rounded, 100)
if (clamped >= 100) return '100%'
return `${clamped.toFixed(1)}%`
}
@@ -0,0 +1,104 @@
import { post } from 'data/fetchers'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getEdgeFunctionsLastHourStats } from './edge-functions-last-hour-stats-query'
vi.mock('data/fetchers', () => ({
post: vi.fn(),
handleError: vi.fn(),
}))
type PostResponse = Awaited<ReturnType<typeof post>>
describe('getEdgeFunctionsLastHourStats', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z'))
vi.mocked(post).mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('requests last-hour function stats from logs.all', async () => {
vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse)
await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1', 'fn_2'],
})
expect(post).toHaveBeenCalledWith('/platform/projects/{ref}/analytics/endpoints/logs.all', {
params: {
path: { ref: 'project-ref' },
query: { key: 'last-hour-stats' },
},
body: expect.objectContaining({
sql: expect.stringContaining(`and function_id in ('fn_1', 'fn_2')`),
iso_timestamp_start: '2024-01-15T11:00:00.000Z',
iso_timestamp_end: '2024-01-15T12:00:00.000Z',
}),
signal: undefined,
})
const postCalls = vi.mocked(post).mock.calls as Array<
[string, { body?: { sql?: string } } | undefined]
>
expect(postCalls[0]?.[1]?.body?.sql).toContain('from\n function_edge_logs')
})
it('coerces counts to numbers and computes error rates per function', async () => {
vi.mocked(post).mockResolvedValue({
data: {
result: [
{ function_id: 'fn_1', requests_count: '100', server_err_count: '5' },
{ function_id: 'fn_2', requests_count: 8, server_err_count: 0 },
],
},
error: null,
} as PostResponse)
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1', 'fn_2'],
})
expect(result).toEqual({
fn_1: {
functionId: 'fn_1',
requestsCount: 100,
serverErrorCount: 5,
errorRate: 5,
},
fn_2: {
functionId: 'fn_2',
requestsCount: 8,
serverErrorCount: 0,
errorRate: 0,
},
})
})
it('handles empty results', async () => {
vi.mocked(post).mockResolvedValue({ data: { result: [] }, error: null } as PostResponse)
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: ['fn_1'],
})
expect(result).toEqual({})
})
it('skips the logs query when there are no function ids', async () => {
const result = await getEdgeFunctionsLastHourStats({
projectRef: 'project-ref',
functionIds: [],
})
expect(result).toEqual({})
expect(post).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,119 @@
import { useQuery } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
import dayjs from 'dayjs'
import { quoteLiteral } from 'lib/pg-format'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { edgeFunctionsKeys } from './keys'
export type EdgeFunctionsLastHourStatsVariables = { projectRef?: string; functionIds?: string[] }
export type EdgeFunctionLastHourStats = {
functionId: string
requestsCount: number
serverErrorCount: number
errorRate: number
}
export type EdgeFunctionsLastHourStatsResponse = Record<string, EdgeFunctionLastHourStats>
function getEdgeFunctionsLastHourStatsSql(functionIds: string[]) {
const functionIdFilter =
functionIds.length > 0
? ` and function_id in (${functionIds.map(quoteLiteral).join(', ')})\n`
: ''
return `
-- edge-functions-last-hour-stats
select
function_id,
count(distinct id) as requests_count,
count(distinct case when response.status_code >= 500 then id end) as server_err_count
from
function_edge_logs
cross join unnest(metadata) as m
cross join unnest(m.response) as response
where
function_id is not null
${functionIdFilter}group by
function_id
`
}
export async function getEdgeFunctionsLastHourStats(
{ projectRef, functionIds = [] }: EdgeFunctionsLastHourStatsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
if (functionIds.length === 0) return {}
const endDate = dayjs().toISOString()
const startDate = dayjs().subtract(1, 'hour').toISOString()
const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
params: {
path: { ref: projectRef },
// @ts-ignore [Joshen] Just to easily identify this request in the network tools
query: { key: 'last-hour-stats' },
},
body: {
sql: getEdgeFunctionsLastHourStatsSql(functionIds),
iso_timestamp_start: startDate,
iso_timestamp_end: endDate,
},
signal,
})
if (error || data?.error) {
handleError(error ?? data?.error)
}
const result = (data?.result ?? []) as {
function_id: string
requests_count: number | string
server_err_count: number | string
}[]
return result.reduce<EdgeFunctionsLastHourStatsResponse>((acc, row) => {
const toSafeNumber = (v: number | string | undefined) => {
const n = Number(v ?? 0)
return Number.isFinite(n) ? n : 0
}
const safeRequestsCount = toSafeNumber(row.requests_count)
const safeServerErrorCount = toSafeNumber(row.server_err_count)
acc[row.function_id] = {
functionId: row.function_id,
requestsCount: safeRequestsCount,
serverErrorCount: safeServerErrorCount,
errorRate: safeRequestsCount > 0 ? (safeServerErrorCount / safeRequestsCount) * 100 : 0,
}
return acc
}, {})
}
export type EdgeFunctionsLastHourStatsData = Awaited<
ReturnType<typeof getEdgeFunctionsLastHourStats>
>
export type EdgeFunctionsLastHourStatsError = ResponseError
export const useEdgeFunctionsLastHourStatsQuery = <TData = EdgeFunctionsLastHourStatsData>(
{ projectRef, functionIds = [] }: EdgeFunctionsLastHourStatsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<
EdgeFunctionsLastHourStatsData,
EdgeFunctionsLastHourStatsError,
TData
> = {}
) =>
useQuery<EdgeFunctionsLastHourStatsData, EdgeFunctionsLastHourStatsError, TData>({
queryKey: edgeFunctionsKeys.lastHourStats(projectRef, functionIds),
queryFn: ({ signal }) => getEdgeFunctionsLastHourStats({ projectRef, functionIds }, signal),
enabled: enabled && typeof projectRef !== 'undefined' && functionIds.length > 0,
staleTime: 60 * 1000,
retry: false,
...options,
})
+11
View File
@@ -1,5 +1,16 @@
export const normalizeFunctionIds = (functionIds: string[]): string[] =>
Array.from(new Set(functionIds)).sort()
export const edgeFunctionsKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
lastHourStats: (projectRef: string | undefined, functionIds: string[] = []) =>
[
'projects',
projectRef,
'edge-functions',
'last-hour-stats',
normalizeFunctionIds(functionIds),
] as const,
detail: (projectRef: string | undefined, slug: string | undefined) =>
['projects', projectRef, 'edge-function', slug, 'detail'] as const,
body: (projectRef: string | undefined, slug: string | undefined) =>
@@ -18,6 +18,7 @@ import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunc
import AlertError from 'components/ui/AlertError'
import { DocsButton } from 'components/ui/DocsButton'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { usePHFlag } from 'hooks/ui/useFlag'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import { ExternalLink, Search, X } from 'lucide-react'
import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
@@ -40,6 +41,15 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
const EdgeFunctionsPage: NextPageWithLayout = () => {
const { ref } = useParams()
const showEdgeFunctionsRequestMetrics = usePHFlag<boolean>('edgeFunctionsRequestMetrics') === true
const showLastHourStats = IS_PLATFORM && showEdgeFunctionsRequestMetrics
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
const [sort, setSortQueryParam] = useQueryState(
'sort',
parseAsStringLiteral<EdgeFunctionsSort>(EDGE_FUNCTIONS_SORT_VALUES).withDefault('name:asc')
)
const {
data: functions,
error,
@@ -48,12 +58,6 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
isSuccess,
} = useEdgeFunctionsQuery({ projectRef: ref })
const [search, setSearch] = useQueryState('search', parseAsString.withDefault(''))
const [sort, setSortQueryParam] = useQueryState(
'sort',
parseAsStringLiteral<EdgeFunctionsSort>(EDGE_FUNCTIONS_SORT_VALUES).withDefault('name:asc')
)
const filteredFunctions = useMemo(() => {
const temp = (functions ?? []).filter((x) =>
x.name.toLowerCase().includes(search.toLowerCase())
@@ -142,7 +146,13 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
<TableHead>URL</TableHead>
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
<TableHead className="lg:table-cell">Updated</TableHead>
<TableHead className="lg:table-cell">Deployments</TableHead>
{showLastHourStats && (
<>
<TableHead className="lg:table-cell">Total requests (1h)</TableHead>
<TableHead className="lg:table-cell">5xx error rate (1h)</TableHead>
</>
)}
<TableHead className="hidden 2xl:table-cell">Deployments</TableHead>
</TableRow>
</TableHeader>
@@ -154,7 +164,7 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
))
) : (
<TableRow>
<TableCell colSpan={5}>
<TableCell colSpan={showLastHourStats ? 7 : 5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{search}" did not return any results