diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c490ff11e44c..4f87f1aff36f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -42,7 +42,7 @@ import { createClientReportEnvelope } from './utils/clientreport'; import { debug } from './utils/debug-logger'; import { dsnToString, makeDsn } from './utils/dsn'; import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils/envelope'; -import { getPossibleEventMessages } from './utils/eventUtils'; +import { getPossibleEventMessages, isInternalException } from './utils/eventUtils'; import { isObjectLike, isParameterizedString, isPlainObject, isPrimitive, isThenable } from './utils/is'; import { merge } from './utils/merge'; import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; @@ -554,8 +554,10 @@ export abstract class Client { let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); - for (const attachment of hint.attachments || []) { - env = addItemToEnvelope(env, createAttachmentEnvelopeItem(attachment)); + if (!isInternalException(hint)) { + for (const attachment of hint.attachments || []) { + env = addItemToEnvelope(env, createAttachmentEnvelopeItem(attachment)); + } } if (genAiSpanItem) { @@ -1446,8 +1448,7 @@ export abstract class Client { throw _makeDoNotSendEventError('An event processor returned `null`, will not send event.'); } - const isInternalException = (hint.data as { __sentry__: boolean })?.__sentry__ === true; - if (isInternalException) { + if (isInternalException(hint)) { return prepared; } diff --git a/packages/core/src/utils/eventUtils.ts b/packages/core/src/utils/eventUtils.ts index 6414957b55e0..a506cf2453a1 100644 --- a/packages/core/src/utils/eventUtils.ts +++ b/packages/core/src/utils/eventUtils.ts @@ -1,4 +1,8 @@ -import type { Event } from '../types/event'; +import type { Event, EventHint } from '../types/event'; + +export function isInternalException(hint: EventHint): boolean { + return (hint.data as { __sentry__: boolean })?.__sentry__ === true; +} /** * Get a list of possible event messages from a Sentry event. diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 1522376a22c0..78c5b336daac 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -7,6 +7,7 @@ import type { Event, EventHint } from '../types/event'; import type { ClientOptions } from '../types/options'; import type { StackParser } from '../types/stacktrace'; import { getFilenameToDebugIdMap } from './debug-ids'; +import { isInternalException } from './eventUtils'; import { addExceptionMechanism, uuid4 } from './misc'; import { normalize } from './normalize'; import { applyScopeDataToEvent, getCombinedScopeData } from './scopeData'; @@ -95,9 +96,7 @@ export function prepareEvent( ]; // Skip event processors for internal exceptions to prevent recursion - // oxlint-disable-next-line typescript/prefer-optional-chain - const isInternalException = hint.data && (hint.data as { __sentry__: boolean }).__sentry__ === true; - const result = isInternalException + const result: PromiseLike = isInternalException(hint) ? resolvedSyncPromise(prepared) : notifyEventProcessors(eventProcessors, prepared, hint); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 18e2c0e2871f..6fa5cd4fbf31 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -4,6 +4,7 @@ import { addBreadcrumb, dsnToString, getCurrentScope, + getGlobalScope, getIsolationScope, lastEventId, makeSession, @@ -19,8 +20,9 @@ import { _INTERNAL_captureMetric } from '../../src/metrics/internal'; import * as traceModule from '../../src/tracing/trace'; import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base'; import type { Envelope } from '../../src/types/envelope'; -import type { ErrorEvent, Event, TransactionEvent } from '../../src/types/event'; +import type { ErrorEvent, Event, EventHint, TransactionEvent } from '../../src/types/event'; import type { SpanJSON } from '../../src/types/span'; +import type { Transport } from '../../src/types/transport'; import * as debugLoggerModule from '../../src/utils/debug-logger'; import * as miscModule from '../../src/utils/misc'; import * as timeModule from '../../src/utils/time'; @@ -2443,6 +2445,141 @@ describe('Client', () => { }); }); + describe('attachments', () => { + const send = vi.fn(() => Promise.resolve({})); + let client: TestClient; + + beforeEach(() => { + vi.useRealTimers(); + client = new TestClient({ + dsn: 'https://public@example.com/1', + integrations: [], + stackParser: () => [], + enableSend: true, + transport: () => ({ send, flush: () => Promise.resolve(true) }), + }); + }); + + it('excludes hint and hook attachments from internal events', async () => { + const event = { message: 'Internal SDK failure' }; + const hint: EventHint = { + data: { __sentry__: true }, + attachments: [{ filename: 'state.txt', data: 'private state' }], + }; + client.on('beforeSendEvent', (_event, eventHint) => { + eventHint!.attachments!.push({ filename: 'hook.txt', data: 'private hook data' }); + }); + + client.sendEvent(event, hint); + await client.flush(1000); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith([expect.any(Object), [[{ type: 'event' }, event]]]); + }); + + it.each([undefined, { __sentry__: false }])( + 'preserves attachments for ordinary events with hint data %j', + async data => { + const event = { message: 'Application failure' }; + const hint: EventHint = { + data, + attachments: [{ filename: 'state.txt', data: 'safe state' }], + }; + client.on('beforeSendEvent', (_event, eventHint) => { + eventHint!.attachments!.push({ filename: 'hook.txt', data: 'safe hook data' }); + }); + + client.sendEvent(event, hint); + await client.flush(1000); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith([ + expect.any(Object), + [ + [{ type: 'event' }, event], + [ + { + type: 'attachment', + length: 10, + filename: 'state.txt', + content_type: undefined, + attachment_type: undefined, + }, + new TextEncoder().encode('safe state'), + ], + [ + { + type: 'attachment', + length: 14, + filename: 'hook.txt', + content_type: undefined, + attachment_type: undefined, + }, + new TextEncoder().encode('safe hook data'), + ], + ], + ]); + }, + ); + + it('excludes scope attachments when a pipeline failure bypasses filtering callbacks', async () => { + getGlobalScope().addAttachment({ filename: 'global.txt', data: 'private global data' }); + getIsolationScope().addAttachment({ filename: 'isolation.txt', data: 'private isolation data' }); + getCurrentScope().addAttachment({ filename: 'current.txt', data: 'private current data' }); + const beforeSend = vi.fn((event: ErrorEvent, hint: EventHint) => { + hint.attachments = []; + return event; + }); + client.getOptions().beforeSend = beforeSend; + const processor = vi.fn((event: Event, hint: EventHint) => { + hint.attachments = []; + return event; + }); + client.addEventProcessor(processor); + client.on( + 'postprocessEvent', + vi.fn().mockImplementationOnce(() => { + throw new Error('SDK pipeline failure'); + }), + ); + + client.captureMessage('Application failure'); + await client.flush(1000); + + expect(beforeSend).not.toHaveBeenCalled(); + expect(processor).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + { type: 'event' }, + expect.objectContaining({ + exception: { + values: [ + { + type: 'Error', + value: 'SDK pipeline failure', + mechanism: { type: 'internal', handled: false }, + }, + ], + }, + }), + ], + ], + ]); + expect(getGlobalScope().getScopeData().attachments).toEqual([ + { filename: 'global.txt', data: 'private global data' }, + ]); + expect(getIsolationScope().getScopeData().attachments).toEqual([ + { filename: 'isolation.txt', data: 'private isolation data' }, + ]); + expect(getCurrentScope().getScopeData().attachments).toEqual([ + { filename: 'current.txt', data: 'private current data' }, + ]); + }); + }); + describe('captureSession()', () => { test('sends sessions to the client', () => { expect.assertions(1);