mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## Context The query to retrieve counts in unified logs have been error-ing out with this `sql parser error: Expected: end of statement, found: UNION at Line: 71, Column: 2` This PR fixes that - can verify visually as the status filters are now populated correctly <img width="365" height="148" alt="image" src="https://github.com/user-attachments/assets/9397cca1-0519-4fe1-9397-c37d399c4b44" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Improved status-based log filtering so single-value and multi-value filters return more accurate results across all log sources. * Made facet count calculations (method, status, pathname) more robust for consistent counts. * **Bug Fixes** * Standardized log endpoint resolution so log retrieval behaves consistently when telemetry mode changes. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46093?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 -->
193 lines
7.9 KiB
TypeScript
193 lines
7.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
getFacetCountQuery,
|
|
getLogsChartQuery,
|
|
getLogsCountQuery,
|
|
getUnifiedLogsQuery,
|
|
} from './UnifiedLogs.queries'
|
|
import { getUnifiedLogsQuery as getUnifiedLogsQueryBQ } from './UnifiedLogs.queries.bq'
|
|
|
|
const baseSearch = {
|
|
date: [new Date('2026-05-08T09:00:00Z'), new Date('2026-05-08T10:00:00Z')],
|
|
} as any
|
|
|
|
describe('UnifiedLogs.queries (OTEL flat)', () => {
|
|
describe('getUnifiedLogsQuery', () => {
|
|
it('defaults to postgres + postgrest log types when none specified', () => {
|
|
const sql = getUnifiedLogsQuery(baseSearch)
|
|
expect(sql).toContain(`source = 'postgres_logs'`)
|
|
// postgrest = edge_logs filtered by /rest/ path
|
|
expect(sql).toContain(
|
|
`source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/rest/%'`
|
|
)
|
|
})
|
|
|
|
it('routes the `edge` log type to edge_logs without /rest/ or /storage/ paths', () => {
|
|
const sql = getUnifiedLogsQuery({ ...baseSearch, log_type: ['edge'] } as any)
|
|
expect(sql).toContain(`NOT LIKE '%/rest/%'`)
|
|
expect(sql).toContain(`NOT LIKE '%/storage/%'`)
|
|
const where = sql.split(/\bWHERE\b/)[1] ?? ''
|
|
expect(where).not.toContain(`source = 'postgres_logs'`)
|
|
})
|
|
|
|
it('routes the `storage` log type to edge_logs filtered by /storage/', () => {
|
|
const sql = getUnifiedLogsQuery({ ...baseSearch, log_type: ['storage'] } as any)
|
|
expect(sql).toContain(
|
|
`source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/storage/%'`
|
|
)
|
|
})
|
|
|
|
it('escapes single quotes in filter values to prevent SQL injection', () => {
|
|
const sql = getUnifiedLogsQuery({
|
|
...baseSearch,
|
|
method: [`G'ET`],
|
|
pathname: `/customers'; DROP TABLE logs --`,
|
|
} as any)
|
|
// Single quotes are doubled (SQL-standard escaping) by pg-meta's
|
|
// literal(); a raw single quote from user input never closes its
|
|
// string literal early.
|
|
expect(sql).toContain(`'G''ET'`)
|
|
expect(sql).toContain(`%/customers''; DROP TABLE logs --%`)
|
|
})
|
|
|
|
it('translates method/status/pathname filters to log_attributes predicates', () => {
|
|
const sql = getUnifiedLogsQuery({
|
|
...baseSearch,
|
|
method: ['GET'],
|
|
status: ['401'],
|
|
pathname: '/customers',
|
|
} as any)
|
|
expect(sql).toContain(`log_attributes['request.method'] IN ('GET')`)
|
|
// Status filter wraps the CASE that picks HTTP code or Postgres SQLSTATE
|
|
// so e.g. '00000' matches postgres success rows.
|
|
expect(sql).toContain(`log_attributes['parsed.sql_state_code']`)
|
|
expect(sql).toMatch(/END\) IN \('401'\)/)
|
|
expect(sql).toContain(`log_attributes['request.path'] LIKE '%/customers%'`)
|
|
})
|
|
|
|
it('does not emit subqueries or CTEs (rejected by the OTEL endpoint)', () => {
|
|
const sql = getUnifiedLogsQuery(baseSearch)
|
|
expect(sql).not.toMatch(/WITH\s+\w+\s+AS\s*\(/i)
|
|
expect(sql).not.toMatch(/FROM\s*\(\s*SELECT/i)
|
|
// Single SELECT * FROM logs (not "SELECT *" wildcard usage either).
|
|
expect(sql).not.toMatch(/SELECT\s+\*/)
|
|
})
|
|
})
|
|
|
|
describe('getLogsCountQuery', () => {
|
|
it('emits one UNION ALL branch per log_type bucket and per level', () => {
|
|
const sql = getLogsCountQuery(baseSearch)
|
|
// Per-log-type counts
|
|
for (const lt of ['edge', 'postgrest', 'storage', 'postgres', 'edge function', 'auth']) {
|
|
expect(sql).toContain(`'${lt}'`)
|
|
}
|
|
// Per-level counts
|
|
for (const lvl of ['success', 'warning', 'error']) {
|
|
expect(sql).toContain(`'${lvl}'`)
|
|
}
|
|
// Bundled via UNION ALL — multiple occurrences expected
|
|
expect(sql.match(/UNION ALL/g)?.length ?? 0).toBeGreaterThan(5)
|
|
})
|
|
|
|
it('honours an active log_type filter in the total count branch', () => {
|
|
const sql = getLogsCountQuery({ ...baseSearch, log_type: ['edge'] } as any)
|
|
// The first branch is the total — its WHERE must include the edge
|
|
// log_type predicate, otherwise the total badge would over-count
|
|
// when a log_type filter is active.
|
|
const totalBranch = sql.split(/\bUNION ALL\b/)[0]
|
|
expect(totalBranch).toContain(`'total'`)
|
|
expect(totalBranch).toContain(`source = 'edge_logs'`)
|
|
expect(totalBranch).not.toContain(`source = 'postgres_logs'`)
|
|
})
|
|
})
|
|
|
|
describe('getLogsChartQuery', () => {
|
|
it('uses minute bucketing for short ranges', () => {
|
|
const sql = getLogsChartQuery(baseSearch)
|
|
expect(sql).toContain('toStartOfMinute(timestamp)')
|
|
})
|
|
|
|
it('uses hour bucketing for ranges spanning more than 12 hours', () => {
|
|
const sql = getLogsChartQuery({
|
|
...baseSearch,
|
|
date: [new Date('2026-05-08T00:00:00Z'), new Date('2026-05-08T18:00:00Z')],
|
|
} as any)
|
|
expect(sql).toContain('toStartOfHour(timestamp)')
|
|
})
|
|
|
|
it('uses day bucketing for ranges spanning more than 2 days', () => {
|
|
const sql = getLogsChartQuery({
|
|
...baseSearch,
|
|
date: [new Date('2026-05-01T00:00:00Z'), new Date('2026-05-08T00:00:00Z')],
|
|
} as any)
|
|
expect(sql).toContain('toStartOfDay(timestamp)')
|
|
})
|
|
})
|
|
|
|
describe('getFacetCountQuery', () => {
|
|
it('groups by the requested facet and excludes that facet from the WHERE filters', () => {
|
|
const sql = getFacetCountQuery({
|
|
search: { ...baseSearch, method: ['GET'], status: ['200'] } as any,
|
|
facet: 'method',
|
|
})
|
|
// Filtered facet is excluded from WHERE; other filters still applied.
|
|
// For facet='method' the SELECT projection doesn't include STATUS_EXPR,
|
|
// so SQLSTATE/IN ('200') must come from the WHERE clause.
|
|
expect(sql).not.toContain(`log_attributes['request.method'] IN ('GET')`)
|
|
expect(sql).toContain(`log_attributes['parsed.sql_state_code']`)
|
|
expect(sql).toMatch(/END\) IN \('200'\)/)
|
|
expect(sql).toContain('GROUP BY value')
|
|
expect(sql).toContain('LIMIT 20')
|
|
})
|
|
})
|
|
|
|
describe('analyticsLiteral escaping', () => {
|
|
it('emits ClickHouse / BigQuery escape syntax (doubled `\\\\`, no `E` prefix)', () => {
|
|
// pg-meta's literal() would emit `E'a\\b'` for `a\b` — the `E` prefix is
|
|
// Postgres-only and rejected by both analytics engines. analyticsLiteral
|
|
// doubles the backslash inside plain `'…'` delimiters instead.
|
|
const sql = getUnifiedLogsQuery({ ...baseSearch, method: 'a\\b' } as any)
|
|
expect(sql).toContain(`log_attributes['request.method'] = 'a\\\\b'`)
|
|
expect(sql).not.toContain(`E'a`)
|
|
})
|
|
|
|
it("escapes single quotes by doubling them ('' rather than \\')", () => {
|
|
const sql = getUnifiedLogsQuery({ ...baseSearch, method: "GET' OR '1'='1" } as any)
|
|
expect(sql).toContain(`log_attributes['request.method'] = 'GET'' OR ''1''=''1'`)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('UnifiedLogs.queries.bq', () => {
|
|
it('backtick-quotes column identifiers (BigQuery syntax)', () => {
|
|
const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: 'GET' } as any)
|
|
expect(sql).toContain("`method` = 'GET'")
|
|
})
|
|
|
|
it('rejects keys with non-identifier characters', () => {
|
|
// A key like "foo; DROP TABLE" fails the bqIdent regex (the `;` is not
|
|
// in `[A-Za-z_][A-Za-z0-9_]*`), so the predicate is dropped entirely.
|
|
const sql = getUnifiedLogsQueryBQ({ ...baseSearch, 'foo; DROP TABLE x': 'y' } as any)
|
|
expect(sql).not.toContain('DROP TABLE')
|
|
expect(sql).not.toContain('foo;')
|
|
})
|
|
|
|
it('rejects keys containing spaces', () => {
|
|
const sql = getUnifiedLogsQueryBQ({
|
|
...baseSearch,
|
|
'level OR id IS NOT NULL': 'anything',
|
|
} as any)
|
|
expect(sql).not.toContain('IS NOT NULL')
|
|
expect(sql).not.toContain('level OR')
|
|
})
|
|
|
|
it('escapes injection attempts in filter values via analyticsLiteral', () => {
|
|
const sql = getUnifiedLogsQueryBQ({ ...baseSearch, method: "GET' OR '1'='1" } as any)
|
|
// The value is single-quote-escaped, so the synthetic OR can't break out
|
|
// of the string literal.
|
|
expect(sql).toContain("`method` = 'GET'' OR ''1''=''1'")
|
|
expect(sql).not.toMatch(/`method` = 'GET' OR '1'='1'/)
|
|
})
|
|
})
|