diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 8e2f3de06df0..f1905609fb94 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -1,17 +1,13 @@ import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; -import type { SpanAttributes } from '@sentry/core'; import { captureException, continueTrace, flush, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + getHttpSpanDetailsFromUrlObject, + parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_URL_FULL, setHttpStatus, startSpan, - stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; @@ -42,28 +38,15 @@ export function wrapRequestHandler( const client = init(options); isolationScope.setClient(client); - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method, - [SEMANTIC_ATTRIBUTE_URL_FULL]: request.url, - }; + const urlObject = parseStringToURLObject(request.url); + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'auto.http.cloudflare', request); const contentLength = request.headers.get('content-length'); if (contentLength) { attributes['http.request.body.size'] = parseInt(contentLength, 10); } - let pathname = ''; - try { - const url = new URL(request.url); - pathname = url.pathname; - attributes['server.address'] = url.hostname; - attributes['url.scheme'] = url.protocol.replace(':', ''); - } catch { - // skip - } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; addCloudResourceContext(isolationScope); if (request) { @@ -74,8 +57,6 @@ export function wrapRequestHandler( } } - const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`; - // Do not capture spans for OPTIONS and HEAD requests if (request.method === 'OPTIONS' || request.method === 'HEAD') { try { @@ -96,7 +77,7 @@ export function wrapRequestHandler( // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ return startSpan( { - name: routeName, + name, attributes, }, async span => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a778b60befeb..4fc9b308ec54 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -254,12 +254,13 @@ describe('withSentry', () => { data: { 'sentry.origin': 'auto.http.cloudflare', 'sentry.op': 'http.server', - 'sentry.source': 'url', + 'sentry.source': 'route', 'http.request.method': 'GET', 'url.full': 'https://example.com/', 'server.address': 'example.com', 'network.protocol.name': 'HTTP/1.1', - 'url.scheme': 'https', + 'url.scheme': 'https:', + 'url.path': '/', 'sentry.sample_rate': 1, 'http.response.status_code': 200, 'http.request.body.size': 10, @@ -269,6 +270,8 @@ describe('withSentry', () => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), + parent_span_id: undefined, + links: undefined, }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c35ea212b94..6d281fde0ac9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -255,6 +255,7 @@ export { parseUrl, stripUrlQueryAndFragment, parseStringToURLObject, + getHttpSpanDetailsFromUrlObject, isURLObjectRelative, getSanitizedUrlStringFromUrlObject, } from './utils-hoist/url'; diff --git a/packages/core/src/utils-hoist/url.ts b/packages/core/src/utils-hoist/url.ts index 7a7893a36b68..ca09e6e8b5e7 100644 --- a/packages/core/src/utils-hoist/url.ts +++ b/packages/core/src/utils-hoist/url.ts @@ -1,3 +1,11 @@ +import { + SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_URL_FULL, +} from '../semanticAttributes'; +import type { SpanAttributes } from '../types-hoist/span'; + type PartialURL = { host?: string; path?: string; @@ -53,7 +61,7 @@ export function isURLObjectRelative(url: URLObject): url is RelativeURL { * @returns The parsed URL object or undefined if the URL is invalid */ export function parseStringToURLObject(url: string, urlBase?: string | URL | undefined): URLObject | undefined { - const isRelative = url.startsWith('/'); + const isRelative = url.indexOf('://') <= 0 && url.indexOf('//') !== 0; const base = urlBase ?? (isRelative ? DEFAULT_BASE_URL : undefined); try { // Use `canParse` to short-circuit the URL constructor if it's not a valid URL @@ -107,6 +115,95 @@ export function getSanitizedUrlStringFromUrlObject(url: URLObject): string { return newUrl.toString(); } +type PartialRequest = { + method?: string; +}; + +function getHttpSpanNameFromUrlObject( + urlObject: URLObject | undefined, + kind: 'server' | 'client', + request?: PartialRequest, + routeName?: string, +): string { + const method = request?.method?.toUpperCase() ?? 'GET'; + const route = routeName + ? routeName + : urlObject + ? kind === 'client' + ? getSanitizedUrlStringFromUrlObject(urlObject) + : urlObject.pathname + : '/'; + + return `${method} ${route}`; +} + +/** + * Takes a parsed URL object and returns a set of attributes for the span + * that represents the HTTP request for that url. This is used for both server + * and client http spans. + * + * Follows https://opentelemetry.io/docs/specs/semconv/http/. + * + * @param urlObject - see {@link parseStringToURLObject} + * @param kind - The type of HTTP operation (server or client) + * @param spanOrigin - The origin of the span + * @param request - The request object, see {@link PartialRequest} + * @param routeName - The name of the route, must be low cardinality + * @returns The span name and attributes for the HTTP operation + */ +export function getHttpSpanDetailsFromUrlObject( + urlObject: URLObject | undefined, + kind: 'server' | 'client', + spanOrigin: string, + request?: PartialRequest, + routeName?: string, +): [name: string, attributes: SpanAttributes] { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + + if (routeName) { + // This is based on https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name + attributes[kind === 'server' ? 'http.route' : 'url.template'] = routeName; + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + } + + if (request?.method) { + attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] = request.method.toUpperCase(); + } + + if (urlObject) { + if (urlObject.search) { + attributes['url.query'] = urlObject.search; + } + if (urlObject.hash) { + attributes['url.fragment'] = urlObject.hash; + } + if (urlObject.pathname) { + attributes['url.path'] = urlObject.pathname; + if (urlObject.pathname === '/') { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + } + } + + if (!isURLObjectRelative(urlObject)) { + attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = urlObject.href; + if (urlObject.port) { + attributes['url.port'] = urlObject.port; + } + if (urlObject.protocol) { + attributes['url.scheme'] = urlObject.protocol; + } + if (urlObject.hostname) { + attributes[kind === 'server' ? 'server.address' : 'url.domain'] = urlObject.hostname; + } + } + } + + return [getHttpSpanNameFromUrlObject(urlObject, kind, request, routeName), attributes]; +} + /** * Parses string form of URL into an object * // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B diff --git a/packages/core/test/utils-hoist/url.test.ts b/packages/core/test/utils-hoist/url.test.ts index 7f1d1dae8b40..67ec8b31644f 100644 --- a/packages/core/test/utils-hoist/url.test.ts +++ b/packages/core/test/utils-hoist/url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getHttpSpanDetailsFromUrlObject, getSanitizedUrlString, getSanitizedUrlStringFromUrlObject, isURLObjectRelative, @@ -203,28 +204,86 @@ describe('parseUrl', () => { }); describe('parseStringToURLObject', () => { - it('returns undefined for invalid URLs', () => { - expect(parseStringToURLObject('invalid-url')).toBeUndefined(); - }); - - it('returns a URL object for valid URLs', () => { - expect(parseStringToURLObject('https://somedomain.com')).toBeInstanceOf(URL); - }); - - it('returns a URL object for valid URLs with a base URL', () => { - expect(parseStringToURLObject('https://somedomain.com', 'https://base.com')).toBeInstanceOf(URL); - }); - - it('returns a relative URL object for relative URLs', () => { - expect(parseStringToURLObject('/path/to/happiness')).toEqual({ - isRelative: true, - pathname: '/path/to/happiness', - search: '', - hash: '', - }); + it.each([ + [ + 'invalid URL', + 'invalid-url', + { + isRelative: true, + pathname: '/invalid-url', + search: '', + hash: '', + }, + ], + ['valid absolute URL', 'https://somedomain.com', expect.any(URL)], + ['valid absolute URL with base', 'https://somedomain.com', expect.any(URL), 'https://base.com'], + [ + 'relative URL', + '/path/to/happiness', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '', + hash: '', + }, + ], + [ + 'relative URL with query', + '/path/to/happiness?q=1', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '?q=1', + hash: '', + }, + ], + [ + 'relative URL with hash', + '/path/to/happiness#section', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '', + hash: '#section', + }, + ], + [ + 'relative URL with query and hash', + '/path/to/happiness?q=1#section', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '?q=1', + hash: '#section', + }, + ], + ['URL with port', 'https://somedomain.com:8080/path', expect.any(URL)], + ['URL with auth', 'https://user:pass@somedomain.com', expect.any(URL)], + ['URL with special chars', 'https://somedomain.com/path/with spaces/and/special@chars', expect.any(URL)], + ['URL with unicode', 'https://somedomain.com/path/with/unicode/测试', expect.any(URL)], + ['URL with multiple query params', 'https://somedomain.com/path?q1=1&q2=2&q3=3', expect.any(URL)], + ['URL with encoded chars', 'https://somedomain.com/path/%20%2F%3F%23', expect.any(URL)], + ['URL with IPv4', 'https://192.168.1.1/path', expect.any(URL)], + ['URL with IPv6', 'https://[2001:db8::1]/path', expect.any(URL)], + ['URL with subdomain', 'https://sub.somedomain.com/path', expect.any(URL)], + ['URL with multiple subdomains', 'https://sub1.sub2.somedomain.com/path', expect.any(URL)], + ['URL with trailing slash', 'https://somedomain.com/path/', expect.any(URL)], + ['URL with empty path', 'https://somedomain.com', expect.any(URL)], + ['URL with root path', 'https://somedomain.com/', expect.any(URL)], + ['URL with file extension', 'https://somedomain.com/path/file.html', expect.any(URL)], + ['URL with custom protocol', 'custom://somedomain.com/path', expect.any(URL)], + ['URL with query containing special chars', 'https://somedomain.com/path?q=hello+world&x=1/2', expect.any(URL)], + ['URL with hash containing special chars', 'https://somedomain.com/path#section/1/2', expect.any(URL)], + [ + 'URL with all components', + 'https://user:pass@sub.somedomain.com:8080/path/file.html?q=1#section', + expect.any(URL), + ], + ])('handles %s', (_, url: string, expected: any, base?: string) => { + expect(parseStringToURLObject(url, base)).toEqual(expected); }); - it('does not throw an error if URl.canParse is not defined', () => { + it('does not throw an error if URL.canParse is not defined', () => { const canParse = (URL as any).canParse; delete (URL as any).canParse; expect(parseStringToURLObject('https://somedomain.com')).toBeInstanceOf(URL); @@ -286,6 +345,48 @@ describe('getSanitizedUrlStringFromUrlObject', () => { ['url with port 4433', 'http://172.31.12.144:4433/test', 'http://172.31.12.144:4433/test'], ['url with port 443', 'http://172.31.12.144:443/test', 'http://172.31.12.144/test'], ['url with IP and port 80', 'http://172.31.12.144:80/test', 'http://172.31.12.144/test'], + ['invalid URL', 'invalid-url', '/invalid-url'], + ['valid absolute URL with base', 'https://somedomain.com', 'https://somedomain.com/'], + ['relative URL', '/path/to/happiness', '/path/to/happiness'], + ['relative URL with query', '/path/to/happiness?q=1', '/path/to/happiness'], + ['relative URL with hash', '/path/to/happiness#section', '/path/to/happiness'], + ['relative URL with query and hash', '/path/to/happiness?q=1#section', '/path/to/happiness'], + [ + 'URL with special chars', + 'https://somedomain.com/path/with spaces/and/special@chars', + 'https://somedomain.com/path/with%20spaces/and/special@chars', + ], + [ + 'URL with unicode', + 'https://somedomain.com/path/with/unicode/测试', + 'https://somedomain.com/path/with/unicode/%E6%B5%8B%E8%AF%95', + ], + ['URL with multiple query params', 'https://somedomain.com/path?q1=1&q2=2&q3=3', 'https://somedomain.com/path'], + ['URL with encoded chars', 'https://somedomain.com/path/%20%2F%3F%23', 'https://somedomain.com/path/%20%2F%3F%23'], + ['URL with IPv4', 'https://192.168.1.1/path', 'https://192.168.1.1/path'], + ['URL with IPv6', 'https://[2001:db8::1]/path', 'https://[2001:db8::1]/path'], + ['URL with subdomain', 'https://sub.somedomain.com/path', 'https://sub.somedomain.com/path'], + ['URL with multiple subdomains', 'https://sub1.sub2.somedomain.com/path', 'https://sub1.sub2.somedomain.com/path'], + ['URL with trailing slash', 'https://somedomain.com/path/', 'https://somedomain.com/path/'], + ['URL with empty path', 'https://somedomain.com', 'https://somedomain.com/'], + ['URL with root path', 'https://somedomain.com/', 'https://somedomain.com/'], + ['URL with file extension', 'https://somedomain.com/path/file.html', 'https://somedomain.com/path/file.html'], + ['URL with custom protocol', 'custom://somedomain.com/path', 'custom://somedomain.com/path'], + [ + 'URL with query containing special chars', + 'https://somedomain.com/path?q=hello+world&x=1/2', + 'https://somedomain.com/path', + ], + [ + 'URL with hash containing special chars', + 'https://somedomain.com/path#section/1/2', + 'https://somedomain.com/path', + ], + [ + 'URL with all components', + 'https://user:pass@sub.somedomain.com:8080/path/file.html?q=1#section', + 'https://%filtered%:%filtered%@sub.somedomain.com:8080/path/file.html', + ], ])('returns a sanitized URL for a %s', (_, rawUrl: string, sanitizedURL: string) => { const urlObject = parseStringToURLObject(rawUrl); if (!urlObject) { @@ -294,3 +395,246 @@ describe('getSanitizedUrlStringFromUrlObject', () => { expect(getSanitizedUrlStringFromUrlObject(urlObject)).toEqual(sanitizedURL); }); }); + +describe('getHttpSpanDetailsFromUrlObject', () => { + it('handles undefined URL object', () => { + const [name, attributes] = getHttpSpanDetailsFromUrlObject(undefined, 'server', 'test-origin'); + expect(name).toBe('GET /'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + }); + }); + + it('handles relative URL object', () => { + const urlObject = parseStringToURLObject('/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + }); + }); + + it('handles absolute URL object', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users?q=test#section')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.query': '?q=test', + 'url.fragment': '#section', + 'url.full': 'https://example.com/api/users?q=test#section', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL object with request method', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin', { method: 'POST' }); + expect(name).toBe('POST /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.request.method': 'POST', + }); + }); + + it('handles URL object with route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/users/:id', + ); + expect(name).toBe('GET /api/users/:id'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/users', + 'url.full': 'https://example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/users/:id', + }); + }); + + it('handles root path URL', () => { + const urlObject = parseStringToURLObject('https://example.com/')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/', + 'url.full': 'https://example.com/', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL with port', () => { + const urlObject = parseStringToURLObject('https://example.com:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com:8080/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with non-standard port and request method', () => { + const urlObject = parseStringToURLObject('https://example.com:3000/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin', { method: 'PUT' }); + expect(name).toBe('PUT /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com:3000/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'url.port': '3000', + 'http.request.method': 'PUT', + }); + }); + + it('handles URL with route name and request method', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users/123')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + { method: 'PATCH' }, + '/api/users/:id', + ); + expect(name).toBe('PATCH /api/users/:id'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/users/123', + 'url.full': 'https://example.com/api/users/123', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/users/:id', + 'http.request.method': 'PATCH', + }); + }); + + it('handles URL with query params and route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/search?q=test&page=1')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/search', + ); + expect(name).toBe('GET /api/search'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/search', + 'url.query': '?q=test&page=1', + 'url.full': 'https://example.com/api/search?q=test&page=1', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/search', + }); + }); + + it('handles URL with fragment and route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/docs#section-1')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/docs', + ); + expect(name).toBe('GET /api/docs'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/docs', + 'url.fragment': '#section-1', + 'url.full': 'https://example.com/api/docs#section-1', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/docs', + }); + }); + + it('handles URL with auth credentials', () => { + const urlObject = parseStringToURLObject('https://user:pass@example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://user:pass@example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL with IPv4 address', () => { + const urlObject = parseStringToURLObject('https://192.168.1.1:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://192.168.1.1:8080/api/users', + 'server.address': '192.168.1.1', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with IPv6 address', () => { + const urlObject = parseStringToURLObject('https://[2001:db8::1]:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://[2001:db8::1]:8080/api/users', + 'server.address': '[2001:db8::1]', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with subdomain', () => { + const urlObject = parseStringToURLObject('https://api.example.com/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/users', + 'url.full': 'https://api.example.com/users', + 'server.address': 'api.example.com', + 'url.scheme': 'https:', + }); + }); +});