feat: show recent invocations in functions view (#43030)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

By far the most important feature in the functions view is the
Invocations tab. This just adds recent invocations to the dashboard for
a function. So it's easy for you to digest what's happening.

## Demo 
<img width="1818" height="1312" alt="image"
src="https://github.com/user-attachments/assets/b3f59255-f780-4edc-bc9c-cc711b0d22f9"
/>
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-02-20 07:55:42 -07:00
1 parent 03660838ef
commit 65dac5429e
4 files changed
+274 -3

No files matched your search

@@ -0,0 +1,139 @@
import { useParams } from 'common'
import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants'
import useLogsPreview from 'hooks/analytics/useLogsPreview'
import { Clock, ExternalLink, RefreshCw } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Button, cn } from 'ui'
import { Admonition, TimestampInfo } from 'ui-patterns'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils'
interface EdgeFunctionRecentInvocationsProps {
functionId: string
functionSlug: string
}
export const EdgeFunctionRecentInvocations = ({
functionId,
functionSlug,
}: EdgeFunctionRecentInvocationsProps) => {
const { ref } = useParams()
const router = useRouter()
const { logData, isLoading, isSuccess, refresh } = useLogsPreview({
projectRef: ref as string,
table: LOGS_TABLES.fn_edge,
filterOverride: { function_id: functionId },
limit: 10,
})
return (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm">Recent Invocations</p>
<p className="text-xs text-foreground-light">
Latest invocation requests for this function
</p>
</div>
<Button
type="default"
loading={isLoading}
disabled={isLoading}
icon={<RefreshCw size={14} />}
onClick={() => refresh()}
>
Refresh
</Button>
</div>
{isLoading && !isSuccess ? (
<GenericSkeletonLoader />
) : logData.length === 0 ? (
<Admonition
type="note"
title="No recent invocations"
description="Invocation logs will appear here when requests are made to this function"
/>
) : (
<div className="border rounded-md divide-y overflow-hidden">
{logData.map((log) => {
const statusCode = String(log.status_code ?? '')
const method = String(log.method ?? '')
const executionTime = log.execution_time_ms
const is2xx = statusCode.startsWith('2')
const is4xx = statusCode.startsWith('4')
const is5xx = statusCode.startsWith('5')
const logUrl = `/project/${ref}/functions/${functionSlug}/invocations?log=${log.id}`
return (
<div
key={log.id}
role="button"
tabIndex={0}
onClick={() => router.push(logUrl)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push(logUrl)
}
}}
className="group flex items-center font-mono px-3 py-2 gap-3 bg-surface-100 cursor-pointer hover:bg-surface-200 transition-colors"
>
<span className="text-xs text-foreground-light whitespace-nowrap">
<TimestampInfo utcTimestamp={log.timestamp!} format="DD MMM YY, HH:mm:ss" />
</span>
<div className="flex items-center">
{statusCode ? (
<div
className={cn(
'flex items-center justify-center border px-1.5 py-0.5 rounded text-xs font-mono',
is2xx && 'text-brand border-brand bg-brand-300',
is4xx && 'text-warning border-warning bg-warning-300',
is5xx && 'text-destructive border-destructive bg-destructive-300',
!is2xx &&
!is4xx &&
!is5xx &&
'text-foreground-light border-default bg-surface-200'
)}
>
{statusCode}
</div>
) : (
<span className="text-xs text-foreground-lighter">-</span>
)}
</div>
<span className="text-xs text-foreground-light">{method || '-'}</span>
{executionTime !== undefined && (
<span className="flex items-center gap-1 text-xs text-foreground-light">
<Clock size={12} className="text-foreground-muted" />
{Number(executionTime).toFixed(0)}ms
</span>
)}
<span className="flex-1 text-xs text-foreground-light truncate">
{parseEdgeFunctionEventMessage(
String(log.event_message ?? ''),
method,
statusCode
)}
</span>
<ExternalLink
size={14}
className="shrink-0 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
)
})}
<Link
href={`/project/${ref}/functions/${functionSlug}/invocations`}
className="flex items-center justify-center py-2 text-xs text-foreground-light hover:text-foreground transition-colors"
>
View all invocations
</Link>
</div>
)}
</div>
)
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils'
describe('parseEdgeFunctionEventMessage', () => {
it('should strip method and status code when they match the structured fields', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(
'https://example.supabase.red/functions/v1/hello-world'
)
})
it('should strip method and status code for different HTTP methods', () => {
const message = 'GET | 404 | https://example.supabase.red/functions/v1/not-found'
expect(parseEdgeFunctionEventMessage(message, 'GET', '404')).toBe(
'https://example.supabase.red/functions/v1/not-found'
)
})
it('should strip method and status code for 500 errors', () => {
const message = 'POST | 500 | https://example.supabase.red/functions/v1/broken'
expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(
'https://example.supabase.red/functions/v1/broken'
)
})
it('should preserve the rest of the message when it contains pipe separators', () => {
const message = 'POST | 200 | some | complex | message'
expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe('some | complex | message')
})
it('should return original message when method does not match', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, 'GET', '200')).toBe(message)
})
it('should return original message when status code does not match', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message)
})
it('should return original message when method is undefined', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, undefined, '200')).toBe(message)
})
it('should return original message when status code is undefined', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, 'POST', undefined)).toBe(message)
})
it('should return original message when both method and status code are undefined', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, undefined, undefined)).toBe(message)
})
it('should return original message when format has fewer than 3 parts', () => {
const message = 'some simple message'
expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message)
})
it('should return original message when format has only 2 pipe-separated parts', () => {
const message = 'POST | 200'
expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message)
})
it('should return empty string for empty input', () => {
expect(parseEdgeFunctionEventMessage('', 'POST', '200')).toBe('')
})
it('should handle whitespace around parts correctly', () => {
const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world'
expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(
'https://example.supabase.red/functions/v1/hello-world'
)
})
it('should return original message when the message does not follow the expected format', () => {
const message = 'Error: function crashed unexpectedly'
expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message)
})
it('should handle messages with extra whitespace in the URL portion', () => {
const message = 'DELETE | 204 | '
expect(parseEdgeFunctionEventMessage(message, 'DELETE', '204')).toBe('')
})
})
@@ -0,0 +1,37 @@
/**
* Parses an edge function invocation event_message to extract the meaningful
* content, stripping out the method and status code that are already displayed
* as structured fields.
*
* The event_message typically follows the format:
* "{METHOD} | {STATUS_CODE} | {URL_OR_DETAIL}"
*
* e.g. "POST | 200 | https://example.supabase.red/functions/v1/hello-world"
*
* When the structured method and status_code fields match what's in the message,
* we strip them out to avoid duplication. If parsing fails or the message doesn't
* match the expected format, we return the original message as-is.
*/
export function parseEdgeFunctionEventMessage(
eventMessage: string,
method?: string,
statusCode?: string
): string {
if (!eventMessage) return eventMessage
const parts = eventMessage.split(' | ')
if (parts.length < 3) return eventMessage
const messageMethod = parts[0].trim()
const messageStatus = parts[1].trim()
const methodMatches = method !== undefined && messageMethod === method
const statusMatches = statusCode !== undefined && messageStatus === statusCode
if (methodMatches && statusMatches) {
return parts.slice(2).join(' | ').trim()
}
return eventMessage
}
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { useFlag } from 'common'
import { IS_PLATFORM, useFlag, useParams } from 'common'
import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
@@ -22,9 +22,9 @@ import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import type { ChartIntervals, NextPageWithLayout } from 'types'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
WarningIcon,
} from 'ui'
@@ -138,6 +138,14 @@ const PageLayout: NextPageWithLayout = () => {
<PageContainer size="full">
<PageSection>
<PageSectionContent>
{IS_PLATFORM && id && (
<div className="mb-8">
<EdgeFunctionRecentInvocations
functionId={id}
functionSlug={functionSlug as string}
/>
</div>
)}
<div className="flex flex-row items-center gap-2 mb-4">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {