Files
supabase/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
Joshen Lim dc211a972c Fix count query for unified logs (#46093)
## 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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 -->
2026-05-19 20:53:30 +07:00

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'/)
})
})