diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx
index b1496a1dc3c..05cfc78cf37 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx
@@ -214,7 +214,7 @@ export const QueueSettings = ({}: QueueSettingsProps) => {
toast.success('Successfully updated permissions')
setOpen(false)
} catch (error: unknown) {
- toast.error(`Failed to update permissions: ${getErrorMessage(error)}`)
+ toast.error(`Failed to update permissions: ${getErrorMessage(error, 'unknown error')}`)
} finally {
setIsSaving(false)
}
diff --git a/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx
new file mode 100644
index 00000000000..9bb41fe5430
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/LegacyLogsRewriteBanner.tsx
@@ -0,0 +1,77 @@
+import { useDebounce } from '@uidotdev/usehooks'
+import { useFlag } from 'common'
+import { useMemo } from 'react'
+
+import { DiffType } from './SQLEditor.types'
+import { useSqlEditorAssistant, useSqlEditorRun, useSqlEditorSnippet } from './SQLEditorControllers'
+import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition'
+import {
+ LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS,
+ shouldOfferLegacyLogsRewrite,
+} from '@/data/logs/logs-sql-rewrite'
+import { useLegacyLogsRewrite } from '@/hooks/analytics/useLegacyLogsRewrite'
+import {
+ getSqlEditorV2StateSnapshot,
+ useSqlEditorV2StateSnapshot,
+} from '@/state/sql-editor/sql-editor-state'
+
+/**
+ * Offers to rewrite a logs snippet still written in the old BigQuery dialect
+ * (per-service `FROM` tables, `unnest(metadata)` joins) to ClickHouse SQL. Legacy
+ * Logs Explorer saved queries open in the SQL editor as `log_sql` snippets, and
+ * those queries error against the ClickHouse-backed endpoint the editor runs them
+ * on — this is the in-editor path out.
+ *
+ * The request itself is `useLegacyLogsRewrite` (shared with the Logs Explorer);
+ * this decides when to offer it and routes the result into the editor's existing
+ * AI diff view, so the user accepts or discards it the same way as any other AI
+ * edit rather than having the snippet rewritten under them.
+ *
+ * Mount with `key={id}` so the offer resets when the user switches snippets. This
+ * component must NOT be conditionally mounted by its parent — it hides itself, so
+ * that opening a diff doesn't unmount it and throw away a dismissal.
+ */
+export const LegacyLogsRewriteBanner = () => {
+ const { id } = useSqlEditorSnippet()
+ const { runSource } = useSqlEditorRun()
+ const {
+ diff: { isDiffOpen, setSourceSqlDiff, setSelectedDiffType },
+ } = useSqlEditorAssistant()
+
+ const isOtelLogsEnabled = useFlag('otelLegacyLogs')
+ const snapV2 = useSqlEditorV2StateSnapshot()
+
+ // The store is written on every keystroke, so debounce before running the
+ // dialect heuristics — the banner's visibility doesn't need per-character
+ // precision, and a settled value avoids flapping mid-edit.
+ const liveSql = snapV2.snippets[id]?.snippet.content?.unchecked_sql ?? ''
+ const settledSql = useDebounce(liveSql, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS)
+
+ const isLogsSnippetNeedingRewrite = useMemo(
+ () =>
+ runSource.type === 'logs' &&
+ shouldOfferLegacyLogsRewrite({ sql: settledSql, isClickhouseLogsEnabled: isOtelLogsEnabled }),
+ [runSource.type, settledSql, isOtelLogsEnabled]
+ )
+
+ const { state, requestRewrite, dismiss } = useLegacyLogsRewrite({
+ // Rewrite exactly what's in the editor now, not the debounced value the
+ // visibility check used — they differ if the user clicked mid-edit.
+ readSql: () => getSqlEditorV2StateSnapshot().snippets[id]?.snippet.content?.unchecked_sql ?? '',
+ onProposal: ({ original, modified }) => {
+ setSourceSqlDiff({ original, modified })
+ setSelectedDiffType(DiffType.Modification)
+ },
+ })
+
+ // An outcome the user hasn't acknowledged stays up even once the query no longer
+ // looks legacy — otherwise a successful proposal would yank its own result away.
+ const hasUnacknowledgedOutcome = state.status === 'failed' || state.status === 'noRewriteNeeded'
+ const canShowBanner = !isDiffOpen && (isLogsSnippetNeedingRewrite || hasUnacknowledgedOutcome)
+
+ if (!canShowBanner) return null
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts
index 982ef10bb16..aeefbdcfe32 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts
@@ -29,6 +29,7 @@ import {
resolveConnectionString,
resolveDiffKeyAction,
shouldAutoGenerateTitle,
+ sqlSourceToDialect,
trimTrailingSemicolons,
} from './SQLEditor.utils'
import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query'
@@ -432,6 +433,31 @@ describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => {
completionMetadata: { prompt: 'add a where clause' },
})
})
+ test('omits dialect when not provided, so the route keeps its Postgres default', () => {
+ const body = buildCompletionRequestBody({
+ projectRef: 'default',
+ connectionString: null,
+ orgSlug: 'acme',
+ })
+ expect(body).not.toHaveProperty('dialect')
+ })
+ test('includes the dialect when provided', () => {
+ expect(
+ buildCompletionRequestBody({
+ projectRef: 'default',
+ connectionString: null,
+ orgSlug: 'acme',
+ dialect: 'clickhouse',
+ }).dialect
+ ).toBe('clickhouse')
+ })
+})
+
+describe('SQLEditor.utils.ts:sqlSourceToDialect', () => {
+ test('logs snippets get ClickHouse, database snippets get Postgres', () => {
+ expect(sqlSourceToDialect('logs')).toBe('clickhouse')
+ expect(sqlSourceToDialect('database')).toBe('postgres')
+ })
})
describe('SQLEditor.utils.ts:createSqlSnippetSkeletonV2', () => {
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
index aa1ba912080..33ec043398d 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts
@@ -497,25 +497,46 @@ export function assembleCompletionDiff(
}
/**
- * Builds the request body sent to the AI completion endpoint. `options` is
- * the caller-provided extra fields (e.g. `completionMetadata`), merged in
- * last so it can override the defaults if it ever needs to.
+ * The SQL dialect the AI writes. Mirrors the `dialect` enum the completion API
+ * route accepts — a snippet's dialect follows its source and never flips, so a
+ * logs snippet always gets ClickHouse SQL and a database snippet Postgres.
+ */
+export type SqlDialect = 'postgres' | 'clickhouse'
+
+/**
+ * Maps a snippet's query source to the dialect the AI should write in. Logs
+ * snippets run against the ClickHouse-backed analytics endpoint; everything
+ * else runs against the user's Postgres database.
+ */
+export function sqlSourceToDialect(source: SqlSnippetSource): SqlDialect {
+ return source === 'logs' ? 'clickhouse' : 'postgres'
+}
+
+/**
+ * Builds the request body sent to the AI completion endpoint. `dialect` is
+ * omitted when undefined so callers that don't care keep the route's Postgres
+ * default. `options` is the caller-provided extra fields (e.g.
+ * `completionMetadata`), merged in last so it can override the defaults if it
+ * ever needs to.
*/
export function buildCompletionRequestBody({
projectRef,
connectionString,
orgSlug,
+ dialect,
options,
}: {
projectRef: string | undefined
connectionString: string | undefined | null
orgSlug: string | undefined
+ dialect?: SqlDialect
options?: { completionMetadata?: unknown }
}): {
projectRef: string | undefined
connectionString: string | undefined | null
language: 'sql'
orgSlug: string | undefined
+ dialect?: SqlDialect
completionMetadata?: unknown
} {
return {
@@ -523,6 +544,7 @@ export function buildCompletionRequestBody({
connectionString,
language: 'sql',
orgSlug,
+ ...(dialect !== undefined && { dialect }),
...(options ?? {}),
}
}
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx
index 0aa70a05313..f4738c230a9 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx
@@ -191,7 +191,7 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) =>
const isExecuting = isExecutingDb || isExecutingLogs
- const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt })
+ const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt, sqlSource: runSource.type })
const { acceptAiHandler, discardAiHandler } = ai
useSqlEditorShortcuts({
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx
index 149a713f4e5..6215f6580d6 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx
@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic'
import { useCallback } from 'react'
import { cn } from 'ui'
+import { LegacyLogsRewriteBanner } from './LegacyLogsRewriteBanner'
import { useSQLEditorContext } from './SQLEditorContext'
import {
useSqlEditorAssistant,
@@ -171,23 +172,32 @@ const SQLEditorMainView = () => {
)
}
-/** The top (editor) resizable panel: loading state, diff view, and main editor. */
-export const SQLEditorEditorPanel = () => {
- const { isLoading } = useSqlEditorSnippet()
- const { diff } = useSqlEditorAssistant()
-
+function LoadingSpinner() {
return (
-
- {isLoading ? (
-
-
-
- ) : (
- <>
- {diff.isDiffOpen &&
}
-
- >
- )}
+
+ )
+}
+
+/** The top (editor) resizable panel: loading state, diff view, and main editor. */
+export const SQLEditorEditorPanel = () => {
+ const { id, isLoading } = useSqlEditorSnippet()
+ const { diff } = useSqlEditorAssistant()
+
+ if (isLoading) {
+ return
+ }
+
+ return (
+
+
+
+ {diff.isDiffOpen && }
+
+
)
}
diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx
index 244f4fc4431..60f2a399f3c 100644
--- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.test.tsx
@@ -1,13 +1,17 @@
import { act, waitFor } from '@testing-library/react'
+import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
+import type { SqlSnippetSource } from './querySource'
import { DiffType } from './SQLEditor.types'
import { useSqlEditorAi } from './useSqlEditorAi'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
+import { API_URL } from '@/lib/constants'
import { sidebarManagerState } from '@/state/sidebar-manager-state'
import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request'
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
+import { mswServer } from '@/tests/lib/msw'
import {
createInMemoryEditor,
renderSqlEditorHook,
@@ -18,15 +22,17 @@ import {
const SNIPPET_ID = 'ai-snippet'
+type HarnessProps = { editorMountCount?: number; sqlSource?: SqlSnippetSource }
+
/**
* Composes the diff + prompt state hooks the AI hook depends on (production
* wires these together in `SQLEditorControllers`), so tests drive the real
* accept/discard/drain flows end to end.
*/
-function useAiHarness({ editorMountCount = 1 }: { editorMountCount?: number } = {}) {
+function useAiHarness({ editorMountCount = 1, sqlSource = 'database' }: HarnessProps = {}) {
const diff = useSqlEditorDiff()
const prompt = useSqlEditorPrompt()
- const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt })
+ const ai = useSqlEditorAi({ id: SNIPPET_ID, editorMountCount, diff, prompt, sqlSource })
return { ai, diff, prompt }
}
@@ -119,6 +125,110 @@ describe('useSqlEditorAi — accept / discard diff', () => {
})
})
+describe('useSqlEditorAi — completion dialect', () => {
+ type CompletionRequestBody = {
+ dialect?: string
+ intent?: string
+ completionMetadata: {
+ prompt: string
+ selection: string
+ textBeforeCursor: string
+ textAfterCursor: string
+ availableKeys?: string[]
+ }
+ }
+
+ /** Replaces the default completion mock so we can read what was posted. */
+ function captureCompletionRequests(response: string) {
+ const bodies: CompletionRequestBody[] = []
+ mswServer.use(
+ http.post(`${API_URL}/ai/code/complete`, async ({ request }) => {
+ bodies.push((await request.json()) as CompletionRequestBody)
+ return HttpResponse.json(response)
+ })
+ )
+ return bodies
+ }
+
+ const context = {
+ beforeSelection: "select timestamp from logs where source = 'edge_logs'\n",
+ selection: 'limit 5',
+ afterSelection: '',
+ }
+
+ it('posts the clickhouse dialect with the raw instruction and cursor context', async () => {
+ const bodies = captureCompletionRequests('limit 10')
+ const { result } = renderSqlEditorHook(useAiHarness, {
+ initialProps: { sqlSource: 'logs' },
+ })
+
+ await act(async () => {
+ await result.current.ai.handlePrompt('only keep 5xx responses', context)
+ })
+
+ expect(bodies).toHaveLength(1)
+ expect(bodies[0].dialect).toBe('clickhouse')
+ // The route assembles the schema section and the selection-wrapped code around
+ // the instruction, so the client posts the instruction verbatim — the same
+ // shape as the Postgres path — and never hand-builds prompt text.
+ expect(bodies[0].completionMetadata.prompt).toBe('only keep 5xx responses')
+ expect(bodies[0].completionMetadata.selection).toBe('limit 5')
+ expect(bodies[0].completionMetadata.textBeforeCursor).toBe(context.beforeSelection)
+ // An inline edit is not a rewrite.
+ expect(bodies[0].intent).toBeUndefined()
+ })
+
+ it('posts the postgres dialect with the raw instruction for a database snippet', async () => {
+ const bodies = captureCompletionRequests('limit 10')
+ const { result } = renderSqlEditorHook(useAiHarness, {
+ initialProps: { sqlSource: 'database' },
+ })
+
+ await act(async () => {
+ await result.current.ai.handlePrompt('bump the limit', context)
+ })
+
+ expect(bodies).toHaveLength(1)
+ expect(bodies[0].dialect).toBe('postgres')
+ expect(bodies[0].completionMetadata.prompt).toBe('bump the limit')
+ })
+
+ it('strips code fences and leaves clickhouse output unformatted', async () => {
+ captureCompletionRequests('```sql\nlimit 10\n```')
+ const { result } = renderSqlEditorHook(useAiHarness, {
+ initialProps: { sqlSource: 'logs' },
+ })
+
+ await act(async () => {
+ await result.current.ai.handlePrompt('bump the limit', context)
+ })
+
+ await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true))
+ // sql-formatter is Postgres-only, so the ClickHouse diff is the reassembled
+ // query verbatim — fences stripped, nothing else touched.
+ expect(result.current.diff.sourceSqlDiff).toEqual({
+ original: `${context.beforeSelection}limit 5`,
+ modified: `${context.beforeSelection}limit 10`,
+ })
+ })
+
+ it('still formats database output through sql-formatter', async () => {
+ captureCompletionRequests('limit 10')
+ const { result } = renderSqlEditorHook(useAiHarness, {
+ initialProps: { sqlSource: 'database' },
+ })
+
+ await act(async () => {
+ await result.current.ai.handlePrompt('bump the limit', context)
+ })
+
+ await waitFor(() => expect(result.current.diff.isDiffOpen).toBe(true))
+ expect(result.current.diff.sourceSqlDiff?.modified).not.toBe(
+ `${context.beforeSelection}limit 10`
+ )
+ })
+})
+
describe('useSqlEditorAi — debug', () => {
it('onDebug opens the assistant sidebar and starts a debug chat from the failing snippet', async () => {
seedSnippet({ id: SNIPPET_ID, name: 'Broken query', sql: 'selct 1;' })
diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts
index 97c5e589d6b..240d3462404 100644
--- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts
+++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react
import { toast } from 'sonner'
import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
+import type { SqlSnippetSource } from './querySource'
import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types'
import {
assembleCompletionDiff,
@@ -13,12 +14,15 @@ import {
createSqlSnippetSkeletonV2,
extractDebugContext,
planDiffRequestApplication,
+ sqlSourceToDialect,
} from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { constructHeaders } from '@/data/fetchers'
+import { stripSqlCodeFences } from '@/data/logs/logs-sql-rewrite'
import { isError } from '@/data/utils/error-check'
+import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { BASE_PATH } from '@/lib/constants'
@@ -37,6 +41,11 @@ type UseSqlEditorAiArgs = {
editorMountCount: number
diff: ReturnType
prompt: ReturnType
+ /**
+ * Where the snippet runs. Selects the dialect the AI writes in — logs snippets
+ * get ClickHouse SQL for the `logs` table, database snippets get Postgres.
+ */
+ sqlSource: SqlSnippetSource
}
/**
@@ -45,7 +54,13 @@ type UseSqlEditorAiArgs = {
* lifecycle effects (one-shot diff-request drain, diff-editor value sync, and the
* ask-AI widget visibility).
*/
-export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEditorAiArgs) {
+export function useSqlEditorAi({
+ id,
+ editorMountCount,
+ diff,
+ prompt,
+ sqlSource,
+}: UseSqlEditorAiArgs) {
const {
sourceSqlDiff,
setSourceSqlDiff,
@@ -77,6 +92,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
const [showWidget, setShowWidget] = useState(false)
+ const dialect = sqlSourceToDialect(sqlSource)
+ const isClickhouse = dialect === 'clickhouse'
+
+ // Grounds ClickHouse edits in the source's real log_attributes keys, the same way
+ // the whole-query rewrite does — otherwise inline edits invent dotted paths.
+ // Looked up when the user submits, not while they type.
+ const { fetchAttributeKeys } = useLogsAttributeKeys()
+
const handleNewQuery = useCallback(
async (sql: string, name: string) => {
if (!ref) return console.error('Project ref is required')
@@ -196,6 +219,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
projectRef: project?.ref,
connectionString: project?.connectionString,
orgSlug: org?.slug,
+ dialect,
options: options?.body,
})
),
@@ -210,9 +234,16 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
const text: string = await response.json()
const meta = options?.body?.completionMetadata ?? {}
- const { original, modified } = assembleCompletionDiff(meta, text)
+ // The clickhouse system prompt forbids fences, but strip them defensively
+ // so a chatty model can't leak backticks into the snippet.
+ const { original, modified } = assembleCompletionDiff(
+ meta,
+ isClickhouse ? stripSqlCodeFences(text) : text
+ )
- const formattedModified = formatSql(modified)
+ // sql-formatter is Postgres-only — it mangles ClickHouse backticks and
+ // map lookups — so ClickHouse output goes into the diff unformatted.
+ const formattedModified = isClickhouse ? modified : formatSql(modified)
setSourceSqlDiff({ original, modified: formattedModified })
setSelectedDiffType(DiffType.Modification)
setPromptState((prev) => ({ ...prev, isLoading: false }))
@@ -224,6 +255,8 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
}
},
[
+ dialect,
+ isClickhouse,
org?.slug,
project?.connectionString,
project?.ref,
@@ -249,10 +282,23 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
beforeSelection: context.beforeSelection,
afterSelection: context.afterSelection,
}))
- const headerData = await constructHeaders()
+ // ClickHouse only: there's no server-side schema to fetch for the logs
+ // table, so the real log_attributes keys travel with the request. Detected
+ // from the whole document, which is what the three context fields spell.
+ const [headerData, availableKeys] = await Promise.all([
+ constructHeaders(),
+ isClickhouse
+ ? fetchAttributeKeys(
+ context.beforeSelection + context.selection + context.afterSelection
+ )
+ : undefined,
+ ])
const authorizationHeader = headerData.get('Authorization')
+ // The instruction goes over as-is for both dialects — the route assembles
+ // the schema section and the cursor context around it, so there's exactly
+ // one place that knows how a completion prompt is built.
await complete(prompt, {
...(authorizationHeader
? { headers: { Authorization: authorizationHeader } }
@@ -264,6 +310,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
language: 'pgsql',
prompt,
selection: context.selection,
+ ...(availableKeys ? { availableKeys } : {}),
},
},
})
@@ -271,7 +318,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
setPromptState((prev) => ({ ...prev, isLoading: false }))
}
},
- [complete, setPromptState]
+ [complete, fetchAttributeKeys, isClickhouse, setPromptState]
)
const handleDiffEditorMount = useCallback(
diff --git a/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx b/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx
new file mode 100644
index 00000000000..581c0b02334
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition.tsx
@@ -0,0 +1,107 @@
+import { Button } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+
+import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
+import type { LegacyLogsRewriteState } from '@/hooks/analytics/useLegacyLogsRewrite'
+
+interface LegacyLogsRewriteAdmonitionProps {
+ state: LegacyLogsRewriteState
+ onRewrite: () => void
+ onDismiss: () => void
+}
+
+const BANNER_CLASSES = 'mb-0 rounded-none border-x-0 border-t-0'
+
+/**
+ * Presentation for the BigQuery → ClickHouse rewrite offer, covering every state
+ * of `useLegacyLogsRewrite`. Shared by the Logs Explorer and the SQL editor so the
+ * copy and the outcome handling live in one place — a surface only decides *when*
+ * to show this, not what each state says.
+ *
+ * Renders nothing once dismissed; callers can also hide it earlier if they have
+ * their own reasons to (the SQL editor hides it while a diff is open).
+ */
+export const LegacyLogsRewriteAdmonition = ({
+ state,
+ onRewrite,
+ onDismiss,
+}: LegacyLogsRewriteAdmonitionProps) => {
+ if (state.status === 'dismissed') return null
+
+ if (state.status === 'failed') {
+ return (
+
+
+
+
+ }
+ />
+ )
+ }
+
+ // The dialect check is a heuristic, so an unchanged rewrite is the Assistant
+ // disagreeing with it. Say so and let the user close it, rather than proposing
+ // an empty diff or silently giving up.
+ if (state.status === 'noRewriteNeeded') {
+ return (
+
+ Dismiss
+
+ }
+ />
+ )
+ }
+
+ const isRewriting = state.status === 'rewriting'
+
+ return (
+
+
+ {/* Disabled mid-rewrite so the offer can't be dismissed out from under
+ an in-flight request — the two states stay mutually exclusive. */}
+
+ Dismiss
+
+
+ }
+ />
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx
deleted file mode 100644
index 4f9bac9cd34..00000000000
--- a/apps/studio/components/interfaces/Settings/Logs/LogsExplorerOtelBanner.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Button } from 'ui'
-import { Admonition } from 'ui-patterns/Admonition'
-
-interface LogsExplorerOtelBannerProps {
- isRewriting: boolean
- onRewrite: () => void
- onDismiss: () => void
-}
-
-export const LogsExplorerOtelBanner = ({
- isRewriting,
- onRewrite,
- onDismiss,
-}: LogsExplorerOtelBannerProps) => {
- return (
-
-
-
-
- }
- />
- )
-}
diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts
index 9aff8c9c58b..f2f8de82ddb 100644
--- a/apps/studio/data/logs/keys.ts
+++ b/apps/studio/data/logs/keys.ts
@@ -1,6 +1,8 @@
import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types'
export const logsKeys = {
+ otelLogKeys: (projectRef: string | undefined, source: string | undefined) =>
+ ['projects', projectRef, 'otel-log-keys', source] as const,
unifiedLogsInfinite: (
projectRef: string | undefined,
searchParams: QuerySearchParamsType | undefined
diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts b/apps/studio/data/logs/logs-sql-rewrite.test.ts
similarity index 63%
rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts
rename to apps/studio/data/logs/logs-sql-rewrite.test.ts
index 1b4f0646410..6e7a80c94ad 100644
--- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.test.ts
+++ b/apps/studio/data/logs/logs-sql-rewrite.test.ts
@@ -1,45 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
- buildClickhouseRewritePrompt,
detectLogSource,
looksLikeLegacyLogsQuery,
rewriteLogsSqlWithAI,
+ shouldOfferLegacyLogsRewrite,
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')
+describe('shouldOfferLegacyLogsRewrite', () => {
+ const legacySql = 'select 1 from edge_logs cross join unnest(metadata) as m'
+
+ it('offers the rewrite for BigQuery-dialect SQL once logs run on ClickHouse', () => {
+ expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: true })).toBe(
+ true
+ )
})
- 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('never offers it on a non-migrated org, where the BigQuery SQL is still correct', () => {
+ expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: false })).toBe(
+ false
+ )
})
- 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')
+ it('does not offer it for SQL that is already ClickHouse, or for empty SQL', () => {
+ expect(
+ shouldOfferLegacyLogsRewrite({
+ sql: "select timestamp from logs where source = 'edge_logs' limit 5",
+ isClickhouseLogsEnabled: true,
+ })
+ ).toBe(false)
+ expect(shouldOfferLegacyLogsRewrite({ sql: '', isClickhouseLogsEnabled: true })).toBe(false)
})
})
@@ -66,6 +57,21 @@ describe('detectLogSource', () => {
it('returns undefined when nothing matches', () => {
expect(detectLogSource('select 1')).toBeUndefined()
})
+
+ it('ignores a column that merely ends in "source"', () => {
+ expect(detectLogSource("select 1 from logs where resource = 'nope'")).toBeUndefined()
+ expect(detectLogSource("select 1 from logs where datasource = 'nope'")).toBeUndefined()
+ })
+
+ it('still reads a qualified source column', () => {
+ expect(detectLogSource("select 1 from logs t where t.source = 'auth_logs'")).toBe('auth_logs')
+ })
+
+ it('prefers the real source column over a lookalike earlier in the query', () => {
+ expect(detectLogSource("select resource = 'nope' from logs where source = 'edge_logs'")).toBe(
+ 'edge_logs'
+ )
+ })
})
describe('looksLikeLegacyLogsQuery', () => {
@@ -112,7 +118,7 @@ describe('rewriteLogsSqlWithAI', () => {
vi.unstubAllGlobals()
})
- it('posts to the completion endpoint and returns the cleaned query', async () => {
+ it('declares the rewrite intent and sends the query as the selection', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => '```sql\nselect 1 from logs\n```',
@@ -122,6 +128,7 @@ describe('rewriteLogsSqlWithAI', () => {
const result = await rewriteLogsSqlWithAI({
sql: 'select 1 from edge_logs',
projectRef: 'abc',
+ availableKeys: ['request.method'],
})
expect(result).toBe('select 1 from logs')
@@ -129,8 +136,14 @@ describe('rewriteLogsSqlWithAI', () => {
expect(url).toContain('/api/ai/code/complete')
const body = JSON.parse(init.body)
expect(body.dialect).toBe('clickhouse')
+ expect(body.intent).toBe('rewrite')
+ // The whole query is the selection, so the rewrite replaces all of it.
expect(body.completionMetadata.selection).toBe('select 1 from edge_logs')
- expect(body.completionMetadata.prompt.toLowerCase()).toContain('reply with only')
+ expect(body.completionMetadata.textBeforeCursor).toBe('')
+ expect(body.completionMetadata.textAfterCursor).toBe('')
+ expect(body.completionMetadata.availableKeys).toEqual(['request.method'])
+ // No prompt text is carried client-side — the route owns the instruction.
+ expect(body.completionMetadata.prompt).toBe('')
})
it('throws when the request fails', async () => {
diff --git a/apps/studio/data/logs/logs-sql-rewrite.ts b/apps/studio/data/logs/logs-sql-rewrite.ts
new file mode 100644
index 00000000000..f9c21d798bd
--- /dev/null
+++ b/apps/studio/data/logs/logs-sql-rewrite.ts
@@ -0,0 +1,113 @@
+import { BASE_PATH } from '@/lib/constants'
+
+export function stripSqlCodeFences(text: string): string {
+ const trimmed = text.trim()
+ const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
+ return (fenced ? fenced[1] : trimmed).trim()
+}
+
+const SOURCE_ALIASES: Record = {
+ pg_cron_logs: 'postgres_logs',
+}
+
+export function detectLogSource(sql: string): string | undefined {
+ // `\b` so only a standalone `source` column counts — an unanchored match reads
+ // the value out of `resource = '...'` or `datasource = '...'` too.
+ const bySource = sql.match(/\bsource\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
+}
+
+/**
+ * How long to let the query text settle before re-running the dialect check.
+ * Shared so every surface offering the rewrite reacts on the same cadence.
+ */
+export const LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS = 500
+
+/**
+ * Whether to offer the ClickHouse rewrite for a query. Both the flag and the
+ * dialect check matter: on an org whose logs haven't moved to ClickHouse the
+ * BigQuery text is still *correct*, so offering to rewrite it would break a
+ * working query. Callers layer their own dismissal state on top.
+ */
+export function shouldOfferLegacyLogsRewrite({
+ sql,
+ isClickhouseLogsEnabled,
+}: {
+ sql: string
+ isClickhouseLogsEnabled: boolean
+}): boolean {
+ return isClickhouseLogsEnabled && looksLikeLegacyLogsQuery(sql)
+}
+
+export interface RewriteLogsSqlArgs {
+ sql: string
+ projectRef: string
+ connectionString?: string | null
+ orgSlug?: string
+ authorizationHeader?: string | null
+ availableKeys?: string[]
+}
+
+/**
+ * Asks the completion route to rewrite a whole BigQuery logs query as ClickHouse
+ * SQL. The prompt itself lives server-side (`lib/ai/clickhouse-logs.ts`) — this
+ * only declares the intent and hands the query over as the selection, the same
+ * shape an inline edit uses, so exactly one place knows how a completion prompt
+ * is assembled.
+ */
+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',
+ intent: 'rewrite',
+ orgSlug,
+ completionMetadata: {
+ // The whole query is the selection, so the rewrite replaces all of it and
+ // the route supplies the instruction for the `rewrite` intent.
+ textBeforeCursor: '',
+ textAfterCursor: '',
+ prompt: '',
+ selection: sql,
+ availableKeys,
+ },
+ }),
+ })
+
+ 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
+}
diff --git a/apps/studio/data/logs/otel-log-keys-query.ts b/apps/studio/data/logs/otel-log-keys-query.ts
index c4918af0af8..33118f09a4c 100644
--- a/apps/studio/data/logs/otel-log-keys-query.ts
+++ b/apps/studio/data/logs/otel-log-keys-query.ts
@@ -1,11 +1,14 @@
-import { useQuery } from '@tanstack/react-query'
+import { queryOptions, useQuery } from '@tanstack/react-query'
import { executeAnalyticsSql } from './execute-analytics-sql'
+import { logsKeys } from './keys'
import { logsAllEndpointUrl } from './logs-endpoint'
import { analyticsLiteral, safeSql } from './safe-analytics-sql'
const LOOKBACK_HOURS = 24 * 7
+const KEYS_STALE_TIME = 5 * 60 * 1000
+
export async function fetchOtelLogKeys({
projectRef,
source,
@@ -31,15 +34,31 @@ export async function fetchOtelLogKeys({
return rows.map((r) => r.key).filter(Boolean)
}
+/**
+ * Shared by the reactive hook and imperative `queryClient.fetchQuery` callers, so
+ * a lookup triggered on submit reuses whatever a subscribed component already
+ * cached for the same source (and vice versa).
+ */
+export function otelLogKeysQueryOptions({
+ projectRef,
+ source,
+}: {
+ projectRef: string
+ source: string
+}) {
+ return queryOptions({
+ queryKey: logsKeys.otelLogKeys(projectRef, source),
+ queryFn: ({ signal }) => fetchOtelLogKeys({ projectRef, source, signal }),
+ staleTime: KEYS_STALE_TIME,
+ })
+}
+
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 }),
+ ...otelLogKeysQueryOptions({ projectRef: projectRef ?? '', source: source ?? '' }),
enabled: enabled && Boolean(projectRef) && Boolean(source),
- staleTime: 5 * 60 * 1000,
})
}
diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx
new file mode 100644
index 00000000000..0dd6b1d4438
--- /dev/null
+++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.test.tsx
@@ -0,0 +1,163 @@
+import { act, waitFor } from '@testing-library/react'
+import { delay, http, HttpResponse } from 'msw'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ INITIAL_LEGACY_LOGS_REWRITE_STATE,
+ legacyLogsRewriteReducer as reduce,
+ useLegacyLogsRewrite,
+ type LegacyLogsRewriteEvent,
+ type LegacyLogsRewriteState,
+} from './useLegacyLogsRewrite'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { API_URL } from '@/lib/constants'
+import { mswServer } from '@/tests/lib/msw'
+import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
+
+const run = (
+ events: LegacyLogsRewriteEvent[],
+ from: LegacyLogsRewriteState = INITIAL_LEGACY_LOGS_REWRITE_STATE
+) => events.reduce(reduce, from)
+
+const FAILED: LegacyLogsRewriteEvent = { type: 'rewriteFailed', message: 'boom' }
+
+describe('legacyLogsRewriteReducer', () => {
+ it('starts out offering the rewrite', () => {
+ expect(INITIAL_LEGACY_LOGS_REWRITE_STATE).toEqual({ status: 'offered' })
+ })
+
+ it('requesting a rewrite moves to rewriting, and a proposal returns to offered', () => {
+ expect(run([{ type: 'rewriteRequested' }])).toEqual({ status: 'rewriting' })
+ expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteProposed' }])).toEqual({
+ status: 'offered',
+ })
+ })
+
+ it('a failure lands in failed and keeps its message for the UI', () => {
+ expect(run([{ type: 'rewriteRequested' }, FAILED])).toEqual({
+ status: 'failed',
+ message: 'boom',
+ })
+ })
+
+ it('a failure is recoverable — the same request retries it', () => {
+ expect(run([{ type: 'rewriteRequested' }, FAILED, { type: 'rewriteRequested' }])).toEqual({
+ status: 'rewriting',
+ })
+ })
+
+ it('an unchanged response waits for acknowledgement instead of retiring silently', () => {
+ const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }])
+ expect(noop).toEqual({ status: 'noRewriteNeeded' })
+ expect(run([{ type: 'dismissed' }], noop)).toEqual({ status: 'dismissed' })
+ })
+
+ it('both outcomes can be dismissed, and neither can be retried into a new outcome', () => {
+ const failed = run([{ type: 'rewriteRequested' }, FAILED])
+ expect(run([{ type: 'dismissed' }], failed)).toEqual({ status: 'dismissed' })
+ // noRewriteNeeded only accepts dismissal — no silent retry.
+ const noop = run([{ type: 'rewriteRequested' }, { type: 'rewriteNoop' }])
+ expect(run([{ type: 'rewriteRequested' }], noop)).toEqual({ status: 'noRewriteNeeded' })
+ })
+
+ it('dismissal is terminal — nothing resurrects the offer', () => {
+ const dismissed = run([{ type: 'dismissed' }])
+ expect(dismissed).toEqual({ status: 'dismissed' })
+ expect(
+ run(
+ [
+ { type: 'rewriteRequested' },
+ { type: 'rewriteProposed' },
+ FAILED,
+ { type: 'rewriteNoop' },
+ ],
+ dismissed
+ )
+ ).toEqual({ status: 'dismissed' })
+ })
+
+ it('cannot be dismissed mid-rewrite, so a settling request never resurrects it', () => {
+ expect(run([{ type: 'rewriteRequested' }, { type: 'dismissed' }])).toEqual({
+ status: 'rewriting',
+ })
+ })
+
+ it('ignores events that are invalid for the current state', () => {
+ // No rewrite in flight to settle.
+ expect(run([{ type: 'rewriteProposed' }])).toEqual({ status: 'offered' })
+ expect(run([{ type: 'rewriteNoop' }])).toEqual({ status: 'offered' })
+ expect(run([FAILED])).toEqual({ status: 'offered' })
+ // Already rewriting; a second request is a no-op rather than a restart.
+ expect(run([{ type: 'rewriteRequested' }, { type: 'rewriteRequested' }])).toEqual({
+ status: 'rewriting',
+ })
+ })
+})
+
+describe('useLegacyLogsRewrite — dismiss', () => {
+ // No detectable source, so key discovery is skipped and the only outbound
+ // request is the completion call we control below.
+ const SQL_WITHOUT_SOURCE = 'select 1 from logs limit 5'
+
+ /** Keeps a requested rewrite in flight for the duration of the test. */
+ function stallTheRewrite() {
+ mswServer.use(
+ http.post(`${API_URL}/ai/code/complete`, async () => {
+ await delay(10_000)
+ return HttpResponse.json('select 1 from logs')
+ })
+ )
+ }
+
+ /**
+ * Exposes the resolved project alongside the hook: `requestRewrite` no-ops
+ * without a project ref, so tests must wait for that query before asking.
+ */
+ async function renderDismissHarness() {
+ const onDismissed = vi.fn()
+ const utils = renderSqlEditorHook(() => {
+ const { data: project } = useSelectedProjectQuery()
+ const rewrite = useLegacyLogsRewrite({
+ readSql: () => SQL_WITHOUT_SOURCE,
+ onProposal: vi.fn(),
+ onDismissed,
+ })
+ return { ...rewrite, projectRef: project?.ref }
+ })
+ await waitFor(() => expect(utils.result.current.projectRef).toBe('default'))
+ return { ...utils, onDismissed }
+ }
+
+ beforeEach(() => {
+ setupSqlEditorMocks()
+ })
+
+ it('dismisses from the offer and reports it', async () => {
+ const { result, onDismissed } = await renderDismissHarness()
+
+ await act(async () => {
+ result.current.dismiss()
+ })
+
+ expect(result.current.state.status).toBe('dismissed')
+ expect(onDismissed).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not report a dismissal the machine rejects mid-rewrite', async () => {
+ stallTheRewrite()
+ const { result, onDismissed } = await renderDismissHarness()
+
+ act(() => {
+ void result.current.requestRewrite()
+ })
+ await waitFor(() => expect(result.current.state.status).toBe('rewriting'))
+
+ await act(async () => {
+ result.current.dismiss()
+ })
+
+ // Persisting this would suppress an offer that's still live.
+ expect(onDismissed).not.toHaveBeenCalled()
+ expect(result.current.state.status).toBe('rewriting')
+ })
+})
diff --git a/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts
new file mode 100644
index 00000000000..9b0ac2aa2a5
--- /dev/null
+++ b/apps/studio/hooks/analytics/useLegacyLogsRewrite.ts
@@ -0,0 +1,162 @@
+import { useReducer } from 'react'
+
+import { constructHeaders } from '@/data/fetchers'
+import { rewriteLogsSqlWithAI } from '@/data/logs/logs-sql-rewrite'
+import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys'
+import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { getErrorMessage } from '@/lib/get-error-message'
+
+export type LegacyLogsRewriteState =
+ | { status: 'offered' }
+ | { status: 'rewriting' }
+ | { status: 'failed'; message: string }
+ | { status: 'noRewriteNeeded' }
+ | { status: 'dismissed' }
+
+export type LegacyLogsRewriteEvent =
+ | { type: 'rewriteRequested' }
+ | { type: 'rewriteProposed' }
+ | { type: 'rewriteFailed'; message: string }
+ | { type: 'rewriteNoop' }
+ | { type: 'dismissed' }
+
+export const INITIAL_LEGACY_LOGS_REWRITE_STATE: LegacyLogsRewriteState = { status: 'offered' }
+
+/**
+ * The events each state accepts. Anything absent is an invalid transition and
+ * leaves the state untouched — notably `dismissed` is terminal, and the offer
+ * can't be dismissed mid-rewrite.
+ */
+const VALID_EVENTS: {
+ [S in LegacyLogsRewriteState['status']]: readonly LegacyLogsRewriteEvent['type'][]
+} = {
+ offered: ['rewriteRequested', 'dismissed'],
+ rewriting: ['rewriteProposed', 'rewriteFailed', 'rewriteNoop'],
+ // A failure is recoverable: the same Rewrite control retries it.
+ failed: ['rewriteRequested', 'dismissed'],
+ noRewriteNeeded: ['dismissed'],
+ dismissed: [],
+}
+
+function targetState(event: LegacyLogsRewriteEvent): LegacyLogsRewriteState {
+ switch (event.type) {
+ case 'rewriteRequested':
+ return { status: 'rewriting' }
+ // The proposal is handed to the caller; the offer returns to idle behind it so
+ // it's ready again if the user discards the proposal.
+ case 'rewriteProposed':
+ return { status: 'offered' }
+ case 'rewriteFailed':
+ return { status: 'failed', message: event.message }
+ case 'rewriteNoop':
+ return { status: 'noRewriteNeeded' }
+ case 'dismissed':
+ return { status: 'dismissed' }
+ }
+}
+
+export function legacyLogsRewriteReducer(
+ state: LegacyLogsRewriteState,
+ event: LegacyLogsRewriteEvent
+): LegacyLogsRewriteState {
+ if (!VALID_EVENTS[state.status].includes(event.type)) return state
+ return targetState(event)
+}
+
+const CHANGED_WHILE_REWRITING_MESSAGE =
+ 'The query changed while the Assistant was working, so the rewrite no longer matches it.'
+
+const NO_RESPONSE_MESSAGE = 'The Assistant did not respond. Try again.'
+
+export type LegacyLogsRewriteProposal = { original: string; modified: string }
+
+type UseLegacyLogsRewriteArgs = {
+ /**
+ * Reads the query to rewrite at the moment the user asks. A callback rather than
+ * a value so the rewrite operates on exactly what the user sees, not on whatever
+ * a surface last rendered.
+ */
+ readSql: () => string
+ /** Receives a rewrite worth reviewing. Each surface routes this to its own diff. */
+ onProposal: (proposal: LegacyLogsRewriteProposal) => void
+ /**
+ * Called when the offer is dismissed, for surfaces that persist that. The
+ * machine covers the current session only — a surface that remembers dismissals
+ * across sessions layers that on top of its own visibility check, since a value
+ * read from storage isn't available in time to seed the machine.
+ */
+ onDismissed?: () => void
+}
+
+/**
+ * Owns the BigQuery → ClickHouse rewrite request end to end: key discovery, the
+ * completion call, the stale-edit guard, no-op detection, and the resulting state.
+ */
+export function useLegacyLogsRewrite({
+ readSql,
+ onProposal,
+ onDismissed,
+}: UseLegacyLogsRewriteArgs) {
+ const { data: project } = useSelectedProjectQuery()
+ const { data: organization } = useSelectedOrganizationQuery()
+ const projectRef = project?.ref
+
+ const [state, dispatch] = useReducer(legacyLogsRewriteReducer, INITIAL_LEGACY_LOGS_REWRITE_STATE)
+
+ const { fetchAttributeKeys } = useLogsAttributeKeys()
+
+ const requestRewrite = async () => {
+ if (!projectRef) return console.error('[useLegacyLogsRewrite] Project ref is required')
+
+ const currentSql = readSql()
+ if (currentSql.trim().length === 0) return
+
+ dispatch({ type: 'rewriteRequested' })
+ try {
+ const [headerData, availableKeys] = await Promise.all([
+ constructHeaders(),
+ fetchAttributeKeys(currentSql),
+ ])
+ const rewritten = await rewriteLogsSqlWithAI({
+ sql: currentSql,
+ projectRef,
+ connectionString: project?.connectionString,
+ orgSlug: organization?.slug,
+ authorizationHeader: headerData.get('Authorization'),
+ availableKeys,
+ })
+
+ // The user may have kept typing while the model worked; a proposal built from
+ // stale text would clobber those edits when accepted.
+ if (readSql() !== currentSql) {
+ dispatch({ type: 'rewriteFailed', message: CHANGED_WHILE_REWRITING_MESSAGE })
+ return
+ }
+
+ // An unchanged response means the query already runs on ClickHouse and the
+ // dialect heuristic was over-eager. Proposing it would show an empty diff.
+ if (rewritten.trim() === currentSql.trim()) {
+ dispatch({ type: 'rewriteNoop' })
+ return
+ }
+
+ onProposal({ original: currentSql, modified: rewritten })
+ dispatch({ type: 'rewriteProposed' })
+ } catch (error) {
+ dispatch({ type: 'rewriteFailed', message: getErrorMessage(error, NO_RESPONSE_MESSAGE) })
+ }
+ }
+
+ const dismiss = () => {
+ const dismissed: LegacyLogsRewriteEvent = { type: 'dismissed' }
+ // The transition table is the contract, not the UI that happens to disable the
+ // control: never report a dismissal the machine rejected (mid-rewrite, say),
+ // or a surface that persists it would suppress an offer that's still live.
+ if (legacyLogsRewriteReducer(state, dismissed) === state) return
+ dispatch(dismissed)
+ onDismissed?.()
+ }
+
+ return { state, requestRewrite, dismiss }
+}
diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx
new file mode 100644
index 00000000000..8b9577015b1
--- /dev/null
+++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx
@@ -0,0 +1,124 @@
+import { act, waitFor } from '@testing-library/react'
+import { HttpResponse } from 'msw'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { useLogsAttributeKeys } from './useLogsAttributeKeys'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { addAPIMock } from '@/tests/lib/msw'
+import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
+
+const OTEL_ENDPOINT = '/platform/projects/:ref/analytics/endpoints/logs.all.otel'
+
+const queryFor = (source: string) => `select 1 from logs where source = '${source}'`
+
+/** Records the SQL of every key-discovery request so we can count them. */
+function mockKeyDiscovery({ fails = false }: { fails?: boolean } = {}) {
+ const requests: string[] = []
+ addAPIMock({
+ method: 'post',
+ path: OTEL_ENDPOINT,
+ response: async ({ request }) => {
+ const body = (await request.clone().json()) as { sql: string }
+ requests.push(body.sql)
+ if (fails) return HttpResponse.json({ message: 'boom' }, { status: 500 })
+ return HttpResponse.json({ result: [{ key: 'request.method' }] })
+ },
+ })
+ return requests
+}
+
+/**
+ * Exposes the resolved project alongside the hook. Discovery needs a project ref,
+ * so tests must wait for that query before asking — otherwise a lookup no-ops and
+ * the request counts below would pass for the wrong reason.
+ */
+function useKeysHarness() {
+ const { data: project } = useSelectedProjectQuery()
+ const { fetchAttributeKeys } = useLogsAttributeKeys()
+ return { projectRef: project?.ref, fetchAttributeKeys }
+}
+
+async function renderReadyHarness() {
+ const utils = renderSqlEditorHook(useKeysHarness)
+ await waitFor(() => expect(utils.result.current.projectRef).toBe('default'))
+ return utils
+}
+
+beforeEach(() => {
+ setupSqlEditorMocks()
+})
+
+describe('useLogsAttributeKeys', () => {
+ it('makes no request until asked', async () => {
+ const requests = mockKeyDiscovery()
+
+ await renderReadyHarness()
+
+ expect(requests).toHaveLength(0)
+ })
+
+ it('returns the discovered keys for the query source when asked', async () => {
+ const requests = mockKeyDiscovery()
+ const { result } = await renderReadyHarness()
+
+ let keys: string[] | undefined
+ await act(async () => {
+ keys = await result.current.fetchAttributeKeys(queryFor('edge_logs'))
+ })
+
+ expect(keys).toEqual(['request.method'])
+ expect(requests).toHaveLength(1)
+ expect(requests[0]).toContain("source = 'edge_logs'")
+ })
+
+ it('reuses the cached result for a source already looked up', async () => {
+ const requests = mockKeyDiscovery()
+ const { result } = await renderReadyHarness()
+
+ await act(async () => {
+ await result.current.fetchAttributeKeys(queryFor('edge_logs'))
+ await result.current.fetchAttributeKeys(queryFor('edge_logs'))
+ })
+
+ expect(requests).toHaveLength(1)
+ })
+
+ it('looks up a different source separately', async () => {
+ const requests = mockKeyDiscovery()
+ const { result } = await renderReadyHarness()
+
+ await act(async () => {
+ await result.current.fetchAttributeKeys(queryFor('edge_logs'))
+ await result.current.fetchAttributeKeys(queryFor('postgres_logs'))
+ })
+
+ expect(requests).toHaveLength(2)
+ expect(requests[1]).toContain("source = 'postgres_logs'")
+ })
+
+ it('resolves undefined without a request when no source is detectable', async () => {
+ const requests = mockKeyDiscovery()
+ const { result } = await renderReadyHarness()
+
+ let keys: string[] | undefined
+ await act(async () => {
+ keys = await result.current.fetchAttributeKeys('select 1 from logs limit 5')
+ })
+
+ expect(keys).toBeUndefined()
+ expect(requests).toHaveLength(0)
+ })
+
+ it('resolves undefined rather than throwing when discovery fails', async () => {
+ mockKeyDiscovery({ fails: true })
+ const { result } = await renderReadyHarness()
+
+ let keys: string[] | undefined
+ await act(async () => {
+ keys = await result.current.fetchAttributeKeys(queryFor('edge_logs'))
+ })
+
+ // Keys are an enhancement — a failed lookup must not block the caller.
+ expect(keys).toBeUndefined()
+ })
+})
diff --git a/apps/studio/hooks/analytics/useLogsAttributeKeys.ts b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts
new file mode 100644
index 00000000000..52a20be8985
--- /dev/null
+++ b/apps/studio/hooks/analytics/useLogsAttributeKeys.ts
@@ -0,0 +1,43 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useCallback } from 'react'
+
+import { detectLogSource } from '@/data/logs/logs-sql-rewrite'
+import { otelLogKeysQueryOptions } from '@/data/logs/otel-log-keys-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+
+/**
+ * Looks up the real `log_attributes` keys for whichever source a logs query
+ * targets. The AI flows pass these along so the model uses exact dotted paths
+ * instead of inventing them.
+ *
+ * Deliberately imperative: discovery aggregates a week of logs, and the source is
+ * derived from query text the user is editing, so anything reactive fires requests
+ * for half-typed source names. Fetching at submit time means one request per
+ * action the user actually took. It still goes through the query client, so a
+ * result already cached for that source (by an earlier submit, or by a component
+ * subscribed via `useOtelLogKeysQuery`) is reused rather than refetched.
+ *
+ * Keys are an enhancement, never a requirement — a failed or impossible lookup
+ * resolves to `undefined` and the caller proceeds without them.
+ */
+export function useLogsAttributeKeys() {
+ const queryClient = useQueryClient()
+ const { data: project } = useSelectedProjectQuery()
+ const projectRef = project?.ref
+
+ const fetchAttributeKeys = useCallback(
+ async (sql: string): Promise => {
+ const source = detectLogSource(sql)
+ if (!projectRef || source === undefined) return undefined
+
+ try {
+ return await queryClient.fetchQuery(otelLogKeysQueryOptions({ projectRef, source }))
+ } catch {
+ return undefined
+ }
+ },
+ [projectRef, queryClient]
+ )
+
+ return { fetchAttributeKeys }
+}
diff --git a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts b/apps/studio/lib/ai/clickhouse-logs.ts
similarity index 50%
rename from apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts
rename to apps/studio/lib/ai/clickhouse-logs.ts
index ca532f204a4..b50e17e05e2 100644
--- a/apps/studio/components/interfaces/Settings/Logs/logs-sql-rewrite.ts
+++ b/apps/studio/lib/ai/clickhouse-logs.ts
@@ -1,12 +1,34 @@
-import { BASE_PATH } from '@/lib/constants'
+/**
+ * Everything the model needs to know about the ClickHouse-backed Supabase `logs`
+ * table, in one place. Both ClickHouse completion flows — the inline "edit this
+ * query" path and the whole-query BigQuery rewrite — are assembled from these,
+ * server-side in `pages/api/ai/code/complete.ts`, so there is exactly one
+ * description of the schema to keep current.
+ */
-export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns:
+/** System-prompt half: the dialect rules that hold for every ClickHouse request. */
+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.
+- Do not use \`select *\`, this is disallowed by the backend.
+`
+
+/**
+ * User-message half: the concrete shape of the table. Complements the dialect
+ * rules above — this is the part that changes when a log source or its fields
+ * change, and it is the ClickHouse counterpart to the Postgres DDL section.
+ */
+const CLICKHOUSE_LOGS_COLUMN_REFERENCE = `The logs table 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.
+- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']
Sources and their common log_attributes keys:
- edge_logs: request.method, request.path, request.search, response.status_code, identifier
@@ -17,7 +39,7 @@ Sources and their common log_attributes keys:
- 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).`
+The editor applies the user's selected time range as a request parameter, so an explicit timestamp filter is usually unnecessary.`
function renderAvailableKeys(availableKeys?: string[]): string {
if (!availableKeys || availableKeys.length === 0) return ''
@@ -26,10 +48,25 @@ function renderAvailableKeys(availableKeys?: string[]): string {
${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:
+/**
+ * The ClickHouse schema section of the user message — the counterpart to
+ * `buildDatabaseSchemaSection` for Postgres. `availableKeys` are the real
+ * `log_attributes` keys observed for the query's source, when the caller
+ * discovered them.
+ */
+export function buildClickhouseLogsSchemaSection(availableKeys?: string[]): string {
+ return `${CLICKHOUSE_LOGS_COLUMN_REFERENCE}\n${renderAvailableKeys(availableKeys)}`
+}
+
+/**
+ * The instruction for the whole-query BigQuery → ClickHouse rewrite. Used in
+ * place of a user instruction when the request's intent is `rewrite`.
+ *
+ * It states that a rewrite is REQUIRED: the system prompt covers writing and
+ * editing ClickHouse SQL generally, and without an explicit demand here the model
+ * echoes the input back, which surfaces to the user as an empty diff.
+ */
+export const CLICKHOUSE_LOGS_REWRITE_INSTRUCTION = `Your task is to REWRITE the selected query. It is BigQuery SQL and will not run on ClickHouse, so returning it unchanged is likely wrong — every rule below that applies must be applied.
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".
@@ -56,87 +93,4 @@ where source = 'postgres_logs'
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
group by log_attributes['parsed.error_severity']
order by count desc
-limit 100
-
-Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences.
-
-${sql}`
-}
-
-export function stripSqlCodeFences(text: string): string {
- const trimmed = text.trim()
- const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
- return (fenced ? fenced[1] : trimmed).trim()
-}
-
-const SOURCE_ALIASES: Record = {
- pg_cron_logs: 'postgres_logs',
-}
-
-export function detectLogSource(sql: string): string | undefined {
- const bySource = sql.match(/source\s*=\s*'([^']+)'/i)
- if (bySource) {
- const source = bySource[1].toLowerCase()
- return SOURCE_ALIASES[source] ?? source
- }
- const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i)
- if (byFrom) {
- const table = byFrom[1].toLowerCase()
- if (table === 'logs') return undefined
- return SOURCE_ALIASES[table] ?? table
- }
- return undefined
-}
-
-export function looksLikeLegacyLogsQuery(sql: string): boolean {
- const lower = sql.toLowerCase()
- if (/\bunnest\s*\(/.test(lower)) return true
- if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true
- const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/)
- return byFrom ? byFrom[1] !== 'logs' : false
-}
-
-export interface RewriteLogsSqlArgs {
- sql: string
- projectRef: string
- connectionString?: string | null
- orgSlug?: string
- authorizationHeader?: string | null
- availableKeys?: string[]
-}
-
-export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) {
- const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args
-
- const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
- },
- body: JSON.stringify({
- projectRef,
- connectionString,
- language: 'sql',
- dialect: 'clickhouse',
- orgSlug,
- completionMetadata: {
- textBeforeCursor: '',
- textAfterCursor: '',
- language: 'pgsql',
- prompt: buildClickhouseRewritePrompt(sql, availableKeys),
- selection: sql,
- },
- }),
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(errorText || 'Failed to rewrite the query')
- }
-
- const raw = await response.json()
- const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw))
- if (!rewritten) throw new Error('The assistant returned an empty query')
- return rewritten
-}
+limit 100`
diff --git a/apps/studio/lib/ai/prompts.ts b/apps/studio/lib/ai/prompts.ts
index 3a74b8b6dcc..81c6f0435f6 100644
--- a/apps/studio/lib/ai/prompts.ts
+++ b/apps/studio/lib/ai/prompts.ts
@@ -786,15 +786,6 @@ export const SQL_COMPLETION_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.
diff --git a/apps/studio/lib/get-error-message.test.ts b/apps/studio/lib/get-error-message.test.ts
index 57f12e24cbd..93cabc4b3ab 100644
--- a/apps/studio/lib/get-error-message.test.ts
+++ b/apps/studio/lib/get-error-message.test.ts
@@ -3,44 +3,48 @@ import { describe, expect, it } from 'vitest'
import { getErrorMessage } from './get-error-message'
describe('getErrorMessage', () => {
- it('returns null for null', () => {
- expect(getErrorMessage(null)).toBe(null)
- })
-
- it('returns null for undefined', () => {
- expect(getErrorMessage(undefined)).toBe(null)
- })
-
- it('returns the string for string errors', () => {
- expect(getErrorMessage('Something went wrong')).toBe('Something went wrong')
- expect(getErrorMessage('')).toBe('')
- })
-
it('returns the message for Error instances', () => {
expect(getErrorMessage(new Error('Failed to load'))).toBe('Failed to load')
expect(getErrorMessage(new TypeError('Invalid type'))).toBe('Invalid type')
})
- it('returns the message property for objects with message', () => {
+ it('returns a string throw, trimmed', () => {
+ expect(getErrorMessage('Something went wrong')).toBe('Something went wrong')
+ expect(getErrorMessage(' boom ')).toBe('boom')
+ })
+
+ it('reads a string message off a plain object', () => {
expect(getErrorMessage({ message: 'Custom error' })).toBe('Custom error')
- expect(getErrorMessage({ message: 123 })).toBe('123')
- expect(getErrorMessage({ message: null })).toBe('null')
})
- it('converts other types to string', () => {
- expect(getErrorMessage(123)).toBe('123')
- expect(getErrorMessage(true)).toBe('true')
- expect(getErrorMessage(false)).toBe('false')
- expect(getErrorMessage({})).toBe('[object Object]')
- expect(getErrorMessage([])).toBe('')
+ it('returns null when there is no usable message', () => {
+ expect(getErrorMessage(null)).toBe(null)
+ expect(getErrorMessage(undefined)).toBe(null)
+ expect(getErrorMessage('')).toBe(null)
+ expect(getErrorMessage(' ')).toBe(null)
+ expect(getErrorMessage(123)).toBe(null)
+ expect(getErrorMessage(true)).toBe(null)
+ expect(getErrorMessage([])).toBe(null)
})
- it('handles objects without message property', () => {
- expect(getErrorMessage({ code: 500 })).toBe('[object Object]')
- expect(getErrorMessage({ error: 'test' })).toBe('[object Object]')
+ it('never surfaces a stringified object as the message', () => {
+ expect(getErrorMessage({})).toBe(null)
+ expect(getErrorMessage({ code: 500 })).toBe(null)
+ expect(getErrorMessage({ error: 'test' })).toBe(null)
+ expect(getErrorMessage({ message: 123 })).toBe(null)
+ expect(getErrorMessage({ message: null })).toBe(null)
+ expect(getErrorMessage({ message: { nested: 'error' } })).toBe(null)
})
- it('handles nested error objects', () => {
- expect(getErrorMessage({ message: { nested: 'error' } })).toBe('[object Object]')
+ it('returns the fallback instead of null when one is given', () => {
+ expect(getErrorMessage(null, 'fallback')).toBe('fallback')
+ expect(getErrorMessage({}, 'fallback')).toBe('fallback')
+ expect(getErrorMessage({ message: ' ' }, 'fallback')).toBe('fallback')
+ expect(getErrorMessage(123, 'fallback')).toBe('fallback')
+ })
+
+ it('prefers a real message over the fallback', () => {
+ expect(getErrorMessage(new Error('Rewrite failed'), 'fallback')).toBe('Rewrite failed')
+ expect(getErrorMessage({ message: 'Bad request' }, 'fallback')).toBe('Bad request')
})
})
diff --git a/apps/studio/lib/get-error-message.ts b/apps/studio/lib/get-error-message.ts
index 87e7b4ace56..e88851c9992 100644
--- a/apps/studio/lib/get-error-message.ts
+++ b/apps/studio/lib/get-error-message.ts
@@ -1,12 +1,22 @@
/**
- * Extracts a human-readable error message from various error types.
+ * Extracts a human-readable message from an unknown thrown value, optionally
+ * falling back when there isn't one.
*/
-export function getErrorMessage(error: unknown): string | null {
- if (error === null || error === undefined) return null
- if (typeof error === 'string') return error
- if (error instanceof Error) return error.message
- if (typeof error === 'object' && error !== null && 'message' in error) {
- return String(error.message)
+export function getErrorMessage(error: unknown): string | null
+export function getErrorMessage(error: unknown, fallback: string): string
+export function getErrorMessage(error: unknown, fallback?: string): string | null {
+ if (typeof error === 'string') {
+ const trimmed = error.trim()
+ if (trimmed.length > 0) return trimmed
}
- return String(error)
+
+ if (typeof error === 'object' && error !== null && 'message' in error) {
+ const { message } = error
+ if (typeof message === 'string') {
+ const trimmed = message.trim()
+ if (trimmed.length > 0) return trimmed
+ }
+ }
+
+ return fallback ?? null
}
diff --git a/apps/studio/pages/api/ai/code/complete.ts b/apps/studio/pages/api/ai/code/complete.ts
index 326400a381e..84b9eff76b6 100644
--- a/apps/studio/pages/api/ai/code/complete.ts
+++ b/apps/studio/pages/api/ai/code/complete.ts
@@ -8,10 +8,14 @@ import z from 'zod'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { getOrgAIDetails } from '@/lib/ai/ai-details'
+import {
+ buildClickhouseLogsSchemaSection,
+ CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS,
+ CLICKHOUSE_LOGS_REWRITE_INSTRUCTION,
+} from '@/lib/ai/clickhouse-logs'
import { getModel } from '@/lib/ai/model'
import { DEFAULT_COMPLETION_MODEL, LOGS_REWRITE_MODEL } from '@/lib/ai/model.utils'
import {
- CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS,
COMPLETION_PROMPT,
EDGE_FUNCTION_PROMPT,
PG_BEST_PRACTICES,
@@ -124,12 +128,24 @@ const requestBodySchema = z.object({
textAfterCursor: z.string(),
prompt: z.string(),
selection: z.string(),
+ /**
+ * The real `log_attributes` keys observed for the query's source, when the
+ * client discovered them. ClickHouse-only — there is no schema to fetch
+ * server-side for the logs table the way there is for Postgres DDL.
+ */
+ availableKeys: z.array(z.string()).optional(),
}),
projectRef: z.string(),
connectionString: z.string().nullish(),
orgSlug: z.string().optional(),
language: z.string().optional(),
dialect: z.enum(['postgres', 'clickhouse']).optional(),
+ /**
+ * What the caller wants done. `rewrite` swaps the user instruction for the
+ * canonical BigQuery → ClickHouse rewrite instruction, so the client never has
+ * to carry prompt text. ClickHouse-only; defaults to `edit`.
+ */
+ intent: z.enum(['edit', 'rewrite']).optional(),
})
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -150,8 +166,10 @@ 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, dialect } = data
- const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata
+ const { completionMetadata, projectRef, connectionString, orgSlug, language, dialect, intent } =
+ data
+ const { textBeforeCursor, textAfterCursor, prompt, selection, availableKeys } =
+ completionMetadata
const isClickhouse = dialect === 'clickhouse'
const authorization = req.headers.authorization
@@ -232,8 +250,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
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.
+ You write and edit ClickHouse SQL for the Supabase logs table.
+ Reply with ONLY the SQL that replaces the block below, keeping the
+ surrounding query valid: no explanation, no comments, and no markdown code fences.
${CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS}
${SECURITY_PROMPT}
`
@@ -243,23 +262,31 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
${SECURITY_PROMPT}
`
- const userMessage = isClickhouse
- ? prompt
- : source`
- ## Database Schema
+ const schemaSection = isClickhouse
+ ? { heading: 'Logs Schema', body: buildClickhouseLogsSchemaSection(availableKeys) }
+ : {
+ heading: 'Database Schema',
+ body: buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult }),
+ }
- ${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })}
+ const instruction =
+ isClickhouse && intent === 'rewrite' ? CLICKHOUSE_LOGS_REWRITE_INSTRUCTION : prompt
- ## Code
+ const userMessage = source`
+ ## ${schemaSection.heading}
- \`\`\`${language ?? ''}
- ${textBeforeCursor}${selection}${textAfterCursor}
- \`\`\`
+ ${schemaSection.body}
- ## Instruction
+ ## Code
- ${prompt}
- `
+ \`\`\`${language ?? ''}
+ ${textBeforeCursor}${selection}${textAfterCursor}
+ \`\`\`
+
+ ## Instruction
+
+ ${instruction}
+ `
// 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
diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
index 4d736497fc4..65153b9cb3c 100644
--- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
+++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx
@@ -1,5 +1,5 @@
import { useMonaco } from '@monaco-editor/react'
-import { useLocalStorage } from '@uidotdev/usehooks'
+import { useDebounce, useLocalStorage } from '@uidotdev/usehooks'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import dayjs from 'dayjs'
import type { editor } from 'monaco-editor'
@@ -8,11 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
-import {
- detectLogSource,
- looksLikeLegacyLogsQuery,
- rewriteLogsSqlWithAI,
-} from '@/components/interfaces/Settings/Logs/logs-sql-rewrite'
+import { LegacyLogsRewriteAdmonition } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteAdmonition'
import {
EXPLORER_DATEPICKER_HELPERS,
getDefaultHelper,
@@ -36,7 +32,6 @@ import {
buildLogQueryParams,
resolveLogDateRange,
} from '@/components/interfaces/Settings/Logs/logsDateRange'
-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'
@@ -52,16 +47,20 @@ import {
UpsertContentPayload,
useContentUpsertMutation,
} from '@/data/content/content-upsert-mutation'
-import { constructHeaders } from '@/data/fetchers'
-import { fetchOtelLogKeys } from '@/data/logs/otel-log-keys-query'
+import {
+ LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS,
+ shouldOfferLegacyLogsRewrite,
+} from '@/data/logs/logs-sql-rewrite'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
+import {
+ useLegacyLogsRewrite,
+ type LegacyLogsRewriteProposal,
+} from '@/hooks/analytics/useLegacyLogsRewrite'
import { useLogsQuery } from '@/hooks/analytics/useLogsQuery'
import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
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'
@@ -101,9 +100,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const useOtelEndpoint = useFlag('otelLegacyLogs')
const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
- const { data: project } = useSelectedProjectQuery()
- const { data: organization } = useSelectedOrganizationQuery()
-
const allTemplates = useMemo(() => {
const templates = getLogsTemplates(useOtelEndpoint)
if (logsShowMetadataIpTemplate) return templates
@@ -142,11 +138,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const [warnings, setWarnings] = useState([])
const [showMissingLimitError, setShowMissingLimitError] = useState(false)
const [selectedLog, setSelectedLog] = useState(null)
- const [rewriteProposal, setRewriteProposal] = useState<{
- original: string
- modified: string
- } | null>(null)
- const [isRewriting, setIsRewriting] = useState(false)
+ const [rewriteProposal, setRewriteProposal] = useState(null)
const [rewriteBannerDismissed, setRewriteBannerDismissed] = useLocalStorage(
`project-${projectRef}-logs-rewrite-banner-dismissed`,
false
@@ -189,7 +181,32 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const results = logData
const isLoading = logsLoading
- const showRewriteCTA = useOtelEndpoint && looksLikeLegacyLogsQuery(editorValue)
+ // Debounced so the dialect heuristics don't run on every keystroke, matching the
+ // SQL editor's rewrite banner.
+ const settledEditorValue = useDebounce(editorValue, LEGACY_LOGS_DIALECT_CHECK_DEBOUNCE_MS)
+ const shouldShowRewriteCTA = useMemo(
+ () =>
+ shouldOfferLegacyLogsRewrite({
+ sql: settledEditorValue,
+ isClickhouseLogsEnabled: useOtelEndpoint,
+ }),
+ [settledEditorValue, useOtelEndpoint]
+ )
+
+ const {
+ state: rewriteState,
+ requestRewrite,
+ dismiss: dismissRewriteBanner,
+ } = useLegacyLogsRewrite({
+ // Read straight from the editor instance — `editorValue` state can lag the
+ // most recent keystroke.
+ readSql: () => editorRef.current?.getValue() ?? editorValue,
+ onProposal: setRewriteProposal,
+ onDismissed: () => setRewriteBannerDismissed(true),
+ })
+ const isRewriting = rewriteState.status === 'rewriting'
+ const hasUnacknowledgedRewriteOutcome =
+ rewriteState.status === 'failed' || rewriteState.status === 'noRewriteNeeded'
const { mutateAsync: upsertContent, isPending: isUpsertingContent } = useContentUpsertMutation({
onError: (e) => {
@@ -243,42 +260,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
addRecentLogSqlSnippet({ unchecked_sql: untrustedLogSql(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: projectRef!, source }).catch(() => undefined)
- : undefined
- const rewritten = await rewriteLogsSqlWithAI({
- sql: currentSql,
- projectRef: 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)
@@ -504,15 +485,16 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
templates={allTemplates.filter((template) => template.mode === 'custom')}
onSelectTemplate={onSelectTemplate}
warnings={warnings}
- showRewriteAction={showRewriteCTA && rewriteBannerDismissed}
+ showRewriteAction={shouldShowRewriteCTA && rewriteBannerDismissed}
isRewriting={isRewriting}
- onRewrite={handleRewrite}
+ onRewrite={requestRewrite}
/>
- {showRewriteCTA && !rewriteBannerDismissed && (
- setRewriteBannerDismissed(true)}
+ {(hasUnacknowledgedRewriteOutcome ||
+ (shouldShowRewriteCTA && !rewriteBannerDismissed)) && (
+
)}