From e217f747bde92ef4cd2e45ebc37060f19da015f6 Mon Sep 17 00:00:00 2001 From: Chris Stavitsky <12092849+cstavitsky@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:57:43 -0700 Subject: [PATCH] feat(studio) instrument sentry on table creation flow (#43673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (working with @charislam @awaseem on this one) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES Per [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md): - ✅ `pnpm --filter studio build` - ✅ `pnpm test:prettier` - ✅ `pnpm format` ## What kind of change does this PR introduce? Instruments `studio` app's table creation flow with Sentry. ## What is the current behavior? The table creation flow isn't instrumented in detail, which means we have less visibility into understanding performance problems or errors within this specific flow. ## What is the new behavior? The table creation flow is instrumented in detail. This instrumentation allows for Sentry dashboards like this one (created based on locally generated Supabase data), to help monitor anything going wrong with this critical flow: image --------- Co-authored-by: Ali Waseem --- .../SidePanelEditor/SidePanelEditor.tsx | 180 ++++++++---- .../SidePanelEditor/SidePanelEditor.utils.tsx | 268 ++++++++++-------- apps/studio/lib/api/self-hosted/query.ts | 57 ++-- apps/studio/state/table-editor.tsx | 6 + 4 files changed, 316 insertions(+), 195 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 6fc6c682997..088655e23a6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -1,4 +1,5 @@ import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' +import * as Sentry from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import { @@ -680,66 +681,137 @@ export const SidePanelEditor = ({ try { if (action === 'create') { - toastId = toast.loading(`Creating new table: ${payload.name}...`) + await Sentry.startSpan( + { + name: 'Create Table', + op: 'db.table.create', + }, + async (createTableSpan) => { + toastId = toast.loading(`Creating new table: ${payload.name}...`) - const { table, failedPolicies } = await createTable({ - projectRef: project?.ref!, - connectionString: project?.connectionString, - toastId, - payload, - columns, - foreignKeyRelations, - isRLSEnabled, - importContent, - organizationSlug: org?.slug, - generatedPolicies, - onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), - }) + // Get existing table count from cache — try entity types first (always loaded + // by the Table Editor sidebar), then fall back to tables query cache. + // Entity types uses useInfiniteQuery, so the cache shape is { pages: [...] }. + // Each page has data.count (total count from SQL count(*) over()). + const entityTypesEntries = queryClient.getQueriesData<{ + pages?: Array<{ data?: { count?: number } }> + }>({ + queryKey: ['projects', project?.ref, 'entity-types'], + }) + const existingTableCount = + entityTypesEntries + .map(([, data]) => data?.pages?.[0]?.data?.count) + .find((count) => typeof count === 'number') ?? + queryClient.getQueryData( + tableKeys.list(project?.ref, payload.schema, true) + )?.length ?? + queryClient.getQueryData( + tableKeys.list(project?.ref, payload.schema, false) + )?.length - if (isRealtimeEnabled) await updateTableRealtime(table, true) + createTableSpan.setAttributes({ + 'table.name': payload.name, + 'table.schema': payload.schema ?? 'public', + 'table.columns_count': columns.length, + 'table.has_rls': isRLSEnabled ? 1 : 0, + 'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0, + 'table.has_import': importContent !== undefined ? 1 : 0, + 'table.generated_policies_count': generatedPolicies.length, + 'project.region': project?.region ?? 'local', + ...(project?.cloud_provider && { + 'project.cloud_provider': project.cloud_provider, + }), + ...(existingTableCount != null && { + 'project.existing_table_count': String(existingTableCount), + }), + }) - if (isApiGrantTogglesEnabled) { - const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed - ? apiAccessToggleHandler.data.privileges - : undefined - if (privilegesToSet) { - await updateTableApiAccess(table, privilegesToSet) - } - } + try { + const { table, failedPolicies } = await createTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + toastId, + payload, + columns, + foreignKeyRelations, + isRLSEnabled, + importContent, + organizationSlug: org?.slug, + generatedPolicies, + onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), + }) - // Invalidate queries for table creation - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), - }), - queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }), - queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(project?.ref) }), - queryClient.invalidateQueries({ - queryKey: privilegeKeys.tablePrivilegesList(project?.ref), - }), - queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }), - ]) + createTableSpan.setAttribute('table.created', 1) + createTableSpan.setAttribute('table.failed_policies', failedPolicies.length) - // Show success toast after everything is complete - if (failedPolicies.length > 0) { - toast.success( - `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, - { - id: toastId, - description: ( -
    - {failedPolicies.map((x) => ( -
  • {x.name}
  • - ))} -
- ), + await Sentry.startSpan( + { name: 'create_table.post_creation', op: 'db.table.post_creation' }, + async () => { + if (isRealtimeEnabled) await updateTableRealtime(table, true) + + if (isApiGrantTogglesEnabled) { + const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed + ? apiAccessToggleHandler.data.privileges + : undefined + if (privilegesToSet) { + await updateTableApiAccess(table, privilegesToSet) + } + } + } + ) + + // Invalidate queries for table creation + await Sentry.startSpan( + { name: 'create_table.cache_invalidation', op: 'cache.invalidate' }, + async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), + }), + queryClient.invalidateQueries({ + queryKey: entityTypeKeys.list(project?.ref), + }), + queryClient.invalidateQueries({ + queryKey: databasePoliciesKeys.list(project?.ref), + }), + queryClient.invalidateQueries({ + queryKey: privilegeKeys.tablePrivilegesList(project?.ref), + }), + queryClient.invalidateQueries({ queryKey: lintKeys.lint(project?.ref) }), + ]) + } + ) + + // Show success toast after everything is complete + if (failedPolicies.length > 0) { + toast.success( + `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, + { + id: toastId, + description: ( +
    + {failedPolicies.map((x) => ( +
  • {x.name}
  • + ))} +
