ncr demo · supabase/supabase#47266 on GitHub · ← all examples ·
what to notice The two files a reader wants first sort dead last on GitHub; here they open the story.

feat(studio): AI-powered BigQuery→ClickHouse logs query rewrite · #47266

121/121 blocks placed ✓

This PR adds an AI-powered query rewrite flow for the Logs Explorer so that users' saved BigQuery-dialect queries continue to work after the backend moved to ClickHouse. The core new file is logs-sql-rewrite.ts, which builds a detailed prompt and calls the existing /api/ai/code/complete endpoint with a new dialect: 'clickhouse' flag. The API endpoint was updated to branch on that flag, skipping the Postgres schema fetch and using a dedicated ClickHouse system prompt and model. The Explorer page gains a banner (shown once, dismissal persisted) and a toolbar button that trigger the rewrite, then overlay a Monaco diff editor for accept/discard review. A parallel new file (Logs.fieldReference.ts) maps BigQuery-style field schemas to OTEL log_attributes paths, and a new data query (otel-log-keys-query.ts) discovers real log_attributes keys from live data so the prompt and Field Reference panel show exact paths. Read from the API contract inward: endpoint → rewrite logic → data layer → UI entry points → field reference → tests.

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.
Adds dialect: 'clickhouse' to the request schema and derives isClickhouse from it.
apps/studio/pages/api/ai/code/complete.ts · b089 b090 b091 · API contract / request schema
   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.
Introduces a separate model entry (gpt-5.4-nano with reasoningEffort: 'low') used only for log rewrites, selected when isClickhouse is true.
apps/studio/lib/ai/model.utils.ts · b085 b087 b092 · Model selection at the API layer
   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.
New system-prompt fragment that tells the model this is ClickHouse (not Postgres/BigQuery): single logs table, log_attributes map lookups, ClickHouse function names.
apps/studio/lib/ai/prompts.ts · b086 b088 · Prompt / system instructions
 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.
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.
apps/studio/pages/api/ai/code/complete.ts · b093 b094 b095 b096 b097 b098 b099 b100 · Handler logic
       ...(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.
apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts · b058 b059 b060 · Pure domain logic, no I/O
+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.
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.
apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts · b046 b047 b048 b049 b050 b051 b052 b053 b054 b055 b056 · Prompt construction, no I/O
+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.
Strips markdown code fences from the model response in case the model wraps output despite being told not to.
apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts · b057 · Output post-processing utility
+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.
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.
apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts · b061 b062 · Network call, depends on prompt builder and endpoint above
+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.
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.
apps/studio/data/logs/otel-log-keys-query.ts · b072 b073 b074 b075 · Data / analytics query
+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.
React Query wrapper around fetchOtelLogKeys with a 5-minute stale time; only fires when both projectRef and source are present and enabled is true.
apps/studio/data/logs/otel-log-keys-query.ts · b076 · Data hook wrapping the fetch
+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.
Imports rewriteLogsSqlWithAI, detectLogSource, looksLikeLegacyLogsQuery, DiffEditor, fetchOtelLogKeys, project/org hooks, and defines otelSourceQuery for OTEL-aware source insertion.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b101 b102 b103 b104 b105 b106 b107 b108 b109 · Page-level wiring
 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.
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.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b110 b111 · Page state
   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.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b112 · Primary orchestration logic for the feature
     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.
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.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b113 b114 · Source insertion behaviour change
     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.
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.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b115 b116 b117 · Lifecycle simplification
     }   }  -  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.
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.
apps/studio/pages/project/[ref]/logs/explorer/index.tsx · b118 b119 b120 · UI rendering
             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.
An Admonition-based banner with 'Rewrite with Assistant' and 'Dismiss' actions. Shown only until dismissed; dismissal is persisted so it doesn't reappear.
apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx · b016 b017 b018 · UI component, consumer of parent state
+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.
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.
apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx · b019 b020 b021 b022 b023 b024 b025 b026 b027 b028 · Component interface change
-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.
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.
apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx · b029 b030 b031 · Feature-flagged schema transformation in component
   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.
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.
apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx · b032 b033 b034 b035 b036 b037 b038 · UI restructuring and OTEL-aware field rendering
             </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.
Minor padding tweak to the error code block.
apps/studio/components/interfaces/Settings/Logs/LogsErrorRenderers/DefaultErrorRenderer.tsx · b015 · Visual fix in the same area
       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.
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.
apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts · b006 b007 b008 b009 · Domain constants
+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.
apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts · b010 b011 b012 b013 · Domain transformation
+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.
Produces a field list from dynamically discovered key strings (returned by fetchOtelLogKeys), filtering out keys that are already real columns to avoid duplicates.
apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.ts · b014 · Domain utility composing base fields + discovered keys
+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.
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.
apps/studio/components/ui/Logs/LogsExplorerHeader.tsx · b063 b064 b065 b066 · Component state / data fetching
+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.
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.
apps/studio/components/ui/Logs/LogsExplorerHeader.tsx · b067 b068 b069 b070 b071 · UI rendering, mirrors LogsQueryPanel changes
         >           <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.
Stabilising these callbacks is a prerequisite for including them in the simplified useEffect dependency array in the Explorer page without triggering infinite loops.
apps/studio/hooks/analytics/useLogsUrlState.ts · b077 b078 b079 b080 b081 b082 b083 b084 · Hook behaviour fix; enables the Explorer page effect change
 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).
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).
apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts · b039 b040 b041 b042 b043 b044 b045 · Unit tests
+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).
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).
apps/studio/components/interfaces/Settings/Logs/Logs.fieldReference.test.ts · b001 b002 b003 b004 b005 · Unit tests
+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.
Updates the import to use the now-named export LogsQueryPanel.
apps/studio/tests/features/logs/LogsQueryPanel.test.tsx · b121 · Test fixture update
 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 () => {