mirror of
https://github.com/supabase/supabase.git
synced 2026-07-11 09:32:44 -04:00
2aa1b52234
## Problem Moving the Logs Explorer to ClickHouse means users' saved BigQuery queries no longer run. <img width="2430" height="1010" alt="CleanShot 2026-06-29 at 11 36 04@2x" src="https://github.com/user-attachments/assets/ae0ab155-7d3d-4ae9-81c3-22bf3a88cf8c" /> ## Fix Rewrite the query with AI instead of a SQL transpiler. AI handles the long tail of nested fields and dialect differences far better than a rule-based rewriter, and it needs no extra runtime dependency. - `rewriteLogsSqlWithAI` posts the current query to `/api/ai/code/complete` with `dialect: 'clickhouse'`. The endpoint skips the Postgres schema and best-practices for that dialect and uses logs-specific instructions and model so the output is ClickHouse logs SQL (FROM `logs` + `source` filter, no `unnest` joins, nested fields read from `log_attributes['...']`). - The query's `source` is detected and its real `log_attributes` keys are fetched and passed to the model, so it maps to exact paths instead of guessing. - The rewrite runs in the background and is proposed as a side-by-side accept/discard diff in the editor. The AI Assistant panel is not opened. - Entry points: a banner shown only for legacy-looking queries (dismissal persisted), and a "Fix Query" button next to Field Reference. - The Field Reference drawers discover `log_attributes` keys from real data so the listed fields match what the source actually emits. ## Dependencies Built on top of #47265 (Logs Explorer -> OTEL endpoint) — that is the base branch of this PR. Merge #47265 first. Behind `otelLegacyLogs` (off by default). Part of DEBUG-145 (split from #47087). ## How to test - Open the Logs Explorer with a BigQuery logs query (the templates have some), click "Fix Query", and confirm the diff shows valid ClickHouse SQL. Accept it and confirm the applied query runs. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an OTEL legacy logs workflow (behind a feature flag) with an interactive banner and a “Fix Query” ClickHouse rewrite action, including an accept/discard diff review overlay. * Introduced OTEL-aware field reference rendering with dynamic discovery of `log_attributes` keys and updated OTEL source insertion behavior. * Enabled dialect-aware SQL completion for ClickHouse logs, using logs-specific instructions and output constraints. * **Bug Fixes** * Improved rewrite flow validation and handling, including log source detection and cleanup of AI-generated SQL formatting. * **Tests** * Added Vitest coverage for rewrite prompt generation, detection/classification utilities, SQL fence stripping, OTEL field mapping, and OTEL log attribute key discovery. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
buildClickhouseRewritePrompt,
|
|
detectLogSource,
|
|
looksLikeLegacyLogsQuery,
|
|
rewriteLogsSqlWithAI,
|
|
stripSqlCodeFences,
|
|
} from './logs-sql-rewrite'
|
|
|
|
describe('buildClickhouseRewritePrompt', () => {
|
|
it('includes the query, the schema, and a reply-with-only-SQL instruction', () => {
|
|
const prompt = buildClickhouseRewritePrompt('select count(*) from edge_logs')
|
|
expect(prompt).toContain('select count(*) from edge_logs')
|
|
expect(prompt).toContain('log_attributes')
|
|
expect(prompt).toContain("source = 'edge_logs'")
|
|
expect(prompt.toLowerCase()).toContain('reply with only')
|
|
})
|
|
|
|
it('spells out the FROM-to-logs conversion and shows a worked example', () => {
|
|
const prompt = buildClickhouseRewritePrompt('select 1 from postgres_logs')
|
|
expect(prompt).toContain("from logs where source = 'postgres_logs'")
|
|
expect(prompt.toLowerCase()).toContain('remove every')
|
|
expect(prompt).toContain('cross join unnest')
|
|
expect(prompt).toContain('BigQuery:')
|
|
expect(prompt).toContain('ClickHouse:')
|
|
expect(prompt).toContain("log_attributes['parsed.error_severity']")
|
|
})
|
|
|
|
it('lists the real log_attributes keys when provided and demands exact paths', () => {
|
|
const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs', [
|
|
'request.headers.x_real_ip',
|
|
'request.cf.country',
|
|
])
|
|
expect(prompt).toContain("log_attributes['request.headers.x_real_ip']")
|
|
expect(prompt).toContain("log_attributes['request.cf.country']")
|
|
expect(prompt.toLowerCase()).toContain('exact')
|
|
})
|
|
|
|
it('omits the keys section when none are provided', () => {
|
|
const prompt = buildClickhouseRewritePrompt('select 1 from edge_logs')
|
|
expect(prompt).not.toContain('actual log_attributes keys present')
|
|
})
|
|
})
|
|
|
|
describe('detectLogSource', () => {
|
|
it('reads an explicit source filter', () => {
|
|
expect(detectLogSource("select 1 from logs where source = 'edge_logs'")).toBe('edge_logs')
|
|
})
|
|
|
|
it('falls back to the legacy FROM table name', () => {
|
|
expect(detectLogSource('select 1 from edge_logs as t')).toBe('edge_logs')
|
|
})
|
|
|
|
it('maps pg_cron_logs to postgres_logs from either the FROM table or the source filter', () => {
|
|
expect(detectLogSource('select 1 from pg_cron_logs')).toBe('postgres_logs')
|
|
expect(detectLogSource("select 1 from logs where source = 'pg_cron_logs'")).toBe(
|
|
'postgres_logs'
|
|
)
|
|
})
|
|
|
|
it('returns undefined for the bare logs table with no source', () => {
|
|
expect(detectLogSource('select 1 from logs limit 5')).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined when nothing matches', () => {
|
|
expect(detectLogSource('select 1')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('looksLikeLegacyLogsQuery', () => {
|
|
it('flags per-service FROM tables', () => {
|
|
expect(looksLikeLegacyLogsQuery('select 1 from edge_logs')).toBe(true)
|
|
})
|
|
|
|
it('flags unnest joins and the cast-timestamp idiom', () => {
|
|
expect(looksLikeLegacyLogsQuery('select 1 from logs cross join unnest(metadata) as m')).toBe(
|
|
true
|
|
)
|
|
expect(looksLikeLegacyLogsQuery('select cast(timestamp as datetime) from logs')).toBe(true)
|
|
})
|
|
|
|
it('does not flag a ClickHouse query against the logs table', () => {
|
|
expect(
|
|
looksLikeLegacyLogsQuery("select timestamp from logs where source = 'edge_logs' limit 5")
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('stripSqlCodeFences', () => {
|
|
it('removes a ```sql fenced block', () => {
|
|
expect(stripSqlCodeFences('```sql\nselect 1 from logs\n```')).toBe('select 1 from logs')
|
|
})
|
|
|
|
it('removes a plain ``` fenced block', () => {
|
|
expect(stripSqlCodeFences('```\nselect 1\n```')).toBe('select 1')
|
|
})
|
|
|
|
it('leaves unfenced SQL untouched (trimmed)', () => {
|
|
expect(stripSqlCodeFences(' select 1 from logs ')).toBe('select 1 from logs')
|
|
})
|
|
|
|
it('extracts the fenced block when wrapped in prose', () => {
|
|
expect(stripSqlCodeFences('Here is the rewrite:\n```sql\nselect 1 from logs\n```')).toBe(
|
|
'select 1 from logs'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('rewriteLogsSqlWithAI', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('posts to the completion endpoint and returns the cleaned query', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => '```sql\nselect 1 from logs\n```',
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const result = await rewriteLogsSqlWithAI({
|
|
sql: 'select 1 from edge_logs',
|
|
projectRef: 'abc',
|
|
})
|
|
|
|
expect(result).toBe('select 1 from logs')
|
|
const [url, init] = fetchMock.mock.calls[0]
|
|
expect(url).toContain('/api/ai/code/complete')
|
|
const body = JSON.parse(init.body)
|
|
expect(body.dialect).toBe('clickhouse')
|
|
expect(body.completionMetadata.selection).toBe('select 1 from edge_logs')
|
|
expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only')
|
|
})
|
|
|
|
it('throws when the request fails', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => 'boom' }))
|
|
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
|
|
'boom'
|
|
)
|
|
})
|
|
|
|
it('throws when the model returns an empty query', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ' ' }))
|
|
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
|
|
'empty'
|
|
)
|
|
})
|
|
})
|