mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user