sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
411 lines (357 loc) • 12.3 kB
text/typescript
import {
breadcrumbsIntegration,
browserApiErrorsIntegration,
BrowserClient,
type BrowserOptions,
captureException,
dedupeIntegration,
defaultStackParser,
type ErrorEvent,
type Event,
functionToStringIntegration,
getClient,
getCurrentScope,
globalHandlersIntegration,
httpContextIntegration,
inboundFiltersIntegration,
init,
isInitialized as sentryIsInitialized,
linkedErrorsIntegration,
Scope,
withScope,
} from '@sentry/react'
import {type Transport} from '@sentry/types'
import {isDev} from '../../environment'
import {hasSanityPackageInImportMap} from '../../environment/hasSanityPackageInImportMap'
import {globalScope} from '../../util/globalScope'
import {supportsLocalStorage} from '../../util/supportsLocalStorage'
import {SANITY_VERSION} from '../../version'
import {type ErrorInfo, type ErrorReporter} from '../errorReporter'
import {type BufferedTransport, makeBufferedTransport} from './makeBufferedTransport'
const SANITY_DSN = 'https://8914c8dde7e1ebce191f15af8bf6b7b9@sentry.sanity.io/4507342122123264'
const IS_EMBEDDED_STUDIO = !('__sanityErrorChannel' in globalScope)
const DEBUG_ERROR_REPORTING =
supportsLocalStorage && Boolean(localStorage.getItem('SANITY_DEBUG_ERROR_REPORTING'))
const IS_BROWSER = typeof window !== 'undefined'
const clientOptions: BrowserOptions = {
dsn: SANITY_DSN,
release: SANITY_VERSION,
environment: isDev ? 'development' : 'production',
debug: DEBUG_ERROR_REPORTING,
enabled: IS_BROWSER && (!isDev || DEBUG_ERROR_REPORTING),
transport: makeBufferedTransport,
}
const integrations = [
inboundFiltersIntegration(),
functionToStringIntegration(),
browserApiErrorsIntegration({eventTarget: false}),
breadcrumbsIntegration({console: false}),
globalHandlersIntegration({onerror: true, onunhandledrejection: true}),
linkedErrorsIntegration(),
dedupeIntegration(),
sanityDedupeIntegration(),
httpContextIntegration(),
]
/**
* Get an instance of the Sentry error reporter
*
* @internal
*/
export function getSentryErrorReporter(): ErrorReporter {
let client: BrowserClient | undefined
let scope: Scope | undefined
// Keep tabs of events reported before initialized.
const preInitErrors: {
error: Error
options: ErrorInfo
}[] = []
function _initialize() {
// If this _Sanity_ implementation of the reporter is already initialized, do not re-instantiate
if (client) {
return
}
// For now, we only want to run error reporting for auto-updating studios in production.
// This may change in the future, but for now this will help us control the amount of errors.
if (!DEBUG_ERROR_REPORTING && !hasSanityPackageInImportMap()) {
return
}
// For now, we also want to avoid running error reporting in embedded studios,
// even if it has a Sanity package in the import map (eg. is auto updating).
if (!DEBUG_ERROR_REPORTING && IS_EMBEDDED_STUDIO) {
return
}
// This normally shouldn't happen, but if we're initialized and already using the Sanity DSN,
// then assume we can reuse the global client
const isSentryInitialized = sentryIsInitialized()
const hasThirdPartySentry = isSentryInitialized && getClient()?.getOptions().dsn === SANITY_DSN
if (isSentryInitialized && !hasThirdPartySentry) {
client = getClient()
scope = getCurrentScope()
return
}
// "Third party" means the customer already has an instance of the Sentry SDK on the page,
// but it is not configured to use the Sanity DSN. In this case, we'll create a new client
// for ourselves, and try to avoid the global scope.
if (hasThirdPartySentry) {
client = new BrowserClient({
...clientOptions,
stackParser: defaultStackParser,
integrations,
beforeSend,
transport: makeBufferedTransport,
})
scope = new Scope()
scope.setClient(client)
// Initializing has to be done after setting the client on the scope
client.init()
return
}
// There is no active client on the page, so assume we can take ownership of the
// global scope and client. This is the default, recommended behavior for the Sentry client,
// and as such is what we primarily want to rely on.
init({
...clientOptions,
defaultIntegrations: false,
integrations,
beforeSend,
})
client = getClient()
scope = getCurrentScope()
}
function initialize() {
_initialize()
if (client && preInitErrors.length > 0) {
preInitErrors.forEach(({error, options}) => reportError(error, options))
preInitErrors.length = 0
}
}
function reportError(error: Error, options: ErrorInfo = {}) {
if (!client) {
preInitErrors.push({error, options})
return null
}
const {reactErrorInfo = {}, errorBoundary} = options
const {componentStack} = reactErrorInfo
// Decorate the error report with relevant context and tags
const contexts: Record<string, Record<string, unknown> | undefined> = {}
if (componentStack) {
contexts.react = {componentStack}
}
const tags: {[key: string]: number | string | boolean | null | undefined} = {
handled: 'no',
}
if (errorBoundary) {
tags.errorBoundary = errorBoundary
}
let eventId: string | null = null
withScope(() => {
if (componentStack && isError(error)) {
const errorBoundaryError = new Error(error.message)
errorBoundaryError.name = `${errorBoundary || 'ErrorBoundary'} ${error.name}`
errorBoundaryError.stack = componentStack
// Using the `LinkedErrors` integration to link the errors together.
setCause(error, errorBoundaryError)
}
eventId = captureException(error, {
mechanism: {handled: false},
captureContext: {contexts, tags},
})
})
return eventId ? {eventId} : null
}
function isBufferedTransport(transport: Transport | undefined): transport is BufferedTransport {
return !!transport && 'setConsent' in transport && typeof transport.setConsent === 'function'
}
function enable() {
const transport = client?.getTransport()
if (isBufferedTransport(transport)) {
transport.setConsent(true)
}
}
function disable() {
const transport = client?.getTransport()
if (isBufferedTransport(transport)) {
transport.setConsent(false)
}
}
return {
initialize,
reportError,
enable,
disable,
}
}
const objectToString = Object.prototype.toString
/**
* Checks whether given value's type is one of a few Error or Error-like
*
* @param thing - A value to be checked
* @returns A boolean representing the result
* @internal
*/
function isError(thing: unknown): thing is Error & {cause?: Error} {
switch (objectToString.call(thing)) {
case '[object Error]':
case '[object Exception]':
case '[object DOMException]':
return true
default:
return isInstanceOf(thing, Error)
}
}
/**
* Checks whether given value's type is an instance of provided constructor.
*
* @param thing - A value to be checked.
* @param base - A constructor to be used in a check.
* @returns A boolean representing the result.
* @internal
*/
function isInstanceOf(thing: unknown, base: any): boolean {
try {
return thing instanceof base
} catch (_e) {
return false
}
}
/**
* Set the `cause` property on an error object
*
* @param error - The error to set the cause on
* @param cause - The cause of the error
* @internal
*/
function setCause(error: Error & {cause?: Error}, cause: Error): void {
const seenErrors = new WeakMap<Error, boolean>()
function recurse(err: Error & {cause?: Error | unknown}, subCause: Error): void {
// If we've already seen the error, there is a recursive loop somewhere in the error's
// cause chain. Let's just bail out then to prevent a stack overflow.
if (seenErrors.has(err)) {
return
}
if (isError(err.cause)) {
seenErrors.set(err, true)
recurse(err.cause, subCause)
return
}
err.cause = subCause
}
recurse(error, cause)
}
/**
* Sentry treats errors that are caught in an error boundary as "handled", which we don't want.
* It gives a false sense of security, as the error is only caught to show a more helpful error
* than a blank page. This function sets the `handled` prop on the error's mechanism to `false`.
* Note: This _mutates_ the event, in order to avoid having to deep-clone.
*
* @param event - The event to mark as unhandled
* @internal
*/
function setAsUnhandled(event: ErrorEvent) {
for (const exception of event.exception?.values || []) {
if (exception.mechanism) {
exception.mechanism.handled = false
}
}
}
/**
* "Before send" event handler, which sets the error as unhandled.
* @see setAsUnhandled for a clearer rationale.
*
* @param event - The event to be sent
* @returns The event to be sent
* @internal
*/
function beforeSend(event: ErrorEvent): ErrorEvent {
setAsUnhandled(event)
return event
}
/**
* We'll want a more aggressive dedupe strategy than the default one, as the default is very
* fine grained, needing the same exact stack and message to be considered a duplicate.
* We want to be more conservative.
*
* @internal
*/
function sanityDedupeIntegration() {
const previousEvents: Event[] = []
return {
name: 'SanityDedupe',
processEvent(currentEvent: Event): Event | null | PromiseLike<Event | null> {
// We want to ignore any non-error type events, e.g. transactions or replays
// These should never be deduped, and also not be compared against _previousEvent.
if (currentEvent.type) {
return currentEvent
}
// Juuust in case something goes wrong
try {
if (shouldDropEvent(currentEvent, previousEvents)) {
if (DEBUG_ERROR_REPORTING) {
console.warn(
'[sanity/sentry] Dropping error from being reported because it is a duplicate',
)
}
return null
}
} catch (_) {
/* empty */
}
// Keep the last 10 events around for comparison
if (previousEvents.length > 10) {
previousEvents.shift()
}
previousEvents.push(currentEvent)
return currentEvent
},
}
}
/**
* Determines whether or not the given event should be dropped or not, based on a window of
* previously reported events.
*
* @param currentEvent - The event to check
* @param previousEvents - An array of previously reported events
* @returns True if event should be dropped, false otherwise
* @internal
*/
function shouldDropEvent(currentEvent: Event, previousEvents: Event[]): boolean {
for (const previousEvent of previousEvents) {
const currentMessage = getMessageFromEvent(currentEvent)
const previousMessage = getMessageFromEvent(previousEvent)
if (currentMessage && previousMessage && currentMessage !== previousMessage) {
continue
}
// Sentry timestamps are in fractional seconds, not milliseconds
const currentTimestamp = Math.floor(currentEvent.timestamp || 0)
const previousTimestamp = Math.floor(previousEvent.timestamp || 0)
// If the events are within 5 minutes of each other, we consider them duplicates.
// 5 minutes is a bit much, but if an error occurs every 5 minutes, we better be
// investigating it - and reporting the same error from the same user every 5 minutes
// is not really helpful.
if (Math.abs(currentTimestamp - previousTimestamp) < 300) {
return true
}
}
return false
}
/**
* Extract the `message` string from a Sentry event. Sometimes this is not available on the `event`
* itself, but buried inside of the `event.exception` property.
*
* @param event - The Sentry event to extract the message from
* @returns A string representing the message, or `undefined` if not found
* @internal
*/
function getMessageFromEvent(event: Event): string | undefined {
if (event.message) {
return event.message
}
if (event.exception) {
for (const exception of event.exception.values || []) {
if (exception.value) {
return exception.value
}
}
}
return undefined
}