POST /api/ai/code/complete — ClickHouse dialect branch
The completion API gains a dialect field; when set to clickhouse it bypasses the Postgres schema fetch, uses a dedicated model and system prompt, and sends the rewrite prompt directly as the user message.
1 Entrypoint
dialect field + isClickhouse flag
Adds dialect: 'clickhouse' to the request schema and derives isClickhouse from it.
dialect: 'clickhouse' to the request schema and derives isClickhouse from it.connectionString: z.string().nullish(), orgSlug: z.string().optional(), language: z.string().optional(),+ dialect: z.enum(['postgres', 'clickhouse']).optional(), }) async function handler(req: NextApiRequest, res: NextApiResponse) {⋯ return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues }) } - const { completionMetadata, projectRef, connectionString, orgSlug, language } = data+ const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect } = data const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata⋯ const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata+ const isClickhouse = dialect === 'clickhouse' const authorization = req.headers.authorization let aiOptInLevel: AiOptInLevel = IS_PLATFORM ? 'disabled' : 'schema'
1 Entrypoint
LOGS_REWRITE_MODEL
Introduces a separate model entry (gpt-5.4-nano with reasoningEffort: 'low') used only for log rewrites, selected when isClickhouse is true.
gpt-5.4-nano with reasoningEffort: 'low') used only for log rewrites, selected when isClickhouse is true.reasoningEffort: 'none', }) +export const LOGS_REWRITE_MODEL = openaiModelEntry({+ id: 'gpt-5.4-nano',+ reasoningEffort: 'low',+})+ // Single source of truth for all Assistant chat model variants and their reasoning levels. // Models with requiresAdvanceModelEntitlement false are available to all users; true requires the assistant.advance_model entitlement. export const ASSISTANT_MODELS = [⋯ import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' import { getOrgAIDetails } from '@/lib/ai/ai-details' import { getModel } from '@/lib/ai/model'-import { DEFAULT_COMPLETION_MODEL } from '@/lib/ai/model.utils'+import { DEFAULT_COMPLETION_MODEL, LOGS_REWRITE_MODEL } from '@/lib/ai/model.utils' import {⋯ systemProviderOptions, } = await getModel({ provider: 'openai',- modelEntry: DEFAULT_COMPLETION_MODEL,+ modelEntry: isClickhouse ? LOGS_REWRITE_MODEL : DEFAULT_COMPLETION_MODEL, }) if (modelError) {
1 Entrypoint
CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS
New system-prompt fragment that tells the model this is ClickHouse (not Postgres/BigQuery): single logs table, log_attributes map lookups, ClickHouse function names.
logs table, log_attributes map lookups, ClickHouse function names.Do not quote identifiers unless they actually require it (uppercase letters, reserved words, or special characters). Plain lowercase identifiers should not be quoted. ` +export const CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS = `+# Supabase logs SQL (ClickHouse)+You are writing SQL for Supabase logs, which run on a ClickHouse-backed engine. This is NOT Postgres and NOT BigQuery. Output valid ClickHouse SQL only.+- All logs are in a single table named \`logs\`, keyed by a \`source\` column. There are no per-service tables (no \`edge_logs\`, \`postgres_logs\`, and so on) and no \`unnest\` joins.+- Per-source fields live in the \`log_attributes\` Map(String, String), read as \`log_attributes['key']\`. Map values are strings, so wrap numeric ones in \`toInt32OrZero(...)\`.+- Use ClickHouse functions, not Postgres or BigQuery ones. Use \`match(col, 'regex')\` or \`col ILIKE '%text%'\` instead of \`regexp_contains\`, \`count()\` instead of \`count(*)\`, and select the \`timestamp\` column directly instead of \`cast(timestamp as datetime)\`.+- Do not quote identifiers with double quotes and do not append a trailing semicolon.+`+ export const LIMITATIONS_PROMPT = ` # Limitations - You are to only answer Supabase, database, or edge function related questions. All other questions should be declined with a polite message.⋯ import {+ CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, COMPLETION_PROMPT, EDGE_FUNCTION_PROMPT, PG_BEST_PRACTICES,
2 Application
handler — clickhouse vs postgres branching
For ClickHouse: skips schema fetch (includeSchema = false), builds a minimal system prompt from CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, and sends the rewrite prompt as the raw user message instead of wrapping it in a schema-annotated template.
includeSchema = false), builds a minimal system prompt from CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS, and sends the rewrite prompt as the raw user message instead of wrapping it in a schema-annotated template....(authorization && { Authorization: authorization }), } - const includeSchema = aiOptInLevel !== 'disabled'+ const includeSchema = !isClickhouse && aiOptInLevel !== 'disabled' // Fetch schema list first so we can determine which schemas to load DDL for. // These are best-effort — if they fail, we proceed without DDL context.⋯ otherSchemas: schemas.filter((s) => !fetchedSchemaSet.has(s.name)).map((s) => s.name), } - // Important: do not use dynamic content in the system prompt or Bedrock will not cache it- const system = source`- ${COMPLETION_PROMPT}- ${language === 'sql' ? SQL_COMPLETION_INSTRUCTIONS : ''}- ${language === 'sql' ? PG_BEST_PRACTICES : EDGE_FUNCTION_PROMPT}- ${SECURITY_PROMPT}- `+ const system = isClickhouse+ ? source`+ You rewrite SQL queries to ClickHouse SQL for the Supabase logs table.+ Output only the rewritten SQL query: no explanation, no markdown, and no code fences.+ ${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS}+ ${SECURITY_PROMPT}+ `+ : source`+ ${COMPLETION_PROMPT}+ ${language === 'sql' ? `${SQL_COMPLETION_INSTRUCTIONS}\n${PG_BEST_PRACTICES}` : EDGE_FUNCTION_PROMPT}+ ${SECURITY_PROMPT}+ ` ⋯ - const userMessage = source`- ## Database Schema+ const userMessage = isClickhouse+ ? prompt+ : source`+ ## Database Schema ⋯ - ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })}+ ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })} ⋯ - ## Code+ ## Code ⋯ - \`\`\`${language ?? ''}- ${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}- \`\`\`+ \`\`\`${language ?? ''}+ ${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}+ \`\`\` ⋯ - ## Instruction+ ## Instruction ⋯ - ${prompt}- `+ ${prompt}+ ` // Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions` // https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88
AI rewrite logic — prompt, detection, and network call
New logs-sql-rewrite.ts encapsulates everything the client needs to invoke the rewrite: detecting whether a query is legacy BigQuery, identifying its source, building the ClickHouse conversion prompt, and posting to the endpoint.
3 Domain
detectLogSource / looksLikeLegacyLogsQuery
detectLogSource extracts the log source from either a source = '...' filter or a legacy FROM <table> clause (with alias normalisation for pg_cron_logs). looksLikeLegacyLogsQuery uses heuristics (unnest, cast-timestamp, non-logs FROM) to decide whether the banner/button should appear.
detectLogSource extracts the log source from either a source = '...' filter or a legacy FROM <table> clause (with alias normalisation for pg_cron_logs). looksLikeLegacyLogsQuery uses heuristics (unnest, cast-timestamp, non-logs FROM) to decide whether the banner/button should appear.+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+}+
3 Domain
buildClickhouseRewritePrompt / LOGS_SCHEMA_REFERENCE
The schema reference constant teaches the model the ClickHouse logs table shape. renderAvailableKeys injects real discovered keys so the model uses exact dotted paths rather than guessing. The 6-rule prompt + worked example gives the model unambiguous conversion instructions.
logs table shape. renderAvailableKeys injects real discovered keys so the model uses exact dotted paths rather than guessing. The 6-rule prompt + worked example gives the model unambiguous conversion instructions.+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}`+}+
3 Domain
stripSqlCodeFences
Strips markdown code fences from the model response in case the model wraps output despite being told not to.
+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()+}+
3 Domain
rewriteLogsSqlWithAI
Posts to /api/ai/code/complete with dialect: 'clickhouse', the full prompt, and the original SQL as selection. Throws on HTTP error or empty result after stripping fences.
/api/ai/code/complete with dialect: 'clickhouse', the full prompt, and the original SQL as selection. Throws on HTTP error or empty result after stripping fences.+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+}
Live log_attributes key discovery — otel-log-keys-query
New data-layer module queries real ClickHouse data to discover which log_attributes keys a source actually emits, so both the rewrite prompt and the Field Reference panel show exact paths.
4 Adapter
fetchOtelLogKeys
Runs SELECT arrayJoin(mapKeys(log_attributes)) ... FROM logs WHERE source = ? over the last 7 days, returning keys sorted by frequency. Uses parameterised safeSql/analyticsLiteral to avoid injection.
SELECT arrayJoin(mapKeys(log_attributes)) ... FROM logs WHERE source = ? over the last 7 days, returning keys sorted by frequency. Uses parameterised safeSql/analyticsLiteral to avoid injection.+import { useQuery } from '@tanstack/react-query'+ ⋯+import { executeAnalyticsSql } from './execute-analytics-sql'+import { logsAllEndpointUrl } from './logs-endpoint'+import { analyticsLiteral, safeSql } from './safe-analytics-sql'+ ⋯+const LOOKBACK_HOURS = 24 * 7+ ⋯+export async function fetchOtelLogKeys({+ projectRef,+ source,+ signal,+}: {+ projectRef: string+ source: string+ signal?: AbortSignal+}): Promise<string[]> {+ const end = new Date()+ const start = new Date(end.getTime() - LOOKBACK_HOURS * 60 * 60 * 1000)+ const sql = safeSql`SELECT arrayJoin(mapKeys(log_attributes)) AS key, count() AS n FROM logs WHERE source = ${analyticsLiteral(source)} GROUP BY key ORDER BY n DESC LIMIT 500`+ const data = await executeAnalyticsSql({+ projectRef,+ endpoint: logsAllEndpointUrl(true),+ sql,+ iso_timestamp_start: start.toISOString(),+ iso_timestamp_end: end.toISOString(),+ method: 'post',+ signal,+ })+ const rows = (data?.result ?? []) as { key: string }[]+ return rows.map((r) => r.key).filter(Boolean)+}+
4 Adapter
useOtelLogKeysQuery
React Query wrapper around fetchOtelLogKeys with a 5-minute stale time; only fires when both projectRef and source are present and enabled is true.
fetchOtelLogKeys with a 5-minute stale time; only fires when both projectRef and source are present and enabled is true.+export function useOtelLogKeysQuery(+ { projectRef, source }: { projectRef?: string; source?: string },+ { enabled = true }: { enabled?: boolean } = {}+) {+ return useQuery({+ queryKey: ['projects', projectRef, 'otel-log-keys', source],+ queryFn: ({ signal }) =>+ fetchOtelLogKeys({ projectRef: projectRef ?? '', source: source ?? '', signal }),+ enabled: enabled && Boolean(projectRef) && Boolean(source),+ staleTime: 5 * 60 * 1000,+ })+}
Logs Explorer page — rewrite entry points, diff review overlay, and OTEL source insertion
The Explorer page wires together detection, the banner, the toolbar button, the AI call, and the Monaco diff overlay for accept/discard.
2 Application
imports and otelSourceQuery
Imports rewriteLogsSqlWithAI, detectLogSource, looksLikeLegacyLogsQuery, DiffEditor, fetchOtelLogKeys, project/org hooks, and defines otelSourceQuery for OTEL-aware source insertion.
rewriteLogsSqlWithAI, detectLogSource, looksLikeLegacyLogsQuery, DiffEditor, fetchOtelLogKeys, project/org hooks, and defines otelSourceQuery for OTEL-aware source insertion.import { useRouter } from 'next/router' import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner'-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'+import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' ⋯ +import {+ detectLogSource,+ looksLikeLegacyLogsQuery,+ rewriteLogsSqlWithAI,+} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite' import { EXPLORER_DATEPICKER_HELPERS, getDefaultHelper,⋯ buildLogQueryParams, resolveLogDateRange, } from '@/components/interfaces/Settings/Logs/logsDateRange'-import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'+import { LogsExplorerOtelBanner } from '@/components/interfaces/Settings/Logs/LogsExplorerOtelBanner'+import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel' import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' import DefaultLayout from '@/components/layouts/DefaultLayout'⋯ import DefaultLayout from '@/components/layouts/DefaultLayout' import LogsLayout from '@/components/layouts/LogsLayout/LogsLayout' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'+import { DiffEditor } from '@/components/ui/DiffEditor' import LoadingOpacity from '@/components/ui/LoadingOpacity' import ShimmerLine from '@/components/ui/ShimmerLine' import { useContentQuery } from '@/data/content/content-query'⋯ UpsertContentPayload, useContentUpsertMutation, } from '@/data/content/content-upsert-mutation'+import { constructHeaders } from '@/data/fetchers'+import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query' import useLogsQuery from '@/hooks/analytics/useLogsQuery' import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState' import { useCustomContent } from '@/hooks/custom-content/useCustomContent'⋯ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'+import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt' import { uuidv4 } from '@/lib/helpers' import { useProfile } from '@/lib/profile'⋯ import { useTrack } from '@/lib/telemetry/track' import type { LogSqlSnippets, NextPageWithLayout } from '@/types' +type SaveQueryFormValues = { name: string; description?: string }+ const LOCAL_PLACEHOLDER_QUERY = 'select\n timestamp, event_message, metadata\n from edge_logs limit 5' ⋯ const OTEL_PLACEHOLDER_QUERY = "select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = 'edge_logs'\norder by timestamp desc\nlimit 5" +const otelSourceQuery = (source: string) =>+ `select\n timestamp,\n event_message,\n log_attributes\nfrom logs\nwhere source = '${source}'\norder by timestamp desc\nlimit 100`+ export const LogsExplorerPage: NextPageWithLayout = () => { useEditorHints() const monaco = useMonaco()⋯ const monaco = useMonaco() const router = useRouter() const { profile } = useProfile()+ const { data: project } = useSelectedProjectQuery()+ const { data: organization } = useSelectedOrganizationQuery() const { ref, q, queryId } = useParams() const track = useTrack() const projectRef = ref as string
2 Application
rewrite state + showRewriteCTA
Adds rewriteProposal (original/modified pair), isRewriting, and rewriteBannerDismissed (persisted via localStorage). showRewriteCTA gates both entry points: only shown when the OTEL endpoint is active and the current query looks legacy.
rewriteProposal (original/modified pair), isRewriting, and rewriteBannerDismissed (persisted via localStorage). showRewriteCTA gates both entry points: only shown when the OTEL endpoint is active and the current query looks legacy.const [saveModalOpen, setSaveModalOpen] = useState<boolean>(false) const [warnings, setWarnings] = useState<LogsWarning[]>([]) const [selectedLog, setSelectedLog] = useState<LogData | null>(null)+ const [rewriteProposal, setRewriteProposal] = useState<{+ original: string+ modified: string+ } | null>(null)+ const [isRewriting, setIsRewriting] = useState<boolean>(false)+ const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage<boolean>(+ `project-${projectRef}-logs-rewrite-banner-dismissed`,+ false+ ) const [recentLogs, setRecentLogs] = useLocalStorage<LogSqlSnippets.Content[]>( `project-content-${projectRef}-recent-log-sql`,⋯ const results = logData const isLoading = logsLoading + const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue)+ const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({ onError: (e) => { const error = e as { message: string }
2 Application
handleRewrite / acceptRewrite / discardRewrite
handleRewrite detects the source, fetches real keys, calls rewriteLogsSqlWithAI, then guards against clobbering an editor that changed while the async calls were in flight before setting rewriteProposal. Accept writes the result into the editor; discard clears the proposal.
handleRewrite detects the source, fetches real keys, calls rewriteLogsSqlWithAI, then guards against clobbering an editor that changed while the async calls were in flight before setting rewriteProposal. Accept writes the result into the editor; discard clears the proposal.addRecentLogSqlSnippet({ sql: template.searchString }) } + const handleRewrite = async () => {+ const currentSql = editorRef.current?.getValue() ?? editorValue+ if (!currentSql.trim()) {+ toast.info('Write a query to rewrite first')+ return+ }+ setIsRewriting(true)+ try {+ const headerData = await constructHeaders()+ const source = detectLogSource(currentSql)+ const availableKeys = source+ ? await fetchOtelLogKeys({ projectRef, source }).catch(() => undefined)+ : undefined+ const rewritten = await rewriteLogsSqlWithAI({+ sql: currentSql,+ projectRef,+ connectionString: project?.connectionString,+ orgSlug: organization?.slug,+ authorizationHeader: headerData.get('Authorization'),+ availableKeys,+ })+ // The editor may have changed while awaiting key discovery and the AI call;+ // don't offer a proposal that would clobber intervening edits.+ const latestSql = editorRef.current?.getValue() ?? editorValue+ if (latestSql !== currentSql) {+ toast.info('The query changed while rewriting. Please try again.')+ return+ }+ setRewriteProposal({ original: currentSql, modified: rewritten })+ } catch (error) {+ toast.error(`Couldn't rewrite the query: ${(error as Error).message}`)+ } finally {+ setIsRewriting(false)+ }+ }+ + const acceptRewrite = () => {+ if (!rewriteProposal) return+ editorRef.current?.setValue(rewriteProposal.modified)+ setEditorValue(rewriteProposal.modified)+ setRewriteProposal(null)+ toast.success('Applied the ClickHouse rewrite')+ }+ + const discardRewrite = () => setRewriteProposal(null)+ const handleRun = (value?: string | React.MouseEvent) => { track('log_explorer_query_run_button_clicked', { is_saved_query: !!queryId })
2 Application
handleInsertSource — OTEL branch
When the OTEL endpoint is active, 'Insert source' either replaces an existing source = '...' filter in-place or replaces the whole query with the OTEL starter template, instead of the old string-splice into from.
source = '...' filter in-place or replaces the whole query with the OTEL starter template, instead of the old string-splice into from.if (editorRef.current && monaco) { const editorModel = editorRef.current?.getModel() const currentValue = editorRef.current.getValue()- const index = currentValue.indexOf('from') ⋯ - const updatedValue =- index < 0- ? `${currentValue}${source}`- : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`+ let updatedValue: string+ if (useOtelEndpoint) {+ const sourceFilter = /source\s*=\s*'[^']*'/i+ updatedValue = sourceFilter.test(currentValue)+ ? currentValue.replace(sourceFilter, `source = '${source}'`)+ : otelSourceQuery(source)+ } else {+ const index = currentValue.indexOf('from')+ updatedValue =+ index < 0+ ? `${currentValue}${source}`+ : `${currentValue.substring(0, index + 4)} ${source} ${currentValue.substring(index + 5)}`+ } editorRef.current.pushUndoStop() editorRef.current.executeEdits(`insert-identifier`, [
2 Application
useEffect — simplified placeholder logic
Collapses the two separate useEffects (one for search/q, one for OTEL placeholder) into one. The OTEL vs. legacy placeholder choice is resolved upstream via PLACEHOLDER_QUERY.
PLACEHOLDER_QUERY.} } - type SaveQueryFormValues = { name: string; description?: string }- const handleCreateQuery = async (values: SaveQueryFormValues) => { if (!projectRef) return console.error('Project ref is required') if (!profile) return console.error('Profile is required')⋯ } else if (q) { setEditorValue(q) setSearch(q)+ } else if (!queryId) {+ setEditorValue(PLACEHOLDER_QUERY)+ editorRef.current?.setValue(PLACEHOLDER_QUERY) }⋯ }- }, [q, search, setSearch])- - useEffect(() => {- if (!useOtelEndpoint || q || search || queryId) return- if (editorValue === OTEL_PLACEHOLDER_QUERY) return- const isUntouchedDefault =- editorValue === LOCAL_PLACEHOLDER_QUERY ||- editorValue === PLATFORM_PLACEHOLDER_QUERY ||- editorValue === logsDefaultQuery- if (!isUntouchedDefault) return- setEditorValue(OTEL_PLACEHOLDER_QUERY)- editorRef.current?.setValue(OTEL_PLACEHOLDER_QUERY)- }, [useOtelEndpoint, q, search, queryId, editorValue, logsDefaultQuery])+ }, [q, search, queryId, setSearch, PLACEHOLDER_QUERY]) useEffect(() => { // prevents overwriting when the user selects a helper.
2 Application
banner, toolbar button, and diff overlay in JSX
Renders LogsExplorerOtelBanner (below the query panel, shown until dismissed), passes showRewriteAction/isRewriting/onRewrite to LogsQueryPanel (shown after banner is dismissed), and overlays a DiffEditor with Accept/Discard controls when a rewriteProposal is pending.
LogsExplorerOtelBanner (below the query panel, shown until dismissed), passes showRewriteAction/isRewriting/onRewrite to LogsQueryPanel (shown after banner is dismissed), and overlays a DiffEditor with Accept/Discard controls when a rewriteProposal is pending.templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings}+ showRewriteAction={showRewriteCTA && rewriteBannerDismissed}+ isRewriting={isRewriting}+ onRewrite={handleRewrite} />⋯ />+ {showRewriteCTA && !rewriteBannerDismissed && (+ <LogsExplorerOtelBanner+ isRewriting={isRewriting}+ onRewrite={handleRewrite}+ onDismiss={() => setRewriteBannerDismissed(true)}+ />+ )} <ShimmerLine active={isLoading} />⋯ <ShimmerLine active={isLoading} />- <CodeEditor- // Ensure we reset the editor to the query content whenever the selected query changes- key={queryId}- id={editorId}- editorRef={editorRef}- language="pgsql"- defaultValue={editorValue}- onInputChange={(v) => setEditorValue(v || '')}- actions={{ runQuery: { enabled: true, callback: handleRun } }}- />+ <div className="relative h-full">+ <CodeEditor+ // Ensure we reset the editor to the query content whenever the selected query changes+ key={queryId}+ id={editorId}+ editorRef={editorRef}+ language="pgsql"+ defaultValue={editorValue}+ onInputChange={(v) => setEditorValue(v || '')}+ actions={{ runQuery: { enabled: true, callback: handleRun } }}+ />+ {rewriteProposal && (+ <div className="absolute inset-0 z-10 flex flex-col bg-studio">+ <div className="flex items-center justify-between gap-2 border-b bg-surface-100 px-4 py-2">+ <span className="text-xs text-foreground-light">+ Review the ClickHouse SQL rewrite before accepting it+ </span>+ <div className="flex items-center gap-2">+ <Button variant="default" size="tiny" onClick={discardRewrite}>+ Discard+ </Button>+ <Button variant="primary" size="tiny" onClick={acceptRewrite}>+ Accept+ </Button>+ </div>+ </div>+ <div className="min-h-0 flex-1">+ <DiffEditor+ language="pgsql"+ original={rewriteProposal.original}+ modified={rewriteProposal.modified}+ options={{ renderSideBySide: true, renderGutterMenu: false }}+ />+ </div>+ </div>+ )}+ </div> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel collapsible minSize="5" className="overflow-auto">
OTEL banner and 'Fix Query' toolbar button components
Two new/modified UI components provide the visible entry points for the rewrite flow.
2 Application
LogsExplorerOtelBanner
An Admonition-based banner with 'Rewrite with Assistant' and 'Dismiss' actions. Shown only until dismissed; dismissal is persisted so it doesn't reappear.
Admonition-based banner with 'Rewrite with Assistant' and 'Dismiss' actions. Shown only until dismissed; dismissal is persisted so it doesn't reappear.+import { Button } from 'ui'+import { Admonition } from 'ui-patterns'+ ⋯+interface LogsExplorerOtelBannerProps {+ isRewriting: boolean+ onRewrite: () => void+ onDismiss: () => void+}+ ⋯+export const LogsExplorerOtelBanner = ({+ isRewriting,+ onRewrite,+ onDismiss,+}: LogsExplorerOtelBannerProps) => {+ return (+ <Admonition+ type="default"+ layout="horizontal"+ className="mb-0 rounded-none border-x-0 border-t-0"+ title="Logs now run on a ClickHouse-backed engine"+ description="This query needs to be adjusted to ClickHouse SQL, which the Assistant can do for you."+ actions={+ <div className="flex items-center gap-2">+ <Button variant="default" size="tiny" loading={isRewriting} onClick={onRewrite}>+ Rewrite with Assistant+ </Button>+ <Button variant="text" size="tiny" onClick={onDismiss}>+ Dismiss+ </Button>+ </div>+ }+ />+ )+}
2 Application
LogsQueryPanel — new props and imports
Adds showRewriteAction, isRewriting, onRewrite props; switches from SidePanel to Sheet for the Field Reference drawer; imports OTEL field utilities and the new log-keys query.
showRewriteAction, isRewriting, onRewrite props; switches from SidePanel to Sheet for the Field Reference drawer; imports OTEL field utilities and the new log-keys query.-import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink, X } from 'lucide-react'+import { useFlag, useParams } from 'common'+import { BookOpen, Check, ChevronDown, ChevronsUpDown, Copy, ExternalLink } from 'lucide-react' import Link from 'next/link' import { ReactNode, useEffect, useState } from 'react' import { logConstants } from 'shared-data'⋯ import { Badge, Button,+ Card, cn, Command, CommandEmpty,⋯ Popover, PopoverContent, PopoverTrigger,- SidePanel,+ Sheet,+ SheetContent,+ SheetDescription,+ SheetHeader,+ SheetSection,+ SheetTitle,+ SheetTrigger,+ Table,+ TableBody,+ TableCell,+ TableHead,+ TableHeader,+ TableRow, Tooltip, TooltipContent, TooltipTrigger,⋯ TooltipContent, TooltipTrigger, } from 'ui'+import { ShimmeringLoader } from 'ui-patterns' import { EXPLORER_DATEPICKER_HELPERS,⋯ LogsTableName, } from './Logs.constants' import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'+import { otelFieldsFromKeys, toOtelFieldSchemas } from './Logs.fieldReference' import { LogsWarning, LogTemplate } from './Logs.types'⋯ import { LogsWarning, LogTemplate } from './Logs.types'-import Table from '@/components/to-be-cleaned/Table'+import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs' import { DOCS_URL } from '@/lib/constants'⋯ import { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs' import { DOCS_URL } from '@/lib/constants' -export interface LogsQueryPanelProps {+interface LogsQueryPanelProps { templates?: LogTemplate[] value: DatePickerValue warnings: LogsWarning[]⋯ onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: string) => void onDateChange: (value: DatePickerValue) => void+ showRewriteAction?: boolean+ isRewriting?: boolean+ onRewrite?: () => void } function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) {⋯ ) } -const LogsQueryPanel = ({+export const LogsQueryPanel = ({ templates = [], value, warnings,⋯ onSelectTemplate, onSelectSource, onDateChange,+ showRewriteAction = false,+ isRewriting = false,+ onRewrite, }: LogsQueryPanelProps) => { const [showReference, setShowReference] = useState(false) const { logsTemplates } = useIsFeatureEnabled(['logs:templates'])
2 Application
LogsQueryPanel — OTEL schema selection
Under the otelLegacyLogs flag, applies toOtelFieldSchemas to convert BigQuery-style field paths to log_attributes[...] form, and fetches real discovered keys for the selected source when the drawer is open.
otelLegacyLogs flag, applies toOtelFieldSchemas to convert BigQuery-style field paths to log_attributes[...] form, and fetches real discovered keys for the selected source when the drawer is open.const [open, setOpen] = useState(false) const showMultigresLogs = useShowMultigresLogs()- const schemas = logConstants.schemas.filter(+ const useOtel = useFlag('otelLegacyLogs')+ const baseSchemas = logConstants.schemas.filter( (schema) => schema.reference !== 'multigres_logs' || showMultigresLogs )⋯ (schema) => schema.reference !== 'multigres_logs' || showMultigresLogs )+ const schemas = useOtel ? toOtelFieldSchemas(baseSchemas) : baseSchemas ⋯ - const [selectedSchema, setSelectedSchema] = useState(schemas[0])+ const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference)+ const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0]+ + const { ref: projectRef } = useParams()+ const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery(+ { projectRef, source: selectedRef },+ { enabled: useOtel && showReference }+ ) return ( <div className="flex items-center border-b bg-surface-100 h-(--header-height)">
2 Application
LogsQueryPanel — Field Reference UI overhaul
Replaces the old SidePanel/Table (to-be-cleaned) with a Sheet/shadcn Table. Adds the 'Rewrite query to ClickHouse SQL' tooltip+button next to Field Reference. In OTEL mode, shows a loading skeleton while keys are fetched, then renders otelFieldsFromKeys(discoveredKeys) instead of the static schema.
SidePanel/Table (to-be-cleaned) with a Sheet/shadcn Table. Adds the 'Rewrite query to ClickHouse SQL' tooltip+button next to Field Reference. In OTEL mode, shows a loading skeleton while keys are fetched, then renders otelFieldsFromKeys(discoveredKeys) instead of the static schema.</div> </div> - <SidePanel- size="large"- header={- <div className="flex flex-row justify-between items-center">- <h3>Field Reference</h3>- <Button- variant="text"- className="px-1"- onClick={() => setShowReference(false)}- icon={<X />}- />- </div>- }- visible={showReference}- cancelText="Close"- onCancel={() => setShowReference(false)}- hideFooter- triggerElement={- <Button- variant="text"- onClick={() => setShowReference(true)}- icon={<BookOpen />}- className="px-2"- >- <span>Field Reference</span>- </Button>- }- >- <SidePanel.Content>- <div className="pt-4 pb-2 space-y-1">- <p className="text-sm">- The following table shows all the available paths that can be queried from each- respective source. Do note that to access nested keys, you would need to perform- the necessary{' '}- <Link- href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}- target="_blank"- rel="noreferrer"- className="text-brand"- >- unnesting joins- <ExternalLink- size="14"- className="ml-1 inline translate-y-[-2px]"- strokeWidth={1.5}- />- </Link>- </p>- </div>- </SidePanel.Content>- <SidePanel.Separator />- - <div className="px-4 pb-4 flex flex-col gap-4">- <Popover open={open} onOpenChange={setOpen}>- <PopoverTrigger asChild>+ <div className="flex items-center gap-1">+ {showRewriteAction && onRewrite && (+ <Tooltip>+ <TooltipTrigger asChild> <Button⋯ <Button- variant="default"- role="combobox"- size={'small'}- aria-expanded={open}- className="w-full justify-between"- iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}+ variant="outline"+ loading={isRewriting}+ onClick={onRewrite}+ className="px-2" >⋯ >- {value ? selectedSchema?.name : 'Select source...'}+ Rewrite query to ClickHouse SQL </Button>⋯ </Button>- </PopoverTrigger>- <PopoverContent className="p-0" sameWidthAsTrigger>- <Command>- <CommandInput placeholder="Search source..." />- <CommandList>- <CommandEmpty>No source found.</CommandEmpty>- <CommandGroup>- {schemas.map((schema) => (- <CommandItem- key={schema.reference}- value={schema.reference}- onSelect={() => {- setSelectedSchema(schema)- setOpen(false)- }}- >- <Check- className={cn(- 'mr-2 h-4 w-4',- selectedSchema === schema ? 'opacity-100' : 'opacity-0'- )}- />- {schema.name}- </CommandItem>- ))}- </CommandGroup>- </CommandList>- </Command>- </PopoverContent>- </Popover>- <Table- head={[- <Table.th className="text-xs p-2!" key="path">- Path- </Table.th>,- <Table.th key="type" className="text-xs p-2!">- Type- </Table.th>,- ]}- body={selectedSchema.fields.map((field) => (- <Field key={field.path} field={field} />- ))}- />- </div>- </SidePanel>+ </TooltipTrigger>+ <TooltipContent side="bottom" className="w-72 text-center">+ Logs now run on a ClickHouse-backed engine. Click to rewrite this query with the+ Assistant.+ </TooltipContent>+ </Tooltip>+ )}+ + <Sheet open={showReference} onOpenChange={setShowReference}>+ <SheetTrigger asChild>+ <Button variant="text" icon={<BookOpen />} className="px-2">+ <span>Field Reference</span>+ </Button>+ </SheetTrigger>+ <SheetContent size="lg" className="flex w-full flex-col gap-0 p-0">+ <SheetHeader>+ <SheetTitle>Field Reference</SheetTitle>+ {useOtel ? (+ <SheetDescription>+ The following table shows the fields available on each source. Nested fields+ live in the{' '}+ <code className="text-code-inline text-xs !break-keep">log_attributes</code>{' '}+ map and are read with{' '}+ <code className="text-code-inline text-xs !break-keep">+ log_attributes['key']+ </code>{' '}+ — no unnesting joins needed.+ </SheetDescription>+ ) : (+ <SheetDescription>+ The following table shows all the available paths that can be queried from+ each respective source. Do note that to access nested keys, you would need to+ perform the necessary{' '}+ <Link+ href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}+ target="_blank"+ rel="noreferrer"+ className="text-brand"+ >+ unnesting joins+ <ExternalLink+ size="14"+ className="ml-1 inline translate-y-[-2px]"+ strokeWidth={1.5}+ />+ </Link>+ </SheetDescription>+ )}+ </SheetHeader>+ + <SheetSection className="flex flex-col gap-y-4 overflow-y-auto ">+ <Popover open={open} onOpenChange={setOpen}>+ <PopoverTrigger asChild>+ <Button+ variant="default"+ role="combobox"+ size={'small'}+ aria-expanded={open}+ className="w-full justify-between"+ iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}+ >+ {value ? selectedSchema?.name : 'Select source...'}+ </Button>+ </PopoverTrigger>+ <PopoverContent className="p-0" sameWidthAsTrigger>+ <Command>+ <CommandInput placeholder="Search source..." />+ <CommandList>+ <CommandEmpty>No source found.</CommandEmpty>+ <CommandGroup>+ {schemas.map((schema) => (+ <CommandItem+ key={schema.reference}+ value={schema.reference}+ onSelect={() => {+ setSelectedRef(schema.reference)+ setOpen(false)+ }}+ >+ <Check+ className={cn(+ 'mr-2 h-4 w-4',+ selectedSchema?.reference === schema.reference+ ? 'opacity-100'+ : 'opacity-0'+ )}+ />+ {schema.name}+ </CommandItem>+ ))}+ </CommandGroup>+ </CommandList>+ </Command>+ </PopoverContent>+ </Popover>+ + <Card className="overflow-y-auto">+ <Table>+ <TableHeader>+ <TableRow>+ <TableHead className="text-xs p-2!">Path</TableHead>+ <TableHead className="text-xs p-2!">Type</TableHead>+ </TableRow>+ </TableHeader>+ <TableBody>+ {useOtel && isLoadingKeys+ ? Array.from({ length: 3 }).map((_, i) => (+ <TableRow key={i}>+ <TableCell colSpan={2} className="p-2">+ <ShimmeringLoader />+ </TableCell>+ </TableRow>+ ))+ : (useOtel+ ? otelFieldsFromKeys(discoveredKeys ?? [])+ : selectedSchema.fields+ ).map((field) => <Field key={field.path} field={field} />)}+ </TableBody>+ </Table>+ </Card>+ </SheetSection>+ </SheetContent>+ </Sheet>+ </div> </div> </div> </div>⋯ const [isCopied, setIsCopied] = useState(false) return (- <Table.tr>- <Table.td+ <TableRow>+ <TableCell className="font-mono text-xs p-2! cursor-pointer hover:text-foreground transition flex items-center space-x-2" onClick={() => copyToClipboard(field.path, () => {⋯ <TooltipContent side="bottom">Copy value</TooltipContent> </Tooltip> )}- </Table.td>- <Table.td className="font-mono text-xs p-2!">{field.type}</Table.td>- </Table.tr>+ </TableCell>+ <TableCell className="font-mono text-xs p-2!">{field.type}</TableCell>+ </TableRow> ) }⋯ ) }- -export default LogsQueryPanel
2 Application
DefaultErrorRenderer padding
Minor padding tweak to the error code block.
language="json" hideLineNumbers value={typeof error === 'string' ? error : JSON.stringify(error, null, 2)}- className="w-full font-mono"+ className="w-full font-mono px-4" /> </div> )
OTEL field schema mapping — Logs.fieldReference.ts
A new module converts the existing BigQuery-style log field schemas (from shared-data) into OTEL-schema representations with log_attributes['key'] paths, and builds field lists from dynamically discovered keys.
3 Domain
OTEL_BASE_FIELDS / OTEL_COLUMN_PATHS
Defines the five fixed columns in the ClickHouse logs table (id, timestamp, event_message, severity_text, source) that are real columns, not log_attributes entries.
logs table (id, timestamp, event_message, severity_text, source) that are real columns, not log_attributes entries.+import { logConstants } from 'shared-data'+ ⋯+export type LogFieldSchema = (typeof logConstants.schemas)[number]+type LogField = LogFieldSchema['fields'][number]+ ⋯+const OTEL_BASE_FIELDS: LogField[] = [+ { path: 'id', type: 'string' },+ { path: 'timestamp', type: 'datetime' },+ { path: 'event_message', type: 'string' },+ { path: 'severity_text', type: 'string' },+ { path: 'source', type: 'string' },+]+ ⋯+const OTEL_COLUMN_PATHS = new Set(OTEL_BASE_FIELDS.map(({ path }) => path))+
3 Domain
toOtelFieldSchema / toOtelFieldSchemas
toOtelAttributeKey strips the metadata. prefix; toLogAttributePath produces the log_attributes['...'] syntax with escaping. toOtelFieldSchema keeps the base columns unchanged and remaps every other field through those two helpers.
toOtelAttributeKey strips the metadata. prefix; toLogAttributePath produces the log_attributes['...'] syntax with escaping. toOtelFieldSchema keeps the base columns unchanged and remaps every other field through those two helpers.+const toOtelAttributeKey = (path: string) => path.replace(/^metadata\./, '')+ ⋯+const toLogAttributePath = (key: string) =>+ `log_attributes['${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}']`+ ⋯+const toOtelFieldSchema = (schema: LogFieldSchema): LogFieldSchema => ({+ ...schema,+ fields: [+ ...OTEL_BASE_FIELDS,+ ...schema.fields+ .filter((field) => !OTEL_COLUMN_PATHS.has(field.path))+ .map((field) => ({+ path: toLogAttributePath(toOtelAttributeKey(field.path)),+ type: field.type,+ })),+ ],+})+ ⋯+export const toOtelFieldSchemas = (schemas: LogFieldSchema[]): LogFieldSchema[] =>+ schemas.map(toOtelFieldSchema)+
3 Domain
otelFieldsFromKeys
Produces a field list from dynamically discovered key strings (returned by fetchOtelLogKeys), filtering out keys that are already real columns to avoid duplicates.
fetchOtelLogKeys), filtering out keys that are already real columns to avoid duplicates.+export function otelFieldsFromKeys(keys: string[]): LogFieldSchema['fields'] {+ const attributeFields = keys+ .filter((key) => !OTEL_COLUMN_PATHS.has(key))+ .map((key) => ({ path: toLogAttributePath(key), type: 'string' as const }))+ return [...OTEL_BASE_FIELDS, ...attributeFields]+}
LogsExplorerHeader — OTEL-aware Field Reference
The header's Field Reference drawer gains the same OTEL-mode behaviour as the panel version: schema transformation, discovered-key loading, and dialect-appropriate description text.
2 Application
LogsExplorerHeader — OTEL state
Imports the field reference utilities and the log-keys query. Under the flag, applies toOtelFieldSchemas and fetches discoveredKeys for the selected source when the panel is open.
toOtelFieldSchemas and fetches discoveredKeys for the selected source when the panel is open.+import { useFlag, useParams } from 'common' import { BookOpen, Check, ChevronsUpDown, Copy, ExternalLink, List, X } from 'lucide-react' import Link from 'next/link' import { useState } from 'react'⋯ import { DocsButton } from '../DocsButton' import { LOGS_EXPLORER_DOCS_URL } from '@/components/interfaces/Settings/Logs/Logs.constants'+import {+ otelFieldsFromKeys,+ toOtelFieldSchemas,+} from '@/components/interfaces/Settings/Logs/Logs.fieldReference' import Table from '@/components/to-be-cleaned/Table'⋯ import Table from '@/components/to-be-cleaned/Table'+import { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query' import { DOCS_URL } from '@/lib/constants' export interface LogsExplorerHeaderProps {⋯ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => { const [showReference, setShowReference] = useState(false) const [open, setOpen] = useState(false)- const [selectedSchema, setSelectedSchema] = useState(logConstants.schemas[0])+ const useOtel = useFlag('otelLegacyLogs')+ const schemas = useOtel ? toOtelFieldSchemas(logConstants.schemas) : logConstants.schemas+ const [selectedRef, setSelectedRef] = useState(schemas[0]?.reference)+ const selectedSchema = schemas.find((s) => s.reference === selectedRef) ?? schemas[0]+ + const { ref: projectRef } = useParams()+ const { data: discoveredKeys, isPending: isLoadingKeys } = useOtelLogKeysQuery(+ { projectRef, source: selectedRef },+ { enabled: useOtel && showReference }+ ) return ( <div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 transition-all pb-6 justify-between">
2 Application
LogsExplorerHeader — OTEL-aware field table
Branches the description text (unnesting joins vs. log_attributes['key'] map lookups) and the field table body (otelFieldsFromKeys(discoveredKeys) vs. static schema fields), with a loading state.
log_attributes['key'] map lookups) and the field table body (otelFieldsFromKeys(discoveredKeys) vs. static schema fields), with a loading state.> <SidePanel.Content> <div className="pt-4 pb-2 space-y-1">- <p className="text-sm">- The following table shows all the available paths that can be queried from each- respective source. Do note that to access nested keys, you would need to perform the- necessary{' '}- <Link- href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}- target="_blank"- rel="noreferrer"- className="text-brand"- >- unnesting joins- <ExternalLink- size={14}- className="ml-1 inline translate-y-[-2px]"- strokeWidth={1.5}- />- </Link>- </p>+ {useOtel ? (+ <p className="text-sm">+ The following table shows the fields available on each source. Nested fields live+ in the <code className="text-xs">log_attributes</code> map and are read with{' '}+ <code className="text-xs">log_attributes['key']</code> — no unnesting joins+ needed.+ </p>+ ) : (+ <p className="text-sm">+ The following table shows all the available paths that can be queried from each+ respective source. Do note that to access nested keys, you would need to perform+ the necessary{' '}+ <Link+ href={`${DOCS_URL}/guides/platform/logs#unnesting-arrays`}+ target="_blank"+ rel="noreferrer"+ className="text-brand"+ >+ unnesting joins+ <ExternalLink+ size={14}+ className="ml-1 inline translate-y-[-2px]"+ strokeWidth={1.5}+ />+ </Link>+ </p>+ )} </div> </SidePanel.Content> <SidePanel.Separator />⋯ <CommandList> <CommandEmpty>No source found.</CommandEmpty> <CommandGroup>- {logConstants.schemas.map((schema) => (+ {schemas.map((schema) => ( <CommandItem key={schema.reference} value={schema.reference}⋯ key={schema.reference} value={schema.reference} onSelect={() => {- setSelectedSchema(schema)+ setSelectedRef(schema.reference) setOpen(false) }} >⋯ <Check className={cn( 'mr-2 h-4 w-4',- selectedSchema === schema ? 'opacity-100' : 'opacity-0'+ selectedSchema?.reference === schema.reference+ ? 'opacity-100'+ : 'opacity-0' )} /> {schema.name}⋯ </Command> </PopoverContent> </Popover>- <Table- head={[- <Table.th className="text-xs p-2!" key="path">- Path- </Table.th>,- <Table.th key="type" className="text-xs p-2!">- Type- </Table.th>,- ]}- body={selectedSchema.fields.map((field) => (- <Field key={field.path} field={field} />- ))}- />+ {useOtel && isLoadingKeys ? (+ <p className="text-sm text-foreground-light py-2">Loading fields…</p>+ ) : (+ <Table+ head={[+ <Table.th className="text-xs p-2!" key="path">+ Path+ </Table.th>,+ <Table.th key="type" className="text-xs p-2!">+ Type+ </Table.th>,+ ]}+ body={(() => {+ const fields = useOtel+ ? otelFieldsFromKeys(discoveredKeys ?? [])+ : selectedSchema.fields+ return fields.map((field) => <Field key={field.path} field={field} />)+ })()}+ />+ )} </div> </SidePanel> </div>
useLogsUrlState — stable setter callbacks
All setter functions in the URL-state hook are wrapped in useCallback so their identities are stable across renders, preventing spurious re-executions of effects that depend on them.
3 Domain
useLogsUrlState setters → useCallback
Stabilising these callbacks is a prerequisite for including them in the simplified useEffect dependency array in the Explorer page without triggering infinite loops.
useEffect dependency array in the Explorer page without triggering infinite loops.import { createParser, useQueryState } from 'nuqs'+import { useCallback } from 'react' import { Filters } from '@/components/interfaces/Settings/Logs/Logs.types' ⋯ const selectedLogId = selectedLogIdValue || null const filters = filtersValue || defaultState.filters - const setSearch = (value: string) => {- setSearchValue(value || defaultState.search)- }+ const setSearch = useCallback(+ (value: string) => {+ setSearchValue(value || defaultState.search)+ },+ [setSearchValue]+ ) ⋯ - const setTimeRange = (start: string, end: string) => {- setTimestampStartValue(start || defaultState.timestampStart)- setTimestampEndValue(end || defaultState.timestampEnd)- }+ const setTimeRange = useCallback(+ (start: string, end: string) => {+ setTimestampStartValue(start || defaultState.timestampStart)+ setTimestampEndValue(end || defaultState.timestampEnd)+ },+ [setTimestampStartValue, setTimestampEndValue]+ ) ⋯ - const setSelectedLogId = (value: string | null) => {- setSelectedLogIdValue(value || '')- }+ const setSelectedLogId = useCallback(+ (value: string | null) => {+ setSelectedLogIdValue(value || '')+ },+ [setSelectedLogIdValue]+ ) ⋯ - const setFilters = (value: Filters) => {- setFiltersValue(value || defaultState.filters)- }+ const setFilters = useCallback(+ (value: Filters) => {+ setFiltersValue(value || defaultState.filters)+ },+ [setFiltersValue]+ ) ⋯ - const updateFilter = (key: string, value: any) => {- setFilters({ ...filters, [key]: value })- }+ const updateFilter = useCallback(+ (key: string, value: any) => {+ setFilters({ ...filters, [key]: value })+ },+ [filters, setFilters]+ ) ⋯ - const reset = () => {+ const reset = useCallback(() => { setSearch(defaultState.search) setTimestampStartValue(defaultState.timestampStart) setTimestampEndValue(defaultState.timestampEnd)⋯ setTimestampEndValue(defaultState.timestampEnd) setSelectedLogIdValue('') setFiltersValue(defaultState.filters)- }+ }, [+ setSearch,+ setTimestampStartValue,+ setTimestampEndValue,+ setSelectedLogIdValue,+ setFiltersValue,+ ]) return { // State
Tests
Unit tests cover every new pure function and the network call.
6 Tests/Docs
logs-sql-rewrite.test.ts
Tests buildClickhouseRewritePrompt (schema reference inclusion, key injection, worked example), detectLogSource (both path styles, alias mapping), looksLikeLegacyLogsQuery (positive and negative cases), stripSqlCodeFences, and rewriteLogsSqlWithAI (happy path, HTTP error, empty response).
buildClickhouseRewritePrompt (schema reference inclusion, key injection, worked example), detectLogSource (both path styles, alias mapping), looksLikeLegacyLogsQuery (positive and negative cases), stripSqlCodeFences, and rewriteLogsSqlWithAI (happy path, HTTP error, empty response).+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'+ )+ })+})
6 Tests/Docs
Logs.fieldReference.test.ts
Tests toOtelFieldSchemas (base columns preserved, non-column fields remapped, metadata. prefix dropped, types preserved) and otelFieldsFromKeys (base fields, key-to-path mapping, column exclusion, escaping, empty input).
toOtelFieldSchemas (base columns preserved, non-column fields remapped, metadata. prefix dropped, types preserved) and otelFieldsFromKeys (base fields, key-to-path mapping, column exclusion, escaping, empty input).+import { describe, expect, it } from 'vitest'+ ⋯+import { otelFieldsFromKeys, toOtelFieldSchemas, type LogFieldSchema } from './Logs.fieldReference'+ ⋯+const edgeSchema: LogFieldSchema = {+ name: 'API Gateway',+ reference: 'edge_logs',+ fields: [+ { path: 'id', type: 'string' },+ { path: 'timestamp', type: 'datetime' },+ { path: 'event_message', type: 'string' },+ { path: 'identifier', type: 'string' },+ { path: 'metadata.request.method', type: 'string' },+ { path: 'metadata.request.cf.asn', type: 'number' },+ ],+}+ ⋯+describe('toOtelFieldSchemas', () => {+ it('keeps source name and reference', () => {+ const [otel] = toOtelFieldSchemas([edgeSchema])+ expect(otel.name).toBe('API Gateway')+ expect(otel.reference).toBe('edge_logs')+ })+ + it('keeps id/timestamp/event_message as real columns and adds source + severity_text', () => {+ const [otel] = toOtelFieldSchemas([edgeSchema])+ const paths = otel.fields.map((f) => f.path)+ expect(paths.slice(0, 5)).toEqual([+ 'id',+ 'timestamp',+ 'event_message',+ 'severity_text',+ 'source',+ ])+ })+ + it('moves non-column fields into log_attributes, dropping the metadata root', () => {+ const [otel] = toOtelFieldSchemas([edgeSchema])+ const paths = otel.fields.map((f) => f.path)+ expect(paths).toContain("log_attributes['identifier']")+ expect(paths).toContain("log_attributes['request.method']")+ expect(paths).toContain("log_attributes['request.cf.asn']")+ expect(paths).not.toContain('metadata.request.method')+ expect(paths).not.toContain('identifier')+ })+ + it('preserves the original field type for casting hints', () => {+ const [otel] = toOtelFieldSchemas([edgeSchema])+ const asn = otel.fields.find((f) => f.path === "log_attributes['request.cf.asn']")+ expect(asn?.type).toBe('number')+ })+})+ ⋯+describe('otelFieldsFromKeys', () => {+ it('starts with the five base OTEL columns', () => {+ const fields = otelFieldsFromKeys(['request.cf.asn'])+ const paths = fields.map((f) => f.path)+ expect(paths.slice(0, 5)).toEqual([+ 'id',+ 'timestamp',+ 'event_message',+ 'severity_text',+ 'source',+ ])+ })+ + it('maps discovered keys to log_attributes lookups', () => {+ const fields = otelFieldsFromKeys(['request.cf.asn', 'response.headers.x-foo'])+ const paths = fields.map((f) => f.path)+ expect(paths).toContain("log_attributes['request.cf.asn']")+ expect(paths).toContain("log_attributes['response.headers.x-foo']")+ })+ + it('excludes keys that are real OTEL columns', () => {+ const fields = otelFieldsFromKeys([+ 'id',+ 'timestamp',+ 'event_message',+ 'severity_text',+ 'source',+ 'trace_id',+ ])+ const paths = fields.map((f) => f.path)+ for (const column of ['id', 'timestamp', 'event_message', 'severity_text', 'source']) {+ expect(paths.filter((p) => p === column)).toHaveLength(1)+ expect(paths).not.toContain(`log_attributes['${column}']`)+ }+ expect(paths).toContain("log_attributes['trace_id']")+ })+ + it('escapes quotes and backslashes in discovered keys', () => {+ const fields = otelFieldsFromKeys(["weird'key", 'back\\slash'])+ const paths = fields.map((f) => f.path)+ expect(paths).toContain("log_attributes['weird''key']")+ expect(paths).toContain("log_attributes['back\\\\slash']")+ })+ + it('returns only base fields for an empty key list', () => {+ const fields = otelFieldsFromKeys([])+ expect(fields).toHaveLength(5)+ })+})
6 Tests/Docs
LogsQueryPanel.test.tsx — named export update
Updates the import to use the now-named export LogsQueryPanel.
LogsQueryPanel.import { screen } from '@testing-library/react' import { expect, test } from 'vitest' -import LogsQueryPanel from '@/components/interfaces/Settings/Logs/LogsQueryPanel'+import { LogsQueryPanel } from '@/components/interfaces/Settings/Logs/LogsQueryPanel' import { render } from '@/tests/helpers' test('run and clear', async () => {