filter network errors globally in sentry

This commit is contained in:
Samuel Newman
2026-08-28 14:59:16 +01:00
parent 076cdd650d
commit 1d136b07ed
8 changed files with 271 additions and 30 deletions
+18
View File
@@ -301,6 +301,15 @@ describe('general functionality', () => {
timestamp,
)
// call sites do not consistently use the same metadata key
sentryTransport(
LogLevel.Error,
Logger.Context.Default,
'request failed',
{underlyingError: new Error('fetch failed: connection closed')},
timestamp,
)
jest.runAllTimers()
expect(Sentry.captureMessage).not.toHaveBeenCalled()
@@ -312,6 +321,15 @@ describe('general functionality', () => {
{},
timestamp,
)
// network error in metadata with an Error object message
sentryTransport(
LogLevel.Error,
Logger.Context.Default,
new Error('request failed'),
{underlyingError: new Error('fetch failed: connection closed')},
timestamp,
)
expect(Sentry.captureException).not.toHaveBeenCalled()
})
+102
View File
@@ -0,0 +1,102 @@
import {type Procedure, XrpcFetchError, XrpcResponseError} from '@atproto/lex'
import {describe, expect, test} from '@jest/globals'
import {type ErrorEvent} from '@sentry/react-native'
import {
dropExpectedNetworkErrors,
isExpectedSentryNetworkError,
} from '#/logger/sentry/network-errors'
const method = {
nsid: 'app.bsky.test.getTest',
type: 'query',
} as unknown as Procedure
function xrpcResponseError(status: number) {
return new XrpcResponseError(method, new Response(null, {status}), undefined)
}
describe('isExpectedSentryNetworkError', () => {
test.each([502, 503, 504])('detects transient upstream status %s', status => {
expect(isExpectedSentryNetworkError(xrpcResponseError(status))).toBe(true)
})
test.each([429, 500])('keeps actionable HTTP status %s', status => {
expect(isExpectedSentryNetworkError(xrpcResponseError(status))).toBe(false)
})
test('checks an XRPC fetch error cause', () => {
expect(
isExpectedSentryNetworkError(
new XrpcFetchError(method, new Error('fetch failed: connection reset')),
),
).toBe(true)
})
test('keeps implementation errors wrapped by XRPC fetch errors', () => {
expect(
isExpectedSentryNetworkError(
new XrpcFetchError(
method,
new TypeError('URL.canParse is not a function'),
),
),
).toBe(false)
})
})
describe('dropExpectedNetworkErrors', () => {
test('drops automatic captures using the original exception', () => {
const event = {type: undefined} satisfies ErrorEvent
expect(
dropExpectedNetworkErrors(event, {
originalException: new Error('fetch failed: connection closed'),
}),
).toBeNull()
})
test('checks every exception in a linked error chain', () => {
const event = {
type: undefined,
exception: {
values: [
{type: 'Error', value: 'Network request failed'},
{
type: 'XrpcInternalError',
value: 'Unable to fulfill XRPC request',
},
],
},
} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {})).toBeNull()
})
test('drops serialized transient upstream errors', () => {
const event = {
type: undefined,
exception: {
values: [{type: 'XrpcResponseError', value: 'Upstream Timeout'}],
},
} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {})).toBeNull()
})
test('keeps non-network events', () => {
const event = {
type: undefined,
exception: {
values: [
{
type: 'XrpcFetchError',
value: 'URL.canParse is not a function',
},
],
},
} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {})).toBe(event)
})
})
+56
View File
@@ -0,0 +1,56 @@
import {XrpcResponseError} from '@atproto/lex'
import {type ErrorEvent} from '@sentry/react-native'
import {isNetworkError} from '#/lib/network-error'
const TRANSIENT_UPSTREAM_ERRORS = new Set([
'UpstreamFailure',
'NotEnoughResources',
'UpstreamTimeout',
])
const TRANSIENT_UPSTREAM_MESSAGES = [
/upstream\s*failure/i,
/not\s*enough\s*resources/i,
/upstream\s*timeout/i,
/operation timed out, please try again/i,
]
/**
* Sentry should not report expected transport failures or transient upstream
* availability errors. Keep this narrower than `XrpcError.shouldRetry()`:
* fetch handlers can wrap implementation bugs, while 429s and generic 500s
* can reveal actionable client or server regressions.
*/
export function isExpectedSentryNetworkError(value: unknown): boolean {
if (isNetworkError(value)) {
return true
}
if (
value instanceof XrpcResponseError &&
TRANSIENT_UPSTREAM_ERRORS.has(value.error)
) {
return true
}
const message = String(value)
return TRANSIENT_UPSTREAM_MESSAGES.some(pattern => pattern.test(message))
}
/** Global backstop for automatic captures that bypass the logger transport. */
export function dropExpectedNetworkErrors(
event: ErrorEvent,
hint: {originalException?: unknown},
): ErrorEvent | null {
const candidates: unknown[] = [hint.originalException, event.message]
for (const exception of event.exception?.values ?? []) {
candidates.push(exception.value)
if (exception.type && exception.value) {
candidates.push(`${exception.type}: ${exception.value}`)
}
}
return candidates.some(isExpectedSentryNetworkError) ? null : event
}
+2
View File
@@ -1,5 +1,6 @@
import {getGlobalScope, init} from '@sentry/react-native'
import {dropExpectedNetworkErrors} from '#/logger/sentry/network-errors'
import * as env from '#/env'
init({
@@ -10,6 +11,7 @@ init({
environment: env.ENV,
dist: env.BUNDLE_IDENTIFIER,
release: env.RELEASE_VERSION,
beforeSend: dropExpectedNetworkErrors,
ignoreErrors: [
/*
* Unknown internals errors
+12 -10
View File
@@ -1,5 +1,5 @@
import {isNetworkError} from '#/lib/strings/errors'
import {Sentry} from '#/logger/sentry/lib'
import {isExpectedSentryNetworkError} from '#/logger/sentry/network-errors'
import {LogLevel, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util'
@@ -47,14 +47,14 @@ export const sentryTransport: Transport = (
timestamp: timestamp / 1000, // Sentry expects seconds
})
// We don't want to send any network errors to sentry. The underlying
// cause is often passed via metadata rather than the message itself, so
// check the common metadata keys too.
/*
* Keep the breadcrumb, but don't send expected network failures as events.
* The underlying cause is often passed in metadata rather than the message
* itself, and call sites do not consistently use the same metadata key.
*/
if (
isNetworkError(message) ||
isNetworkError(metadata.safeMessage) ||
isNetworkError(metadata.message) ||
isNetworkError(metadata.error)
isExpectedSentryNetworkError(message) ||
Object.values(metadata).some(isExpectedSentryNetworkError)
) {
return
}
@@ -74,8 +74,10 @@ export const sentryTransport: Transport = (
})
}
} else {
// We don't want to send any network errors to sentry
if (isNetworkError(message)) {
if (
isExpectedSentryNetworkError(message) ||
Object.values(metadata).some(isExpectedSentryNetworkError)
) {
return
}