diff --git a/cspell.json b/cspell.json index 45bf825e039c..c4cc037494c0 100644 --- a/cspell.json +++ b/cspell.json @@ -873,6 +873,7 @@ "oxfmt", "oxlint", "padangle", + "pagead", "parasharrajat", "passcodes", "passplus", @@ -955,6 +956,7 @@ "RNFS", "RNLinksdk", "rnmapbox", + "rmkt", "rock", "rowcount", "rowindex", diff --git a/src/libs/telemetry/integrations/common.ts b/src/libs/telemetry/integrations/common.ts index fc5d1c7cf1f5..f2d42ed49822 100644 --- a/src/libs/telemetry/integrations/common.ts +++ b/src/libs/telemetry/integrations/common.ts @@ -2,7 +2,20 @@ import * as SentryReact from '@sentry/react'; import * as Sentry from '@sentry/react-native'; const shouldCreateSpanForRequest = (url: string): boolean => { - const filteredPhrases = ['/api/Log', 'firebaselogging-pa.googleapis.com', 'analytics.google.com', 'rs.fullstory.com', 'api.github.com', 'group-ib.com', 'fp-api.expensify.com']; + const filteredPhrases = [ + '/api/Log', + 'firebaselogging-pa.googleapis.com', + 'analytics.google.com', + 'rs.fullstory.com', + 'api.github.com', + 'group-ib.com', + 'fp-api.expensify.com', + '/api/Ping', + 'ccm/collect', + 'rmkt/collect', + 'pagead/form-data', + 'ccm/form-data', + ]; return !filteredPhrases.some((phrase) => url.includes(phrase)); }; diff --git a/src/libs/telemetry/integrations/index.web.ts b/src/libs/telemetry/integrations/index.web.ts index 5e30f0a3969e..6c7c773553e2 100644 --- a/src/libs/telemetry/integrations/index.web.ts +++ b/src/libs/telemetry/integrations/index.web.ts @@ -16,9 +16,13 @@ function isApplicationKeyStamped(): boolean { /** * Browser tracing integration is enabled on Web to support web health measurements * such as INP, LCP, FCP, CLS. + * + * `ignoreResourceSpans` stops the SDK from creating a span per stylesheet/font (`resource.link`) and per script + * (`resource.script`) on every pageload and navigation */ const tracingIntegration = SentryReact.browserTracingIntegration({ shouldCreateSpanForRequest, + ignoreResourceSpans: ['resource.link', 'resource.script'], }); /** diff --git a/tests/unit/shouldCreateSpanForRequestTest.ts b/tests/unit/shouldCreateSpanForRequestTest.ts new file mode 100644 index 000000000000..ed0a3c884c5a --- /dev/null +++ b/tests/unit/shouldCreateSpanForRequestTest.ts @@ -0,0 +1,35 @@ +import {shouldCreateSpanForRequest} from '@libs/telemetry/integrations/common'; + +// The module builds integrations at import time, so the SDK factories are stubbed out. +jest.mock('@sentry/react-native', () => ({ + reactNavigationIntegration: jest.fn(), + breadcrumbsIntegration: jest.fn(), + consoleLoggingIntegration: jest.fn(), +})); + +jest.mock('@sentry/react', () => ({ + browserProfilingIntegration: jest.fn(), +})); + +describe('shouldCreateSpanForRequest', () => { + it('creates a span for the API commands we debug with', () => { + expect(shouldCreateSpanForRequest('https://www.expensify.com/api/OpenReport?')).toBe(true); + }); + + it('drops the NetInfo reachability heartbeat, which every client issues on a timer', () => { + expect(shouldCreateSpanForRequest('https://www.expensify.com/api/Ping?accountID=123')).toBe(false); + }); + + it('drops the logging command, so writing logs cannot generate more telemetry', () => { + expect(shouldCreateSpanForRequest('https://www.expensify.com/api/Log?')).toBe(false); + }); + + it.each([ + ['ccm/collect', 'https://www.google.com/ccm/collect?tid=G-12345&en=page_view'], + ['rmkt/collect', 'https://www.google.com/rmkt/collect?tid=12345'], + ['pagead/form-data', 'https://googleads.g.doubleclick.net/pagead/form-data?id=12345'], + ['ccm/form-data', 'https://www.google.com/ccm/form-data?id=12345'], + ])('drops the third-party Google Ads/Analytics request to %s', (_endpoint, url) => { + expect(shouldCreateSpanForRequest(url)).toBe(false); + }); +}); diff --git a/tests/unit/webTracingIntegrationTest.ts b/tests/unit/webTracingIntegrationTest.ts new file mode 100644 index 000000000000..fe04cf90d4de --- /dev/null +++ b/tests/unit/webTracingIntegrationTest.ts @@ -0,0 +1,27 @@ +// Held outside the factory so the assertion can read the calls without importing `@sentry/react`, +// which is a transitive dependency that knip flags when imported by name. +const mockBrowserTracingIntegration = jest.fn(() => ({name: 'BrowserTracing'})); + +// The module builds integrations at import time, so the SDK factories are stubbed out. +jest.mock('@sentry/react', () => ({ + browserTracingIntegration: mockBrowserTracingIntegration, + reportingObserverIntegration: jest.fn(() => ({name: 'ReportingObserver'})), + thirdPartyErrorFilterIntegration: jest.fn(() => ({name: 'ThirdPartyErrorFilter'})), + browserProfilingIntegration: jest.fn(() => ({name: 'BrowserProfiling'})), +})); + +jest.mock('@sentry/react-native', () => ({ + reactNavigationIntegration: jest.fn(), + breadcrumbsIntegration: jest.fn(), + consoleLoggingIntegration: jest.fn(), +})); + +describe('web tracing integration', () => { + it('tells the browser SDK not to create resource spans for stylesheets/fonts and scripts', async () => { + // Given the web integrations module, which configures browserTracingIntegration at import time + await import('@libs/telemetry/integrations/index.web'); + + // Then the two highest-volume resource span ops are never created + expect(mockBrowserTracingIntegration).toHaveBeenCalledWith(expect.objectContaining({ignoreResourceSpans: ['resource.link', 'resource.script']})); + }); +});