Files
supabase/apps/studio/hooks/analytics/useLogsAttributeKeys.test.tsx
Charis 8b38e0d1ed feat(studio): ClickHouse dialect for logs snippet AI + rewrite to ClickHouse (#48501)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature, plus a refactor of the shared logs-rewrite flow.

PR 8 of the SQL editor query-source series. Stacked on #48457 — review
that one first, and merge this after it.

## What is the current behavior?

A `log_sql` snippet runs against the ClickHouse-backed analytics
endpoint, but the SQL editor's AI still writes Postgres: inline edits
get Postgres system prompts, and the result is run through
`sql-formatter`, which mangles ClickHouse backticks and `log_attributes`
map lookups.

Legacy Logs Explorer saved queries open in the editor as `log_sql`
snippets. Those are BigQuery dialect and error against the ClickHouse
endpoint the editor runs them on, with no in-editor way out — only the
Logs Explorer offered a rewrite.

The completion route was also asymmetric. It assembled a
schema/code/instruction message for Postgres but forwarded `prompt`
verbatim for ClickHouse, so a client wanting ClickHouse had to
hand-build the equivalent string.

## What is the new behavior?

**Inline AI speaks ClickHouse for logs snippets.** `sqlSourceToDialect`
maps a snippet's source to `postgres`/`clickhouse` and
`buildCompletionRequestBody` threads it through. For ClickHouse,
`useSqlEditorAi` strips code fences from the response and skips
`formatSql`. Execution and dialect both follow the snippet type, so a
snippet's valid dialect never flips.

**Rewrite to ClickHouse in the editor.** A banner offers the rewrite for
a logs snippet whose text trips `looksLikeLegacyLogsQuery`, and proposes
the result through the editor's existing AI diff view rather than
replacing the snippet, so it's accepted or discarded like any other AI
edit. Gated on `otelLegacyLogs`: on a non-migrated org the BigQuery text
is still correct, so rewriting it would break a working query.

The offer is a state machine (`offered` / `rewriting` / `failed` /
`noRewriteNeeded` / `dismissed`) with a declarative table of valid
transitions, so the states are mutually exclusive by construction and
dismissal is terminal. A failure keeps its message and offers a retry; a
response identical to the input is reported rather than opening an empty
diff.

**One place assembles completion prompts.** The route now uses a single
template for both dialects, branching only the schema section and — for
`intent: 'rewrite'` — the instruction. `lib/ai/clickhouse-logs.ts` is
the single home for ClickHouse-logs prompt content, replacing two
independently maintained descriptions of the same table. Clients carry
no prompt text.

**The rewrite flow is shared with the Logs Explorer.** Both surfaces
previously hand-rolled the same sequence and had drifted: only one
detected a no-op rewrite, they sourced `log_attributes` keys
differently, and the Explorer formatted errors with an `as Error` cast.
Both now use `useLegacyLogsRewrite` and the same state-driven banner, so
the Explorer picks up no-op detection and typed error extraction.

**Attribute keys are fetched on submit, not while typing.** The detected
source would otherwise feed a reactive query key, making every edit that
changed it cost another network call. `useLogsAttributeKeys` is
imperative and goes through `queryClient.fetchQuery`, so a source
already cached — including by the Explorer header and query panel, which
subscribe reactively — is reused. This also closes a gap where inline
edits never received keys at all, unlike full rewrites.

`getErrorMessage` gains an optional typed fallback and no longer
stringifies a bare object into `'[object Object]'`; every existing
caller already hand-rolled a fallback, except `QueueSettings`, which
interpolated the raw result and now passes one.

Nothing here is user-visible until the `sqlEditorLogsSource` flag is
enabled.

Tests: dialect selection and request-body shape, the ClickHouse prompt
content (including that the schema section does not restate the dialect
rules), the reducer's valid and invalid transitions,
`shouldOfferLegacyLogsRewrite`, on-submit key discovery with cache
reuse, and `getErrorMessage`.

## Additional context

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an Assistant banner to help rewrite legacy BigQuery-style logs
queries into ClickHouse SQL.
* SQL assistance now adapts to the selected query type, including
relevant log attribute context.
* Rewrite suggestions can be reviewed as editor diffs before being
applied.

* **Bug Fixes**
* Improved rewrite failure handling, retry options, dismissal behavior,
and “no rewrite needed” messaging.
* Error notifications now provide a clearer fallback message when
details are unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 09:02:40 -04:00

125 lines
4.1 KiB
TypeScript

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