feat(studio): add feature to rewrite queries DEBUG-145 (#47266)

## Problem

Moving the Logs Explorer to ClickHouse means users' saved BigQuery
queries no longer run.
<img width="2430" height="1010" alt="CleanShot 2026-06-29 at 11 36
04@2x"
src="https://github.com/user-attachments/assets/ae0ab155-7d3d-4ae9-81c3-22bf3a88cf8c"
/>

## Fix

Rewrite the query with AI instead of a SQL transpiler. AI handles the
long tail of nested fields and dialect differences far better than a
rule-based rewriter, and it needs no extra runtime dependency.

- `rewriteLogsSqlWithAI` posts the current query to
`/api/ai/code/complete` with `dialect: 'clickhouse'`. The endpoint skips
the Postgres schema and best-practices for that dialect and uses
logs-specific instructions and model so the output is ClickHouse logs
SQL (FROM `logs` + `source` filter, no `unnest` joins, nested fields
read from `log_attributes['...']`).
- The query's `source` is detected and its real `log_attributes` keys
are fetched and passed to the model, so it maps to exact paths instead
of guessing.
- The rewrite runs in the background and is proposed as a side-by-side
accept/discard diff in the editor. The AI Assistant panel is not opened.
- Entry points: a banner shown only for legacy-looking queries
(dismissal persisted), and a "Fix Query" button next to Field Reference.
- The Field Reference drawers discover `log_attributes` keys from real
data so the listed fields match what the source actually emits.

## Dependencies

Built on top of #47265 (Logs Explorer -> OTEL endpoint) — that is the
base branch of this PR. Merge #47265 first. Behind `otelLegacyLogs` (off
by default).

