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 (