Skip to content

Commit 01a1f3d

Browse files
Add safeParseUrl utility and update URL parsing logic
1 parent 83badb1 commit 01a1f3d

File tree

2 files changed

+57
-48
lines changed

2 files changed

+57
-48
lines changed

sdk/highlight-run/src/client/otel/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,23 @@ export const shutdown = async () => {
481481
])
482482
}
483483

484+
/**
485+
* Safely parses a URL, handling both absolute and relative URLs.
486+
* For relative URLs, resolves against window.location.origin (browser)
487+
* or a placeholder base (non-browser environments).
488+
*/
489+
export const safeParseUrl = (url: string): URL => {
490+
try {
491+
return new URL(url)
492+
} catch {
493+
if (typeof window !== 'undefined') {
494+
return new URL(url, window.location.origin)
495+
}
496+
497+
return new URL(url, 'http://localhost')
498+
}
499+
}
500+
484501
const enhanceSpanWithHttpRequestAttributes = (
485502
span: api.Span,
486503
body: Request['body'] | RequestInit['body'] | BrowserXHR['_body'],
@@ -496,7 +513,7 @@ const enhanceSpanWithHttpRequestAttributes = (
496513
const readableSpan = span as unknown as ReadableSpan
497514
const url = getUrlFromSpan(readableSpan)
498515
const sanitizedUrl = sanitizeUrl(url)
499-
const sanitizedUrlObject = new URL(sanitizedUrl)
516+
const sanitizedUrlObject = safeParseUrl(sanitizedUrl)
500517

501518
const stringBody = typeof body === 'string' ? body : String(body)
502519
try {
@@ -520,10 +537,13 @@ const enhanceSpanWithHttpRequestAttributes = (
520537
})
521538

522539
// Set sanitized query params as JSON object for easier querying
523-
if (sanitizedUrlObject.searchParams.size > 0) {
540+
const searchParamsEntries = Array.from(
541+
sanitizedUrlObject.searchParams.entries(),
542+
)
543+
if (searchParamsEntries.length > 0) {
524544
span.setAttribute(
525545
'url.query_params',
526-
JSON.stringify(Object.fromEntries(sanitizedUrlObject.searchParams)),
546+
JSON.stringify(Object.fromEntries(searchParamsEntries)),
527547
)
528548
}
529549

sdk/highlight-run/src/client/otel/instrumentation.test.ts

Lines changed: 34 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
parseXhrResponseHeaders,
88
splitHeaderValue,
99
convertHeadersToOtelAttributes,
10+
safeParseUrl,
1011
} from './index'
1112

1213
describe('Network Instrumentation Custom Attributes', () => {
@@ -1255,84 +1256,72 @@ describe('Network Instrumentation Custom Attributes', () => {
12551256
})
12561257
})
12571258

1258-
describe('parseUrlComponents', () => {
1259+
describe('safeParseUrl', () => {
12591260
describe('absolute URLs', () => {
12601261
it('should parse absolute URL with path and query', () => {
1261-
const result = parseUrlComponents(
1262-
'https://example.com/api/data?foo=bar&baz=qux',
1262+
const result = safeParseUrl(
1263+
'https://example.com/api/data?foo=bar',
12631264
)
12641265
expect(result.pathname).toBe('/api/data')
1265-
expect(result.search).toBe('?foo=bar&baz=qux')
1266+
expect(result.search).toBe('?foo=bar')
12661267
expect(result.searchParams.get('foo')).toBe('bar')
1267-
expect(result.searchParams.get('baz')).toBe('qux')
1268+
expect(result.origin).toBe('https://example.com')
12681269
})
12691270

12701271
it('should parse absolute URL with only path', () => {
1271-
const result = parseUrlComponents(
1272-
'https://example.com/api/data',
1273-
)
1272+
const result = safeParseUrl('https://example.com/api/data')
12741273
expect(result.pathname).toBe('/api/data')
12751274
expect(result.search).toBe('')
1276-
expect(Array.from(result.searchParams.keys()).length).toBe(0)
12771275
})
12781276

1279-
it('should parse absolute URL with root path', () => {
1280-
const result = parseUrlComponents('https://example.com/')
1281-
expect(result.pathname).toBe('/')
1282-
expect(result.search).toBe('')
1277+
it('should parse absolute URL with port', () => {
1278+
const result = safeParseUrl('http://localhost:3000/api')
1279+
expect(result.pathname).toBe('/api')
1280+
expect(result.port).toBe('3000')
1281+
expect(result.origin).toBe('http://localhost:3000')
12831282
})
12841283
})
12851284

12861285
describe('relative URLs', () => {
1287-
it('should parse relative URL with path only', () => {
1288-
const result = parseUrlComponents('/api/data')
1286+
it('should parse relative URL with leading slash', () => {
1287+
const result = safeParseUrl('/api/data')
12891288
expect(result.pathname).toBe('/api/data')
12901289
expect(result.search).toBe('')
1291-
expect(Array.from(result.searchParams.keys()).length).toBe(0)
12921290
})
12931291

12941292
it('should parse relative URL with path and query', () => {
1295-
const result = parseUrlComponents('/api/data?foo=bar&baz=qux')
1293+
const result = safeParseUrl('/api/data?foo=bar&baz=qux')
12961294
expect(result.pathname).toBe('/api/data')
12971295
expect(result.search).toBe('?foo=bar&baz=qux')
12981296
expect(result.searchParams.get('foo')).toBe('bar')
12991297
expect(result.searchParams.get('baz')).toBe('qux')
13001298
})
13011299

1302-
it('should parse relative URL with only query params', () => {
1303-
const result = parseUrlComponents('?foo=bar')
1304-
expect(result.pathname).toBe('')
1305-
expect(result.search).toBe('?foo=bar')
1306-
expect(result.searchParams.get('foo')).toBe('bar')
1307-
})
1308-
13091300
it('should parse relative URL with nested path', () => {
1310-
const result = parseUrlComponents(
1311-
'/api/v1/users/123?include=profile',
1312-
)
1301+
const result = safeParseUrl('/api/v1/users/123?include=profile')
13131302
expect(result.pathname).toBe('/api/v1/users/123')
1314-
expect(result.search).toBe('?include=profile')
13151303
expect(result.searchParams.get('include')).toBe('profile')
13161304
})
13171305

1318-
it('should handle relative URL without leading slash', () => {
1319-
const result = parseUrlComponents('api/data?key=value')
1320-
expect(result.pathname).toBe('api/data')
1321-
expect(result.search).toBe('?key=value')
1322-
expect(result.searchParams.get('key')).toBe('value')
1306+
it('should handle query-only relative URL', () => {
1307+
const result = safeParseUrl('?foo=bar')
1308+
expect(result.search).toBe('?foo=bar')
1309+
expect(result.searchParams.get('foo')).toBe('bar')
13231310
})
13241311
})
13251312

13261313
describe('edge cases', () => {
1327-
it('should handle empty string', () => {
1328-
const result = parseUrlComponents('')
1329-
expect(result.pathname).toBe('')
1330-
expect(result.search).toBe('')
1331-
expect(Array.from(result.searchParams.keys()).length).toBe(0)
1314+
it('should handle URL with encoded characters in query', () => {
1315+
const result = safeParseUrl(
1316+
'/search?q=hello%20world&filter=a%26b',
1317+
)
1318+
expect(result.pathname).toBe('/search')
1319+
expect(result.searchParams.get('q')).toBe('hello world')
1320+
expect(result.searchParams.get('filter')).toBe('a&b')
13321321
})
13331322

13341323
it('should handle URL with multiple query params of same key', () => {
1335-
const result = parseUrlComponents('/path?tag=a&tag=b&tag=c')
1324+
const result = safeParseUrl('/path?tag=a&tag=b&tag=c')
13361325
expect(result.pathname).toBe('/path')
13371326
expect(result.searchParams.getAll('tag')).toEqual([
13381327
'a',
@@ -1341,13 +1330,13 @@ describe('Network Instrumentation Custom Attributes', () => {
13411330
])
13421331
})
13431332

1344-
it('should handle URL with encoded characters in query', () => {
1345-
const result = parseUrlComponents(
1346-
'/search?q=hello%20world&filter=a%26b',
1333+
it('should handle URL with fragment', () => {
1334+
const result = safeParseUrl(
1335+
'https://example.com/page?q=test#section',
13471336
)
1348-
expect(result.pathname).toBe('/search')
1349-
expect(result.searchParams.get('q')).toBe('hello world')
1350-
expect(result.searchParams.get('filter')).toBe('a&b')
1337+
expect(result.pathname).toBe('/page')
1338+
expect(result.search).toBe('?q=test')
1339+
expect(result.hash).toBe('#section')
13511340
})
13521341
})
13531342
})

0 commit comments

Comments
 (0)