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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Pamela ChiaandJoshen Lim authored and GitHub committed 2026-09-29 13:54:13 -07:00
1 parent 13de8189c9
commit 4a941518e3
10 files changed
+307 -11

No files matched your search

+1 -1
View File
@@ -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.
@@ -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<string, QueryEditorHandle>())
const savedContentRef = useRef<typeof content>(undefined)
const confirmedCreatedNotebookIdsRef = useRef(new Set<string>())
const scrollContainerRef = useRef<HTMLDivElement>(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!')
@@ -133,6 +133,7 @@ export const ExplorerQueryTab = () => {
<QueryEditor
ref={queryEditorRef}
id={id}
location={{ surface: 'query_tab', queryId: id }}
variant="viewport"
title={draft.name}
query={query}
@@ -118,6 +118,15 @@ export const QueryCell = forwardRef<QueryEditorHandle, QueryCellProps>(function
<QueryEditor
ref={ref}
id={cell._id}
location={
currentNotebook
? {
surface: 'notebook_cell',
notebookId: currentNotebook.notebook.id,
cellId: cell._id,
}
: undefined
}
variant="embedded"
className="min-h-0"
title={title}
@@ -1,6 +1,7 @@
import { useMonaco } from '@monaco-editor/react'
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useFlag } from 'common'
import type { ExplorerQueryLocation } from 'common/telemetry-constants'
import { CodeSquare, Eye, EyeOff } from 'lucide-react'
import type { editor as monacoEditor, Selection } from 'monaco-editor'
import {
@@ -73,6 +74,7 @@ import { useLatest } from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { detectOS } from '@/lib/helpers'
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
import { useTrack } from '@/lib/telemetry/track'
import {
isRoleImpersonationEnabled,
type RoleImpersonationController,
@@ -122,6 +124,7 @@ export type QueryEditorHandle = {
type QueryEditorProps = {
id: string
location?: ExplorerQueryLocation
isReadOnly?: boolean
variant: 'embedded' | 'viewport'
title: string
@@ -154,6 +157,7 @@ type QueryEditorProps = {
export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(function QueryEditor(
{
id,
location,
isReadOnly = false,
variant,
title,
@@ -179,6 +183,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
ref
) {
const os = detectOS()
const track = useTrack()
const sql = query.uncheckedSql
const sqlRef = useLatest<string>(sql)
const onSqlCommitRef = useLatest(onSqlCommit)
@@ -260,13 +265,33 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(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<QueryEditorHandle, QueryEditorProps>(funct
range: resolveLogTimeRange(query.time_range),
endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint,
}).then(
(data) =>
(data) => {
trackRunResult()
onResultChange({
rows: data.rows as readonly Record<string, unknown>[],
...querySnapshot,
}),
(error) => onResultChange({ error, ...querySnapshot })
})
},
(error) => {
trackRunResult('execution_error')
onResultChange({ error, ...querySnapshot })
}
)
return
}
@@ -294,6 +324,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(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<QueryEditorHandle, QueryEditorProps>(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 })
}
)
}
@@ -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<string, boolean>,
}))
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => testContext.track }))
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
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<void>((resolve) => {
@@ -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<string, boolean>,
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<typeof import('common')>()
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' }))
@@ -69,17 +69,17 @@ export async function upsertNotebook(
export type UpdateNotebookData = Awaited<ReturnType<typeof upsertNotebook>>
export const useUpsertNotebookMutation = ({
export const useUpsertNotebookMutation = <TContext = unknown>({
onError,
onSuccess,
...options
}: Omit<
UseCustomMutationOptions<UpdateNotebookData, ResponseError, UpsertNotebookVariables>,
UseCustomMutationOptions<UpdateNotebookData, ResponseError, UpsertNotebookVariables, TContext>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<UpdateNotebookData, ResponseError, UpsertNotebookVariables>({
return useMutation<UpdateNotebookData, ResponseError, UpsertNotebookVariables, TContext>({
mutationFn: (args) => upsertNotebook(args),
async onSuccess(data, variables, context) {
const { projectRef, id } = variables
+84
View File
@@ -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<TelemetryGroups, 'project'> &
Partial<Pick<TelemetryGroups, 'organization'>>
/**
* 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
+2
View File
@@ -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') {