Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions sdk/highlight-run/src/client/otel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,16 +727,14 @@ const enhanceSpanWithHttpRequestAttributes = (
[SemanticAttributes.ATTR_URL_QUERY]: sanitizedUrlObject.search,
})

// Set sanitized query params as JSON object for easier querying
const searchParamsEntries = Array.from(
sanitizedUrlObject.searchParams.entries(),
)
if (searchParamsEntries.length > 0) {
span.setAttribute(
// Emit each query param as its own dotted attribute so the backend's
// hlog.FormatAttributes handles them as a nested attribute map natively.
span.setAttributes(
convertSearchParamsToOtelAttributes(
sanitizedUrlObject.searchParams,
'url.query_params',
JSON.stringify(Object.fromEntries(searchParamsEntries)),
)
}
),
)

if (networkRecordingOptions?.recordHeadersAndBody) {
const requestBody = getBodyThatShouldBeRecorded(
Expand Down Expand Up @@ -821,6 +819,36 @@ export const convertHeadersToOtelAttributes = (
return attributes
}

/**
* Converts URL query params to OpenTelemetry attributes with dotted keys.
* Each param becomes its own attribute (`<prefix>.<name>`), so the backend's
* `hlog.FormatAttributes` flattens them natively into a nested attribute map
* without needing to JSON-parse a stringified blob.
*
* Repeated keys (`?foo=1&foo=2`) become array-valued attributes — single
* values stay as strings to keep simple equality queries working.
*/
export const convertSearchParamsToOtelAttributes = (
searchParams: URLSearchParams,
prefix: string,
): { [key: string]: string | string[] } => {
const attributes: { [key: string]: string | string[] } = {}

for (const [key, value] of searchParams.entries()) {
const attributeName = `${prefix}.${key}`
const existing = attributes[attributeName]
if (existing === undefined) {
attributes[attributeName] = value
} else if (Array.isArray(existing)) {
existing.push(value)
} else {
attributes[attributeName] = [existing, value]
}
}

return attributes
}

/**
* HTTP headers that are explicitly defined as comma-separated lists
* per RFC 7231 and related specifications. Only these headers should
Expand Down
88 changes: 88 additions & 0 deletions sdk/highlight-run/src/client/otel/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
parseXhrResponseHeaders,
splitHeaderValue,
convertHeadersToOtelAttributes,
convertSearchParamsToOtelAttributes,
} from './index'

describe('Network Instrumentation Custom Attributes', () => {
Expand Down Expand Up @@ -385,6 +386,93 @@ describe('Network Instrumentation Custom Attributes', () => {
})
})

describe('convertSearchParamsToOtelAttributes', () => {
it('should emit one dotted attribute per query param', () => {
const params = new URLSearchParams('foo=bar&baz=qux')

const result = convertSearchParamsToOtelAttributes(
params,
'url.query_params',
)

expect(result).toEqual({
'url.query_params.foo': 'bar',
'url.query_params.baz': 'qux',
})
})

it('should keep single-value params as strings', () => {
const params = new URLSearchParams('only=once')

const result = convertSearchParamsToOtelAttributes(
params,
'url.query_params',
)

expect(result['url.query_params.only']).toBe('once')
expect(result['url.query_params.only']).not.toBeInstanceOf(Array)
})

it('should collect repeated keys into an array preserving order', () => {
const params = new URLSearchParams('id=1&id=2&id=3')

const result = convertSearchParamsToOtelAttributes(
params,
'url.query_params',
)

expect(result['url.query_params.id']).toEqual(['1', '2', '3'])
})

it('should handle empty values', () => {
const params = new URLSearchParams('flag=&name=alice')

const result = convertSearchParamsToOtelAttributes(
params,
'url.query_params',
)

expect(result).toEqual({
'url.query_params.flag': '',
'url.query_params.name': 'alice',
})
})

it('should preserve URL-decoded values', () => {
const params = new URLSearchParams('q=hello%20world&filter=a%26b')

const result = convertSearchParamsToOtelAttributes(
params,
'url.query_params',
)

expect(result['url.query_params.q']).toBe('hello world')
expect(result['url.query_params.filter']).toBe('a&b')
})

it('should return an empty object when there are no params', () => {
const result = convertSearchParamsToOtelAttributes(
new URLSearchParams(),
'url.query_params',
)

expect(result).toEqual({})
})

it('should respect a custom prefix', () => {
const params = new URLSearchParams('utm_source=email')

const result = convertSearchParamsToOtelAttributes(
params,
'request.query',
)

expect(result).toEqual({
'request.query.utm_source': 'email',
})
})
})

describe('parseXhrResponseHeaders', () => {
it('should parse XHR header string correctly', () => {
const headerString =
Expand Down
Loading