instrumentation-client.ts314 lines · main
| 1 | // This file configures the initialization of Sentry on the client. |
| 2 | // The config you add here will be used whenever a user loads a page in their browser. |
| 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ |
| 4 | |
| 5 | import * as Sentry from '@sentry/nextjs' |
| 6 | import { hasConsented } from 'common' |
| 7 | import { IS_PLATFORM } from 'common/constants/environment' |
| 8 | |
| 9 | import { MIRRORED_BREADCRUMBS } from '@/lib/breadcrumbs' |
| 10 | import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from '@/lib/sanitize' |
| 11 | |
| 12 | const DEFAULT_ERROR_SAMPLE_RATE = 1.0 |
| 13 | const LOW_PRIORITY_ERROR_SAMPLE_RATE = 0.01 |
| 14 | const CHUNK_LOAD_ERROR_PATTERNS = [ |
| 15 | /ChunkLoadError/i, |
| 16 | /Loading chunk [\d]+ failed/i, |
| 17 | /Loading CSS chunk [\d]+ failed/i, |
| 18 | ] |
| 19 | |
| 20 | // This is a workaround to ignore hCaptcha related errors. |
| 21 | function isHCaptchaRelatedError(event: Sentry.Event): boolean { |
| 22 | const errors = event.exception?.values ?? [] |
| 23 | for (const error of errors) { |
| 24 | if ( |
| 25 | error.value?.includes('is not a function') && |
| 26 | error.stacktrace?.frames?.some((f) => f.filename === 'api.js') |
| 27 | ) { |
| 28 | return true |
| 29 | } |
| 30 | } |
| 31 | return false |
| 32 | } |
| 33 | |
| 34 | // Filter browser wallet extension errors (e.g., Gate.io wallet) |
| 35 | // These errors come from injected wallet scripts and are not actionable |
| 36 | // Examples: BRIVEN-APP-AFC, BRIVEN-APP-92A |
| 37 | export function isBrowserWalletExtensionError(event: Sentry.Event): boolean { |
| 38 | const frames = event.exception?.values?.flatMap((e) => e.stacktrace?.frames || []) || [] |
| 39 | return frames.some((frame) => { |
| 40 | const filename = frame.filename || frame.abs_path || '' |
| 41 | return filename.includes('gt-window-provider') || filename.includes('wallet-provider') |
| 42 | }) |
| 43 | } |
| 44 | |
| 45 | // Filter user-aborted operations (intentional cancellations) |
| 46 | // These are expected when users cancel requests or navigate away |
| 47 | // Examples: BRIVEN-APP-BG6, BRIVEN-APP-BG7 |
| 48 | export function isUserAbortedOperation(error: unknown, event: Sentry.Event): boolean { |
| 49 | const errorMessage = error instanceof Error ? error.message : '' |
| 50 | const eventMessage = event.message || '' |
| 51 | const message = errorMessage || eventMessage |
| 52 | |
| 53 | return ( |
| 54 | message.includes('operation was aborted') || |
| 55 | message.includes('signal is aborted') || |
| 56 | message.includes('manually canceled') || |
| 57 | message.includes('AbortError') |
| 58 | ) |
| 59 | } |
| 60 | |
| 61 | // Filter cancellation promise rejections (e.g., from query cancellation) |
| 62 | // These occur when operations are intentionally cancelled by the user |
| 63 | // Example: BRIVEN-APP-353 (~466k events) |
| 64 | export function isCancellationRejection(event: Sentry.Event): boolean { |
| 65 | const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined |
| 66 | return serialized?.type === 'cancelation' |
| 67 | } |
| 68 | |
| 69 | // Filter challenge/captcha expired errors (user timeout) |
| 70 | // These happen when users don't complete captcha in time - expected behavior |
| 71 | // Example: BRIVEN-APP-ACC |
| 72 | export function isChallengeExpiredError(error: unknown, event: Sentry.Event): boolean { |
| 73 | const errorMessage = error instanceof Error ? error.message : '' |
| 74 | const eventMessage = event.message || '' |
| 75 | const message = errorMessage || eventMessage |
| 76 | |
| 77 | return message.includes('challenge-expired') |
| 78 | } |
| 79 | |
| 80 | function isChunkLoadError(error: unknown, event: Sentry.Event): boolean { |
| 81 | const errorMessage = error instanceof Error ? error.message : '' |
| 82 | const eventMessage = event.message || '' |
| 83 | const exceptionMessages = event.exception?.values?.map((ex) => ex.value ?? '') ?? [] |
| 84 | const combinedMessages = [errorMessage, eventMessage, ...exceptionMessages].filter(Boolean) |
| 85 | |
| 86 | return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => |
| 87 | combinedMessages.some((message) => pattern.test(message)) |
| 88 | ) |
| 89 | } |
| 90 | |
| 91 | Sentry.init({ |
| 92 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, |
| 93 | ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { |
| 94 | environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, |
| 95 | }), |
| 96 | // Setting this option to true will print useful information to the console while you're setting up Sentry. |
| 97 | debug: false, |
| 98 | |
| 99 | // Enable performance monitoring |
| 100 | tracesSampleRate: 0.02, |
| 101 | |
| 102 | integrations: (() => { |
| 103 | const thirdPartyErrorFilterIntegration = (Sentry as any).thirdPartyErrorFilterIntegration |
| 104 | if (!thirdPartyErrorFilterIntegration) return [] |
| 105 | |
| 106 | // Tag errors whose stack trace only contains third-party frames (browser extensions, |
| 107 | // injected scripts, etc.). This uses build-time code annotation via the applicationKey |
| 108 | // in next.config.ts to reliably distinguish our code from third-party code. |
| 109 | // We use 'apply-tag' instead of 'drop' so that beforeSend can exempt error boundary |
| 110 | // crashes — these may originate in third-party code but are caused by first-party bugs. |
| 111 | return [ |
| 112 | thirdPartyErrorFilterIntegration({ |
| 113 | filterKeys: ['briven-studio'], |
| 114 | behaviour: 'apply-tag-if-exclusively-contains-third-party-frames', |
| 115 | }), |
| 116 | ] |
| 117 | })(), |
| 118 | |
| 119 | // Only capture errors originating from our own code. |
| 120 | // This is a whitelist on the source URL in stack frames — it drops errors from |
| 121 | // browser extensions, injected scripts, third-party widgets, etc. (FE-2094) |
| 122 | allowUrls: [ |
| 123 | /https?:\/\/(.*\.)?briven\.(com|co|green|io)/, |
| 124 | /app:\/\//, // Next.js rewrites source URLs to app:// with source maps |
| 125 | ], |
| 126 | beforeBreadcrumb(breadcrumb, _hint) { |
| 127 | const cleanedBreadcrumb = { ...breadcrumb } |
| 128 | |
| 129 | if (cleanedBreadcrumb.category === 'navigation') { |
| 130 | if (typeof cleanedBreadcrumb.data?.from === 'string') { |
| 131 | cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) |
| 132 | } |
| 133 | if (typeof cleanedBreadcrumb.data?.to === 'string') { |
| 134 | cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) |
| 139 | return cleanedBreadcrumb |
| 140 | }, |
| 141 | beforeSend(event, hint) { |
| 142 | const consent = hasConsented() |
| 143 | |
| 144 | if (!consent) { |
| 145 | return null |
| 146 | } |
| 147 | |
| 148 | if (!IS_PLATFORM) { |
| 149 | return null |
| 150 | } |
| 151 | |
| 152 | const isErrorBoundaryCrash = |
| 153 | event.tags?.globalErrorBoundary === true || event.tags?.globalErrorBoundary === 'true' |
| 154 | const isThirdPartyOnly = |
| 155 | event.tags?.third_party_code === true || event.tags?.third_party_code === 'true' |
| 156 | |
| 157 | // Drop third-party-only errors UNLESS they crashed the page via the global error boundary. |
| 158 | // This preserves noise reduction for browser extensions and injected scripts, |
| 159 | // while ensuring page-crashing errors from third-party libs (caused by first-party bugs) |
| 160 | // are always reported. |
| 161 | if (isThirdPartyOnly && !isErrorBoundaryCrash) { |
| 162 | return null |
| 163 | } |
| 164 | |
| 165 | // Downsample only known high-noise classes; keep all other errors at full rate. |
| 166 | const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( |
| 167 | `Failed to construct 'URL': Invalid URL` |
| 168 | ) |
| 169 | const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( |
| 170 | 'Session error detected' |
| 171 | ) |
| 172 | const isChunkLoadFailure = isChunkLoadError(hint.originalException, event) |
| 173 | |
| 174 | const codeSampleRate = |
| 175 | isInvalidUrlEvent || isSessionTimeoutEvent || isChunkLoadFailure |
| 176 | ? LOW_PRIORITY_ERROR_SAMPLE_RATE |
| 177 | : DEFAULT_ERROR_SAMPLE_RATE |
| 178 | |
| 179 | if (Math.random() > codeSampleRate) { |
| 180 | return null |
| 181 | } |
| 182 | |
| 183 | event.tags = { |
| 184 | ...event.tags, |
| 185 | codeSampleRate: codeSampleRate.toString(), |
| 186 | } |
| 187 | |
| 188 | if (isHCaptchaRelatedError(event)) { |
| 189 | return null |
| 190 | } |
| 191 | |
| 192 | // Drop events where every exception has no stack trace — these are not debuggable. |
| 193 | // Exempt error boundary crashes: even without stack frames, a page crash is always worth reporting. |
| 194 | const exceptions = event.exception?.values ?? [] |
| 195 | if ( |
| 196 | !isErrorBoundaryCrash && |
| 197 | exceptions.length > 0 && |
| 198 | exceptions.every((ex) => !ex.stacktrace?.frames?.length) |
| 199 | ) { |
| 200 | return null |
| 201 | } |
| 202 | |
| 203 | // Filter out errors like 'e._5BLbSXV[t] is not a function' or anything matching '[t] is not a function' |
| 204 | if ( |
| 205 | hint.originalException instanceof Error && |
| 206 | hint.originalException.message.includes('[t] is not a function') |
| 207 | ) { |
| 208 | return null |
| 209 | } |
| 210 | |
| 211 | if (isBrowserWalletExtensionError(event)) { |
| 212 | return null |
| 213 | } |
| 214 | if (isUserAbortedOperation(hint.originalException, event)) { |
| 215 | return null |
| 216 | } |
| 217 | if (isCancellationRejection(event)) { |
| 218 | return null |
| 219 | } |
| 220 | if (isChallengeExpiredError(hint.originalException, event)) { |
| 221 | return null |
| 222 | } |
| 223 | |
| 224 | if (event.breadcrumbs) { |
| 225 | event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] |
| 226 | } |
| 227 | return event |
| 228 | }, |
| 229 | ignoreErrors: [ |
| 230 | // === Monaco Editor === |
| 231 | 'ResizeObserver', |
| 232 | 's.getModifierState is not a function', |
| 233 | /^Uncaught NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope'/, |
| 234 | |
| 235 | // === Browser extension errors === |
| 236 | // Gate.io wallet |
| 237 | 'shouldSetTallyForCurrentProvider is not a function', |
| 238 | // SAP browser extensions (SAP GUI, SAP Companion) |
| 239 | 'sap is not defined', |
| 240 | // Non-Error objects thrown as exceptions (e.g., Event objects) |
| 241 | '[object Event]', |
| 242 | |
| 243 | // === Third-party SDK errors === |
| 244 | // stripe-js: https://github.com/stripe/stripe-js/issues/26 |
| 245 | 'Failed to load Stripe.js', |
| 246 | // hCaptcha |
| 247 | "undefined is not an object (evaluating 'n.chat.setReady')", |
| 248 | "undefined is not an object (evaluating 'i.chat.setReady')", |
| 249 | |
| 250 | // === Next.js internals === |
| 251 | // Ref: https://github.com/briven/briven/pull/9729 |
| 252 | /The provided `href` \(\/org\/\[slug\]\/.*\) value is missing query values/, |
| 253 | // Next.js throws these during navigation, not actual errors |
| 254 | 'NEXT_NOT_FOUND', |
| 255 | 'NEXT_REDIRECT', |
| 256 | |
| 257 | // === User input errors (not bugs) === |
| 258 | // sql-formatter lexer on invalid SQL input |
| 259 | /^Parse error: Unexpected ".+" at line \d+ column \d+$/, |
| 260 | |
| 261 | // === Network / infrastructure (not actionable on FE) === |
| 262 | /504 Gateway Time-out/, |
| 263 | 'Network request failed', |
| 264 | 'Failed to fetch', |
| 265 | 'Load failed', |
| 266 | 'AbortError', |
| 267 | 'TypeError: cancelled', |
| 268 | 'TypeError: Cancelled', |
| 269 | |
| 270 | // === Browser extensions & Google Translate DOM manipulation === |
| 271 | 'Node.insertBefore: Child to insert before is not a child of this node', |
| 272 | 'Node.removeChild: The node to be removed is not a child of this node', |
| 273 | "NotFoundError: Failed to execute 'removeChild' on 'Node'", |
| 274 | "NotFoundError: Failed to execute 'insertBefore' on 'Node'", |
| 275 | 'NotFoundError: The object can not be found here.', |
| 276 | "Cannot read properties of null (reading 'parentNode')", |
| 277 | "Cannot read properties of null (reading 'removeChild')", |
| 278 | "TypeError: can't access dead object", |
| 279 | /^NS_ERROR_/, |
| 280 | |
| 281 | // === Non-Error throws (extensions, third-party libs throwing strings/objects) === |
| 282 | 'Non-Error exception captured', |
| 283 | 'Non-Error promise rejection captured', |
| 284 | /^Object captured as exception with keys:/, |
| 285 | |
| 286 | // === Cross-origin script errors (no useful info) === |
| 287 | 'Script error.', |
| 288 | 'Script error', |
| 289 | |
| 290 | // === React hydration mismatches caused by extensions modifying DOM === |
| 291 | // Note: we only suppress the generic browser messages, NOT "Hydration failed because..." |
| 292 | // which can indicate real SSR/client mismatches in our own code. |
| 293 | /text content does not match/i, |
| 294 | /There was an error while hydrating/i, |
| 295 | |
| 296 | // === Web crawler / bot errors === |
| 297 | 'instantSearchSDKJSBridgeClearHighlight', |
| 298 | |
| 299 | // === Third-party library race conditions === |
| 300 | // cmdk: useSyncExternalStore subscribe called before store context is available |
| 301 | "Cannot read properties of undefined (reading 'subscribe')", |
| 302 | "undefined is not an object (evaluating 't.subscribe')", |
| 303 | |
| 304 | // === Misc known noise === |
| 305 | 'r.default.setDefaultLevel is not a function', |
| 306 | // Clipboard permission denied |
| 307 | 'The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', |
| 308 | // Facebook pixel |
| 309 | 'fb_xd_fragment', |
| 310 | ], |
| 311 | }) |
| 312 | |
| 313 | // This export will instrument router navigations, and is only relevant if you enable tracing. |
| 314 | export const onRouterTransitionStart = Sentry.captureRouterTransitionStart |