feat(studio) instrument sentry on table creation flow (#43673)

(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:

<img width="1915" height="1131" alt="image"
src="https://github.com/user-attachments/assets/e2c5380e-7b00-41c1-9109-3ee4e47ff63b"
/>

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
Chris StavitskyandAli Waseem authored and GitHub committed 2026-03-16 09:57:43 -06:00
1 parent edf51dfd98
commit e217f747bd
4 files changed
+316 -195

No files matched your search

@@ -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<unknown[]>(
tableKeys.list(project?.ref, payload.schema, true)
)?.length ??
queryClient.getQueryData<unknown[]>(
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: (
<ul className="list-disc pl-6">
{failedPolicies.map((x) => (
<li key={x.name}>{x.name}</li>
))}
</ul>
),
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: (
<ul className="list-disc pl-6">
{failedPolicies.map((x) => (
<li key={x.name}>{x.name}</li>
))}
</ul>
),
}
)
} 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}...`)
@@ -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(
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
<SparkBar
value={progress}
max={100}
type="horizontal"
barClass="bg-brand"
labelBottom={`Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`}
labelBottomClass=""
labelTop={`${progress.toFixed(2)}%`}
labelTopClass="tabular-nums"
/>
</div>,
{ 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(
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
<SparkBar
value={progress}
max={100}
type="horizontal"
barClass="bg-brand"
labelBottom={`Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`}
labelBottomClass=""
labelTop={`${progress.toFixed(2)}%`}
labelTopClass="tabular-nums"
/>
</div>,
{ 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(
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
<SparkBar
value={progress}
max={100}
type="horizontal"
barClass="bg-brand"
labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
labelTop={`${progress.toFixed(2)}%`}
labelTopClass="tabular-nums"
/>
</div>,
{ 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(
<div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
<SparkBar
value={progress}
max={100}
type="horizontal"
barClass="bg-brand"
labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
labelTop={`${progress.toFixed(2)}%`}
labelTopClass="tabular-nums"
/>
</div>,
{ 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 }
+32 -25
View File
@@ -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<T = unknown>({
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
}
}
+6
View File
@@ -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<TableField>) => {
// 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 },