Files
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

143 lines
6.5 KiB
TypeScript

import { BASE_PATH } from '@/lib/constants'
export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns:
- id (String)
- timestamp (DateTime64, UTC) formatted like 2026-06-22T09:34:06.215000 (ISO 8601, microsecond precision, no trailing Z)
- event_message (String): the raw log line
- severity_text (String): log level when present
- source (String): the service the log belongs to. Always filter by it, e.g. where source = 'edge_logs'.
- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']. Values are strings, so wrap numeric ones in toInt32OrZero(...) for comparisons.
Sources and their common log_attributes keys:
- edge_logs: request.method, request.path, request.search, response.status_code, identifier
- postgres_logs: parsed.error_severity, parsed.detail, parsed.hint, parsed.query, identifier
- pg_cron logs live under source = 'postgres_logs' (parsed.error_severity, parsed.query)
- auth_logs: level, status, path, msg, error
- function_edge_logs: response.status_code, request.method, request.pathname, function_id, execution_id, execution_time_ms
- function_logs: event_type, function_id, execution_id, level
- storage_logs, realtime_logs, postgrest_logs, supavisor_logs, pgbouncer_logs: mostly id, timestamp, event_message, with extra fields in log_attributes
Rules: always filter by source; the editor applies the selected time range so a timestamp filter is usually unnecessary; the old BigQuery unnest joins become log_attributes['key'] lookups (drop the metadata root).`
function renderAvailableKeys(availableKeys?: string[]): string {
if (!availableKeys || availableKeys.length === 0) return ''
const list = availableKeys.map((key) => `- log_attributes['${key}']`).join('\n')
return `\nThe actual log_attributes keys present for this source are listed below. Use these EXACT keys — do not invent, shorten, or drop any dotted prefix. If a BigQuery field maps to one of these (e.g. request.headers.x_real_ip, request.cf.country), use the full key shown here:
${list}\n`
}
export function buildClickhouseRewritePrompt(sql: string, availableKeys?: string[]): string {
return `${LOGS_SCHEMA_REFERENCE}
${renderAvailableKeys(availableKeys)}
Convert the BigQuery logs query below to ClickHouse SQL for the logs table. There are no per-service tables and no unnest joins in ClickHouse. Follow these rules exactly:
1. Replace the FROM table with the single logs table and filter by source. The old table name is the source value: "from postgres_logs as t" becomes "from logs where source = 'postgres_logs'". This is required, never select from a table like postgres_logs or edge_logs.
2. Remove every join that unnests metadata or its structs. This includes "cross join unnest(...)" and "left join unnest(...) on true".
3. Replace any column that came from an unnest alias with a log_attributes lookup. A field off unnest(metadata) becomes log_attributes['field']; a field off a nested struct like unnest(m.parsed) becomes log_attributes['parsed.field'] (keep the struct name as a dotted prefix, drop the metadata root and every alias). When the actual keys are listed above, match against them and use the full dotted key exactly.
4. Wrap numeric fields in toInt32OrZero(...) before comparing or aggregating them.
5. Replace BigQuery functions with ClickHouse equivalents: regexp_contains(x, 'p') becomes match(x, 'p'), or x ILIKE '%p%' for a plain substring. Replace cast(timestamp as datetime) with timestamp. Use count() instead of count(*).
6. Preserve the original select list, filters, group by, order by, and limit intent.
Example.
BigQuery:
select count(t.timestamp) as count, p.error_severity
from postgres_logs as t
cross join unnest(metadata) as m
cross join unnest(m.parsed) as p
where p.error_severity in ('ERROR', 'FATAL', 'PANIC')
group by p.error_severity
order by count desc
limit 100
ClickHouse:
select count() as count, log_attributes['parsed.error_severity'] as error_severity
from logs
where source = 'postgres_logs'
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
group by log_attributes['parsed.error_severity']
order by count desc
limit 100
Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences.
${sql}`
}
export function stripSqlCodeFences(text: string): string {
const trimmed = text.trim()
const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
return (fenced ? fenced[1] : trimmed).trim()
}
const SOURCE_ALIASES: Record<string, string> = {
pg_cron_logs: 'postgres_logs',
}
export function detectLogSource(sql: string): string | undefined {
const bySource = sql.match(/source\s*=\s*'([^']+)'/i)
if (bySource) {
const source = bySource[1].toLowerCase()
return SOURCE_ALIASES[source] ?? source
}
const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i)
if (byFrom) {
const table = byFrom[1].toLowerCase()
if (table === 'logs') return undefined
return SOURCE_ALIASES[table] ?? table
}
return undefined
}
export function looksLikeLegacyLogsQuery(sql: string): boolean {
const lower = sql.toLowerCase()
if (/\bunnest\s*\(/.test(lower)) return true
if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true
const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/)
return byFrom ? byFrom[1] !== 'logs' : false
}
export interface RewriteLogsSqlArgs {
sql: string
projectRef: string
connectionString?: string | null
orgSlug?: string
authorizationHeader?: string | null
availableKeys?: string[]
}
export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) {
const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args
const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
},
body: JSON.stringify({
projectRef,
connectionString,
language: 'sql',
dialect: 'clickhouse',
orgSlug,
completionMetadata: {
textBeforeCursor: '',
textAfterCursor: '',
language: 'pgsql',
prompt: buildClickhouseRewritePrompt(sql, availableKeys),
selection: sql,
},
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Failed to rewrite the query')
}
const raw = await response.json()
const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw))
if (!rewritten) throw new Error('The assistant returned an empty query')
return rewritten
}