diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 8981208653a..d1157bb5b2c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,6 +1,7 @@ import { IS_PLATFORM } from 'common' import Table from 'components/to-be-cleaned/Table' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { usePHFlag } from 'hooks/ui/useFlag' import { DOCS_URL } from 'lib/constants' import { BookOpen, Check, ChevronDown, Copy, ExternalLink, X } from 'lucide-react' import Link from 'next/link' @@ -36,6 +37,8 @@ export interface LogsQueryPanelProps { onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: string) => void onDateChange: (value: DatePickerValue) => void + queryMode?: 'sql' | 'natural' + onQueryModeChange?: (mode: 'sql' | 'natural') => void } function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: string }) { @@ -54,9 +57,12 @@ const LogsQueryPanel = ({ onSelectTemplate, onSelectSource, onDateChange, + queryMode = 'sql', + onQueryModeChange, }: LogsQueryPanelProps) => { const [showReference, setShowReference] = useState(false) const { logsTemplates } = useIsFeatureEnabled(['logs:templates']) + const logsExplorerAiQuerying = usePHFlag('logsExplorerAiQuerying') const { projectAuthAll: authEnabled, @@ -85,50 +91,74 @@ const LogsQueryPanel = ({
- - - - - - {logsTableNames - .sort((a, b) => a.localeCompare(b)) - .map((source) => ( - onSelectSource(source)}> - - - ))} - - + +
+ )} + {queryMode !== 'natural' && ( + <> + + + + + + {logsTableNames + .sort((a, b) => a.localeCompare(b)) + .map((source) => ( + onSelectSource(source)}> + + + ))} + + - {IS_PLATFORM && logsTemplates && ( - - - - - - {templates - .sort((a, b) => a.label!.localeCompare(b.label!)) - .map((template) => ( - onSelectTemplate(template)} - > -

{template.label}

-
- ))} -
-
+ {IS_PLATFORM && logsTemplates && ( + + + + + + {templates + .sort((a, b) => a.label!.localeCompare(b.label!)) + .map((template) => ( + onSelectTemplate(template)} + > +

{template.label}

+
+ ))} +
+
+ )} + )} > + +export const useLogsQueryGenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => generateLogsQuery(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate log query: ${error.message}`) + } else { + onError(error, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/pages/api/ai/sql/logs-query-v1.ts b/apps/studio/pages/api/ai/sql/logs-query-v1.ts new file mode 100644 index 00000000000..3e7c8bfa68c --- /dev/null +++ b/apps/studio/pages/api/ai/sql/logs-query-v1.ts @@ -0,0 +1,127 @@ +import { generateText } from 'ai' +import { source } from 'common-tags' +import { getModel } from 'lib/ai/model' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' +import { logConstants } from 'shared-data' +import { z } from 'zod' + +const requestSchema = z.object({ + prompt: z.string().min(1).max(2000), +}) + +const LOG_TABLES = [ + { name: 'edge_logs', description: 'Logs obtained from the network edge, containing all API requests' }, + { name: 'postgres_logs', description: 'Database logs obtained directly from Postgres' }, + { name: 'function_logs', description: 'Function logs generated from runtime execution' }, + { name: 'function_edge_logs', description: 'Function call logs, containing the request and response' }, + { name: 'auth_logs', description: 'Errors, warnings, and performance details from the auth service' }, + { name: 'auth_audit_logs', description: 'Audit records of user signups, logins, and account changes' }, + { name: 'realtime_logs', description: 'Realtime server for Postgres logical replication broadcasting' }, + { name: 'storage_logs', description: 'Object storage logs' }, + { name: 'postgrest_logs', description: 'RESTful API web server logs' }, + { name: 'supavisor_logs', description: 'Shared connection pooler logs for PostgreSQL' }, + { name: 'pgbouncer_logs', description: 'Dedicated connection pooler for PostgreSQL' }, + { name: 'pg_upgrade_logs', description: 'Logs generated by the Postgres version upgrade process' }, + { name: 'pg_cron_logs', description: 'Postgres logs from pg_cron cron jobs' }, + { name: 'etl_replication_logs', description: 'Logs from the replication process' }, +] + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +export async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const parseResult = requestSchema.safeParse(req.body) + + if (!parseResult.success) { + const errorMessage = parseResult.error.errors.map((e) => e.message).join(', ') + return res.status(400).json({ error: errorMessage }) + } + + const { prompt } = parseResult.data + + try { + const { + model, + error: modelError, + providerOptions, + } = await getModel({ + provider: 'openai', + routingKey: 'sql', + }) + + if (modelError) { + return res.status(500).json({ error: modelError.message }) + } + + const result = await generateText({ + model, + providerOptions, + system: source` + You are an expert at writing SQL queries for Supabase Log Explorer. + The system uses BigQuery-compatible SQL syntax with standard PostgreSQL functions. + + Available log tables: + ${JSON.stringify(LOG_TABLES, null, 2)} + + Schema fields per table (nested paths require UNNEST joins to access): + ${JSON.stringify( + logConstants.schemas.map((s) => ({ table: s.reference, fields: s.fields })), + null, + 2 + )} + + Key SQL syntax rules: + - To access nested metadata fields, use LEFT JOIN UNNEST(metadata) AS m ON TRUE + - For deeper nesting (e.g. request inside metadata): LEFT JOIN UNNEST(m.request) AS req ON TRUE + - Use cast(timestamp as datetime) for readable timestamps + - Use regexp_contains(field, r'pattern') for regex matching + - Always include a LIMIT clause (default to 100 if not specified) + - If the user doesn't specify a log source, default to edge_logs + - Return ONLY the SQL query — no markdown code blocks, no explanation, no comments + `, + prompt, + }) + + const sql = result.text.trim() + + // Strip any markdown code blocks the model may have added + const cleanedSql = sql + .replace(/^```(?:sql)?\n?/i, '') + .replace(/\n?```$/i, '') + .trim() + + return res.json({ sql: cleanedSql }) + } catch (error) { + if (error instanceof Error) { + console.error(`AI log query generation failed: ${error.message}`) + + if (error.message.includes('context_length') || error.message.includes('too long')) { + return res.status(400).json({ + error: + 'Your query prompt is too large for Supabase Assistant to ingest. Try a shorter description.', + }) + } + } else { + console.error(`Unknown error: ${error}`) + } + + return res.status(500).json({ + error: 'There was an unknown error generating the log query. Please try again.', + }) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index df6375cc053..05ecd91d283 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -25,6 +25,7 @@ import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import LoadingOpacity from 'components/ui/LoadingOpacity' import ShimmerLine from 'components/ui/ShimmerLine' +import { useLogsQueryGenerateMutation } from 'data/ai/logs-query-mutation' import { useContentQuery } from 'data/content/content-query' import { UpsertContentPayload, @@ -105,6 +106,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [saveModalOpen, setSaveModalOpen] = useState(false) const [warnings, setWarnings] = useState([]) const [selectedLog, setSelectedLog] = useState(null) + const [queryMode, setQueryMode] = useState<'sql' | 'natural'>('sql') + const [nlQuery, setNlQuery] = useState('') const [recentLogs, setRecentLogs] = useLocalStorage( `project-content-${projectRef}-recent-log-sql`, @@ -169,6 +172,19 @@ export const LogsExplorerPage: NextPageWithLayout = () => { }, }) + const { mutate: generateLogsQuery, isPending: isGenerating } = useLogsQueryGenerateMutation({ + onSuccess: (data) => { + setEditorValue(data.sql) + handleRun(data.sql) + track('log_explorer_nl_query_generated') + }, + }) + + const handleNlSubmit = () => { + if (!nlQuery.trim() || isGenerating) return + generateLogsQuery({ prompt: nlQuery.trim() }) + } + const addRecentLogSqlSnippet = (snippet: Partial) => { const defaults: LogSqlSnippets.Content = { schema_version: '1', @@ -389,16 +405,44 @@ export const LogsExplorerPage: NextPageWithLayout = () => { templates={allTemplates.filter((template) => template.mode === 'custom')} onSelectTemplate={onSelectTemplate} warnings={warnings} + queryMode={queryMode} + onQueryModeChange={setQueryMode} /> - - setEditorValue(v || '')} - actions={{ runQuery: { enabled: true, callback: handleRun } }} - /> + + {queryMode === 'sql' ? ( + setEditorValue(v || '')} + actions={{ runQuery: { enabled: true, callback: handleRun } }} + /> + ) : ( +
+