diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js new file mode 100644 index 000000000000..de6b87574482 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js new file mode 100644 index 000000000000..37441bf4463a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js @@ -0,0 +1,19 @@ +function withRootSpan(cb) { + return Sentry.startSpan({ name: 'rootSpan' }, cb); +} + +document.getElementById('btnQuery').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/0?id=123;page=5')); +}); + +document.getElementById('btnFragment').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/1#fragment')); +}); + +document.getElementById('btnQueryFragment').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/2?id=1#fragment')); +}); + +document.getElementById('btnQueryFragmentSameOrigin').addEventListener('click', async () => { + await withRootSpan(() => fetch('/api/users?id=1#fragment')); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html new file mode 100644 index 000000000000..d02fa0868f56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts new file mode 100644 index 000000000000..a0fea6e6af29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts @@ -0,0 +1,176 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('strips query params in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQuery').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/0', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/0?id=123;page=5', + 'http.query': '?id=123;page=5', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/0?id=123;page=5', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.fragment'); +}); + +sentryTest('strips hash fragment in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/1', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/1#fragment', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/1#fragment', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.query'); +}); + +sentryTest('strips hash fragment and query params in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/2', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/2?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/2?id=1#fragment', + }), + }); +}); + +sentryTest( + 'strips hash fragment and query params in same-origin fetch request spans', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('**/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragmentSameOrigin').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET /api/users', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test.io/api/users?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test.io', + url: '/api/users?id=1#fragment', + }), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js new file mode 100644 index 000000000000..de6b87574482 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js new file mode 100644 index 000000000000..e27c6d3cf013 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js @@ -0,0 +1,29 @@ +function withRootSpan(cb) { + return Sentry.startSpan({ name: 'rootSpan' }, cb); +} + +function makeXHRRequest(url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(xhr.statusText); + xhr.send(); + }); +} + +document.getElementById('btnQuery').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/0?id=123;page=5')); +}); + +document.getElementById('btnFragment').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/1#fragment')); +}); + +document.getElementById('btnQueryFragment').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/2?id=1#fragment')); +}); + +document.getElementById('btnQueryFragmentSameOrigin').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('/api/users?id=1#fragment')); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html new file mode 100644 index 000000000000..533636f821c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts new file mode 100644 index 000000000000..d4ed06fcdd4e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts @@ -0,0 +1,172 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('strips query params in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQuery').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/0', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/0?id=123;page=5', + 'http.query': '?id=123;page=5', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/0?id=123;page=5', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.fragment'); +}); + +sentryTest('strips hash fragment in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/1', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/1#fragment', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/1#fragment', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.query'); +}); + +sentryTest('strips hash fragment and query params in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/2', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/2?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/2?id=1#fragment', + }), + }); +}); + +sentryTest( + 'strips hash fragment and query params in same-origin XHR request spans', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('**/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragmentSameOrigin').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET /api/users', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test.io/api/users?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test.io', + url: '/api/users?id=1#fragment', + }), + }); + }, +); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e123e852f4b9..9d89dea67940 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -20,6 +20,7 @@ "fix": "eslint . --format stylish --fix", "type-check": "tsc", "test": "jest --config ./jest.config.js", + "test:no-prisma": "jest --config ./jest.config.js", "test:watch": "yarn test --watch" }, "dependencies": { diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts new file mode 100644 index 000000000000..44ea548bab8f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts @@ -0,0 +1,15 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + await fetch(`${process.env.SERVER_URL}/api/v1`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts new file mode 100644 index 000000000000..006190864fe6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts @@ -0,0 +1,48 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('captures spans for outgoing fetch requests', done => { + expect.assertions(3); + + createTestServer(done) + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .get( + '/api/v1', + () => { + // Just ensure we're called + expect(true).toBe(true); + }, + 404, + ) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + transaction: 'test_transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v0/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + }), + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v1/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'not_found', + data: expect.objectContaining({ + 'http.response.status_code': 404, + }), + }), + ]), + }, + }) + .start(closeTestServer); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts new file mode 100644 index 000000000000..0c72d545c39b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts @@ -0,0 +1,14 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0/users?id=1#fragment`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts new file mode 100644 index 000000000000..12bb11727228 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts @@ -0,0 +1,53 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('strips and handles query params in spans of outgoing fetch requests', done => { + expect.assertions(4); + + createTestServer(done) + .get('/api/v0/users', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toEqual('test_transaction'); + expect(txn.spans).toHaveLength(1); + expect(txn.spans?.[0]).toMatchObject({ + data: { + url: `${SERVER_URL}/api/v0/users`, + 'url.full': `${SERVER_URL}/api/v0/users?id=1`, + 'url.path': '/api/v0/users', + 'url.query': '?id=1', + 'url.scheme': 'http', + 'http.query': 'id=1', + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', + 'http.response.header.content-length': 0, + 'http.response.status_code': 200, + 'network.peer.address': '::1', + 'network.peer.port': expect.any(Number), + 'otel.kind': 'CLIENT', + 'server.port': expect.any(Number), + 'user_agent.original': 'node', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'server.address': 'localhost', + }, + description: `GET ${SERVER_URL}/api/v0/users`, + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + parent_span_id: txn.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: txn.contexts?.trace?.trace_id, + }); + }, + }) + .start(closeTestServer); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/spans/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/spans/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts similarity index 87% rename from dev-packages/node-integration-tests/suites/tracing/spans/test.ts rename to dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index e349622d39f8..bb642baf0e1c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -1,7 +1,7 @@ -import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; -test('should capture spans for outgoing http requests', done => { +test('captures spans for outgoing http requests', done => { expect.assertions(3); createTestServer(done) diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts new file mode 100644 index 000000000000..074c9778aa75 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts @@ -0,0 +1,31 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import * as http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0/users?id=1#fragment`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts new file mode 100644 index 000000000000..37b638635eb9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -0,0 +1,53 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('strips and handles query params in spans of outgoing http requests', done => { + expect.assertions(4); + + createTestServer(done) + .get('/api/v0/users', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toEqual('test_transaction'); + expect(txn.spans).toHaveLength(1); + expect(txn.spans?.[0]).toMatchObject({ + data: { + url: `${SERVER_URL}/api/v0/users`, + 'http.url': `${SERVER_URL}/api/v0/users?id=1`, + 'http.target': '/api/v0/users?id=1', + 'http.flavor': '1.1', + 'http.host': expect.stringMatching(/localhost:\d+$/), + 'http.method': 'GET', + 'http.query': 'id=1', + 'http.response.status_code': 200, + 'http.response_content_length_uncompressed': 0, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'net.peer.ip': '::1', + 'net.peer.name': 'localhost', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'CLIENT', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + }, + description: `GET ${SERVER_URL}/api/v0/users`, + op: 'http.client', + origin: 'auto.http.otel.http', + status: 'ok', + parent_span_id: txn.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: txn.contexts?.trace?.trace_id, + }); + }, + }) + .start(closeTestServer); + }); +}); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 144aec73c977..17dd71f0abba 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -24,6 +24,7 @@ import { spanToJSON, startInactiveSpan, stringMatchesSomePattern, + stripUrlQueryAndFragment, } from '@sentry/core'; import { WINDOW } from '../helpers'; @@ -324,7 +325,9 @@ export function xhrCallback( return undefined; } - const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(sentryXhrData.url); + const { url, method } = sentryXhrData; + + const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url); // check first if the request has finished and is tracked by an existing span which should now end if (handlerData.endTimestamp && shouldCreateSpanResult) { @@ -342,23 +345,27 @@ export function xhrCallback( return undefined; } - const fullUrl = getFullURL(sentryXhrData.url); - const host = fullUrl ? parseUrl(fullUrl).host : undefined; + const fullUrl = getFullURL(url); + const parsedUrl = fullUrl ? parseUrl(fullUrl) : parseUrl(url); + + const urlForSpanName = stripUrlQueryAndFragment(url); const hasParent = !!getActiveSpan(); const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ - name: `${sentryXhrData.method} ${sentryXhrData.url}`, + name: `${method} ${urlForSpanName}`, attributes: { + url, type: 'xhr', - 'http.method': sentryXhrData.method, + 'http.method': method, 'http.url': fullUrl, - url: sentryXhrData.url, - 'server.address': host, + 'server.address': parsedUrl?.host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + ...(parsedUrl?.search && { 'http.query': parsedUrl?.search }), + ...(parsedUrl?.hash && { 'http.fragment': parsedUrl?.hash }), }, }) : new SentryNonRecordingSpan(); @@ -366,7 +373,7 @@ export function xhrCallback( xhr.__sentry_xhr_span_id__ = span.spanContext().spanId; spans[xhr.__sentry_xhr_span_id__] = span; - if (shouldAttachHeaders(sentryXhrData.url)) { + if (shouldAttachHeaders(url)) { addTracingHeadersToXhrRequest( xhr, // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index dd1f738a334b..e448402c0479 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -41,17 +41,26 @@ describe('Bun Serve Integration', () => { }, port, }); - await fetch(`http://localhost:${port}/`); + await fetch(`http://localhost:${port}/users?id=123`); server.stop(); if (!generatedSpan) { throw 'No span was generated in the test'; } - expect(spanToJSON(generatedSpan).status).toBe('ok'); - expect(spanToJSON(generatedSpan).data?.['http.response.status_code']).toEqual(200); - expect(spanToJSON(generatedSpan).op).toEqual('http.server'); - expect(spanToJSON(generatedSpan).description).toEqual('GET /'); + const spanJson = spanToJSON(generatedSpan); + expect(spanJson.status).toBe('ok'); + expect(spanJson.op).toEqual('http.server'); + expect(spanJson.description).toEqual('GET /users'); + expect(spanJson.data).toEqual({ + 'http.query': '?id=123', + 'http.request.method': 'GET', + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.bun.serve', + 'sentry.sample_rate': 1, + 'sentry.source': 'url', + }); }); test('generates a post transaction', async () => { diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 3c43584b3951..a96d421d0023 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -5,7 +5,7 @@ import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; import { isInstanceOf } from './utils-hoist/is'; -import { parseUrl } from './utils-hoist/url'; +import { parseUrl, stripUrlQueryAndFragment } from './utils-hoist/url'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; import { getActiveSpan } from './utils/spanUtils'; import { getTraceData } from './utils/traceData'; @@ -35,7 +35,9 @@ export function instrumentFetchRequest( return undefined; } - const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(handlerData.fetchData.url); + const { method, url } = handlerData.fetchData; + + const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url); if (handlerData.endTimestamp && shouldCreateSpanResult) { const spanId = handlerData.fetchData.__span; @@ -51,25 +53,25 @@ export function instrumentFetchRequest( return undefined; } - const { method, url } = handlerData.fetchData; - const fullUrl = getFullURL(url); - const host = fullUrl ? parseUrl(fullUrl).host : undefined; + const parsedUrl = fullUrl ? parseUrl(fullUrl) : parseUrl(url); const hasParent = !!getActiveSpan(); const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ - name: `${method} ${url}`, + name: `${method} ${stripUrlQueryAndFragment(url)}`, attributes: { url, type: 'fetch', 'http.method': method, 'http.url': fullUrl, - 'server.address': host, + 'server.address': parsedUrl?.host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + ...(parsedUrl?.search && { 'http.query': parsedUrl?.search }), + ...(parsedUrl?.hash && { 'http.fragment': parsedUrl?.hash }), }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/test/utils-hoist/url.test.ts b/packages/core/test/utils-hoist/url.test.ts index cd066201945d..a16c72dc1cd2 100644 --- a/packages/core/test/utils-hoist/url.test.ts +++ b/packages/core/test/utils-hoist/url.test.ts @@ -72,3 +72,126 @@ describe('getSanitizedUrlString', () => { expect(getSanitizedUrlString(urlObject)).toEqual(sanitizedURL); }); }); + +describe('parseUrl', () => { + it.each([ + [ + 'https://somedomain.com', + { host: 'somedomain.com', path: '', search: '', hash: '', protocol: 'https', relative: '' }, + ], + [ + 'https://somedomain.com/path/to/happiness', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '', + protocol: 'https', + relative: '/path/to/happiness', + }, + ], + [ + 'https://somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '', + protocol: 'https', + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar', + }, + ], + [ + 'https://somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '#wildfragment', + protocol: 'https', + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + }, + ], + [ + 'https://somedomain.com/path/to/happiness#somewildfragment123', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '#somewildfragment123', + protocol: 'https', + relative: '/path/to/happiness#somewildfragment123', + }, + ], + [ + 'https://somedomain.com/path/to/happiness#somewildfragment123?auhtToken=abc123¶m2=bar', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '#somewildfragment123?auhtToken=abc123¶m2=bar', + protocol: 'https', + relative: '/path/to/happiness#somewildfragment123?auhtToken=abc123¶m2=bar', + }, + ], + [ + // yup, this is a valid URL (protocol-agnostic URL) + '//somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '#wildfragment', + protocol: undefined, + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + }, + ], + ['', {}], + [ + '\n', + { + hash: '', + host: undefined, + path: '\n', + protocol: undefined, + relative: '\n', + search: '', + }, + ], + [ + 'somerandomString', + { + hash: '', + host: undefined, + path: 'somerandomString', + protocol: undefined, + relative: 'somerandomString', + search: '', + }, + ], + [ + 'somedomain.com', + { + host: undefined, + path: 'somedomain.com', + search: '', + hash: '', + protocol: undefined, + relative: 'somedomain.com', + }, + ], + [ + 'somedomain.com/path/?q=1#fragment', + { + host: undefined, + path: 'somedomain.com/path/', + search: '?q=1', + hash: '#fragment', + protocol: undefined, + relative: 'somedomain.com/path/?q=1#fragment', + }, + ], + ])('returns parsed partial URL object for %s', (url: string, expected: any) => { + expect(parseUrl(url)).toEqual(expected); + }); +});