Files
supabase/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts
Jordi Enric 74c75c6d3a fix(studio): cap errors-since-last-deploy log range to 24h DEBUG-173 (#47911)
## Problem

The edge function overview's "Errors since last deploy" panel queried
ClickHouse from the deploy timestamp to now, with no upper bound. When a
function had not been redeployed in a long time, this produced an
unbounded query range, which is suspected to have caused a recent uptime
incident.

## Fix

`getSinceLastDeployLogRange` now clamps the query start to at most 24
hours before now, shared by all three queries this panel issues
(invocation list, invocation count, runtime logs). The section title was
also updated from "Errors since last deploy" to "Errors in the last 24h"
to reflect the new bound.

## How to test

- Open an edge function's overview page for a function that was deployed
more than 24 hours ago
- Confirm the "Errors in the last 24h" panel loads without an
excessively large query range
- Expected result: the panel only queries the last 24 hours of logs
regardless of how old the last deploy was
- Run `pnpm test:studio -- EdgeFunctionRecentErrors.utils` and confirm
the range-clamping tests pass

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Updated the Edge Function errors section to show errors from the last
24 hours.

- **Bug Fixes**
- Limited error log searches to a maximum 24-hour window, preventing
outdated results from appearing.
- Improved handling of error time ranges when the last deployment
occurred earlier than the available window.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 18:24:30 +02:00

374 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGroupAssistantPrompt,
buildTroubleshootingDocsUrl,
formatLogTimestamp,
formatSingleLineMessage,
getDisplayErrorMessage,
getFunctionRuntimeLogsSql,
getNoErrorsSinceLastDeployMessage,
getRecentErrorGroups,
getRecentErrorGroupsBase,
getRecentErrorInvocationsSql,
getRelatedExecutionIds,
getSinceLastDeployInvocationCount,
getSinceLastDeployInvocationCountSql,
getSinceLastDeployInvocationPhrase,
getSinceLastDeployLogRange,
getStatusBadgeVariant,
summarizeErrorMessage,
toAlertError,
toIsoTimestamp,
} from './EdgeFunctionRecentErrors.utils'
describe('EdgeFunctionRecentErrors.utils', () => {
afterEach(() => {
vi.useRealTimers()
})
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(`-- runtime logs for error groups
select
toUnixTimestamp64Micro(timestamp) as timestamp,
event_message,
log_attributes['level'] as level,
log_attributes['event_type'] as event_type,
log_attributes['function_id'] as function_id,
log_attributes['execution_id'] as execution_id
from logs
where
source = 'function_logs'
and log_attributes['function_id'] = 'fn_''123'
and log_attributes['execution_id'] in ('exec_1', 'exec_''2')
order by timestamp desc
limit 25`)
})
it('builds recent error invocations SQL and escapes the function id', () => {
expect(getRecentErrorInvocationsSql("fn_'123", 25)).toBe(`-- errors since last deploy
select
toUnixTimestamp64Micro(timestamp) as timestamp,
event_message,
log_attributes['request.method'] as method,
log_attributes['response.status_code'] as status_code,
toFloat64OrZero(log_attributes['execution_time_ms']) as execution_time_ms,
log_attributes['execution_id'] as execution_id
from logs
where
source = 'function_edge_logs'
and log_attributes['function_id'] = 'fn_''123'
and toInt32OrZero(log_attributes['response.status_code']) >= 500
order by timestamp desc
limit 25`)
})
it('normalizes deploy timestamps and derives the logs query range', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-20T12:00:00.000Z'))
const deployedAt = '2026-03-20T10:15:00.000Z'
const deployedAtMilliseconds = Date.parse(deployedAt)
expect(toIsoTimestamp(deployedAt)).toBe(deployedAt)
expect(toIsoTimestamp(String(deployedAtMilliseconds))).toBe(deployedAt)
expect(toIsoTimestamp(String(deployedAtMilliseconds * 1000))).toBe(deployedAt)
expect(toIsoTimestamp('')).toBeUndefined()
expect(toIsoTimestamp('not-a-date')).toBeUndefined()
expect(getSinceLastDeployLogRange(deployedAt)).toEqual({
isoTimestampStart: deployedAt,
isoTimestampEnd: '2026-03-20T12:00:00.000Z',
})
expect(getSinceLastDeployLogRange('2026-03-20T13:00:00.000Z')).toEqual({
isoTimestampStart: '2026-03-20T13:00:00.000Z',
isoTimestampEnd: '2026-03-20T13:00:00.000Z',
})
expect(getSinceLastDeployLogRange()).toEqual({})
expect(getSinceLastDeployLogRange('2026-03-18T00:00:00.000Z')).toEqual({
isoTimestampStart: '2026-03-19T12:00:00.000Z',
isoTimestampEnd: '2026-03-20T12:00:00.000Z',
})
vi.useRealTimers()
})
it('builds the since-deploy invocation count query and empty-state message', () => {
expect(getSinceLastDeployInvocationCountSql()).toContain(
"select count() as count from logs where source = 'function_edge_logs'"
)
expect(getSinceLastDeployInvocationCountSql()).toContain(
"log_attributes['function_id'] = '__pending__'"
)
expect(
getSinceLastDeployInvocationCount([
{
count: '12',
},
] as unknown as Parameters<typeof getSinceLastDeployInvocationCount>[0])
).toBe(12)
expect(getSinceLastDeployInvocationCount([])).toBe(0)
expect(getSinceLastDeployInvocationPhrase(1)).toBe('1 invocation')
expect(getSinceLastDeployInvocationPhrase(1200)).toBe('1,200 invocations')
expect(getNoErrorsSinceLastDeployMessage(0)).toBe(
'There have been 0 invocations since last deploy and no errors.'
)
expect(getNoErrorsSinceLastDeployMessage(1)).toBe(
'There has been 1 invocation since last deploy and no errors.'
)
expect(getNoErrorsSinceLastDeployMessage(1200)).toBe(
'There have been 1,200 invocations since last deploy and no errors.'
)
})
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 edge function error since the last deploy for `my-function`.')
expect(getStatusBadgeVariant()).toBe('destructive')
expect(getStatusBadgeVariant('500')).toBe('destructive')
expect(getStatusBadgeVariant('404')).toBe('default')
})
it('summarizes verbose error messages by trimming the stack trace', () => {
expect(summarizeErrorMessage('')).toBe('')
expect(summarizeErrorMessage('boom')).toBe('boom')
expect(
summarizeErrorMessage(
"SyntaxError: Expected ',' or '}' after property value in JSON at position 22 at parse (<anonymous>) at packageData (ext:deno_fetch/22_body.js:408:14)"
)
).toBe("SyntaxError: Expected ',' or '}' after property value in JSON at position 22")
expect(summarizeErrorMessage(' multi\n line\t error ')).toBe('multi line error')
})
it('prefers the first runtime error log message and falls back to invocation message', () => {
expect(
getDisplayErrorMessage({
message: 'https://example.supabase.red/functions/v1/hello-world',
count: 1,
lastSeen: 0,
executionIds: [],
logs: [
{
key: 'log:booted (time: 22ms)',
message: 'booted (time: 22ms)',
level: 'log',
count: 1,
lastSeen: 1,
},
{
key: 'error:SyntaxError: bad json at parse (<anonymous>)',
message: 'SyntaxError: bad json at parse (<anonymous>)',
level: 'error',
count: 1,
lastSeen: 2,
},
],
})
).toBe('SyntaxError: bad json')
expect(
getDisplayErrorMessage({
message: 'https://example.supabase.red/functions/v1/hello-world',
count: 1,
lastSeen: 0,
executionIds: [],
logs: [],
})
).toBe('https://example.supabase.red/functions/v1/hello-world')
})
it('builds a troubleshooting docs URL keyed off the response status code', () => {
expect(buildTroubleshootingDocsUrl({ statusCode: '500' })).toBe(
'https://supabase.com/docs/guides/troubleshooting/edge-function-500-response'
)
expect(buildTroubleshootingDocsUrl({ statusCode: '503' })).toBe(
'https://supabase.com/docs/guides/troubleshooting/edge-function-503-response'
)
expect(buildTroubleshootingDocsUrl({})).toBe(
'https://supabase.com/docs/guides/troubleshooting?search=edge%20function'
)
expect(buildTroubleshootingDocsUrl({ statusCode: 'not-a-number' })).toBe(
'https://supabase.com/docs/guides/troubleshooting?search=edge%20function'
)
})
})