+ ), + } + ) + } else { + toast.success(`Table ${table.name} is good to go!`, { id: toastId }) + } + + onTableCreated(table) + } catch (error) { + createTableSpan.setAttribute('table.error', 1) + Sentry.captureException(error, { + tags: { workflow: 'create-table' }, + }) + saveTableError = true + throw error } - ) - } else { - toast.success(`Table ${table.name} is good to go!`, { id: toastId }) - } - - onTableCreated(table) + } + ) } else if (action === 'duplicate' && !!selectedTable) { const tableToDuplicate = selectedTable toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 09ecf6c2ba3..8fd48e910d2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs' import pgMeta from '@supabase/pg-meta' import { Query } from '@supabase/pg-meta/src/query' import type { PostgresPrimaryKey } from '@supabase/postgres-meta' @@ -612,12 +613,18 @@ export const createTable = async ({ // Execute all table creation SQL in a single transaction toast.loading(`Creating table ${payload.name}...`, { id: toastId }) - await executeSql({ - projectRef, - connectionString, - sql: sqlStatements.join(';\n'), - queryKey: ['table', 'create-with-columns'], - }) + await Sentry.startSpan( + { name: 'create_table.execute_sql', op: 'db.sql.transaction' }, + async (span) => { + span.setAttribute('sql.statement_count', sqlStatements.length) + await executeSql({ + projectRef, + connectionString, + sql: sqlStatements.join(';\n'), + queryKey: ['table', 'create-with-columns'], + }) + } + ) // 6. Create generated RLS policies if any // [Joshen] Possible area for optimization to create all policies in a single query call @@ -625,31 +632,38 @@ export const createTable = async ({ const failedPolicies: GeneratedPolicy[] = [] if (generatedPolicies.length > 0 && isRLSEnabled) { - toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId }) - await Promise.all( - generatedPolicies.map(async (policy) => { - try { - return await createDatabasePolicy({ - projectRef, - connectionString, - payload: { - name: policy.name, - table: policy.table, - schema: policy.schema, - definition: policy.definition, - check: policy.check, - action: policy.action, - command: policy.command, - roles: policy.roles, - }, + await Sentry.startSpan( + { name: 'create_table.create_policies', op: 'db.policies.create' }, + async (span) => { + span.setAttribute('policies.count', generatedPolicies.length) + toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId }) + await Promise.all( + generatedPolicies.map(async (policy) => { + try { + return await createDatabasePolicy({ + projectRef, + connectionString, + payload: { + name: policy.name, + table: policy.table, + schema: policy.schema, + definition: policy.definition, + check: policy.check, + action: policy.action, + command: policy.command, + roles: policy.roles, + }, + }) + } catch (error: any) { + console.error('Failed to generate policy', error.message) + failedPolicies.push(policy) + } }) - } catch (error: any) { - console.error('Failed to generate policy', error.message) - failedPolicies.push(policy) - } - }) + ) + span.setAttribute('policies.failed_count', failedPolicies.length) + onCreatePoliciesSuccess?.() + } ) - onCreatePoliciesSuccess?.() } // Track table creation event (fire-and-forget to avoid blocking) @@ -692,102 +706,124 @@ export const createTable = async ({ } // Fetch the created table - const table = await getTableQuery({ - projectRef, - connectionString, - name: payload.name, - schema: payload.schema, - }) + const table = await Sentry.startSpan( + { name: 'create_table.fetch_table', op: 'db.table.fetch' }, + async () => { + return await getTableQuery({ + projectRef, + connectionString, + name: payload.name, + schema: payload.schema, + }) + } + ) // If the user is importing data via a spreadsheet if (importContent !== undefined) { - if (importContent.file && importContent.rowCount > 0) { - // Via a CSV file - const { error }: any = await insertRowsViaSpreadsheet( - projectRef, - connectionString, - importContent.file, - table, - importContent.selectedHeaders, - (progress: number) => { - toast.loading( -
- -
, - { id: toastId } + await Sentry.startSpan( + { name: 'create_table.import_data', op: 'db.table.import' }, + async (span) => { + const rowCount = importContent.file + ? importContent.rowCount + : importContent.rows?.length ?? 0 + span.setAttribute('import.row_count', rowCount) + span.setAttribute('import.method', importContent.file ? 'csv' : 'paste') + + if (importContent.file && importContent.rowCount > 0) { + // Via a CSV file + const { error }: any = await insertRowsViaSpreadsheet( + projectRef, + connectionString, + importContent.file, + table, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
+ +
, + { id: toastId } + ) + } + ) + + if (error !== undefined) { + span.setAttribute('import.error', 1) + toast.error('Do check your spreadsheet if there are any discrepancies.') + const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}` + toast.error(message) + console.error('Error:', { error, message }) + } + } else { + // Via text copy and paste + await insertTableRows( + projectRef, + connectionString, + table, + importContent.rows, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
+ +
, + { id: toastId } + ) + } ) } - ) - if (error !== undefined) { - toast.error('Do check your spreadsheet if there are any discrepancies.') - const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}` - toast.error(message) - console.error('Error:', { error, message }) - } - } else { - // Via text copy and paste - await insertTableRows( - projectRef, - connectionString, - table, - importContent.rows, - importContent.selectedHeaders, - (progress: number) => { - toast.loading( -
- -
, - { id: toastId } - ) - } - ) - } - - // For identity columns, manually raise the sequences (batched for performance) - const identityColumns = columns.filter((column) => column.isIdentity) - if (identityColumns.length > 0) { - const updateSequenceSQL = identityColumns - .map((column) => - getUpdateIdentitySequenceSQL({ - schema: table.schema, - table: table.name, - column: column.name, + // For identity columns, manually raise the sequences (batched for performance) + const identityColumns = columns.filter((column) => column.isIdentity) + if (identityColumns.length > 0) { + const updateSequenceSQL = identityColumns + .map((column) => + getUpdateIdentitySequenceSQL({ + schema: table.schema, + table: table.name, + column: column.name, + }) + ) + .join(';\n') + await executeSql({ + projectRef, + connectionString, + sql: updateSequenceSQL, + queryKey: ['sequences', 'update-batch'], }) - ) - .join(';\n') - await executeSql({ - projectRef, - connectionString, - sql: updateSequenceSQL, - queryKey: ['sequences', 'update-batch'], - }) - } + } + } + ) } - await prefetchEditorTablePage({ - queryClient, - projectRef, - connectionString, - id: table.id, - }) + await Sentry.startSpan( + { name: 'create_table.prefetch_editor', op: 'db.table.prefetch' }, + async () => { + await prefetchEditorTablePage({ + queryClient, + projectRef, + connectionString, + id: table.id, + }) + } + ) // Finally, return the created table return { table, failedPolicies } diff --git a/apps/studio/lib/api/self-hosted/query.ts b/apps/studio/lib/api/self-hosted/query.ts index 4bfd077f48c..59a061fae8a 100644 --- a/apps/studio/lib/api/self-hosted/query.ts +++ b/apps/studio/lib/api/self-hosted/query.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs' import { PG_META_URL } from 'lib/constants/index' import { constructHeaders } from '../apiHelpers' import { PgMetaDatabaseError, databaseErrorSchema, WrappedResult } from './types' @@ -31,30 +32,36 @@ export async function executeQuery({ requestBody.parameters = parameters } - const response = await fetch(`${PG_META_URL}/query`, { - method: 'POST', - headers: constructHeaders({ - ...headers, - 'Content-Type': 'application/json', - 'x-connection-encrypted': connectionStringEncrypted, - }), - body: JSON.stringify(requestBody), + return await Sentry.startSpan({ name: 'pg-meta.query', op: 'db.query' }, async (span) => { + const response = await fetch(`${PG_META_URL}/query`, { + method: 'POST', + headers: constructHeaders({ + ...headers, + 'Content-Type': 'application/json', + 'x-connection-encrypted': connectionStringEncrypted, + }), + body: JSON.stringify(requestBody), + }) + + try { + const result = await response.json() + + if (!response.ok) { + const { message, code, formattedError } = databaseErrorSchema.parse(result) + span.setAttribute('db.error', 1) + span.setAttribute('db.status_code', response.status) + const error = new PgMetaDatabaseError(message, code, response.status, formattedError) + return { data: undefined, error } + } + + span.setAttribute('db.status_code', response.status) + return { data: result, error: undefined } + } catch (error) { + span.setAttribute('db.error', 1) + if (error instanceof Error) { + return { data: undefined, error } + } + throw error + } }) - - try { - const result = await response.json() - - if (!response.ok) { - const { message, code, formattedError } = databaseErrorSchema.parse(result) - const error = new PgMetaDatabaseError(message, code, response.status, formattedError) - return { data: undefined, error } - } - - return { data: result, error: undefined } - } catch (error) { - if (error instanceof Error) { - return { data: undefined, error } - } - throw error - } } diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 9f78e710c75..f8b2df8a3be 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -1,4 +1,5 @@ import type { PostgresColumn } from '@supabase/postgres-meta' +import * as Sentry from '@sentry/nextjs' import { useConstant } from 'common' import type { SupaRow } from 'components/grid/types' import { @@ -105,6 +106,11 @@ export const createTableEditorState = () => { /* Tables */ onAddTable: (templateData?: Partial) => { + // Record that the table creator was opened + Sentry.startSpan({ name: 'table_creator.opened', op: 'ui.action' }, (span) => { + span.setAttribute('table_creator.opened', 1) + }) + state.ui = { open: 'side-panel', sidePanel: { type: 'table', mode: 'new', templateData },