error-reporter.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. // Conditionally enable Sentry based on whether the DSN token is set
  2. import getMeta from '../utils/meta'
  3. import OError from '@overleaf/o-error'
  4. import { debugConsole } from '@/utils/debugging'
  5. import type { ErrorEvent } from '@sentry/types/types/event'
  6. const {
  7. sentryAllowedOriginRegex,
  8. sentryDsn,
  9. sentryEnvironment,
  10. sentryRelease,
  11. } = getMeta('ol-ExposedSettings')
  12. const buildIdRegex = /(\/build\/|buildId=)[a-z0-9-]+/
  13. const reporterPromise = sentryDsn ? sentryReporter() : nullReporter()
  14. const sanitizeUrl = (url: string) => {
  15. return url.replace(buildIdRegex, '$1[redacted]')
  16. }
  17. const sanitizeUrls = (event: ErrorEvent) => {
  18. if (event.request?.url) {
  19. event.request.url = sanitizeUrl(event.request.url)
  20. }
  21. // Clean any breadcrumb URLs too
  22. if (event.breadcrumbs) {
  23. event.breadcrumbs = event.breadcrumbs.map(breadcrumb => {
  24. if (breadcrumb.data?.url) {
  25. return {
  26. ...breadcrumb,
  27. data: {
  28. ...breadcrumb.data,
  29. url: sanitizeUrl(breadcrumb.data.url),
  30. },
  31. }
  32. }
  33. return breadcrumb
  34. })
  35. }
  36. if (event.extra?.pdfUrl) {
  37. event.extra.pdfUrl = sanitizeUrl(event.extra.pdfUrl as string)
  38. }
  39. if (event.extra?.url) {
  40. event.extra.url = sanitizeUrl(event.extra.url as string)
  41. }
  42. return event
  43. }
  44. const isPropensityNetworkError = (err: ErrorEvent) => {
  45. const errorBreadcrumbs = err.breadcrumbs?.filter(b => b.level === 'error')
  46. if (!errorBreadcrumbs || errorBreadcrumbs.length !== 1) {
  47. // don't ignore Propensity if there are more errors to report
  48. return false
  49. }
  50. const breadcrumbUrl = errorBreadcrumbs[0]?.data?.url
  51. return Boolean(
  52. breadcrumbUrl &&
  53. [
  54. 'https://analytics.propensity.com/',
  55. 'https://analytics.propensity-abm.com/',
  56. ].some(url => breadcrumbUrl.startsWith(url))
  57. )
  58. }
  59. function sentryReporter() {
  60. return (
  61. import(/* webpackMode: "eager" */ '@sentry/browser')
  62. .then(Sentry => {
  63. let eventCount = 0
  64. Sentry.init({
  65. dsn: sentryDsn,
  66. environment: sentryEnvironment,
  67. release: sentryRelease,
  68. autoSessionTracking: false,
  69. // Ignore errors unless they come from our origins
  70. // Adapted from: https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
  71. allowUrls: [new RegExp(sentryAllowedOriginRegex)],
  72. ignoreErrors: [
  73. // Ignore very noisy error
  74. 'SecurityError: Permission denied to access property "pathname" on cross-origin object',
  75. // Ignore unhandled error that is "expected" - see https://github.com/overleaf/issues/issues/3321
  76. /^Missing PDF/,
  77. // Ignore "expected" error from aborted fetch - see https://github.com/overleaf/issues/issues/3321
  78. /^AbortError/,
  79. // Ignore spurious error from Ace internals - see https://github.com/overleaf/issues/issues/3321
  80. 'ResizeObserver loop limit exceeded',
  81. 'ResizeObserver loop completed with undelivered notifications.',
  82. // Microsoft Outlook SafeLink crawler
  83. // https://forum.sentry.io/t/unhandledrejection-non-error-promise-rejection-captured-with-value/14062
  84. /Non-Error promise rejection captured with value: Object Not Found Matching Id/,
  85. // Ignore CM6 error until upgraded
  86. "Cannot read properties of undefined (reading 'length')",
  87. // Ignore Angular digest iteration limit - see https://github.com/overleaf/internal/issues/15750
  88. '10 $digest() iterations reached',
  89. // Ignore a frequent unhandled promise rejection
  90. /Non-Error promise rejection captured with keys: currentTarget, detail, isTrusted, target/,
  91. /Non-Error promise rejection captured with keys: message, status/,
  92. // Ignore a frequent blocked image
  93. "Blocked 'image' from 'www.googletagmanager.com'",
  94. ],
  95. denyUrls: [
  96. // Chrome extensions
  97. /extensions\//i,
  98. /^chrome:\/\//i,
  99. ],
  100. beforeSend(event, hint) {
  101. // Limit number of events sent to Sentry to 100 events "per page load",
  102. // (i.e. the cap will be reset if the page is reloaded). This prevent
  103. // hitting their server-side event cap.
  104. eventCount++
  105. if (eventCount > 100) {
  106. return null // Block the event from sending
  107. }
  108. // Do not send events related to third party code (extensions)
  109. if (
  110. (event.extra?.arguments as { type: string }[] | undefined)?.[0]
  111. ?.type === 'UNSTABLE_editor:extensions'
  112. ) {
  113. return null // Block the event from sending
  114. }
  115. // Do not send link-sharing token to Sentry
  116. if (event.request?.headers?.Referer) {
  117. const refererUrl = new URL(event.request.headers.Referer)
  118. if (
  119. refererUrl.hostname === window.location.hostname &&
  120. refererUrl.pathname.startsWith('/read/')
  121. ) {
  122. refererUrl.pathname = '/read/'
  123. event.request.headers.Referer = refererUrl.toString()
  124. }
  125. }
  126. if (isPropensityNetworkError(event)) {
  127. return null
  128. }
  129. // Extract OError tag info so it appears in Sentry extra for all
  130. // captured errors, including auto-captured unhandled rejections
  131. // that bypass our captureException() wrapper.
  132. const originalException = hint?.originalException
  133. if (originalException && typeof originalException === 'object') {
  134. const oErrorInfo = OError.getFullInfo(originalException)
  135. if (Object.keys(oErrorInfo).length > 0) {
  136. event.extra = { ...event.extra, ...oErrorInfo }
  137. }
  138. }
  139. return sanitizeUrls(event)
  140. },
  141. })
  142. Sentry.setUser({ id: getMeta('ol-user_id') })
  143. const splitTestAssignments = getMeta('ol-splitTestVariants')
  144. if (splitTestAssignments) {
  145. for (const [name, value] of Object.entries(splitTestAssignments)) {
  146. // Ensure Sentry tag name is within the 32-character limit
  147. Sentry.setTag(`ol.${name}`.slice(0, 32), value.toString())
  148. }
  149. }
  150. return Sentry
  151. })
  152. // If Sentry fails to load, use the null reporter instead
  153. .catch(error => {
  154. debugConsole.error(error)
  155. return nullReporter()
  156. })
  157. )
  158. }
  159. function nullReporter() {
  160. return Promise.resolve({
  161. captureException: debugConsole.error,
  162. captureMessage: debugConsole.error,
  163. })
  164. }
  165. // https://develop.sentry.dev/sdk/data-model/event-payloads/contexts/
  166. // https://docs.sentry.io/platforms/javascript/enriching-events/context/#passing-context-directly
  167. type Options = {
  168. tags?: Record<string, any>
  169. extra?: Record<string, any>
  170. }
  171. export function captureException(err: Error, options?: Options) {
  172. options = options || {}
  173. const extra = Object.assign(OError.getFullInfo(err), options.extra || {})
  174. const fullStack = OError.getFullStack(err)
  175. if (err.stack !== fullStack) {
  176. // Attach tracebacks from OError.tag() and OError.cause.
  177. extra.fullStack = fullStack
  178. }
  179. reporterPromise.then(reporter =>
  180. reporter.captureException(err, {
  181. ...options,
  182. extra,
  183. })
  184. )
  185. }