Files
supabase/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts
Jordi Enric 2aa1b52234 feat(studio): add feature to rewrite queries DEBUG-145 (#47266)
## 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>
2026-06-29 14:31:18 +02:00

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