diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreStaticAssets.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreStaticAssets.js new file mode 100644 index 000000000000..bcc47257a2f1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreStaticAssets.js @@ -0,0 +1,39 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + // Test default for ignoreStaticAssets: true + integrations: [Sentry.httpIntegration()], +}); + +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +app.get('/favicon.ico', (_req, res) => { + res.type('image/x-icon').send(Buffer.from([0])); +}); + +app.get('/robots.txt', (_req, res) => { + res.type('text/plain').send('User-agent: *\nDisallow:\n'); +}); + +app.get('/assets/app.js', (_req, res) => { + res.type('application/javascript').send('/* js */'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-traceStaticAssets.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-traceStaticAssets.js new file mode 100644 index 000000000000..743d1b48e21f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-traceStaticAssets.js @@ -0,0 +1,38 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.httpIntegration({ ignoreStaticAssets: false })], +}); + +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'ok' }); +}); + +app.get('/favicon.ico', (_req, res) => { + res.type('image/x-icon').send(Buffer.from([0])); +}); + +app.get('/robots.txt', (_req, res) => { + res.type('text/plain').send('User-agent: *\nDisallow:\n'); +}); + +app.get('/assets/app.js', (_req, res) => { + res.type('application/javascript').send('/* js */'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 9682f4aa28ac..97043c998814 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -185,4 +185,44 @@ describe('httpIntegration', () => { closeTestServer(); }); }); + + test('ignores static asset requests by default', async () => { + const runner = createRunner(__dirname, 'server-ignoreStaticAssets.js') + .expect({ + transaction: event => { + expect(event.transaction).toBe('GET /test'); + expect(event.contexts?.trace?.data?.url).toMatch(/\/test$/); + expect(event.contexts?.trace?.op).toBe('http.server'); + expect(event.contexts?.trace?.status).toBe('ok'); + }, + }) + .start(); + + // These should be ignored by default + await runner.makeRequest('get', '/favicon.ico'); + await runner.makeRequest('get', '/robots.txt'); + await runner.makeRequest('get', '/assets/app.js'); + + // This one should be traced + await runner.makeRequest('get', '/test'); + + await runner.completed(); + }); + + test('traces static asset requests when ignoreStaticAssets is false', async () => { + const runner = createRunner(__dirname, 'server-traceStaticAssets.js') + .expect({ + transaction: event => { + expect(event.transaction).toBe('GET /favicon.ico'); + expect(event.contexts?.trace?.data?.url).toMatch(/\/favicon.ico$/); + expect(event.contexts?.trace?.op).toBe('http.server'); + expect(event.contexts?.trace?.status).toBe('ok'); + }, + }) + .start(); + + await runner.makeRequest('get', '/favicon.ico'); + + await runner.completed(); + }); }); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 0f2e87e54280..4f1eeeea9e9c 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -3,7 +3,7 @@ import { diag } from '@opentelemetry/api'; import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import type { Span } from '@sentry/core'; -import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core'; +import { defineIntegration, getClient, hasSpansEnabled, stripUrlQueryAndFragment } from '@sentry/core'; import type { HTTPModuleRequestIncomingMessage, NodeClient } from '@sentry/node-core'; import { type SentryHttpInstrumentationOptions, @@ -74,6 +74,14 @@ interface HttpOptions { */ ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * This helps reduce noise in your transactions. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + /** * Do not capture spans for incoming HTTP requests with the given status codes. * By default, spans with 404 status code are ignored. @@ -284,6 +292,11 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume return true; } + // Default static asset filtering + if (options.ignoreStaticAssets !== false && method === 'GET' && urlPath && isStaticAssetRequest(urlPath)) { + return true; + } + const _ignoreIncomingRequests = options.ignoreIncomingRequests; if (urlPath && _ignoreIncomingRequests?.(urlPath, request)) { return true; @@ -316,3 +329,23 @@ function getConfigWithDefaults(options: Partial = {}): HttpInstrume return instrumentationConfig; } + +/** + * Check if a request is for a common static asset that should be ignored by default. + * + * Only exported for tests. + */ +export function isStaticAssetRequest(urlPath: string): boolean { + const path = stripUrlQueryAndFragment(urlPath); + // Common static file extensions + if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) { + return true; + } + + // Common metadata files + if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) { + return true; + } + + return false; +} diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index 89052a348ea4..2a24464c801c 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { _shouldInstrumentSpans } from '../../src/integrations/http'; +import { _shouldInstrumentSpans, isStaticAssetRequest } from '../../src/integrations/http'; import { conditionalTest } from '../helpers/conditional'; describe('httpIntegration', () => { @@ -27,4 +27,34 @@ describe('httpIntegration', () => { expect(actual).toBe(true); }); }); + + describe('isStaticAssetRequest', () => { + it.each([ + ['/favicon.ico', true], + ['/apple-touch-icon.png', true], + ['/static/style.css', true], + ['/assets/app.js', true], + ['/fonts/font.woff2', true], + ['/images/logo.svg', true], + ['/img/photo.jpeg', true], + ['/img/photo.jpg', true], + ['/img/photo.jpg?v=123', true], + ['/img/photo.webp', true], + ['/font/font.ttf', true], + ['/robots.txt', true], + ['/sitemap.xml', true], + ['/manifest.json', true], + ['/browserconfig.xml', true], + // non-static routes + ['/api/users', false], + ['/some-json.json', false], + ['/some-xml.xml', false], + ['/some-txt.txt', false], + ['/users', false], + ['/graphql', false], + ['/', false], + ])('returns %s -> %s', (urlPath, expected) => { + expect(isStaticAssetRequest(urlPath)).toBe(expected); + }); + }); });