diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx
index 226379f7557c9..b75ce7f895bc3 100644
--- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx
+++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx
@@ -1,4 +1,3 @@
-import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
import CardButton from 'components/ui/CardButton'
import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
@@ -8,8 +7,8 @@ import type { ResourceWarning } from 'data/usage/resource-warnings-query'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { BASE_PATH } from 'lib/constants'
-import { Copy, Github, MoreVertical, Trash } from 'lucide-react'
-import { useState } from 'react'
+import { Copy, Github, MoreVertical, Settings } from 'lucide-react'
+import { useRouter } from 'next/router'
import InlineSVG from 'react-inlinesvg'
import { toast } from 'sonner'
import type { Organization } from 'types'
@@ -38,14 +37,13 @@ export interface ProjectCardProps {
export const ProjectCard = ({
slug,
project,
- organization,
rewriteHref,
githubIntegration,
vercelIntegration,
resourceWarnings,
}: ProjectCardProps) => {
+ const router = useRouter()
const { name, ref: projectRef } = project
- const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const { infraAwsNimbusLabel } = useCustomContent(['infra:aws_nimbus_label'])
const providerLabel =
@@ -104,11 +102,11 @@ export const ProjectCard = ({
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
- setIsDeleteModalOpen(true)
+ router.push(`/project/${projectRef}/settings/general`)
}}
>
-
- Delete project
+
+ Settings
@@ -156,12 +154,6 @@ export const ProjectCard = ({
containerElement={}
/>
- setIsDeleteModalOpen(false)}
- project={project}
- organization={organization}
- />
>
)
}
diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
index a6e3e94951a15..5721177a6e08f 100644
--- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
+++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx
@@ -1,4 +1,4 @@
-import { Github, MoreVertical, Trash, Copy, Check } from 'lucide-react'
+import { Github, MoreVertical, Settings, Copy, Check } from 'lucide-react'
import { useRouter } from 'next/router'
import InlineSVG from 'react-inlinesvg'
import { useState } from 'react'
@@ -22,7 +22,6 @@ import {
import { TimestampInfo } from 'ui-patterns'
import { inferProjectStatus } from './ProjectCard.utils'
import { ProjectCardStatus } from './ProjectCardStatus'
-import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
import { toast } from 'sonner'
import { copyToClipboard } from 'ui'
@@ -46,7 +45,6 @@ export const ProjectTableRow = ({
const router = useRouter()
const { name, ref: projectRef } = project
const projectStatus = inferProjectStatus(project.status)
- const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isCopied, setIsCopied] = useState(false)
const url = rewriteHref ?? `/project/${project.ref}`
@@ -177,23 +175,17 @@ export const ProjectTableRow = ({
className="gap-x-2"
onClick={(e) => {
e.stopPropagation()
- setIsDeleteModalOpen(true)
+ router.push(`/project/${projectRef}/settings/general`)
}}
>
-
- Delete project
+
+ Settings
- setIsDeleteModalOpen(false)}
- project={project}
- organization={organization}
- />
>
)
}
diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts
index 98f460b84bfd5..055ca24acab1f 100644
--- a/apps/studio/instrumentation-client.ts
+++ b/apps/studio/instrumentation-client.ts
@@ -8,6 +8,14 @@ import { IS_PLATFORM } from 'common/constants/environment'
import { MIRRORED_BREADCRUMBS } from 'lib/breadcrumbs'
import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from 'lib/sanitize'
+const DEFAULT_ERROR_SAMPLE_RATE = 1.0
+const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01
+const CHUNK_LOAD_ERROR_PATTERNS = [
+ /ChunkLoadError/i,
+ /Loading chunk [\d]+ failed/i,
+ /Loading CSS chunk [\d]+ failed/i,
+]
+
// This is a workaround to ignore hCaptcha related errors.
function isHCaptchaRelatedError(event: Sentry.Event): boolean {
const errors = event.exception?.values ?? []
@@ -22,32 +30,6 @@ function isHCaptchaRelatedError(event: Sentry.Event): boolean {
return false
}
-// We want to ignore errors not originating from docs app static files
-// (such as errors from browser extensions). Those errors come from files
-// not starting with 'app:///_next'.
-//
-// However, there is a complication because the Sentry code that sends
-// the error shows up in the stack trace, and that _does_ start with
-// 'app:///_next'. It is always the first frame in the stack trace,
-// and has a specific pre_context comment that we can use for filtering.
-// Copied from docs app instrumentation-client.ts
-function isThirdPartyError(frames: Sentry.StackFrame[] | undefined) {
- if (!frames || frames.length === 0) return false
-
- function isSentryFrame(frame: Sentry.StackFrame, index: number) {
- return index === 0 && frame.pre_context?.some((line) => line.includes('sentry.javascript'))
- }
-
- // Check if any frame is from our app (excluding Sentry's own frame)
- const hasAppFrame = frames.some((frame, index) => {
- const path = frame.abs_path || frame.filename
- return path?.startsWith('app:///_next') && !isSentryFrame(frame, index)
- })
-
- // If no app frames found, it's a third-party error
- return !hasAppFrame
-}
-
// Filter browser wallet extension errors (e.g., Gate.io wallet)
// These errors come from injected wallet scripts and are not actionable
// Examples: SUPABASE-APP-AFC, SUPABASE-APP-92A
@@ -94,6 +76,17 @@ export function isChallengeExpiredError(error: unknown, event: Sentry.Event): bo
return message.includes('challenge-expired')
}
+function isChunkLoadError(error: unknown, event: Sentry.Event): boolean {
+ const errorMessage = error instanceof Error ? error.message : ''
+ const eventMessage = event.message || ''
+ const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? []
+ const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean)
+
+ return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) =>
+ combinedMessages.some((message) => pattern.test(message))
+ )
+}
+
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && {
@@ -102,12 +95,31 @@ Sentry.init({
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
- // Enable performance monitoring - Next.js routes and API calls are automatically instrumented
+ // Enable performance monitoring
tracesSampleRate: 0.001, // Capture 0.1% of transactions for performance monitoring
- // [Ali] Filter out browser extensions and user scripts (FE-2094)
- // Using denyUrls to block known third-party script patterns
- denyUrls: [/userscript/i],
+ integrations: (() => {
+ const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration
+ if (!thirdPartyErrorFilterIntegration) return []
+
+ // Drop errors whose stack trace only contains third-party frames (browser extensions,
+ // injected scripts, etc.). This uses build-time code annotation via the applicationKey
+ // in next.config.js to reliably distinguish our code from third-party code.
+ return [
+ thirdPartyErrorFilterIntegration({
+ filterKeys: ['supabase-studio'],
+ behaviour: 'drop-error-if-exclusively-contains-third-party-frames',
+ }),
+ ]
+ })(),
+
+ // Only capture errors originating from our own code.
+ // This is a whitelist on the source URL in stack frames — it drops errors from
+ // browser extensions, injected scripts, third-party widgets, etc. (FE-2094)
+ allowUrls: [
+ /https?:\/\/(.*\.)?supabase\.(com|co|green|io)/,
+ /app:\/\//, // Next.js rewrites source URLs to app:// with source maps
+ ],
beforeBreadcrumb(breadcrumb, _hint) {
const cleanedBreadcrumb = { ...breadcrumb }
@@ -134,25 +146,36 @@ Sentry.init({
return null
}
- // Ignore invalid URL events for 99% of the time because it's using up a lot of quota.
+ // Downsample only known high-noise classes; keep all other errors at full rate.
const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes(
`Failed to construct 'URL': Invalid URL`
)
- // [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage
const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes(
'Session error detected'
)
+ const isChunkLoadFailure = isChunkLoadError(hint.originalException, event)
+
+ const codeSampleRate =
+ isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure
+ ? LOW_PRIORITY_ERROR_SAMPLE_RATE
+ : DEFAULT_ERROR_SAMPLE_RATE
- if ((isInvalidUrlEvent || isSessionTimeoutEvent) && Math.random() > 0.01) {
+ if (Math.random() > codeSampleRate) {
return null
}
+ event.tags = {
+ ...event.tags,
+ codeSampleRate: codeSampleRate.toString(),
+ }
+
if (isHCaptchaRelatedError(event)) {
return null
}
- const frames = event.exception?.values?.[0].stacktrace?.frames || []
- if (isThirdPartyError(frames)) {
+ // Drop events where every exception has no stack trace — these are not debuggable
+ const exceptions = event.exception?.values ?? []
+ if (exceptions.length > 0 && exceptions.every((ex) => !ex.stacktrace?.frames?.length)) {
return null
}
@@ -183,73 +206,79 @@ Sentry.init({
return event
},
ignoreErrors: [
- // Used exclusively in Monaco Editor.
+ // === Monaco Editor ===
'ResizeObserver',
's.getModifierState is not a function',
/^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/,
- // Browser wallet extension errors (e.g., Gate.io wallet)
+
+ // === Browser extension errors ===
+ // Gate.io wallet
'shouldSetTallyForCurrentProvider is not a function',
- // [Joshen] We currently use stripe-js for customers to save their credit card data
- // I'm unable to reproduce this error on local, staging nor prod across chrome, safari or firefox
- // Based on https://github.com/stripe/stripe-js/issues/26, it seems like this error is safe to ignore,
+ // SAP browser extensions (SAP GUI, SAP Companion)
+ 'sap is not defined',
+ // Non-Error objects thrown as exceptions (e.g., Event objects)
+ '[object Event]',
+
+ // === Third-party SDK errors ===
+ // stripe-js: https://github.com/stripe/stripe-js/issues/26
'Failed to load Stripe.js',
- // [Joshen] This event started occurring after our fix in the org dropdown by reading the slug from
- // the URL params instead of the store, but we cannot repro locally, staging nor on prod
- // Safe to ignore since it's not a user-facing issue + we've not received any user feedback/report about it
- // Ref: https://github.com/supabase/supabase/pull/9729
- 'The provided `href` (/org/[slug]/general) value is missing query values (slug)',
- 'The provided `href` (/org/[slug]/team) value is missing query values (slug)',
- 'The provided `href` (/org/[slug]/billing) value is missing query values (slug)',
- 'The provided `href` (/org/[slug]/invoices) value is missing query values (slug)',
- // [Joshen] Seems to be from hcaptcha
+ // hCaptcha
"undefined is not an object (evaluating 'n.chat.setReady')",
"undefined is not an object (evaluating 'i.chat.setReady')",
- // [Terry] When users paste in an embedded GitHub Gist
- // Error thrown by `sql-formatter` lexer when given invalid input
- // Original format: new Error(`Parse error: Unexpected "${text}" at line ${line} column ${col}`)
+
+ // === Next.js internals ===
+ // Ref: https://github.com/supabase/supabase/pull/9729
+ /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/,
+ // Next.js throws these during navigation, not actual errors
+ 'NEXT_NOT_FOUND',
+ 'NEXT_REDIRECT',
+
+ // === User input errors (not bugs) ===
+ // sql-formatter lexer on invalid SQL input
/^Parse error: Unexpected ".+" at line \d+ column \d+$/,
- // [Joshen] IMO, should be caught on API if there's anything to handle - FE shouldn't dupe this alert
+
+ // === Network / infrastructure (not actionable on FE) ===
/504 Gateway Time-out/,
- // [Joshen] This is the one caused by Google translate in the browser + 3rd party extensions
+ 'Network request failed',
+ 'Failed to fetch',
+ 'Load failed',
+ 'AbortError',
+ 'TypeError: cancelled',
+ 'TypeError: Cancelled',
+
+ // === Browser extensions & Google Translate DOM manipulation ===
'Node.insertBefore: Child to insert before is not a child of this node',
- // [Ali] Google Translate / browser extension DOM manipulation errors
+ "NotFoundError: Failed to execute 'removeChild' on 'Node'",
+ "NotFoundError: Failed to execute 'insertBefore' on 'Node'",
'NotFoundError: The object can not be found here.',
- // [Joshen] This one sprung up recently and I've no idea where this is coming from
- 'r.default.setDefaultLevel is not a function',
- // [Joshen] Safe to ignore, it's an error from the copyToClipboard
- 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
-
- // API/Network errors - should be handled by API layer, not actionable on frontend
- /502/,
- 'upstream request timeout',
- /upstream connect error or disconnect\/reset before headers/,
- /Failed to fetch/,
-
- // Google Translate / browser extension DOM manipulation errors
- /Failed to execute 'removeChild' on 'Node'/,
+ "Cannot read properties of null (reading 'parentNode')",
+ "Cannot read properties of null (reading 'removeChild')",
+ "TypeError: can't access dead object",
+ /^NS_ERROR_/,
- // Monaco editor errors - already filtering ResizeObserver, adding general monaco errors
- /monaco-editor/,
+ // === Non-Error throws (extensions, third-party libs throwing strings/objects) ===
+ 'Non-Error exception captured',
+ 'Non-Error promise rejection captured',
- // JWT/Auth errors - expected during session expiry, handled by auth flow
- 'jwt expired',
- /InvalidJWTToken: Invalid value for JWT claim "exp"/,
+ // === Cross-origin script errors (no useful info) ===
+ 'Script error.',
+ 'Script error',
- // User cancellation - intentional user action
- 'Canceled: Canceled',
+ // === React hydration mismatches caused by extensions modifying DOM ===
+ // Note: we only suppress the generic browser messages, NOT "Hydration failed because..."
+ // which can indicate real SSR/client mismatches in our own code.
+ /text content does not match/i,
+ /There was an error while hydrating/i,
- // Third-party/extension errors
- 'html2canvas is not defined',
- '[object Event]',
- 'ConnectorClass.onMessage(extensionPageScript)',
- "Cannot destructure property 'address' of '(intermediate value)' as it is undefined.",
-
- // Expected user flow errors
- 'Profile already exists: User already exists',
+ // === Web crawler / bot errors ===
+ 'instantSearchSDKJSBridgeClearHighlight',
- // JSON parse errors from invalid responses
- '"undefined" is not valid JSON',
- 'SyntaxError: "undefined" is not valid JSON',
+ // === Misc known noise ===
+ 'r.default.setDefaultLevel is not a function',
+ // Clipboard permission denied
+ 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.',
+ // Facebook pixel
+ 'fb_xd_fragment',
],
})
diff --git a/apps/studio/lib/error-reporting.test.ts b/apps/studio/lib/error-reporting.test.ts
index 746b524b2ea13..2a533c5132b34 100644
--- a/apps/studio/lib/error-reporting.test.ts
+++ b/apps/studio/lib/error-reporting.test.ts
@@ -5,7 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { captureCriticalError } from './error-reporting'
vi.mock('@sentry/nextjs', () => ({
- captureMessage: vi.fn(),
+ captureException: vi.fn(),
+ withScope: vi.fn((cb: (scope: any) => void) => {
+ const scope = { setTag: vi.fn() }
+ cb(scope)
+ }),
}))
describe('error-reporting', () => {
@@ -18,15 +22,15 @@ describe('error-reporting', () => {
const error = { message: '' }
captureCriticalError(error, 'test context')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should capture regular Error objects', () => {
const error = new Error('Something went wrong')
captureCriticalError(error, 'test action')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][test action] Failed: Something went wrong'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Something went wrong', name: 'CriticalError' })
)
})
@@ -34,22 +38,22 @@ describe('error-reporting', () => {
const error = new Error('email must be an email')
captureCriticalError(error, 'validation')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should not capture errors with partial whitelisted message', () => {
const error = new Error('User error: A user with this email already exists in the system')
captureCriticalError(error, 'sign up')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should capture errors that are not whitelisted', () => {
const error = new Error('Database connection failed')
captureCriticalError(error, 'database')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][database] Failed: Database connection failed'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Database connection failed', name: 'CriticalError' })
)
})
@@ -63,8 +67,11 @@ describe('error-reporting', () => {
)
captureCriticalError(error, 'api call')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][api call] Failed: requestPathname /api/test w/ message: Internal server error'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'requestPathname /api/test w/ message: Internal server error',
+ name: 'CriticalError',
+ })
)
})
@@ -72,15 +79,18 @@ describe('error-reporting', () => {
const error = new ResponseError('Not found', 404, undefined, undefined, '/api/test')
captureCriticalError(error, 'api call')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should capture ResponseError with 5XX status code', () => {
const error = new ResponseError('Gateway timeout', 504, undefined, undefined, '/api/gateway')
captureCriticalError(error, 'gateway request')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][gateway request] Failed: requestPathname /api/gateway w/ message: Gateway timeout'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'requestPathname /api/gateway w/ message: Gateway timeout',
+ name: 'CriticalError',
+ })
)
})
@@ -88,8 +98,11 @@ describe('error-reporting', () => {
const error = new ResponseError('Unknown error')
captureCriticalError(error, 'unknown')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][unknown] Failed: Response Error (no code or requestPathname) w/ message: Unknown error'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Response Error (no code or requestPathname) w/ message: Unknown error',
+ name: 'CriticalError',
+ })
)
})
@@ -97,8 +110,8 @@ describe('error-reporting', () => {
const error = { message: 'Custom error object' }
captureCriticalError(error, 'custom')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][custom] Failed: Custom error object'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Custom error object', name: 'CriticalError' })
)
})
@@ -106,7 +119,7 @@ describe('error-reporting', () => {
const error = { foo: 'bar' }
captureCriticalError(error as any, 'no message')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should not capture whitelisted password validation error', () => {
@@ -115,29 +128,29 @@ describe('error-reporting', () => {
)
captureCriticalError(error, 'password update')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should not capture whitelisted TOTP error', () => {
const error = new Error('Invalid TOTP code entered')
captureCriticalError(error, 'mfa verification')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should not capture whitelisted project name error', () => {
const error = new Error('name should not contain a . string')
captureCriticalError(error, 'create project')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should capture non-whitelisted errors even if similar to whitelisted ones', () => {
const error = new Error('email format is invalid')
captureCriticalError(error, 'validation')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][validation] Failed: email format is invalid'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'email format is invalid', name: 'CriticalError' })
)
})
@@ -145,7 +158,7 @@ describe('error-reporting', () => {
const error = new Error('')
captureCriticalError(error, 'empty error')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should handle ResponseError at boundary of 4XX/5XX (499)', () => {
@@ -158,15 +171,18 @@ describe('error-reporting', () => {
)
captureCriticalError(error, 'request')
- expect(Sentry.captureMessage).not.toHaveBeenCalled()
+ expect(Sentry.captureException).not.toHaveBeenCalled()
})
it('should handle ResponseError at boundary of 4XX/5XX (500)', () => {
const error = new ResponseError('Internal error', 500, undefined, undefined, '/api/test')
captureCriticalError(error, 'request')
- expect(Sentry.captureMessage).toHaveBeenCalledWith(
- '[CRITICAL][request] Failed: requestPathname /api/test w/ message: Internal error'
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'requestPathname /api/test w/ message: Internal error',
+ name: 'CriticalError',
+ })
)
})
})
diff --git a/apps/studio/lib/error-reporting.ts b/apps/studio/lib/error-reporting.ts
index d6b0e6d904916..bf634e5e9af44 100644
--- a/apps/studio/lib/error-reporting.ts
+++ b/apps/studio/lib/error-reporting.ts
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/nextjs'
-
import { ResponseError } from 'types'
type CaptureMessageOptions = {
@@ -29,25 +28,17 @@ const WHITELIST_ERRORS = [
]
/**
- * Captures a critical error message to Sentry, filtering out whitelisted errors.
+ * Captures a critical error to Sentry, filtering out whitelisted errors.
*
* @param error - The error object (ResponseError, Error, or any object with a message property)
* @param context - The context/action that failed (e.g., 'reset password', 'sign up', 'create project')
- *
- * @example
- * captureCriticalError(error, 'reset password')
- * // Captures: '[CRITICAL][reset password] Failed: '
- *
- * @example
- * captureCriticalError(error, 'sign up')
- * // Captures: '[CRITICAL][sign up] Failed: '
+ * Attached as the `context` tag on the Sentry event.
*/
export function captureCriticalError(
error: ResponseError | Error | { message: string },
context: string
): void {
- const errorMessage = error instanceof Error ? error.message : error.message
- if (!errorMessage) {
+ if (!error.message) {
return
}
@@ -84,13 +75,12 @@ function handleResponseError(error: ResponseError, context: string) {
}
function handleError(error: Error, context: string) {
- const message = error.message
- if (!message) {
+ if (!error.message) {
return
}
captureMessage({
- message,
+ message: error.message,
context,
})
}
@@ -113,5 +103,14 @@ function captureMessage({ message, context }: CaptureMessageOptions) {
if (WHITELIST_ERRORS.some((whitelisted) => message.includes(whitelisted))) {
return
}
- Sentry.captureMessage(`[CRITICAL][${context}] Failed: ${message}`)
+ // Use captureException (vs captureMessage) so these appear as exceptions in Sentry
+ // and can have dedicated alert rules. Grouping is still by message since all
+ // CriticalErrors share the same synthetic stack trace.
+ Sentry.withScope((scope) => {
+ scope.setTag('critical', 'true')
+ scope.setTag('context', context)
+ const error = new Error(message)
+ error.name = `CriticalError`
+ Sentry.captureException(error)
+ })
}
diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js
index c615c69b2b361..fe82397649714 100644
--- a/apps/studio/next.config.js
+++ b/apps/studio/next.config.js
@@ -627,5 +627,11 @@ module.exports =
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
+
+ // Annotate bundles at build time so thirdPartyErrorFilterIntegration can
+ // distinguish our code from browser extensions / injected scripts at runtime.
+ unstable_sentryWebpackPluginOptions: {
+ applicationKey: 'supabase-studio',
+ },
})
: nextConfig
diff --git a/apps/studio/sentry.edge.config.ts b/apps/studio/sentry.edge.config.ts
index 09b5a3803c1bd..edac95e2056c0 100644
--- a/apps/studio/sentry.edge.config.ts
+++ b/apps/studio/sentry.edge.config.ts
@@ -15,4 +15,12 @@ Sentry.init({
// Enable performance monitoring
tracesSampleRate: 0.001, // Capture 0.1% of transactions for performance monitoring
+ ignoreErrors: [
+ 'NEXT_NOT_FOUND',
+ 'NEXT_REDIRECT',
+ /504 Gateway Time-out/,
+ 'Network request failed',
+ 'Failed to fetch',
+ 'AbortError',
+ ],
})
diff --git a/apps/studio/sentry.server.config.ts b/apps/studio/sentry.server.config.ts
index c29862808f786..f4fee2dd10455 100644
--- a/apps/studio/sentry.server.config.ts
+++ b/apps/studio/sentry.server.config.ts
@@ -15,11 +15,21 @@ Sentry.init({
// Enable performance monitoring
tracesSampleRate: 0.001, // Capture 0.1% of transactions for performance monitoring
ignoreErrors: [
- // Used exclusively in Monaco Editor.
'ResizeObserver',
- // [Joshen] We currently use stripe-js for customers to save their credit card data
- // I'm unable to reproduce this error on local, staging nor prod across chrome, safari or firefox
- // Based on https://github.com/stripe/stripe-js/issues/26, it seems like this error is safe to ignore,
'Failed to load Stripe.js',
+ // Next.js internals — not actual errors
+ 'NEXT_NOT_FOUND',
+ 'NEXT_REDIRECT',
+ // Network / infrastructure
+ /504 Gateway Time-out/,
+ 'Network request failed',
+ 'Failed to fetch',
+ 'AbortError',
+ // Code-split loading failures
+ 'ChunkLoadError',
+ /Loading chunk [\d]+ failed/,
+ // React hydration mismatches caused by extensions modifying DOM before hydration
+ /text content does not match/i,
+ /There was an error while hydrating/i,
],
})
diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx
index 36b3ea4ee011c..0a0df847c540a 100644
--- a/packages/common/telemetry.tsx
+++ b/packages/common/telemetry.tsx
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef } from 'react'
import { useLatest } from 'react-use'
import { useUser } from './auth'
-import { hasConsented } from './consent-state'
+import { hasConsented, useConsentState } from './consent-state'
import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants'
import { useFeatureFlags } from './feature-flags'
import { post } from './fetchWrappers'
@@ -30,11 +30,14 @@ const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
// Reexports GoogleTagManager with the right API key set
export const TelemetryTagManager = () => {
- const isGTMEnabled = Boolean(IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID)
+ // useConsentState is used here to trigger a re-render when consent state changes
+ const { hasAccepted } = useConsentState()
- if (!isGTMEnabled) {
- return
- }
+ const isGTMEnabled = Boolean(
+ IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID && hasAccepted
+ )
+
+ if (!isGTMEnabled) return null
return (