Part of DEBUG-145 (split from #47087).

## How to test

- Open the Logs Explorer with a BigQuery logs query (the templates have
some), click "Fix Query", and confirm the diff shows valid ClickHouse
SQL. Accept it and confirm the applied query runs.

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

* **New Features**
* Added an OTEL legacy logs workflow (behind a feature flag) with an
interactive banner and a “Fix Query” ClickHouse rewrite action,
including an accept/discard diff review overlay.
* Introduced OTEL-aware field reference rendering with dynamic discovery
of `log_attributes` keys and updated OTEL source insertion behavior.
* Enabled dialect-aware SQL completion for ClickHouse logs, using
logs-specific instructions and output constraints.
* **Bug Fixes**
* Improved rewrite flow validation and handling, including log source
detection and cleanup of AI-generated SQL formatting.
* **Tests**
* Added Vitest coverage for rewrite prompt generation,
detection/classification utilities, SQL fence stripping, OTEL field
mapping, and OTEL log attribute key discovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-29 14:31:18 +02:00
1 parent 3d931aceb7
commit 2aa1b52234
15 files changed
+986 -229

No files matched your search

@@ -0,0 +1,102 @@
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)
})
})
@@ -0,0 +1,42 @@
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))
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)
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]
}
@@ -14,7 +14,7 @@ export const DefaultErrorRenderer: React.FC<ErrorRendererProps> = ({ error }) =>
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>
)
@@ -0,0 +1,34 @@
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>
}
/>
)
}
@@ -1,10 +1,12 @@
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,
@@ -20,11 +22,24 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
SidePanel,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns'
import {
EXPLORER_DATEPICKER_HELPERS,
@@ -32,19 +47,23 @@ import {
LogsTableName,
} from './Logs.constants'
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
import { otelFieldsFromKeys, toOtelFieldSchemas } from './Logs.fieldReference'
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'
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 }) {
@@ -56,13 +75,16 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin
)
}
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'])
@@ -92,11 +114,20 @@ const LogsQueryPanel = ({
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
)
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)">
@@ -187,116 +218,142 @@ const LogsQueryPanel = ({
</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
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>
</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>
@@ -314,8 +371,8 @@ const Field = ({
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, () => {
@@ -340,10 +397,8 @@ const Field = ({
<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
@@ -0,0 +1,149 @@
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'
)
})
})
@@ -0,0 +1,142 @@
import { BASE_PATH } from '@/lib/constants'
export const LOGS_SCHEMA_REFERENCE = `The logs table (ClickHouse) has these columns:
- id (String)
- timestamp (DateTime64, UTC) formatted like 2026-06-22T09:34:06.215000 (ISO 8601, microsecond precision, no trailing Z)
- event_message (String): the raw log line
- severity_text (String): log level when present
- source (String): the service the log belongs to. Always filter by it, e.g. where source = 'edge_logs'.
- log_attributes (Map(String, String)): structured per-source fields, read as log_attributes['key']. Values are strings, so wrap numeric ones in toInt32OrZero(...) for comparisons.
Sources and their common log_attributes keys:
- edge_logs: request.method, request.path, request.search, response.status_code, identifier
- postgres_logs: parsed.error_severity, parsed.detail, parsed.hint, parsed.query, identifier
- pg_cron logs live under source = 'postgres_logs' (parsed.error_severity, parsed.query)
- auth_logs: level, status, path, msg, error
- function_edge_logs: response.status_code, request.method, request.pathname, function_id, execution_id, execution_time_ms
- function_logs: event_type, function_id, execution_id, level
- storage_logs, realtime_logs, postgrest_logs, supavisor_logs, pgbouncer_logs: mostly id, timestamp, event_message, with extra fields in log_attributes
Rules: always filter by source; the editor applies the selected time range so a timestamp filter is usually unnecessary; the old BigQuery unnest joins become log_attributes['key'] lookups (drop the metadata root).`
function renderAvailableKeys(availableKeys?: string[]): string {
if (!availableKeys || availableKeys.length === 0) return ''
const list = availableKeys.map((key) => `- log_attributes['${key}']`).join('\n')
return `\nThe actual log_attributes keys present for this source are listed below. Use these EXACT keys — do not invent, shorten, or drop any dotted prefix. If a BigQuery field maps to one of these (e.g. request.headers.x_real_ip, request.cf.country), use the full key shown here:
${list}\n`
}
export function buildClickhouseRewritePrompt(sql: string, availableKeys?: string[]): string {
return `${LOGS_SCHEMA_REFERENCE}
${renderAvailableKeys(availableKeys)}
Convert the BigQuery logs query below to ClickHouse SQL for the logs table. There are no per-service tables and no unnest joins in ClickHouse. Follow these rules exactly:
1. Replace the FROM table with the single logs table and filter by source. The old table name is the source value: "from postgres_logs as t" becomes "from logs where source = 'postgres_logs'". This is required, never select from a table like postgres_logs or edge_logs.
2. Remove every join that unnests metadata or its structs. This includes "cross join unnest(...)" and "left join unnest(...) on true".
3. Replace any column that came from an unnest alias with a log_attributes lookup. A field off unnest(metadata) becomes log_attributes['field']; a field off a nested struct like unnest(m.parsed) becomes log_attributes['parsed.field'] (keep the struct name as a dotted prefix, drop the metadata root and every alias). When the actual keys are listed above, match against them and use the full dotted key exactly.
4. Wrap numeric fields in toInt32OrZero(...) before comparing or aggregating them.
5. Replace BigQuery functions with ClickHouse equivalents: regexp_contains(x, 'p') becomes match(x, 'p'), or x ILIKE '%p%' for a plain substring. Replace cast(timestamp as datetime) with timestamp. Use count() instead of count(*).
6. Preserve the original select list, filters, group by, order by, and limit intent.
Example.
BigQuery:
select count(t.timestamp) as count, p.error_severity
from postgres_logs as t
cross join unnest(metadata) as m
cross join unnest(m.parsed) as p
where p.error_severity in ('ERROR', 'FATAL', 'PANIC')
group by p.error_severity
order by count desc
limit 100
ClickHouse:
select count() as count, log_attributes['parsed.error_severity'] as error_severity
from logs
where source = 'postgres_logs'
and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC')
group by log_attributes['parsed.error_severity']
order by count desc
limit 100
Reply with ONLY the rewritten SQL query: no explanation, no comments, and no markdown code fences.
${sql}`
}
export function stripSqlCodeFences(text: string): string {
const trimmed = text.trim()
const fenced = trimmed.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/i)
return (fenced ? fenced[1] : trimmed).trim()
}
const SOURCE_ALIASES: Record<string, string> = {
pg_cron_logs: 'postgres_logs',
}
export function detectLogSource(sql: string): string | undefined {
const bySource = sql.match(/source\s*=\s*'([^']+)'/i)
if (bySource) {
const source = bySource[1].toLowerCase()
return SOURCE_ALIASES[source] ?? source
}
const byFrom = sql.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i)
if (byFrom) {
const table = byFrom[1].toLowerCase()
if (table === 'logs') return undefined
return SOURCE_ALIASES[table] ?? table
}
return undefined
}
export function looksLikeLegacyLogsQuery(sql: string): boolean {
const lower = sql.toLowerCase()
if (/\bunnest\s*\(/.test(lower)) return true
if (/cast\s*\(\s*timestamp\s+as\s+datetime\s*\)/.test(lower)) return true
const byFrom = lower.match(/\bfrom\s+([a-z_][a-z0-9_]*)/)
return byFrom ? byFrom[1] !== 'logs' : false
}
export interface RewriteLogsSqlArgs {
sql: string
projectRef: string
connectionString?: string | null
orgSlug?: string
authorizationHeader?: string | null
availableKeys?: string[]
}
export async function rewriteLogsSqlWithAI(args: RewriteLogsSqlArgs) {
const { sql, projectRef, connectionString, orgSlug, authorizationHeader, availableKeys } = args
const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
},
body: JSON.stringify({
projectRef,
connectionString,
language: 'sql',
dialect: 'clickhouse',
orgSlug,
completionMetadata: {
textBeforeCursor: '',
textAfterCursor: '',
language: 'pgsql',
prompt: buildClickhouseRewritePrompt(sql, availableKeys),
selection: sql,
},
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Failed to rewrite the query')
}
const raw = await response.json()
const rewritten = stripSqlCodeFences(typeof raw === 'string' ? raw : String(raw))
if (!rewritten) throw new Error('The assistant returned an empty query')
return rewritten
}
@@ -1,3 +1,4 @@
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'
@@ -23,7 +24,12 @@ import {
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 { useOtelLogKeysQuery } from '@/data/logs/otel-log-keys-query'
import { DOCS_URL } from '@/lib/constants'
export interface LogsExplorerHeaderProps {
@@ -33,7 +39,16 @@ 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">
@@ -79,24 +94,33 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
>
<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 />
@@ -120,19 +144,21 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
<CommandList>
<CommandEmpty>No source found.</CommandEmpty>
<CommandGroup>
{logConstants.schemas.map((schema) => (
{schemas.map((schema) => (
<CommandItem
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}
@@ -143,19 +169,26 @@ const LogsExplorerHeader = ({ subtitle }: LogsExplorerHeaderProps) => {
</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>
@@ -0,0 +1,45 @@
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)
}
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,
})
}
+40 -18
View File
@@ -1,4 +1,5 @@
import { createParser, useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { Filters } from '@/components/interfaces/Settings/Logs/Logs.types'
@@ -67,34 +68,55 @@ export function useLogsUrlState() {
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)
setSelectedLogIdValue('')
setFiltersValue(defaultState.filters)
}
}, [
setSearch,
setTimestampStartValue,
setTimestampEndValue,
setSelectedLogIdValue,
setFiltersValue,
])
return {
// State
+5
View File
@@ -51,6 +51,11 @@ export const DEFAULT_COMPLETION_MODEL = openaiModelEntry({
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 = [
+9
View File
@@ -786,6 +786,15 @@ 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.
+31 -21
View File
@@ -9,8 +9,9 @@ import { executeSql } from '@/data/sql/execute-sql-mutation'
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 {
CLICKHOUSE_LOGS_COMPLETION_INSTRUCTIONS,
COMPLETION_PROMPT,
EDGE_FUNCTION_PROMPT,
PG_BEST_PRACTICES,
@@ -128,6 +129,7 @@ const requestBodySchema = z.object({
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) {
@@ -148,8 +150,9 @@ 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 isClickhouse = dialect === 'clickhouse'
const authorization = req.headers.authorization
let aiOptInLevel: AiOptInLevel = IS_PLATFORM ? 'disabled' : 'schema'
@@ -168,7 +171,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
systemProviderOptions,
} = await getModel({
provider: 'openai',
modelEntry: DEFAULT_COMPLETION_MODEL,
modelEntry: isClickhouse ? LOGS_REWRITE_MODEL : DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
@@ -180,7 +183,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
...(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.
@@ -227,29 +230,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
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
@@ -6,8 +6,13 @@ import type { editor } from 'monaco-editor'
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,
@@ -25,12 +30,14 @@ import {
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 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'
@@ -38,17 +45,23 @@ import {
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 { 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'
@@ -58,11 +71,16 @@ const PLATFORM_PLACEHOLDER_QUERY =
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 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
@@ -106,6 +124,15 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
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`,
@@ -150,6 +177,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
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 }
@@ -202,6 +231,52 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
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 })
@@ -232,12 +307,20 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
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`, [
@@ -251,8 +334,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
}
}
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')
@@ -333,20 +414,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
} 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.
@@ -402,18 +474,55 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
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} />
<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">
@@ -1,7 +1,7 @@
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 () => {