diff --git a/.size-limit.js b/.size-limit.js index 13b963bacd8d..a46d905fe89f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '144 KB', + limit: '146 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/webpack-4/build.mjs b/dev-packages/e2e-tests/test-applications/webpack-4/build.mjs index 0818243ad9ee..32d475aac75c 100644 --- a/dev-packages/e2e-tests/test-applications/webpack-4/build.mjs +++ b/dev-packages/e2e-tests/test-applications/webpack-4/build.mjs @@ -17,6 +17,11 @@ webpack( minimize: true, minimizer: [new TerserPlugin()], }, + performance: { + hints: false, + maxEntrypointSize: 512000, + maxAssetSize: 512000 + }, plugins: [new HtmlWebpackPlugin(), new webpack.EnvironmentPlugin(['E2E_TEST_DSN'])], mode: 'production', // webpack 4 does not support ES2020 features out of the box, so we need to transpile them diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8b03392106d9..2b1a96266a4d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -42,6 +42,7 @@ import { merge } from './utils/merge'; import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; +import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { getActiveSpan, showSpanDropWarning, spanToTraceContext } from './utils/spanUtils'; import { rejectedSyncPromise, resolvedSyncPromise, SyncPromise } from './utils/syncpromise'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; @@ -1281,7 +1282,7 @@ function processBeforeSend( event: Event, hint: EventHint, ): PromiseLike | Event | null { - const { beforeSend, beforeSendTransaction, beforeSendSpan } = options; + const { beforeSend, beforeSendTransaction, beforeSendSpan, ignoreSpans } = options; let processedEvent = event; if (isErrorEvent(processedEvent) && beforeSend) { @@ -1289,28 +1290,59 @@ function processBeforeSend( } if (isTransactionEvent(processedEvent)) { - if (beforeSendSpan) { - // process root span - const processedRootSpanJson = beforeSendSpan(convertTransactionEventToSpanJson(processedEvent)); - if (!processedRootSpanJson) { - showSpanDropWarning(); - } else { - // update event with processed root span values - processedEvent = merge(event, convertSpanJsonToTransactionEvent(processedRootSpanJson)); + // Avoid processing if we don't have to + if (beforeSendSpan || ignoreSpans) { + // 1. Process root span + const rootSpanJson = convertTransactionEventToSpanJson(processedEvent); + + // 1.1 If the root span should be ignored, drop the whole transaction + if (shouldIgnoreSpan(rootSpanJson, ignoreSpans)) { + // dropping the whole transaction! + return null; } - // process child spans + // 1.2 If a `beforeSendSpan` callback is defined, process the root span + if (beforeSendSpan) { + const processedRootSpanJson = beforeSendSpan(rootSpanJson); + if (!processedRootSpanJson) { + showSpanDropWarning(); + } else { + // update event with processed root span values + processedEvent = merge(event, convertSpanJsonToTransactionEvent(processedRootSpanJson)); + } + } + + // 2. Process child spans if (processedEvent.spans) { const processedSpans: SpanJSON[] = []; - for (const span of processedEvent.spans) { - const processedSpan = beforeSendSpan(span); - if (!processedSpan) { - showSpanDropWarning(); - processedSpans.push(span); + + const initialSpans = processedEvent.spans; + + for (const span of initialSpans) { + // 2.a If the child span should be ignored, reparent it to the root span + if (shouldIgnoreSpan(span, ignoreSpans)) { + reparentChildSpans(initialSpans, span); + continue; + } + + // 2.b If a `beforeSendSpan` callback is defined, process the child span + if (beforeSendSpan) { + const processedSpan = beforeSendSpan(span); + if (!processedSpan) { + showSpanDropWarning(); + processedSpans.push(span); + } else { + processedSpans.push(processedSpan); + } } else { - processedSpans.push(processedSpan); + processedSpans.push(span); } } + + const droppedSpans = processedEvent.spans.length - processedSpans.length; + if (droppedSpans) { + client.recordDroppedEvent('before_send', 'span', droppedSpans); + } processedEvent.spans = processedSpans; } } diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index dc094d218812..f01a1c214083 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -26,6 +26,7 @@ import { getSdkMetadataForEnvelopeHeader, } from './utils/envelope'; import { uuid4 } from './utils/misc'; +import { shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning, spanToJSON } from './utils/spanUtils'; /** @@ -122,7 +123,15 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; - const beforeSendSpan = client?.getOptions().beforeSendSpan; + const { beforeSendSpan, ignoreSpans } = client?.getOptions() || {}; + + const filteredSpans = ignoreSpans ? spans.filter(span => !shouldIgnoreSpan(spanToJSON(span), ignoreSpans)) : spans; + const droppedSpans = spans.length - filteredSpans.length; + + if (droppedSpans) { + client?.recordDroppedEvent('before_send', 'span', droppedSpans); + } + const convertToSpanJSON = beforeSendSpan ? (span: SentrySpan) => { const spanJson = spanToJSON(span); @@ -138,7 +147,7 @@ export function createSpanEnvelope(spans: [SentrySpan, ...SentrySpan[]], client? : spanToJSON; const items: SpanItem[] = []; - for (const span of spans) { + for (const span of filteredSpans) { const spanJson = convertToSpanJSON(span); if (spanJson) { items.push(createSpanEnvelopeItem(spanJson)); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index a4119ed42a6e..76b128c9db4e 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -10,6 +10,11 @@ import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; +interface IgnoreSpanFilter { + name?: string | RegExp; + op?: string | RegExp; +} + export interface ClientOptions { /** * Enable debug functionality in the SDK itself. If `debug` is set to `true` the SDK will attempt @@ -208,6 +213,13 @@ export interface ClientOptions; + /** + * A list of span names or patterns to ignore. + * + * @default [] + */ + ignoreSpans?: (string | RegExp | IgnoreSpanFilter)[]; + /** * A URL to an envelope tunnel endpoint. An envelope tunnel is an HTTP endpoint * that accepts Sentry envelopes for forwarding. This can be used to force data diff --git a/packages/core/src/utils/should-ignore-span.ts b/packages/core/src/utils/should-ignore-span.ts new file mode 100644 index 000000000000..58040c2ecdd1 --- /dev/null +++ b/packages/core/src/utils/should-ignore-span.ts @@ -0,0 +1,67 @@ +import type { ClientOptions } from '../types-hoist/options'; +import type { SpanJSON } from '../types-hoist/span'; +import { isMatchingPattern, stringMatchesSomePattern } from './string'; + +/** + * Check if a span should be ignored based on the ignoreSpans configuration. + */ +export function shouldIgnoreSpan( + span: Pick, + ignoreSpans: ClientOptions['ignoreSpans'], +): boolean { + if (!ignoreSpans?.length) { + return false; + } + + if (!span.description) { + return false; + } + + // First we check the simple string/regex patterns - if the name matches any of them, we ignore the span + const simplePatterns = ignoreSpans.filter(isStringOrRegExp); + if (simplePatterns.length && stringMatchesSomePattern(span.description, simplePatterns)) { + return true; + } + + // Then we check the more complex patterns, where both parts must match + for (const pattern of ignoreSpans) { + // Have already checked for simple patterns, so we can skip these + if (isStringOrRegExp(pattern) || (!pattern.name && !pattern.op)) { + continue; + } + + const nameMatches = pattern.name ? isMatchingPattern(span.description, pattern.name) : true; + const opMatches = pattern.op ? span.op && isMatchingPattern(span.op, pattern.op) : true; + + if (nameMatches && opMatches) { + return true; + } + } + + return false; +} + +/** + * Takes a list of spans, and a span that was dropped, and re-parents the child spans of the dropped span to the parent of the dropped span, if possible. + * This mutates the spans array in place! + */ +export function reparentChildSpans(spans: SpanJSON[], dropSpan: SpanJSON): void { + const droppedSpanParentId = dropSpan.parent_span_id; + const droppedSpanId = dropSpan.span_id; + + // This should generally not happen, as we do not apply this on root spans + // but to be safe, we just bail in this case + if (!droppedSpanParentId) { + return; + } + + for (const span of spans) { + if (span.parent_span_id === droppedSpanId) { + span.parent_span_id = droppedSpanParentId; + } + } +} + +function isStringOrRegExp(value: unknown): value is string | RegExp { + return typeof value === 'string' || value instanceof RegExp; +} diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 4b1c7a378114..3cae5ed65787 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -1046,6 +1046,178 @@ describe('Client', () => { expect(capturedEvent.transaction).toEqual(transaction.transaction); }); + test('uses `ignoreSpans` to drop root spans', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, ignoreSpans: ['root span'] }); + const client = new TestClient(options); + + const captureExceptionSpy = vi.spyOn(client, 'captureException'); + const loggerLogSpy = vi.spyOn(debugLoggerModule.debug, 'log'); + + const transaction: Event = { + transaction: 'root span', + type: 'transaction', + spans: [ + { + description: 'first span', + span_id: '9e15bf99fbe4bc80', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'second span', + span_id: 'aa554c1f506b0783', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + ], + }; + client.captureEvent(transaction); + + expect(TestClient.instance!.event).toBeUndefined(); + // This proves that the reason the event didn't send/didn't get set on the test client is not because there was an + // error, but because the event processor returned `null` + expect(captureExceptionSpy).not.toBeCalled(); + expect(loggerLogSpy).toBeCalledWith('before send for type `transaction` returned `null`, will not send event.'); + }); + + test('uses `ignoreSpans` to drop child spans', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, ignoreSpans: ['first span'] }); + const client = new TestClient(options); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const transaction: Event = { + contexts: { + trace: { + span_id: 'root-span-id', + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + }, + }, + transaction: 'root span', + type: 'transaction', + spans: [ + { + description: 'first span', + span_id: '9e15bf99fbe4bc80', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'second span', + span_id: 'aa554c1f506b0783', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'third span', + span_id: 'aa554c1f506b0784', + parent_span_id: '9e15bf99fbe4bc80', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + ], + }; + client.captureEvent(transaction); + + const capturedEvent = TestClient.instance!.event!; + expect(capturedEvent.spans).toEqual([ + { + description: 'second span', + span_id: 'aa554c1f506b0783', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'third span', + span_id: 'aa554c1f506b0784', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + ]); + expect(recordDroppedEventSpy).toBeCalledWith('before_send', 'span', 1); + }); + + test('uses complex `ignoreSpans` to drop child spans', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + ignoreSpans: [ + { + name: 'first span', + }, + { + name: 'span', + op: 'op1', + }, + ], + }); + const client = new TestClient(options); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + const transaction: Event = { + contexts: { + trace: { + span_id: 'root-span-id', + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + }, + }, + transaction: 'root span', + type: 'transaction', + spans: [ + { + description: 'first span', + span_id: '9e15bf99fbe4bc80', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'second span', + op: 'op1', + span_id: 'aa554c1f506b0783', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + { + description: 'third span', + op: 'other op', + span_id: 'aa554c1f506b0784', + parent_span_id: '9e15bf99fbe4bc80', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + ], + }; + client.captureEvent(transaction); + + const capturedEvent = TestClient.instance!.event!; + expect(capturedEvent.spans).toEqual([ + { + description: 'third span', + op: 'other op', + span_id: 'aa554c1f506b0784', + parent_span_id: 'root-span-id', + start_timestamp: 1591603196.637835, + trace_id: '86f39e84263a4de99c326acab3bfe3bd', + data: {}, + }, + ]); + expect(recordDroppedEventSpy).toBeCalledWith('before_send', 'span', 2); + }); + test('does not modify existing contexts for root span in `beforeSendSpan`', () => { const beforeSendSpan = vi.fn((span: SpanJSON) => { return { diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts new file mode 100644 index 000000000000..434cb8911518 --- /dev/null +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { ClientOptions, SpanJSON } from '../../../src'; +import { reparentChildSpans, shouldIgnoreSpan } from '../../../src/utils/should-ignore-span'; + +describe('shouldIgnoreSpan', () => { + it('should not ignore spans with empty ignoreSpans', () => { + const span = { description: 'test', op: 'test' }; + const ignoreSpans = [] as ClientOptions['ignoreSpans']; + expect(shouldIgnoreSpan(span, ignoreSpans)).toBe(false); + }); + + describe('string patterns', () => { + it.each([ + ['test', 'test', true], + ['test', 'test2', false], + ['test2', 'test', true], + ])('should ignore spans with description %s & ignoreSpans=%s', (description, ignoreSpansPattern, expected) => { + const span = { description, op: 'default' }; + const ignoreSpans = [ignoreSpansPattern]; + expect(shouldIgnoreSpan(span, ignoreSpans)).toBe(expected); + }); + }); + + describe('regex patterns', () => { + it.each([ + ['test', /test/, true], + ['test', /test2/, false], + ['test2', /test/, true], + ])('should ignore spans with description %s & ignoreSpans=%s', (description, ignoreSpansPattern, expected) => { + const span = { description, op: 'default' }; + const ignoreSpans = [ignoreSpansPattern]; + expect(shouldIgnoreSpan(span, ignoreSpans)).toBe(expected); + }); + }); + + describe('complex patterns', () => { + it.each([ + [{ name: 'test' }, true], + [{ name: 'test2' }, false], + [{ op: 'test' }, true], + [{ op: 'test2' }, false], + [{ name: 'test', op: 'test' }, true], + [{ name: 'test2', op: 'test' }, false], + [{ name: 'test', op: 'test2' }, false], + [{ name: 'test2', op: 'test2' }, false], + [{ name: 'test', op: 'test2' }, false], + ])('should ignore spans with description %s & ignoreSpans=%s', (ignoreSpansPattern, expected) => { + const span = { description: 'test span name', op: 'test span op' }; + const ignoreSpans = [ignoreSpansPattern]; + expect(shouldIgnoreSpan(span, ignoreSpans)).toBe(expected); + }); + }); + + it('works with multiple patterns', () => { + const ignoreSpans = ['test', /test2/, { op: 'test2' }]; + + // All of these are ignored because the name matches + const span1 = { description: 'test span name', op: 'test span op' }; + const span2 = { description: 'test span name2', op: 'test span op2' }; + const span3 = { description: 'test span name3', op: 'test span op3' }; + const span4 = { description: 'test span name4', op: 'test span op4' }; + + expect(shouldIgnoreSpan(span1, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span2, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span3, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span4, ignoreSpans)).toBe(true); + + // All of these are ignored because the op matches + const span5 = { description: 'custom 1', op: 'test2' }; + const span6 = { description: 'custom 2', op: 'test2' }; + const span7 = { description: 'custom 3', op: 'test2' }; + const span8 = { description: 'custom 4', op: 'test2' }; + + expect(shouldIgnoreSpan(span5, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span6, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span7, ignoreSpans)).toBe(true); + expect(shouldIgnoreSpan(span8, ignoreSpans)).toBe(true); + + // None of these are ignored because the name and op don't match + const span9 = { description: 'custom 5', op: 'test' }; + const span10 = { description: 'custom 6', op: 'test' }; + const span11 = { description: 'custom 7', op: 'test' }; + const span12 = { description: 'custom 8', op: 'test' }; + + expect(shouldIgnoreSpan(span9, ignoreSpans)).toBe(false); + expect(shouldIgnoreSpan(span10, ignoreSpans)).toBe(false); + expect(shouldIgnoreSpan(span11, ignoreSpans)).toBe(false); + expect(shouldIgnoreSpan(span12, ignoreSpans)).toBe(false); + }); +}); + +describe('reparentChildSpans', () => { + it('should ignore dropped root spans', () => { + const span1 = { span_id: '1' } as SpanJSON; + const span2 = { span_id: '2', parent_span_id: '1' } as SpanJSON; + const span3 = { span_id: '3', parent_span_id: '2' } as SpanJSON; + + const spans = [span1, span2, span3]; + + reparentChildSpans(spans, span1); + + expect(spans).toEqual([span1, span2, span3]); + expect(span1.parent_span_id).toBeUndefined(); + expect(span2.parent_span_id).toBe('1'); + expect(span3.parent_span_id).toBe('2'); + }); + + it('should reparent child spans of the dropped span', () => { + const span1 = { span_id: '1' } as SpanJSON; + const span2 = { span_id: '2', parent_span_id: '1' } as SpanJSON; + const span3 = { span_id: '3', parent_span_id: '2' } as SpanJSON; + const span4 = { span_id: '4', parent_span_id: '3' } as SpanJSON; + + const spans = [span1, span2, span3, span4]; + + reparentChildSpans(spans, span2); + + expect(spans).toEqual([span1, span2, span3, span4]); + expect(span1.parent_span_id).toBeUndefined(); + expect(span2.parent_span_id).toBe('1'); + expect(span3.parent_span_id).toBe('1'); + expect(span4.parent_span_id).toBe('3'); + }); +});