From 65dac5429e71f6d507d3f3a3314fff92a78b7a89 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 20 Feb 2026 07:55:42 -0700 Subject: [PATCH] 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 image --- .../EdgeFunctionRecentInvocations.tsx | 139 ++++++++++++++++++ ...dgeFunctionRecentInvocations.utils.test.ts | 87 +++++++++++ .../EdgeFunctionRecentInvocations.utils.ts | 37 +++++ .../[ref]/functions/[functionSlug]/index.tsx | 14 +- 4 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx new file mode 100644 index 00000000000..04456c2dc27 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx @@ -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 ( +
+
+
+

Recent Invocations

+

+ Latest invocation requests for this function +

+
+ +
+ + {isLoading && !isSuccess ? ( + + ) : logData.length === 0 ? ( + + ) : ( +
+ {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 ( +
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" + > + + + +
+ {statusCode ? ( +
+ {statusCode} +
+ ) : ( + - + )} +
+ {method || '-'} + {executionTime !== undefined && ( + + + {Number(executionTime).toFixed(0)}ms + + )} + + {parseEdgeFunctionEventMessage( + String(log.event_message ?? ''), + method, + statusCode + )} + + +
+ ) + })} + + View all invocations + +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts new file mode 100644 index 00000000000..e5809e2517b --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts @@ -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('') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts new file mode 100644 index 00000000000..37145dc5787 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts @@ -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 +} diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 7c7b05294bd..b2708a94193 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -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 = () => { + {IS_PLATFORM && id && ( +
+ +
+ )}
{CHART_INTERVALS.map((item, i) => {