Files
supabase/apps/studio/components/ui/AIAssistantPanel/MessagePartQueryLogs.utils.ts
Saxon Fletcher e605178a63 feat(studio): render assistant log query results (#49293)
<img width="1510" height="862" alt="image"
src="https://github.com/user-attachments/assets/f7157bad-9b23-4d73-a9aa-2a7a7c179318"
/>


## 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 and bug fix.

## What is the current behavior?

`query_logs` can return rows to the assistant, but the chat UI does not
hydrate those rows into the query result by default. The query only
becomes visible after clicking **Run query**, even though the same SQL
and time range work when rerun manually.

## What is the new behavior?

- Renders `query_logs` tool output through a dedicated logs message part
using the shared assistant query cell.
- Parses the exact MCP untrusted-data envelope into the initial query
result, without changing what the assistant model receives.
- Preserves the logs source and time range for manual reruns.
- Infers a useful table or chart presentation from the returned rows
while retaining explicit display settings.
- Adds focused tests for MCP result parsing, timestamps, errors, query
source handling, and visualization inference.

## How to test

1. Check out this PR and run Studio against a project that has recent
logs. Generate some project activity first, such as an API request, if
needed.
2. Open the AI Assistant and ask: `Show log counts by minute for the
last 15 minutes and summarize any spikes.`
3. Wait for `query_logs` to finish. Verify the query cell appears with
results already populated; do not click **Run query** first.
4. Verify the aggregate result opens as a chart, then switch to the
table view and confirm the underlying rows are present.
5. Click **Run query** and verify the query runs successfully again
using the same logs source and 15-minute time range.
6. Ask: `Show the 20 most recent log entries from the last 15 minutes.`
Verify this non-aggregate result opens as a table with rows already
populated.
7. Confirm the assistant's written summary agrees with the displayed
rows and does not report zero rows when results are visible.

## Additional context

This is the top PR in stack #49294 and depends on the back-end knowledge
change in #49292.

Verified with 59 focused tests across assistant context, Studio/MCP
tools, query display, and logs result parsing.


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

## Summary by CodeRabbit

* **New Features**
* Added AI Assistant support for querying and displaying application
logs.
* Added automatic visualization selection, including charts for
time-based and categorical data.
* Added source-aware query handling with dedicated titles, time ranges,
and result displays.
  * Added clearer loading, parsing, and error states for log queries.

* **Bug Fixes**
* Improved handling of streamed results, source changes, and query
display updates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 09:30:55 +10:00

167 lines
5.6 KiB
TypeScript

import dayjs from 'dayjs'
import { z, type SafeParseReturnType } from 'zod'
import { DEFAULT_ASSISTANT_LOGS_QUERY_TITLE } from './AssistantQueryCell.utils'
import { type QueryResult } from '@/components/interfaces/Explorer/types'
import { type TimeRange } from '@/data/content/notebooks/notebook-schema'
import { isoDateTimeString } from '@/lib/iso-datetime'
const UNTRUSTED_DATA_CLOSE_RE = /<\/untrusted-data-([^>]+)>/g
const unknownRecordSchema = z.record(z.string(), z.unknown())
/** Matches the MCP `query_logs` window when the model omits timestamps. */
export const DEFAULT_ASSISTANT_LOGS_TIME_RANGE: TimeRange = {
_tag: 'relative_time_range',
unit: 'day',
amount: 1,
}
const queryLogsInputSchema = z.object({
sql: z.string().min(1),
iso_timestamp_start: z.string().optional(),
iso_timestamp_end: z.string().optional(),
})
export function parseQueryLogsInput(
input: unknown
): SafeParseReturnType<unknown, z.infer<typeof queryLogsInputSchema>> {
return queryLogsInputSchema.safeParse(input)
}
export function getAssistantLogsQueryTitle(sql: string): string {
const title = sql
.trim()
.match(/^--[ \t]*([^\r\n]+)/)?.[1]
?.trim()
return title || DEFAULT_ASSISTANT_LOGS_QUERY_TITLE
}
export function getAssistantLogsTimeRange(start?: string, end?: string): TimeRange {
const parsedStart = start ? isoDateTimeString(start) : null
const parsedEnd = end ? isoDateTimeString(end) : null
if (parsedStart && parsedEnd && dayjs(parsedEnd).isAfter(parsedStart)) {
return { _tag: 'absolute_time_range', start: parsedStart, end: parsedEnd }
}
return DEFAULT_ASSISTANT_LOGS_TIME_RANGE
}
export function toQueryLogsResult(output: unknown): QueryResult | undefined {
return parseQueryResult(output)
}
function parseQueryResult(output: unknown, depth = 0): QueryResult | undefined {
if (depth > 6 || output == null) return undefined
if (Array.isArray(output)) return toRowResult(output)
if (typeof output === 'string') {
const extracted = extractUntrustedDataJson(output) ?? tryParseJson(output)
return extracted !== undefined ? parseQueryResult(extracted, depth + 1) : undefined
}
const parsedRecord = unknownRecordSchema.safeParse(output)
if (!parsedRecord.success) return undefined
const record = parsedRecord.data
const mcpError = readMcpToolError(record)
if (mcpError) return { rows: [], error: { message: mcpError } }
const error = readErrorMessage(record.error)
const rows = Array.isArray(record.rows)
? toRowResult(record.rows)
: Array.isArray(record.result)
? toRowResult(record.result)
: undefined
if (rows) return error ? { ...rows, error } : rows
if ('result' in record) {
const result = parseQueryResult(record.result, depth + 1)
const mergedResult = mergeParentError(result, error)
if (mergedResult) return mergedResult
}
if (record.structuredContent != null) {
const result = parseQueryResult(record.structuredContent, depth + 1)
const mergedResult = mergeParentError(result, error)
if (mergedResult) return mergedResult
}
if (Array.isArray(record.content)) {
const result = parseQueryResult(textFromMcpContent(record.content), depth + 1)
return mergeParentError(result, error)
}
return error ? { rows: [], error } : undefined
}
function mergeParentError(
result: QueryResult | undefined,
error: QueryResult['error']
): QueryResult | undefined {
return error ? { ...(result ?? { rows: [] }), error } : result
}
function toRowResult(rows: unknown[]): QueryResult {
return {
rows: rows.filter(
(row): row is Record<string, unknown> =>
row !== null && typeof row === 'object' && !Array.isArray(row)
),
}
}
function textFromMcpContent(content: unknown[]): string | undefined {
const texts = content.flatMap((part) => {
if (typeof part === 'string' && part.length > 0) return [part]
const parsedPart = unknownRecordSchema.safeParse(part)
if (!parsedPart.success) return []
if (typeof parsedPart.data.text === 'string') return [parsedPart.data.text]
if (typeof parsedPart.data.value === 'string') return [parsedPart.data.value]
return []
})
return texts.length > 0 ? texts.join('\n') : undefined
}
function readMcpToolError(record: Record<string, unknown>): string | undefined {
if (record.isError !== true) return undefined
const text = Array.isArray(record.content) ? textFromMcpContent(record.content) : undefined
return text?.trim() || 'Failed to query logs'
}
function readErrorMessage(error: unknown): { message: string } | undefined {
if (typeof error === 'string' && error.length > 0) return { message: error }
const parsedError = unknownRecordSchema.safeParse(error)
const message = parsedError.success ? parsedError.data.message : undefined
if (typeof message === 'string' && message.length > 0) return { message }
return undefined
}
function extractUntrustedDataJson(value: string): unknown {
for (const match of value.matchAll(UNTRUSTED_DATA_CLOSE_RE)) {
const boundaryId = match[1]
const closingIndex = match.index
if (!boundaryId || closingIndex === undefined) continue
const openingTag = `<untrusted-data-${boundaryId}>`
// The MCP wrapper mentions the tag in its explanatory prose before opening
// the real JSON boundary, so select the final opening tag before the close.
const openingIndex = value.lastIndexOf(openingTag, closingIndex)
if (openingIndex === -1) continue
const parsed = tryParseJson(value.slice(openingIndex + openingTag.length, closingIndex).trim())
if (parsed !== undefined) return parsed
}
return undefined
}
function tryParseJson(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return undefined
}
}