scope sentry network filtering to error values and exception events

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-09-02 18:19:22 +03:00
parent 50a569ab72
commit 14824e26c9
5 changed files with 260 additions and 76 deletions
+57
View File
@@ -301,6 +301,15 @@ describe('general functionality', () => {
timestamp, timestamp,
) )
// a named key can carry the failure as a plain string too
sentryTransport(
LogLevel.Error,
Logger.Context.Default,
'poll failed',
{safeMessage: 'Network request failed'},
timestamp,
)
// call sites do not consistently use the same metadata key // call sites do not consistently use the same metadata key
sentryTransport( sentryTransport(
LogLevel.Error, LogLevel.Error,
@@ -312,6 +321,8 @@ describe('general functionality', () => {
jest.runAllTimers() jest.runAllTimers()
expect(Sentry.captureMessage).not.toHaveBeenCalled() expect(Sentry.captureMessage).not.toHaveBeenCalled()
// suppressing the event must not suppress the breadcrumb
expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(4)
// network Error object // network Error object
sentryTransport( sentryTransport(
@@ -333,6 +344,52 @@ describe('general functionality', () => {
expect(Sentry.captureException).not.toHaveBeenCalled() expect(Sentry.captureException).not.toHaveBeenCalled()
}) })
test('sentryTransport reports errors with unrelated metadata', () => {
jest.clearAllMocks()
const timestamp = Date.now()
sentryTransport(
LogLevel.Error,
Logger.Context.Default,
'thumbnail upload failed',
{url: 'https://twitch.tv/abort', reason: 'aborted'},
timestamp,
)
jest.runAllTimers()
expect(Sentry.captureMessage).toHaveBeenCalledTimes(1)
expect(Sentry.captureMessage).toHaveBeenCalledWith(
'thumbnail upload failed',
expect.anything(),
)
sentryTransport(
LogLevel.Error,
Logger.Context.Default,
new Error('thumbnail upload failed'),
{url: 'https://twitch.tv/abort'},
timestamp,
)
expect(Sentry.captureException).toHaveBeenCalledTimes(1)
})
test('sentryTransport keeps breadcrumbs below error level', () => {
jest.clearAllMocks()
const timestamp = Date.now()
sentryTransport(
LogLevel.Info,
Logger.Context.Default,
'polling',
{safeMessage: new Error('Network request failed')},
timestamp,
)
expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1)
jest.runAllTimers()
expect(Sentry.captureMessage).not.toHaveBeenCalled()
})
test('add/remove transport', () => { test('add/remove transport', () => {
const timestamp = Date.now() const timestamp = Date.now()
const logger = new Logger({}) const logger = new Logger({})
+107 -35
View File
@@ -1,4 +1,4 @@
import {type Procedure, XrpcFetchError, XrpcResponseError} from '@atproto/lex' import {getMain, XrpcInternalError, XrpcResponseError} from '@atproto/lex'
import {describe, expect, test} from '@jest/globals' import {describe, expect, test} from '@jest/globals'
import {type ErrorEvent} from '@sentry/react-native' import {type ErrorEvent} from '@sentry/react-native'
@@ -6,52 +6,106 @@ import {
dropExpectedNetworkErrors, dropExpectedNetworkErrors,
isExpectedSentryNetworkError, isExpectedSentryNetworkError,
} from '#/logger/sentry/network-errors' } from '#/logger/sentry/network-errors'
import {com} from '#/lexicons'
const method = { const method = getMain(com.atproto.server.describeServer)
nsid: 'app.bsky.test.getTest',
type: 'query',
} as unknown as Procedure
function xrpcResponseError(status: number) { /** An `XrpcResponseError` for a response that carried no JSON error payload. */
function statusOnlyError(status: number) {
return new XrpcResponseError(method, new Response(null, {status}), undefined) return new XrpcResponseError(method, new Response(null, {status}), undefined)
} }
/**
* An `XrpcResponseError` as the PDS pipethrough produces it: the status is
* rewritten while the upstream lexicon code is forwarded verbatim.
*/
function payloadError(status: number, error: string, message: string) {
const body = {error, message}
return new XrpcResponseError(
method,
new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
}),
{encoding: 'application/json', body},
)
}
function exceptionEvent(type: string, value: string): ErrorEvent {
return {type: undefined, exception: {values: [{type, value}]}}
}
describe('isExpectedSentryNetworkError', () => { describe('isExpectedSentryNetworkError', () => {
test.each([502, 503, 504])('detects transient upstream status %s', status => { test.each([502, 503, 504])('detects transient upstream status %s', status => {
expect(isExpectedSentryNetworkError(xrpcResponseError(status))).toBe(true) expect(isExpectedSentryNetworkError(statusOnlyError(status))).toBe(true)
}) })
test.each([429, 500])('keeps actionable HTTP status %s', status => { test.each([429, 500])('keeps actionable HTTP status %s', status => {
expect(isExpectedSentryNetworkError(xrpcResponseError(status))).toBe(false) expect(isExpectedSentryNetworkError(statusOnlyError(status))).toBe(false)
}) })
test('checks an XRPC fetch error cause', () => { test('detects a pipethrough 502 forwarding an upstream error code', () => {
const error = payloadError(
502,
'InternalServerError',
'Internal Server Error',
)
expect(error.error).toBe('InternalServerError')
expect(isExpectedSentryNetworkError(error)).toBe(true)
})
test('checks the cause of a wrapper that does not embed it', () => {
expect( expect(
isExpectedSentryNetworkError( isExpectedSentryNetworkError(
new XrpcFetchError(method, new Error('fetch failed: connection reset')), new XrpcInternalError(method, undefined, {
cause: new Error('fetch failed: connection reset'),
}),
), ),
).toBe(true) ).toBe(true)
}) })
test('keeps implementation errors wrapped by XRPC fetch errors', () => { test('keeps implementation errors wrapped by XRPC errors', () => {
expect( expect(
isExpectedSentryNetworkError( isExpectedSentryNetworkError(
new XrpcFetchError( new XrpcInternalError(method, undefined, {
method, cause: new TypeError('URL.canParse is not a function'),
new TypeError('URL.canParse is not a function'), }),
),
), ),
).toBe(false) ).toBe(false)
}) })
test.each(['UpstreamFailure', 'Upstream Failure', '[UpstreamFailure]'])(
'detects the transient upstream message %s',
message => {
expect(isExpectedSentryNetworkError(message)).toBe(true)
},
)
test.each(['upstreamTimeoutV2', 'handleUpstreamFailureRetry'])(
'keeps the camelCase identifier %s',
message => {
expect(isExpectedSentryNetworkError(message)).toBe(false)
},
)
}) })
describe('dropExpectedNetworkErrors', () => { describe('dropExpectedNetworkErrors', () => {
test('drops automatic captures using the original exception', () => { test('drops automatic captures using the original exception', () => {
const event = {type: undefined} satisfies ErrorEvent /*
* The serialized values do not carry the cause chain, so the hint is the
* only place the transport failure is visible.
*/
const event = exceptionEvent(
'XrpcInternalError',
'Unable to fulfill XRPC request',
)
expect( expect(
dropExpectedNetworkErrors(event, { dropExpectedNetworkErrors(event, {
originalException: new Error('fetch failed: connection closed'), originalException: new XrpcInternalError(method, undefined, {
cause: new Error('fetch failed: connection closed'),
}),
}), }),
).toBeNull() ).toBeNull()
}) })
@@ -73,29 +127,47 @@ describe('dropExpectedNetworkErrors', () => {
expect(dropExpectedNetworkErrors(event, {})).toBeNull() expect(dropExpectedNetworkErrors(event, {})).toBeNull()
}) })
test('drops serialized transient upstream errors', () => { test('drops a serialized transient upstream error carrying a payload', () => {
const event = { const event = exceptionEvent('XrpcResponseError', 'Upstream Timeout')
type: undefined,
exception: {
values: [{type: 'XrpcResponseError', value: 'Upstream Timeout'}],
},
} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {})).toBeNull() expect(dropExpectedNetworkErrors(event, {})).toBeNull()
}) })
test('drops a serialized transient upstream error with no payload', () => {
const {message} = statusOnlyError(504)
// Locks the text lex builds from the status alone, which the filter matches
expect(message).toBe('Upstream server responded with a 504 error')
expect(
dropExpectedNetworkErrors(
exceptionEvent('XrpcResponseError', message),
{},
),
).toBeNull()
})
test.each([
'[USER REPORT] alice.bsky.social video-upload-aborted',
'[USER REPORT] alice.bsky.social Network request failed',
])('keeps the message event %s', message => {
const event = {type: undefined, message} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {originalException: message})).toBe(
event,
)
})
test('keeps non-network events', () => { test('keeps non-network events', () => {
const event = { const event = exceptionEvent(
type: undefined, 'XrpcFetchError',
exception: { 'Unexpected fetchHandler() error: URL.canParse is not a function',
values: [ )
{
type: 'XrpcFetchError', expect(dropExpectedNetworkErrors(event, {})).toBe(event)
value: 'URL.canParse is not a function', })
},
], test('keeps a camelCase identifier that merely contains an upstream word', () => {
}, const event = exceptionEvent('Error', 'checkout failed: upstreamTimeoutV2')
} satisfies ErrorEvent
expect(dropExpectedNetworkErrors(event, {})).toBe(event) expect(dropExpectedNetworkErrors(event, {})).toBe(event)
}) })
+51 -15
View File
@@ -1,19 +1,39 @@
import {XrpcResponseError} from '@atproto/lex' import {XrpcResponseError} from '@atproto/lex'
import {type ErrorEvent} from '@sentry/react-native' import {type ErrorEvent} from '@sentry/react-native'
import {isNetworkError} from '#/lib/network-error' import {isNetworkError, safeStringify} from '#/lib/network-error'
const TRANSIENT_UPSTREAM_ERRORS = new Set([ /**
'UpstreamFailure', * HTTP statuses that mean an upstream service was unavailable.
'NotEnoughResources', *
'UpstreamTimeout', * Matched on the status rather than the lexicon code because the code is not
]) * reliable here: the PDS pipethrough rewrites the status to 502 while
* forwarding the upstream error code verbatim, so an upstream 500 arrives as a
* 502 still named `InternalServerError`. The status is authoritative.
*
* `#/lib/xrpc-error` is deliberately not used for the `instanceof` check - it
* imports `#/lexicons`, which would pull the whole lexicon graph into the
* module evaluated before `Sentry.init()`.
*/
const TRANSIENT_UPSTREAM_STATUSES = new Set([502, 503, 504])
/**
* Sentry serializes an exception as `{type: error.name, value: error.message}`,
* so these match the message text rather than the `toString()`. Lex builds that
* message from the server's error payload ("Upstream Timeout") or, when the
* response carries no JSON payload, from the status alone ("Upstream server
* responded with a 504 error").
*
* The optional space matches the spaced prose and the space-free lexicon code
* ("UpstreamFailure") alike, and the word boundaries keep camelCase
* identifiers such as `upstreamTimeoutV2` from matching.
*/
const TRANSIENT_UPSTREAM_MESSAGES = [ const TRANSIENT_UPSTREAM_MESSAGES = [
/upstream\s*failure/i, /\bupstream\s?failure\b/i,
/not\s*enough\s*resources/i, /\bnot\s?enough\s?resources\b/i,
/upstream\s*timeout/i, /\bupstream\s?timeout\b/i,
/operation timed out, please try again/i, /operation timed out, please try again/i,
/\bupstream server responded with a 50[234] error\b/i,
] ]
/** /**
@@ -29,23 +49,39 @@ export function isExpectedSentryNetworkError(value: unknown): boolean {
if ( if (
value instanceof XrpcResponseError && value instanceof XrpcResponseError &&
TRANSIENT_UPSTREAM_ERRORS.has(value.error) TRANSIENT_UPSTREAM_STATUSES.has(value.status)
) { ) {
return true return true
} }
const message = String(value) const message = safeStringify(value)
return TRANSIENT_UPSTREAM_MESSAGES.some(pattern => pattern.test(message)) return (
message !== undefined &&
TRANSIENT_UPSTREAM_MESSAGES.some(pattern => pattern.test(message))
)
} }
/** Global backstop for automatic captures that bypass the logger transport. */ /**
* Global backstop for automatic captures that bypass the logger transport.
*
* Only exception events are inspected. Sentry sets `hint.originalException` to
* the message *string* for a `captureMessage`, and message events reach Sentry
* from exactly two places: the logger transport, which has already run this
* check before capturing, and user bug reports, which must never be dropped
* for happening to contain a word like "aborted" in the report slug.
*/
export function dropExpectedNetworkErrors( export function dropExpectedNetworkErrors(
event: ErrorEvent, event: ErrorEvent,
hint: {originalException?: unknown}, hint: {originalException?: unknown},
): ErrorEvent | null { ): ErrorEvent | null {
const candidates: unknown[] = [hint.originalException, event.message] const exceptions = event.exception?.values
if (!exceptions?.length) {
return event
}
for (const exception of event.exception?.values ?? []) { const candidates: unknown[] = [hint.originalException]
for (const exception of exceptions) {
candidates.push(exception.value) candidates.push(exception.value)
if (exception.type && exception.value) { if (exception.type && exception.value) {
candidates.push(`${exception.type}: ${exception.value}`) candidates.push(`${exception.type}: ${exception.value}`)
-4
View File
@@ -18,10 +18,6 @@ init({
*/ */
`t is not defined`, `t is not defined`,
`Can't find variable: t`, `Can't find variable: t`,
/*
* Un-useful errors
*/
`Network request failed`,
], ],
/** /**
* Does not affect traces of error events or other logs, just disables * Does not affect traces of error events or other logs, just disables
+45 -22
View File
@@ -1,21 +1,58 @@
import {Sentry} from '#/logger/sentry/lib' import {Sentry} from '#/logger/sentry/lib'
import {isExpectedSentryNetworkError} from '#/logger/sentry/network-errors' import {isExpectedSentryNetworkError} from '#/logger/sentry/network-errors'
import {LogLevel, type Transport} from '#/logger/types' import {LogLevel, type Metadata, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util' import {prepareMetadata} from '#/logger/util'
/**
* Metadata keys that have historically carried the underlying failure as a
* plain string.
*/
const CHECKED_METADATA_KEYS = ['safeMessage', 'message', 'error']
/**
* Whether this log records a network failure Sentry should not be told about.
*
* The failure is often passed in metadata rather than as the message, and call
* sites do not consistently use the same key, so any `Error` value counts no
* matter which key holds it. Values of other types only count under the keys
* above: metadata is arbitrary, and scanning all of it lets an unrelated string
* - a stream URL containing "abort", say - suppress a real error.
*/
function isExpectedNetworkFailure(
message: string | Error,
metadata: Metadata,
): boolean {
if (isExpectedSentryNetworkError(message)) {
return true
}
for (const value of Object.values(metadata)) {
if (value instanceof Error && isExpectedSentryNetworkError(value)) {
return true
}
}
return CHECKED_METADATA_KEYS.some(
key => key in metadata && isExpectedSentryNetworkError(metadata[key]),
)
}
export const sentryTransport: Transport = ( export const sentryTransport: Transport = (
level, level,
context, context,
message, message,
{type, tags, fingerprint, ...metadata}, {type, tags, fingerprint, __metadata__, ...metadata},
timestamp, timestamp,
) => { ) => {
// Skip debug messages entirely for now - esb // Skip debug messages entirely for now - esb
if (level === LogLevel.Debug) return if (level === LogLevel.Debug) return
/*
* `__metadata__` is ambient context rather than something the call site
* passed, so it is destructured out of the scanned metadata and folded back
* in here to keep the attached data unchanged.
*/
const meta = { const meta = {
__context__: context, __context__: context,
...prepareMetadata(metadata), ...prepareMetadata(__metadata__ ? {__metadata__, ...metadata} : metadata),
} }
let _tags = tags || {} let _tags = tags || {}
_tags = { _tags = {
@@ -47,24 +84,15 @@ export const sentryTransport: Transport = (
timestamp: timestamp / 1000, // Sentry expects seconds timestamp: timestamp / 1000, // Sentry expects seconds
}) })
/*
* 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 (
isExpectedSentryNetworkError(message) ||
Object.values(metadata).some(isExpectedSentryNetworkError)
) {
return
}
/** /**
* Only error-level strings are reported to Sentry as events. Lower levels * Only error-level strings are reported to Sentry as events. Lower levels
* are captured as breadcrumbs above and attached to the next event, if * are captured as breadcrumbs above and attached to the next event, if
* any. * any - so the network check only has to run here.
*/ */
if (level === LogLevel.Error) { if (level === LogLevel.Error) {
// Keep the breadcrumb, but don't send expected network failures as events
if (isExpectedNetworkFailure(message, metadata)) return
// Defer non-critical messages so they're sent in a batch // Defer non-critical messages so they're sent in a batch
queueMessageForSentry(message, { queueMessageForSentry(message, {
level: severity, level: severity,
@@ -74,12 +102,7 @@ export const sentryTransport: Transport = (
}) })
} }
} else { } else {
if ( if (isExpectedNetworkFailure(message, metadata)) return
isExpectedSentryNetworkError(message) ||
Object.values(metadata).some(isExpectedSentryNetworkError)
) {
return
}
/** /**
* It's otherwise an Error and should be reported with captureException * It's otherwise an Error and should be reported with captureException