mirror of
https://github.com/supabase/supabase.git
synced 2026-06-10 21:41:25 +08:00
## 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? Refactor / type safety improvement ## What is the current behavior? The legacy log query stack (`genDefaultQuery`, `genCountQuery`, `genChartQuery`, `genWhereStatement`, `useLogsPreview`, `useSingleLog`) builds SQL from raw strings with no type-level guarantee that values are safely interpolated. Identifier helpers (`bqIdent`, `bqDottedIdent`, `clickhouseIdent`, `clickhouseDottedIdent`) are duplicated across BigQuery and ClickHouse variants, and `bqDottedIdent` wraps the entire dotted path in one backtick pair (`` `request.pathname` ``), which BigQuery treats as a literal column name rather than a UNNEST alias field — causing runtime query failures on dotted filter keys. ## What is the new behavior? - All gen functions return `SafeLogSqlFragment` and all callers route through `executeAnalyticsSql`, enforcing compile-time SQL provenance tracking across the legacy stack. - `bqIdent` / `bqDottedIdent` / `clickhouseIdent` / `clickhouseDottedIdent` are replaced by a single `quotedIdent` function that backtick-quotes each segment individually (e.g. `` `request`.`pathname` ``). ClickHouse natively accepts backticks, so one function serves both engines and the dotted-path quoting bug is fixed. - `SQL_FILTER_TEMPLATES` entries are converted to `SafeLogSqlFragment` (static via `safeSql`, dynamic via `safeSql` + `analyticsLiteral`). - `buildWhereClauses` is extracted as a private helper returning `SafeLogSqlFragment[]` so the pg_cron path can merge clauses without unsafe slice-and-cast. ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Logs query generation migrated to safer, engine-agnostic SQL fragments, typed filter templates, and unified identifier quoting for stronger injection protection and more consistent queries. * Logs preview and single-log retrieval now execute analytics SQL end-to-end using the unified executor. * **New Features** * Analytics SQL executor can call the backend via GET or POST and accepts method selection. * **Tests** * Updated tests to validate unified identifier quoting and safe-SQL helper behavior. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46351?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
buildGroupAssistantPrompt,
|
|
buildTroubleshootingDocsUrl,
|
|
formatLogTimestamp,
|
|
formatSingleLineMessage,
|
|
getDisplayErrorMessage,
|
|
getFunctionRuntimeLogsSql,
|
|
getNoErrorsSinceLastDeployMessage,
|
|
getRecentErrorGroups,
|
|
getRecentErrorGroupsBase,
|
|
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(`select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
|
|
cross join unnest(metadata) as metadata
|
|
where metadata.function_id = 'fn_''123' and metadata.execution_id in ('exec_1', 'exec_''2')
|
|
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({})
|
|
})
|
|
|
|
it('builds the since-deploy invocation count query and empty-state message', () => {
|
|
expect(getSinceLastDeployInvocationCountSql()).toContain(
|
|
'SELECT count(*) as count FROM function_edge_logs'
|
|
)
|
|
expect(getSinceLastDeployInvocationCountSql()).toContain("(`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'
|
|
)
|
|
})
|
|
})
|