From 4c8ed105d2676b5ba4612b735fbdcf735fc30bcc Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:43:48 -0400 Subject: [PATCH] feat(studio): logs SQL execution wiring + source-aware run gestures (#48414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 (SQL editor: execution wiring for logs-source snippets). Part of the stacked SQL-editor "Database vs Logs" query-source series. ## What is the current behavior? The SQL editor only ever runs queries against the user's Postgres database. There is no execution path for a logs (`log_sql`) snippet, and the run-button telemetry event carries no backend discriminator. ## What is the new behavior? - `useRunSource(id)` derives the run backend from the snippet type; a `log_sql` snippet resolves to `{ type: 'logs', dateRange }`, pairing the run with its session time range (default: last hour). - `useLogsSqlExecution` runs a promoted `SafeLogSqlFragment` against the analytics OTEL (ClickHouse) endpoint with the resolved time range as `iso_timestamp_start`/`iso_timestamp_end` request params. The endpoint is **pinned to OTEL** — a snippet's dialect must not flip with org migration. - The run gestures (toolbar button and Cmd+Enter) branch on the source and promote with the matching `acceptUntrusted*` right at the user action, preserving the auditable promotion-at-gesture boundary. pg intellisense is gated off for logs snippets. - The `sql_editor_query_run_button_clicked` telemetry event gains a required `{ source: 'database' | 'logs' }` property, fired from both execution paths. - Capability guard: a `log_sql` snippet is reachable by direct URL regardless of the (later) entry-point flag gating, so `executeLogsQuery` short-circuits when `otelLegacyLogs` is off — recording a clear "not available yet" result message instead of firing a request that would only return an opaque backend error on a non-ClickHouse project. This is a guard on the gesture, not endpoint selection. - Tests: `useRunSource` routing, `useLogsSqlExecution` endpoint/range/structured-error/capability-guard, and a reusable `flags` option on `renderSqlEditorHook`. No UI entry points are added — the feature runs dark until the flag-gated creation/nav PRs later in the stack. ## Additional context Stacked on the query-source series; base branch is `master` now that PR 4 (log date range domain + session state, #48401) is merged. Follow-ups in the stack add the toolbar/creation UI (with a run-affordance gate on `otelLegacyLogs`), nav section, AI dialect support, and reports guard. ## Summary by CodeRabbit * **New Features** * Added support for running log queries directly from the SQL editor. * Log query results, errors, and time ranges are now handled within the editor session. * Added automatic selection between database and log query execution, including support for custom date ranges. * SQL assistance is disabled while editing log queries where database definitions do not apply. * **Tests** * Added coverage for log query execution, date ranges, feature availability, and execution source selection. --- .../interfaces/SQLEditor/SQLEditor.types.ts | 2 +- .../interfaces/SQLEditor/SQLEditor.utils.ts | 2 +- .../interfaces/SQLEditor/SQLEditorContext.tsx | 3 +- .../SQLEditor/SQLEditorControllers.tsx | 62 ++++++-- .../SQLEditor/SQLEditorEditorPanel.tsx | 21 ++- .../interfaces/SQLEditor/SQLEditorLayout.tsx | 33 ++++- .../interfaces/SQLEditor/querySource.ts | 8 ++ .../interfaces/SQLEditor/useAddDefinitions.ts | 15 +- .../SQLEditor/useLogsSqlExecution.test.tsx | 136 ++++++++++++++++++ .../SQLEditor/useLogsSqlExecution.ts | 61 ++++++++ .../SQLEditor/useRunSource.test.tsx | 52 +++++++ .../interfaces/SQLEditor/useRunSource.ts | 27 ++++ .../SQLEditor/useSqlEditorExecution.ts | 2 +- .../tests/lib/sql-editor-test-utils.tsx | 33 +++-- packages/common/telemetry-constants.ts | 4 + 15 files changed, 425 insertions(+), 36 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/useRunSource.ts diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts index bb3c8581a1e..38412451c8e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.types.ts @@ -27,7 +27,7 @@ export type EditorController = { isReady: () => boolean getValue: () => string | undefined getSelectionStartLine: () => number | undefined - getSql: (snippetContent?: UntrustedSqlFragment) => UntrustedSqlFragment | undefined + getSql: (snippetContent?: string) => UntrustedSqlFragment | undefined replaceAll: (text: string, source: string) => void focus: () => void revealLineInCenter: (line: number) => void diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index de83b8a48e5..aa1ba912080 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -457,7 +457,7 @@ export function applyAutoLimit( */ export function getEditorSql( editor: IStandaloneCodeEditor, - snippetContent?: UntrustedSqlFragment + snippetContent?: string ): UntrustedSqlFragment { const selection = editor.getSelection() const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx index f33ad16992f..fb9255b01eb 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorContext.tsx @@ -1,5 +1,4 @@ import type { Monaco } from '@monaco-editor/react' -import type { UntrustedSqlFragment } from '@supabase/pg-meta' import { createContext, use, @@ -103,7 +102,7 @@ export const SQLEditorProvider = ({ [] ) - const getSqlFromEditor = useCallback((snippetContent?: UntrustedSqlFragment) => { + const getSqlFromEditor = useCallback((snippetContent?: string) => { const editorInstance = editorRef.current if (!editorInstance) return undefined return getEditorSql(editorInstance, snippetContent) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx index 26fdb923206..b4af89af282 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorControllers.tsx @@ -15,17 +15,25 @@ import { } from 'react' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' +import { type QuerySource } from './querySource' import type { UtilityTab } from './SQLEditor.types' import { useSQLEditorContext } from './SQLEditorContext' import { useAddDefinitions } from './useAddDefinitions' import { useEditorMount } from './useEditorMount' +import { useLogsSqlExecution } from './useLogsSqlExecution' import { usePrettifyQuery } from './usePrettifyQuery' +import { useRunSource } from './useRunSource' import { useSnippetIdentity } from './useSnippetIdentity' import { useSnippetTitleGenerator } from './useSnippetTitleGenerator' import { useSqlEditorAi } from './useSqlEditorAi' import { useSqlEditorExecution } from './useSqlEditorExecution' import { useSqlEditorShortcuts } from './useSqlEditorShortcuts' import { isValidConnString } from '@/data/fetchers' +import { + untrustedLogSql, + type SafeLogSqlFragment, + type UntrustedLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -73,6 +81,9 @@ type RunContextValue = { potentialIssues: SqlEditorExecution['potentialIssues'] resetPotentialIssues: () => void prettifyQuery: () => void + runSource: QuerySource + executeLogsQuery: (sql: SafeLogSqlFragment) => void + readEditorLogsSql: () => UntrustedLogSqlFragment | undefined } /** Editor-surface UI state: selection, the active results tab, and mount. */ @@ -131,7 +142,9 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => const { id, urlId, generatedNewSnippetName, isLoading } = useSnippetIdentity() const { onMount, editorMountCount } = useEditorMount({ id }) - useAddDefinitions(id, monacoRef.current) + const runSource = useRunSource(id) + + useAddDefinitions(id, monacoRef.current, { enabled: runSource.type !== 'logs' }) const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery( { @@ -152,13 +165,31 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => return editor.getSql(fallback) }, [editor, id]) - const { executeQuery, isExecuting, potentialIssues, resetPotentialIssues } = - useSqlEditorExecution({ - id, - isDiffOpen, - hasSelection, - setAiTitle, - }) + // Reads the SQL to run from the editor as an UntrustedLogSqlFragment — the logs + // sibling of readEditorSql. Promotion (acceptUntrustedLogsSql) happens at each + // user-action site, never here. + const readEditorLogsSql = useCallback((): UntrustedLogSqlFragment | undefined => { + const snippet = getSqlEditorV2StateSnapshot().snippets[id]?.snippet + const fallback = snippet?.type === 'log_sql' ? snippet.content?.unchecked_sql : undefined + const sql = editor.getSql(fallback) + return sql === undefined ? undefined : untrustedLogSql(sql) + }, [editor, id]) + + const { + executeQuery, + isExecuting: isExecutingDb, + potentialIssues, + resetPotentialIssues, + } = useSqlEditorExecution({ + id, + isDiffOpen, + hasSelection, + setAiTitle, + }) + + const { executeLogsQuery, isExecuting: isExecutingLogs } = useLogsSqlExecution({ id }) + + const isExecuting = isExecutingDb || isExecutingLogs const ai = useSqlEditorAi({ id, editorMountCount, diff, prompt }) const { acceptAiHandler, discardAiHandler } = ai @@ -215,8 +246,21 @@ export const SQLEditorControllersProvider = ({ children }: PropsWithChildren) => potentialIssues, resetPotentialIssues, prettifyQuery, + runSource, + executeLogsQuery, + readEditorLogsSql, }), - [executeQuery, readEditorSql, isExecuting, potentialIssues, resetPotentialIssues, prettifyQuery] + [ + executeQuery, + readEditorSql, + isExecuting, + potentialIssues, + resetPotentialIssues, + prettifyQuery, + runSource, + executeLogsQuery, + readEditorLogsSql, + ] ) const uiValue = useMemo( diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx index 78084f48f5c..149a713f4e5 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorEditorPanel.tsx @@ -12,6 +12,7 @@ import { useSqlEditorUi, } from './SQLEditorControllers' import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget' +import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' import { detectOS } from '@/lib/helpers' // Load the monaco editor client-side only (does not behave well server-side) @@ -124,16 +125,28 @@ const SQLEditorMainView = () => { const { diff, prompt } = useSqlEditorAssistant() const { isDiffOpen } = diff const { promptState, openPrompt } = prompt - const { executeQuery, readEditorSql, prettifyQuery } = useSqlEditorRun() + const { + executeQuery, + readEditorSql, + prettifyQuery, + runSource, + executeLogsQuery, + readEditorLogsSql, + } = useSqlEditorRun() const { onMount, setHasSelection } = useSqlEditorUi() const os = detectOS() // Run gesture from the editor — promote here, at the user action. const runQuery = useCallback(() => { - const sql = readEditorSql() - if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql)) - }, [executeQuery, readEditorSql]) + if (runSource.type === 'logs') { + const sql = readEditorLogsSql() + if (sql !== undefined) void executeLogsQuery(acceptUntrustedLogsSql(sql)) + } else { + const sql = readEditorSql() + if (sql !== undefined) void executeQuery(acceptUntrustedSql(sql)) + } + }, [executeLogsQuery, executeQuery, readEditorLogsSql, readEditorSql, runSource.type]) return (
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx index 7401f7c3874..d149bdeb95e 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditorLayout.tsx @@ -17,6 +17,7 @@ import { import { SQLEditorEditorPanel } from './SQLEditorEditorPanel' import { UtilityActions } from './UtilityPanel/UtilityActions' import { UtilityPanel } from './UtilityPanel/UtilityPanel' +import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' const SQLEditorRunWarningModal = () => { const { refocusEditor, clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext() @@ -58,16 +59,38 @@ const SQLEditorToolbar = () => { const { clearPendingRunRefocus, markRefocusAfterRun } = useSQLEditorContext() const { id } = useSqlEditorSnippet() const { diff } = useSqlEditorAssistant() - const { executeQuery, readEditorSql, isExecuting, prettifyQuery } = useSqlEditorRun() + const { + executeQuery, + readEditorSql, + isExecuting, + prettifyQuery, + runSource, + executeLogsQuery, + readEditorLogsSql, + } = useSqlEditorRun() const { hasSelection } = useSqlEditorUi() // Run gesture from the toolbar button — promote here, at the user action. const runQuery = useCallback(() => { markRefocusAfterRun() - const sql = readEditorSql() - if (sql === undefined) return clearPendingRunRefocus() - void executeQuery(acceptUntrustedSql(sql)) - }, [clearPendingRunRefocus, executeQuery, markRefocusAfterRun, readEditorSql]) + if (runSource.type === 'logs') { + const sql = readEditorLogsSql() + if (sql === undefined) return clearPendingRunRefocus() + void executeLogsQuery(acceptUntrustedLogsSql(sql)) + } else { + const sql = readEditorSql() + if (sql === undefined) return clearPendingRunRefocus() + void executeQuery(acceptUntrustedSql(sql)) + } + }, [ + clearPendingRunRefocus, + executeLogsQuery, + executeQuery, + markRefocusAfterRun, + readEditorLogsSql, + readEditorSql, + runSource.type, + ]) return ( { +export const useAddDefinitions = ( + id: string, + monaco: Monaco | null, + { enabled = true }: { enabled?: boolean } = {} +) => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -29,28 +33,28 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: functions, isSuccess: isFunctionsSuccess } = useDatabaseFunctionsQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: schemas, isSuccess: isSchemasSuccess } = useSchemasQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const { data: tableColumns, isSuccess: isTableColumnsSuccess } = useTableColumnsQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: intellisenseEnabled } + { enabled: enabled && intellisenseEnabled } ) const pgInfoRef = useRef(null) @@ -58,6 +62,7 @@ export const useAddDefinitions = (id: string, monaco: Monaco | null) => { const filteredSchemas = useSchemasFilteredForHighAvailability(schemas) const isPgInfoReady = + enabled && intellisenseEnabled && isTableColumnsSuccess && isSchemasSuccess && diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx new file mode 100644 index 00000000000..38e3fb8634b --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.test.tsx @@ -0,0 +1,136 @@ +import { act, waitFor } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useLogsSqlExecution } from './useLogsSqlExecution' +import { + acceptUntrustedLogsSql, + untrustedLogSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { addAPIMock } from '@/tests/lib/msw' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +const SNIPPET_ID = 'logs-execution-snippet' + +/** Promote raw text to the `SafeLogSqlFragment` the run pipeline expects, exactly + * as the toolbar/editor-panel promote it right at the user action. */ +const logsSql = (text: string): SafeLogSqlFragment => acceptUntrustedLogsSql(untrustedLogSql(text)) + +type CapturedBody = { sql: string; iso_timestamp_start: string; iso_timestamp_end: string } + +function mockLogsAllOtel(rows: unknown[] = []) { + const captured: CapturedBody[] = [] + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel', + response: async ({ request }) => { + const body = (await request.json()) as CapturedBody + captured.push(body) + return HttpResponse.json({ result: rows }) + }, + }) + return captured +} + +/** Renders the hook with ClickHouse logs enabled by default; pass + * `{ otelLegacyLogs: false }` to exercise the not-available-yet guard. */ +function renderLogsExecution(flags: Record = { otelLegacyLogs: true }) { + return renderSqlEditorHook(() => useLogsSqlExecution({ id: SNIPPET_ID }), { flags }) +} + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() + seedSnippet({ id: SNIPPET_ID, source: 'logs' }) +}) + +describe('useLogsSqlExecution', () => { + it('runs a logs query and writes the result to the session store', async () => { + const rows = [{ event_message: 'hello' }] + const captured = mockLogsAllOtel(rows) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select event_message from edge_logs')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].rows).toEqual(rows) + + expect(captured).toHaveLength(1) + expect(captured[0].sql).toContain('select event_message from edge_logs') + expect(captured[0].iso_timestamp_start.length).toBeGreaterThan(0) + expect(captured[0].iso_timestamp_end.length).toBeGreaterThan(0) + }) + + it('records a structured 200-body error on the result instead of throwing', async () => { + addAPIMock({ + method: 'post', + path: '/platform/projects/:ref/analytics/endpoints/logs.all.otel', + response: async () => + HttpResponse.json({ + error: { + code: 400, + errors: [{ domain: 'global', message: 'Missing column', reason: 'invalid' }], + message: 'Missing column', + status: 'INVALID_ARGUMENT', + }, + }), + }) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select does_not_exist from edge_logs')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe('Missing column') + }) + + it('resolves a relative session range to a from/to window around now', async () => { + const captured = mockLogsAllOtel([]) + sqlEditorSessionState.setLogRange(SNIPPET_ID, { + kind: 'relative', + last: { amount: 2, unit: 'hour' }, + }) + + const { result } = renderLogsExecution() + + act(() => { + result.current.executeLogsQuery(logsSql('select 1')) + }) + + await waitFor(() => expect(captured).toHaveLength(1)) + const from = Date.parse(captured[0].iso_timestamp_start) + const to = Date.parse(captured[0].iso_timestamp_end) + expect(Number.isNaN(from)).toBe(false) + expect(Number.isNaN(to)).toBe(false) + expect(from).toBeLessThan(to) + }) + + it('records an unavailable message and fires no request when ClickHouse logs are off', async () => { + const captured = mockLogsAllOtel([]) + + const { result } = renderLogsExecution({ otelLegacyLogs: false }) + + act(() => { + result.current.executeLogsQuery(logsSql('select 1')) + }) + + await waitFor(() => expect(sqlEditorSessionState.results[SNIPPET_ID]?.[0]?.error).toBeDefined()) + expect(sqlEditorSessionState.results[SNIPPET_ID][0].error.message).toBe( + "Querying logs from the SQL editor isn't available for this project yet." + ) + // The doomed request never left the client. + expect(captured).toHaveLength(0) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts new file mode 100644 index 00000000000..4c1c006bfd7 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useLogsSqlExecution.ts @@ -0,0 +1,61 @@ +import { useFlag, useParams } from 'common' +import { useCallback } from 'react' + +import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource' +import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' +import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint' +import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' +import { useTrack } from '@/lib/telemetry/track' +import { + getSqlEditorSessionSnapshot, + useSqlEditorSessionSnapshot, +} from '@/state/sql-editor/sql-editor-session-state' + +type UseLogsSqlExecutionArgs = { id: string } + +/** + * Logs counterpart to `useSqlEditorExecution`. Runs a promoted + * `SafeLogSqlFragment` against the analytics OTEL endpoint with the snippet's + * active time range attached as request params. + */ +export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) { + const { ref: projectRef } = useParams() + const isOtelLogsEnabled = useFlag('otelLegacyLogs') + const track = useTrack() + const sessionSnap = useSqlEditorSessionSnapshot() + + const { mutate, isPending: isExecuting } = useExecuteLogsSqlMutation({ + onSuccess: (data) => { + sessionSnap.addResult(id, data.rows) + }, + onError: (error) => { + sessionSnap.addResultError(id, error) + }, + }) + + const executeLogsQuery = useCallback( + (sql: SafeLogSqlFragment) => { + if (isExecuting || projectRef === undefined) return + + if (!isOtelLogsEnabled) { + getSqlEditorSessionSnapshot().addResultError(id, { + message: "Querying logs from the SQL editor isn't available for this project yet.", + }) + return + } + + // Re-read imperatively so a range picked immediately before the run is + // honored; relative ranges re-resolve against `now` here. + const range = resolveLogRunRange( + getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE + ) + + mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) }) + + track('sql_editor_query_run_button_clicked', { source: 'logs' }) + }, + [id, isExecuting, isOtelLogsEnabled, mutate, projectRef, track] + ) + + return { executeLogsQuery, isExecuting } +} diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx new file mode 100644 index 00000000000..820caa97c00 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.test.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { DEFAULT_LOG_DATE_RANGE } from './querySource' +import { useRunSource } from './useRunSource' +import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state' +import { + renderSqlEditorHook, + resetSqlEditorStores, + seedSnippet, + setupSqlEditorMocks, +} from '@/tests/lib/sql-editor-test-utils' + +beforeEach(() => { + resetSqlEditorStores() + setupSqlEditorMocks() +}) + +describe('useRunSource', () => { + it('resolves a database snippet to a database source', () => { + const id = 'database-snippet' + seedSnippet({ id, source: 'database' }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ type: 'database' }) + }) + + it('resolves a logs snippet with no session range to the default range', () => { + const id = 'logs-snippet-default-range' + seedSnippet({ id, source: 'logs' }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE }) + }) + + it('resolves a logs snippet to its session-stored range when one is set', () => { + const id = 'logs-snippet-custom-range' + seedSnippet({ id, source: 'logs' }) + sqlEditorSessionState.setLogRange(id, { + kind: 'relative', + last: { amount: 2, unit: 'hour' }, + }) + + const { result } = renderSqlEditorHook(() => useRunSource(id)) + + expect(result.current).toEqual({ + type: 'logs', + dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } }, + }) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/useRunSource.ts b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts new file mode 100644 index 00000000000..9bccf7fb62b --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useRunSource.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react' + +import { DEFAULT_LOG_DATE_RANGE, type QuerySource } from './querySource' +import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state' + +/** + * Resolves where a snippet's query runs. A `log_sql` snippet targets the logs + * backend and carries its session time range (falling back to the default when + * the user hasn't picked one); every other snippet targets the database. Source + * is derived from the snippet's content type and is NOT flag-gated here — a + * URL-opened logs snippet routes correctly even with the feature flag off. + */ +export function useRunSource(id: string): QuerySource { + const snapV2 = useSqlEditorV2StateSnapshot() + const sessionSnap = useSqlEditorSessionSnapshot() + + const snippetType = snapV2.snippets[id]?.snippet.type + const logRange = sessionSnap.logRange[id] + + return useMemo(() => { + if (snippetType === 'log_sql') { + return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE } + } + return { type: 'database' } + }, [snippetType, logRange]) +} diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts index 7ded54949db..5d9c7f45a8d 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorExecution.ts @@ -139,7 +139,7 @@ export function useSqlEditorExecution({ }, }) - track('sql_editor_query_run_button_clicked') + track('sql_editor_query_run_button_clicked', { source: 'database' }) }, [ editor, diff --git a/apps/studio/tests/lib/sql-editor-test-utils.tsx b/apps/studio/tests/lib/sql-editor-test-utils.tsx index 0f4a6cf855e..d2e30d49bab 100644 --- a/apps/studio/tests/lib/sql-editor-test-utils.tsx +++ b/apps/studio/tests/lib/sql-editor-test-utils.tsx @@ -1,6 +1,7 @@ import { untrustedSql } from '@supabase/pg-meta' import type { QueryClient } from '@tanstack/react-query' import { renderHook, type RenderHookOptions } from '@testing-library/react' +import { FeatureFlagContext } from 'common' import { http, HttpResponse } from 'msw' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import type { ReactNode } from 'react' @@ -323,6 +324,8 @@ type RenderSqlEditorHookOptions = { aiAssistantState?: ReturnType databaseSelectorState?: ReturnType roleImpersonationState?: ReturnType + /** ConfigCat flags to expose via FeatureFlagContext (e.g. `{ otelLegacyLogs: true }`). */ + flags?: Record } export function renderSqlEditorHook( @@ -336,12 +339,10 @@ export function renderSqlEditorHook( options?.roleImpersonationState ?? createRoleImpersonationState('default', { current: async () => ({}) }) - const wrapper = ({ children }: { children: ReactNode }) => ( - + const flags = options?.flags + + const wrapper = ({ children }: { children: ReactNode }) => { + const tree = ( @@ -351,8 +352,24 @@ export function renderSqlEditorHook( - - ) + ) + + return ( + + {flags ? ( + + {tree} + + ) : ( + tree + )} + + ) + } const result = renderHook(hook, { initialProps: options?.initialProps, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 6e91ad815e9..16b212fa948 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1311,6 +1311,10 @@ export interface ImportDataAddedEvent { */ export interface SqlEditorQueryRunButtonClickedEvent { action: 'sql_editor_query_run_button_clicked' + properties: { + /** Which backend the query ran against. */ + source: 'database' | 'logs' + } groups: TelemetryGroups }