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: ( - - ), + 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: ( + + ), + } + ) + } 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 },