From 4a941518e3ed9e962539cbd2070b29c2d162d45e Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 29 Sep 2026 13:54:13 -0700 Subject: [PATCH] feat(studio): track Explorer runs and saves (#51004) I added outcome events for Explorer query runs and successful manual notebook saves. Existing page visits and preview toggles do not show whether users complete queries or persist notebooks. **Changed:** - **Query usage:** Accepted runs from query tabs and notebook cells emit submitted and terminal outcome events with a shared run ID. Canceled confirmations emit no run events. - **Notebook adoption:** Successful manual saves emit created or updated events. Recreated notebooks count as creations. Unsaved drafts and failed saves emit neither. - **Event metadata:** Explorer action events use `Explorer` as their page title. **Note:** Assistant-generated saves are outside this PR. Custom properties omit SQL and notebook content. Page visits still carry the browser title, which can include a notebook name. ## To test Tested on the staging preview: - [x] Run valid and invalid SQL from an Explorer query tab. Each run emits one submitted event and one matching completed or failed event with the same run ID. - [x] Run database and Logs notebook query cells, then add a markdown cell. The query cells emit matching event pairs; the markdown cell emits no query event. - [x] Save a new notebook, then edit and save it again. The successful saves emit created and updated events. - [x] Cancel a guarded query. It emits no query run event. - [ ] Recreate a notebook deleted on the server after local edits. A successful save emits created, not updated. - [x] Inspect an Explorer action event request. Its page title is `Explorer`; page visits still use the browser title. - [ ] Force a notebook save failure. It should emit no save event. This case was not tested manually. ## Linear - fixes GROWTH-1298 ## Summary by CodeRabbit * **Analytics** * Explorer query runs are tracked for database and log queries, including whether they complete or fail. * Query activity is associated with its location in Explorer, such as a query tab or notebook cell. * Successful notebook saves are tracked as creations or updates. --------- Co-authored-by: Joshen Lim --- .coderabbit.yaml | 2 +- .../Explorer/ExplorerNotebookTab.tsx | 25 +++++- .../interfaces/Explorer/ExplorerQueryTab.tsx | 1 + .../interfaces/Explorer/QueryCell/index.tsx | 9 ++ .../interfaces/Explorer/QueryEditor/index.tsx | 47 +++++++++-- .../__tests__/ExplorerNotebookTab.test.tsx | 74 ++++++++++++++++ .../Explorer/__tests__/QueryTab.test.tsx | 68 +++++++++++++++ .../notebooks/notebook-upsert-mutation.ts | 6 +- packages/common/telemetry-constants.ts | 84 +++++++++++++++++++ packages/common/telemetry.tsx | 2 + 10 files changed, 307 insertions(+), 11 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 571f5b7739d..05fb08b4164 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -34,7 +34,7 @@ reviews: Strictly enforce event naming: [object]_[verb] in snake_case. Only approved verbs: opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added, enabled, disabled, copied, exposed, failed, - converted. Properties must be camelCase for new events (match existing + converted, completed. Properties must be camelCase for new events (match existing convention when adding to existing events). Flag any usage of useSendEventMutation. Verify @group Events and @source JSDoc tags are accurate. Check that new interfaces are added to the TelemetryEvent union type. diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index e5cfe360dd5..6fdfef83201 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -83,6 +83,7 @@ import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-ups import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useTrack } from '@/lib/telemetry/track' import { getNotebooksStateSnapshot, useCurrentNotebook, @@ -109,6 +110,7 @@ export const ExplorerNotebookTab = () => { const { name, content } = currentNotebook?.notebook ?? {} const { isNotFound } = useLoadNotebook({ id, projectRef: ref }) const { data: project } = useSelectedProjectQuery() + const track = useTrack() const cells = content?.cells ?? [] const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id) @@ -121,12 +123,31 @@ export const ExplorerNotebookTab = () => { } | null>(null) const [skipMutatingCells, setSkipMutatingCells] = useState(false) const [isExportingPdf, setIsExportingPdf] = useState(false) + const queryCellRefs = useRef(new Map()) const savedContentRef = useRef(undefined) + const confirmedCreatedNotebookIdsRef = useRef(new Set()) const scrollContainerRef = useRef(null) - const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({ - onSuccess: (data) => { + const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation<{ + isCreation: boolean + }>({ + onMutate: (variables) => ({ + isCreation: + (currentNotebook?.status === 'new' || + snap.serverDivergedWhileDirty.get(variables.id) === 'deleted') && + !confirmedCreatedNotebookIdsRef.current.has(variables.id), + }), + onSuccess: (data, variables, context) => { + const { isCreation } = context + if (isCreation) confirmedCreatedNotebookIdsRef.current.add(variables.id) + + track( + isCreation ? 'explorer_notebook_created' : 'explorer_notebook_updated', + { notebookId: variables.id }, + { project: variables.projectRef } + ) + if (id && content === savedContentRef.current) { snap.markSaved({ id, updatedAt: data?.updated_at }) toast.success('Successfully saved notebook!') diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx index 5963fce4cb9..eb2ba1418d9 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTab.tsx @@ -133,6 +133,7 @@ export const ExplorerQueryTab = () => { (function (function QueryEditor( { id, + location, isReadOnly = false, variant, title, @@ -179,6 +183,7 @@ export const QueryEditor = forwardRef(funct ref ) { const os = detectOS() + const track = useTrack() const sql = query.uncheckedSql const sqlRef = useLatest(sql) const onSqlCommitRef = useLatest(onSqlCommit) @@ -260,13 +265,33 @@ export const QueryEditor = forwardRef(funct } onRun?.() + const querySnapshot = { sql: rawSql, source: query._tag } + const runProperties = location + ? { ...location, runId: crypto.randomUUID(), source: query._tag } + : undefined + const groups = { project: project.ref } + + if (runProperties) track('explorer_query_submitted', runProperties, groups) + + const trackRunResult = ( + failureReason?: 'logs_unavailable' | 'connection_unavailable' | 'execution_error' + ) => { + if (!runProperties) return + if (failureReason) { + track('explorer_query_failed', { ...runProperties, failureReason }, groups) + } else { + track('explorer_query_completed', runProperties, groups) + } + } + // [Joshen] This is deliberate to commit the sql, rather than the passed rawSql // As we want to save the cell's content into the store, rather than what's getting run onSqlCommit?.(sql) if (query._tag === 'logs') { if (!isOtelLogsEnabled) { + trackRunResult('logs_unavailable') onResultChange({ error: { message: "Querying logs isn't available for this project yet." }, ...querySnapshot, @@ -280,12 +305,17 @@ export const QueryEditor = forwardRef(funct range: resolveLogTimeRange(query.time_range), endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint, }).then( - (data) => + (data) => { + trackRunResult() onResultChange({ rows: data.rows as readonly Record[], ...querySnapshot, - }), - (error) => onResultChange({ error, ...querySnapshot }) + }) + }, + (error) => { + trackRunResult('execution_error') + onResultChange({ error, ...querySnapshot }) + } ) return } @@ -294,6 +324,7 @@ export const QueryEditor = forwardRef(funct const limitedSql = applyAutoLimit(safeSql, rowLimit) if (!isValidConnString(connectionString)) { + trackRunResult('connection_unavailable') onResultChange({ error: { message: 'Unable to run query: Connection string is missing' }, ...querySnapshot, @@ -310,8 +341,14 @@ export const QueryEditor = forwardRef(funct isStatementTimeoutDisabled: true, isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role), }).then( - (data) => onResultChange({ rows: data.result, ...querySnapshot }), - (error) => onResultChange({ error, ...querySnapshot }) + (data) => { + trackRunResult() + onResultChange({ rows: data.result, ...querySnapshot }) + }, + (error) => { + trackRunResult('execution_error') + onResultChange({ error, ...querySnapshot }) + } ) } diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx index ded7a3582df..5f651c72d9f 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -19,9 +19,12 @@ import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' import type { Notebooks } from '@/types' const testContext = vi.hoisted(() => ({ + track: vi.fn(), flags: { otelLegacyLogs: true } as Record, })) +vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => testContext.track })) + vi.mock('common', async (importOriginal) => { const actual = await importOriginal() return { @@ -119,6 +122,7 @@ const mockDatabaseQueryRequests = () => { } beforeEach(() => { + testContext.track.mockClear() setupSqlEditorMocks() testContext.flags.otelLegacyLogs = true for (const id of Object.keys(notebooksState.notebooks)) { @@ -191,6 +195,26 @@ describe('ExplorerNotebookTab', () => { await waitFor(() => expect(dbRequests).toHaveLength(1)) await waitFor(() => expect(logRequests).toHaveLength(1)) await waitFor(() => expect(runNotebookButton).toBeEnabled()) + const queryEvents = testContext.track.mock.calls.filter(([action]) => + action.startsWith('explorer_query_') + ) + expect(queryEvents.filter(([action]) => action === 'explorer_query_submitted')).toHaveLength(2) + expect(queryEvents.filter(([action]) => action === 'explorer_query_completed')).toHaveLength(2) + for (const [, properties] of queryEvents) { + expect(properties).toMatchObject({ surface: 'notebook_cell', notebookId: NOTEBOOK_ID }) + expect(properties.cellId).toBeDefined() + expect(properties).not.toHaveProperty('sql') + } + const submittedRuns = queryEvents.filter(([action]) => action === 'explorer_query_submitted') + const completedRuns = queryEvents.filter(([action]) => action === 'explorer_query_completed') + expect(new Set(submittedRuns.map(([, properties]) => properties.runId)).size).toBe(2) + for (const [, properties] of submittedRuns) { + expect(completedRuns).toContainEqual([ + 'explorer_query_completed', + properties, + expect.objectContaining({ project: 'default' }), + ]) + } }) describe('mutation confirmation on "Run notebook"', () => { @@ -406,6 +430,56 @@ describe('ExplorerNotebookTab', () => { await waitFor(() => expect(tabsState.tabsMap[tabId]?.isPreview).toBe(false)) }) + it('tracks confirmed creation once and later saves as updates', async () => { + seedNotebook([databaseCell], 'new') + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => HttpResponse.json({ id: NOTEBOOK_ID }), + }) + + renderNotebookTab() + const saveButton = await screen.findByRole('button', { name: 'Save changes' }) + await userEvent.click(saveButton) + await waitFor(() => + expect(testContext.track).toHaveBeenCalledWith( + 'explorer_notebook_created', + { notebookId: NOTEBOOK_ID }, + { project: 'default' } + ) + ) + + notebooksState.insertCellAfter({ id: NOTEBOOK_ID, cell: createMarkdownCellSkeleton() }) + await userEvent.click(saveButton) + await waitFor(() => + expect(testContext.track).toHaveBeenCalledWith( + 'explorer_notebook_updated', + { notebookId: NOTEBOOK_ID }, + { project: 'default' } + ) + ) + expect( + testContext.track.mock.calls.filter(([action]) => action === 'explorer_notebook_created') + ).toHaveLength(1) + }) + + it('does not track an unsaved draft or a failed first save', async () => { + seedNotebook([databaseCell], 'new') + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: () => HttpResponse.json({ message: 'Save failed' }, { status: 500 }), + }) + + renderNotebookTab() + expect(testContext.track).not.toHaveBeenCalled() + await userEvent.click(await screen.findByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled()) + expect( + testContext.track.mock.calls.filter(([action]) => action.startsWith('explorer_notebook_')) + ).toEqual([]) + }) + it('does not mark a newer edit as saved when an earlier save resolves after it', async () => { let resolveSave: (() => void) | undefined const savePromise = new Promise((resolve) => { diff --git a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx index ce0e88143bb..0910f7c752e 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/QueryTab.test.tsx @@ -15,12 +15,15 @@ import { addAPIMock } from '@/tests/lib/msw' import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' const testContext = vi.hoisted(() => ({ + track: vi.fn(), flags: { otelLegacyLogs: true } as Record, params: { ref: 'default', id: 'query-test' } as { ref?: string; id?: string }, /** Simulated editor selection — the mocked CodeEditor's fake editor reads this. */ selectedText: undefined as string | undefined, })) +vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => testContext.track })) + vi.mock('common', async (importOriginal) => { const actual = await importOriginal() return { @@ -218,6 +221,7 @@ const createDraft = ( } beforeEach(() => { + testContext.track.mockClear() setupSqlEditorMocks() testContext.flags.otelLegacyLogs = true testContext.params = { ref: 'default', id: 'query-test' } @@ -248,6 +252,20 @@ describe('QueryTab execution', () => { await waitFor(() => expect(explorerQueryState.results['query-test']).toBeDefined()) expect(explorerQueryState.results['query-test']).toMatchObject({ rows: [] }) expect(explorerQueryState.drafts['query-test']?.pendingAutoRun).toBe(false) + const queryEvents = testContext.track.mock.calls.filter(([action]) => + action.startsWith('explorer_query_') + ) + expect(queryEvents.map(([action]) => action)).toEqual([ + 'explorer_query_submitted', + 'explorer_query_completed', + ]) + expect(queryEvents[0][1]).toMatchObject({ + surface: 'query_tab', + queryId: 'query-test', + source: 'database', + }) + expect(queryEvents[1][1].runId).toBe(queryEvents[0][1].runId) + expect(queryEvents[0][1]).not.toHaveProperty('sql') }) it('does not auto-run a draft created without autoRun', async () => { @@ -285,6 +303,53 @@ describe('QueryTab execution', () => { await screen.findByText("Error: Querying logs isn't available for this project yet.") ).toBeInTheDocument() expect(requests).toHaveLength(0) + expect( + testContext.track.mock.calls.filter(([action]) => action.startsWith('explorer_query_')) + ).toEqual([ + [ + 'explorer_query_submitted', + expect.objectContaining({ source: 'logs', surface: 'query_tab' }), + expect.objectContaining({ project: 'default' }), + ], + [ + 'explorer_query_failed', + expect.objectContaining({ failureReason: 'logs_unavailable' }), + expect.objectContaining({ project: 'default' }), + ], + ]) + }) + + it('records a terminal failure when database execution rejects', async () => { + createDraft({ _tag: 'database' }) + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: ({ request }) => + new URL(request.url).searchParams.get('key') === '' + ? HttpResponse.json({ message: 'Query failed' }, { status: 500 }) + : HttpResponse.json([]), + }) + + renderQueryTab() + const runButton = await screen.findByRole('button', { name: 'Run' }) + await waitFor(() => expect(runButton).toBeEnabled()) + await userEvent.click(runButton) + + await waitFor(() => + expect( + testContext.track.mock.calls.filter(([action]) => action.startsWith('explorer_query_')) + ).toHaveLength(2) + ) + const [submitted, failed] = testContext.track.mock.calls.filter(([action]) => + action.startsWith('explorer_query_') + ) + expect(submitted[0]).toBe('explorer_query_submitted') + expect(failed[0]).toBe('explorer_query_failed') + expect(failed[1]).toMatchObject({ + runId: submitted[1].runId, + failureReason: 'execution_error', + }) + expect(failed[1]).not.toHaveProperty('message') }) it('exposes and persists the shared notebook result view options', async () => { @@ -443,6 +508,9 @@ describe('QueryTab execution', () => { expect(await screen.findByText('Potential issue detected')).toBeInTheDocument() expect(executedQueries).toHaveLength(0) + expect( + testContext.track.mock.calls.filter(([action]) => action.startsWith('explorer_query_')) + ).toEqual([]) await userEvent.click(screen.getByRole('button', { name: 'Run query' })) diff --git a/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts index c3609d7c970..64185077378 100644 --- a/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts +++ b/apps/studio/data/content/notebooks/notebook-upsert-mutation.ts @@ -69,17 +69,17 @@ export async function upsertNotebook( export type UpdateNotebookData = Awaited> -export const useUpsertNotebookMutation = ({ +export const useUpsertNotebookMutation = ({ onError, onSuccess, ...options }: Omit< - UseCustomMutationOptions, + UseCustomMutationOptions, 'mutationFn' > = {}) => { const queryClient = useQueryClient() - return useMutation({ + return useMutation({ mutationFn: (args) => upsertNotebook(args), async onSuccess(data, variables, context) { const { projectRef, id } = variables diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 473b6b7169f..74225277b7f 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3459,6 +3459,85 @@ export interface LogExplorerQueryRunButtonClickedEvent { groups: TelemetryGroups } +export type ExplorerQueryLocation = + | { surface: 'query_tab'; queryId: string; notebookId?: never; cellId?: never } + | { surface: 'notebook_cell'; notebookId: string; cellId: string; queryId?: never } + +export type ExplorerQueryRunProperties = ExplorerQueryLocation & { + runId: string + source: 'database' | 'logs' +} + +type ExplorerGroups = Pick & + Partial> + +/** + * User started an Explorer query run. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerQuerySubmittedEvent { + action: 'explorer_query_submitted' + properties: ExplorerQueryRunProperties + groups: ExplorerGroups +} + +/** + * An Explorer query run completed successfully. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerQueryCompletedEvent { + action: 'explorer_query_completed' + properties: ExplorerQueryRunProperties + groups: ExplorerGroups +} + +/** + * An Explorer query run failed. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerQueryFailedEvent { + action: 'explorer_query_failed' + properties: ExplorerQueryRunProperties & { + failureReason: 'logs_unavailable' | 'connection_unavailable' | 'execution_error' + } + groups: ExplorerGroups +} + +/** + * An Explorer notebook was saved for the first time. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerNotebookCreatedEvent { + action: 'explorer_notebook_created' + properties: { notebookId: string } + groups: ExplorerGroups +} + +/** + * An Explorer notebook was saved after creation. + * + * @group Events + * @source studio + * @page /project/{ref}/explorer + */ +export interface ExplorerNotebookUpdatedEvent { + action: 'explorer_notebook_updated' + properties: { notebookId: string } + groups: ExplorerGroups +} + /** * User clicked an upgrade CTA inside the compute badge hover card. * @@ -3981,6 +4060,11 @@ export interface WarehouseDisabledEvent { * @hidden */ export type TelemetryEvent = + | ExplorerQuerySubmittedEvent + | ExplorerQueryCompletedEvent + | ExplorerQueryFailedEvent + | ExplorerNotebookCreatedEvent + | ExplorerNotebookUpdatedEvent | SignUpEvent | SignInEvent | SignInSubmittedEvent diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index df65839f461..d11933ab7aa 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -430,6 +430,8 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn groups: 'groups' in event ? { ...event.groups } : {}, } + if (event.action.startsWith('explorer_')) body.page_title = 'Explorer' + if (body.groups?.project === 'Unknown') { delete body.groups.project if (body.groups?.organization === 'Unknown') {