diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index c021c959e883..735746fe9ac7 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -81,7 +81,7 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { : url; // `http.client.stream` follows the same name rules as `http.client`. - const domain = getUrlDomain(url, WINDOW.location?.origin); + const domain = getUrlDomain(url, WINDOW.location?.href); const streamedName = domain ? `${method} ${domain}` : method; const streamSpan = startInactiveSpan({ name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index dd5cdb351a3c..31e1b831218e 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -155,7 +155,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { beforeEach(() => { vi.clearAllMocks(); - vi.stubGlobal('location', { origin: 'https://app.example.com' }); + vi.stubGlobal('location', { origin: 'https://app.example.com', href: 'https://app.example.com/' }); }); afterEach(() => { @@ -48,16 +49,27 @@ describe('fetchStreamPerformanceIntegration', () => { expect(trackStreamedFetch('stream', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( expect.objectContaining({ name: 'GET api.example.com', - attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + attributes: expect.objectContaining({ [URL_DOMAIN]: 'api.example.com' }), }), ); }); - it('resolves a relative URL against the page origin', () => { + it('resolves a relative URL against the page URL', () => { expect(trackStreamedFetch('stream', '/v1/chat')).toHaveBeenCalledWith( expect.objectContaining({ name: 'GET app.example.com', - attributes: expect.objectContaining({ 'url.domain': 'app.example.com' }), + attributes: expect.objectContaining({ [URL_DOMAIN]: 'app.example.com' }), + }), + ); + }); + + it('keeps the domain of an absolute URL on a page with an opaque origin', () => { + vi.stubGlobal('location', { origin: 'null', href: 'about:srcdoc' }); + + expect(trackStreamedFetch('stream', 'https://api.example.com/v1/chat')).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET api.example.com', + attributes: expect.objectContaining({ [URL_DOMAIN]: 'api.example.com' }), }), ); }); @@ -66,7 +78,7 @@ describe('fetchStreamPerformanceIntegration', () => { expect(trackStreamedFetch('stream', 'data:text/event-stream,data: hi')).toHaveBeenCalledWith( expect.objectContaining({ name: 'GET', - attributes: expect.objectContaining({ 'url.domain': undefined }), + attributes: expect.objectContaining({ [URL_DOMAIN]: undefined }), }), ); }); @@ -75,7 +87,7 @@ describe('fetchStreamPerformanceIntegration', () => { expect(trackStreamedFetch('static', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( expect.objectContaining({ name: 'GET https://api.example.com/v1/chat', - attributes: expect.objectContaining({ 'url.domain': 'api.example.com' }), + attributes: expect.objectContaining({ [URL_DOMAIN]: 'api.example.com' }), }), ); }); diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 6db5493e742a..e0c8fe8f0947 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -2,7 +2,7 @@ import type { Client } from '@sentry/core'; import * as utils from '@sentry/core'; import * as coreBrowser from '@sentry/core/browser'; import * as browserUtils from '@sentry/browser-utils'; -import { HTTP_REQUEST_METHOD } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; import type { MockInstance } from 'vitest'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../../src/client'; @@ -223,12 +223,12 @@ describe('instrumentOutgoingRequests', () => { const requestSpanJson = utils.spanToJSON(requestSpan!); expect(requestSpanJson.name).toBe('GET example.com'); - expect(requestSpanJson.attributes['url.domain']).toBe('example.com'); - expect(requestSpanJson.attributes['server.address']).toBe('example.com:8443'); + expect(requestSpanJson.attributes[URL_DOMAIN]).toBe('example.com'); + expect(requestSpanJson.attributes[SERVER_ADDRESS]).toBe('example.com:8443'); }); - it('resolves a relative fetch URL against the page origin for the streamed span name', () => { - vi.stubGlobal('location', { origin: 'https://app.example.com' }); + it('resolves a relative fetch URL against the page URL for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com', href: 'https://app.example.com/' }); let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; let requestSpan: utils.Span | undefined; @@ -255,13 +255,13 @@ describe('instrumentOutgoingRequests', () => { const requestSpanJson = utils.spanToJSON(requestSpan!); expect(requestSpanJson.name).toBe('GET app.example.com'); - expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + expect(requestSpanJson.attributes[URL_DOMAIN]).toBe('app.example.com'); vi.unstubAllGlobals(); }); - it('resolves a relative XHR URL against the page origin for the streamed span name', () => { - vi.stubGlobal('location', { origin: 'https://app.example.com' }); + it('resolves a relative XHR URL against the page URL for the streamed span name', () => { + vi.stubGlobal('location', { origin: 'https://app.example.com', href: 'https://app.example.com/' }); let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; let requestSpan: utils.Span | undefined; @@ -294,11 +294,94 @@ describe('instrumentOutgoingRequests', () => { const requestSpanJson = utils.spanToJSON(requestSpan!); expect(requestSpanJson.name).toBe('GET app.example.com'); - expect(requestSpanJson.attributes['url.domain']).toBe('app.example.com'); + expect(requestSpanJson.attributes[URL_DOMAIN]).toBe('app.example.com'); vi.unstubAllGlobals(); }); + describe('resolving request URLs against location.href', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + { + name: 'an absolute URL on a page with an opaque origin', + location: { origin: 'null', href: 'about:srcdoc' }, + requestUrl: 'https://api.example.org/v1/users', + expected: 'https://api.example.org/v1/users', + }, + { + name: 'a relative path without a leading slash', + location: { origin: 'https://app.example.com', href: 'https://app.example.com/dashboard/settings' }, + requestUrl: 'users', + expected: 'https://app.example.com/dashboard/users', + }, + ])('sets url.full on XHR spans for $name', ({ location, requestUrl, expected }) => { + vi.stubGlobal('location', location); + + let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceFetch: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + xhrHandler?.({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { + method: 'GET', + url: requestUrl, + request_headers: {}, + }, + setRequestHeader: vi.fn(), + }, + startTimestamp: Date.now(), + } as utils.HandlerDataXhr); + + expect(utils.spanToJSON(requestSpan!).attributes[URL_FULL]).toBe(expected); + }); + + it('sets url.domain on fetch spans for an absolute URL on a page with an opaque origin', () => { + vi.stubGlobal('location', { origin: 'null', href: 'about:srcdoc' }); + + let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; + let requestSpan: utils.Span | undefined; + + vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + const tracingClient = new BrowserClient(getDefaultBrowserClientOptions({ tracesSampleRate: 1 })); + utils.setCurrentClient(tracingClient); + utils._INTERNAL_setSpanForScope(utils.getCurrentScope(), new utils.SentrySpan({ sampled: true })); + + instrumentOutgoingRequests(tracingClient, { + traceXHR: false, + enableHTTPTimings: false, + onRequestSpanStart: span => { + requestSpan = span; + }, + }); + fetchHandler?.({ + fetchData: { method: 'GET', url: 'https://api.example.org/v1/users' }, + args: ['https://api.example.org/v1/users'], + startTimestamp: Date.now(), + }); + + expect(utils.spanToJSON(requestSpan!).attributes[URL_DOMAIN]).toBe('api.example.org'); + }); + }); + it('falls back to the request method for a data URL, which has no domain', () => { let fetchHandler: ((data: utils.HandlerDataFetch) => void) | undefined; let requestSpan: utils.Span | undefined; @@ -325,7 +408,7 @@ describe('instrumentOutgoingRequests', () => { const requestSpanJson = utils.spanToJSON(requestSpan!); expect(requestSpanJson.name).toBe('GET'); - expect(requestSpanJson.attributes['url.domain']).toBeUndefined(); + expect(requestSpanJson.attributes[URL_DOMAIN]).toBeUndefined(); }); describe('XHR trace header span', () => { diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 76ef3892bea1..314aeac57e71 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -52,7 +52,7 @@ interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; - /** Base URL for relative request URLs. Browsers pass the page origin; server runtimes have none. */ + /** Base URL for relative request URLs. Browsers pass the page URL; server runtimes have none. */ urlBase?: string; }