diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index b7b9039b89..16ed60408d 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -476,6 +476,11 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & { */ beta_encode_cookie_options?: boolean [k: string]: unknown + + /** + * Whether the allowed HTML attributes list is used + */ + use_allowed_html_attributes?: boolean } [k: string]: unknown } diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index bc9cafb4d4..510ec884b8 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -23,6 +23,7 @@ export enum ExperimentalFeature { USE_CHANGE_RECORDS = 'use_change_records', SOURCE_CODE_CONTEXT = 'source_code_context', LCP_SUBPARTS = 'lcp_subparts', + COMPOSED_PATH_SELECTOR = 'composed_path_selector', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index c99eb49a20..5260266671 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -135,6 +135,7 @@ describe('trackClickActions', () => { selector: '#button', width: 100, height: 100, + composed_path_selector: undefined, }, position: { x: 50, y: 50 }, events: [domEvent], @@ -698,6 +699,24 @@ describe('trackClickActions', () => { expect(events[0].target?.selector).not.toContain(SHADOW_DOM_MARKER) }) }) + + describe('when composed path selector is enabled', () => { + it('should return a composed_path_selector', () => { + addExperimentalFeatures([ExperimentalFeature.COMPOSED_PATH_SELECTOR]) + startClickActionsTracking() + emulateClick({ + target: button, + activity: {}, + eventProperty: { + composed: true, + composedPath: () => [button, document.body, document], + }, + }) + + clock.tick(EXPIRE_DELAY) + expect(events[0].target?.composed_path_selector).toBeDefined() + }) + }) }) describe('finalizeClicks', () => { diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 74ef0e8cc4..0499c6ff96 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -1,5 +1,13 @@ import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core' -import { timeStampNow, Observable, timeStampToClocks, relativeToClocks, generateUUID } from '@datadog/browser-core' +import { + timeStampNow, + Observable, + timeStampToClocks, + relativeToClocks, + generateUUID, + isExperimentalFeatureEnabled, + ExperimentalFeature, +} from '@datadog/browser-core' import { isNodeShadowHost } from '../../browser/htmlDomUtils' import type { FrustrationType } from '../../rawRumEvent.types' import { ActionType } from '../../rawRumEvent.types' @@ -13,6 +21,7 @@ import type { RumConfiguration } from '../configuration' import type { RumMutationRecord } from '../../browser/domMutationObservable' import { startEventTracker } from '../eventTracker' import type { StoppedEvent, DiscardedEvent, EventTracker } from '../eventTracker' +import { getComposedPathSelector } from '../getComposedPathSelector' import type { ClickChain } from './clickChain' import { createClickChain } from './clickChain' import { getActionNameFromElement } from './getActionNameFromElement' @@ -36,6 +45,7 @@ export interface ClickAction { nameSource: ActionNameSource target?: { selector: string | undefined + composed_path_selector?: string width: number height: number } @@ -236,6 +246,15 @@ function computeClickActionBase( const rect = target.getBoundingClientRect() const selector = getSelectorFromElement(target, configuration.actionNameAttribute) + const composedPathSelector = + isExperimentalFeatureEnabled(ExperimentalFeature.COMPOSED_PATH_SELECTOR) && typeof event.composedPath === 'function' + ? getComposedPathSelector( + event.composedPath(), + configuration.actionNameAttribute, + configuration.allowedHtmlAttributes || [] + ) + : undefined + if (selector) { updateInteractionSelector(event.timeStamp, selector) } @@ -248,6 +267,7 @@ function computeClickActionBase( width: Math.round(rect.width), height: Math.round(rect.height), selector, + composed_path_selector: composedPathSelector ?? undefined, }, position: { // Use clientX and Y because for SVG element offsetX and Y are relatives to the element diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 80e2cd5bb3..9da0209c53 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -631,6 +631,7 @@ describe('serializeRumConfiguration', () => { profilingSampleRate: 42, propagateTraceBaggage: true, betaTrackActionsInShadowDom: true, + allowedHtmlAttributes: ['data-testid'], } type MapRumInitConfigurationKey = Key extends keyof InitConfiguration @@ -641,6 +642,7 @@ describe('serializeRumConfiguration', () => { | 'excludedActivityUrls' | 'remoteConfigurationProxy' | 'allowedGraphQlUrls' + | 'allowedHtmlAttributes' ? `use_${CamelToSnakeCase}` : Key extends 'trackLongTasks' ? 'track_long_task' // We forgot the s, keeping this for backward compatibility @@ -687,6 +689,7 @@ describe('serializeRumConfiguration', () => { remote_configuration_id: '123', use_remote_configuration_proxy: true, profiling_sample_rate: 42, + use_allowed_html_attributes: true, }) }) }) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 984185f16e..470cb292eb 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -281,6 +281,15 @@ export interface RumInitConfiguration extends InitConfiguration { * @category Data Collection */ allowedGraphQlUrls?: Array | undefined + + /** + * A list of HTML attributes allowed to be used in the action selector collection. + * Matches attributes against the event target and its ancestors. + * If not provided, the SDK will use a default list of HTML attributes. + * + * @category Data Collection + */ + allowedHtmlAttributes?: MatchOption[] | undefined } export type HybridInitConfiguration = Omit @@ -321,6 +330,7 @@ export interface RumConfiguration extends Configuration { profilingSampleRate: number propagateTraceBaggage: boolean allowedGraphQlUrls: GraphQlUrlOption[] + allowedHtmlAttributes?: MatchOption[] } export function validateAndBuildRumConfiguration( @@ -400,6 +410,9 @@ export function validateAndBuildRumConfiguration( profilingSampleRate: initConfiguration.profilingSampleRate ?? 0, propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, allowedGraphQlUrls, + allowedHtmlAttributes: Array.isArray(initConfiguration.allowedHtmlAttributes) + ? initConfiguration.allowedHtmlAttributes.filter(isMatchOption) + : [], ...baseConfiguration, } } @@ -548,6 +561,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { remote_configuration_id: configuration.remoteConfigurationId, profiling_sample_rate: configuration.profilingSampleRate, use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy, + use_allowed_html_attributes: isNonEmptyArray(configuration.allowedHtmlAttributes), ...baseSerializedConfiguration, } satisfies RawTelemetryConfiguration } diff --git a/packages/rum-core/src/domain/getComposedPathSelector.spec.ts b/packages/rum-core/src/domain/getComposedPathSelector.spec.ts new file mode 100644 index 0000000000..c990373195 --- /dev/null +++ b/packages/rum-core/src/domain/getComposedPathSelector.spec.ts @@ -0,0 +1,323 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import { appendElement } from '../../test' +import { getComposedPathSelector, CHARACTER_LIMIT } from './getComposedPathSelector' + +/** Appends content inside a wrapper so the element is the only child (no nth-child from body). */ +function appendElementInIsolation(html: string): HTMLElement { + const wrapper = document.createElement('div') + document.body.appendChild(wrapper) + registerCleanupTask(() => wrapper.remove()) + return appendElement(html, wrapper) +} + +describe('getSelectorFromComposedPath', () => { + describe('getComposedPathSelector', () => { + it('returns an empty string for an empty composedPath', () => { + const result = getComposedPathSelector([], undefined, []) + expect(result).toEqual('') + }) + + it('filters out non-Element items from composedPath', () => { + const element = appendElementInIsolation('
') + const composedPath: EventTarget[] = [element, document.body, document, window] + + const result = getComposedPathSelector(composedPath, undefined, []) + + expect(result).toBe('DIV#test;') + }) + + it('ignores BODY and HTML elements from the composedPath', () => { + const composedPath: EventTarget[] = [document.body, document.documentElement] + + const result = getComposedPathSelector(composedPath, undefined, []) + + expect(result).toBe('') + }) + + describe('element data extraction', () => { + it('extracts tag name from element', () => { + const element = appendElementInIsolation('') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('BUTTON;') + }) + + it('extracts id from element when present', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV#my-id;') + }) + + it('does not include id when not present', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV;') + }) + + it('extracts sorted classes from element', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV.bar.baz.foo;') + }) + + it('excludes generated class names containing digits', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV.bar;') + }) + }) + + describe('safe attribute filtering', () => { + it('only collects allowlisted attributes', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV[data-testid="test-btn"];') + }) + + it('collects multiple safe attributes', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV[data-testid="foo"][data-qa="bar"][data-cy="baz"];') + }) + + it('does not collect non-allowlisted attributes', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV;') + }) + + it('collects data-dd-action-name attribute', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe(`DIV[data-dd-action-name="${CSS.escape('Submit Form')}"];`) + }) + + it('collects role attribute', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV[role="button"];') + }) + + it('collects type attribute', () => { + const element = appendElementInIsolation('') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('INPUT[type="submit"];') + }) + }) + + describe('nthChild and nthOfType', () => { + it('does not include nthChild when element is the only child', () => { + const parent = document.createElement('div') + const child = document.createElement('span') + parent.appendChild(child) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([child], undefined, []) + + expect(result).toBe('SPAN;') + }) + + it('includes nthChild when element has siblings', () => { + const parent = document.createElement('div') + const child1 = document.createElement('span') + const child2 = document.createElement('span') + const child3 = document.createElement('span') + parent.appendChild(child1) + parent.appendChild(child2) + parent.appendChild(child3) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([child2], undefined, []) + + expect(result).toBe('SPAN:nth-child(2):nth-of-type(2);') + }) + + it('calculates nthChild correctly for first child', () => { + const parent = document.createElement('div') + const child1 = document.createElement('span') + const child2 = document.createElement('div') + parent.appendChild(child1) + parent.appendChild(child2) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([child1], undefined, []) + + expect(result).toBe('SPAN:nth-child(1);') + }) + + it('does not include nthOfType when element is unique of its type', () => { + const parent = document.createElement('div') + const span = document.createElement('span') + const div = document.createElement('div') + parent.appendChild(span) + parent.appendChild(div) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([span], undefined, []) + + // span is unique of type, but not unique child (has sibling) + expect(result).toBe('SPAN:nth-child(1);') + }) + + it('includes nthOfType when element has siblings of the same type', () => { + const parent = document.createElement('div') + const span1 = document.createElement('span') + const span2 = document.createElement('span') + const div = document.createElement('div') + parent.appendChild(span1) + parent.appendChild(div) + parent.appendChild(span2) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([span2], undefined, []) + + expect(result).toBe('SPAN:nth-child(3):nth-of-type(2);') + }) + + it('includes nthOfType when the first element has same-type siblings', () => { + const parent = document.createElement('div') + const span1 = document.createElement('span') + const div = document.createElement('div') + const span2 = document.createElement('span') + parent.appendChild(span1) + parent.appendChild(div) + parent.appendChild(span2) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([span1], undefined, []) + + expect(result).toBe('SPAN:nth-child(1):nth-of-type(1);') + }) + + it('calculates nthOfType correctly among mixed siblings', () => { + const parent = document.createElement('div') + const button1 = document.createElement('button') + const span = document.createElement('span') + const button2 = document.createElement('button') + const div = document.createElement('div') + const button3 = document.createElement('button') + parent.appendChild(button1) + parent.appendChild(span) + parent.appendChild(button2) + parent.appendChild(div) + parent.appendChild(button3) + document.body.appendChild(parent) + registerCleanupTask(() => parent.remove()) + + const result = getComposedPathSelector([button2], undefined, []) + + expect(result).toBe('BUTTON:nth-child(3):nth-of-type(2);') + }) + + it('handles elements in composedPath with their position data', () => { + const wrapper = document.createElement('div') + document.body.appendChild(wrapper) + registerCleanupTask(() => wrapper.remove()) + + const grandparent = document.createElement('div') + const parent = document.createElement('section') + const sibling = document.createElement('article') + const target = document.createElement('button') + + grandparent.appendChild(parent) + grandparent.appendChild(sibling) + parent.appendChild(target) + wrapper.appendChild(grandparent) + + const composedPath = [target, parent, grandparent] + const result = getComposedPathSelector(composedPath, undefined, []) + + expect(result).toBe('BUTTON;SECTION:nth-child(1);DIV;') + }) + + it('does not include nthChild or nthOfType for elements without parent', () => { + // Detached element with no parent + const element = document.createElement('div') + + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV;') + }) + }) + + describe('allowed html attributes', () => { + it('includes allowed html attributes', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, ['data-test-allowed']) + + expect(result).toBe('DIV[data-test-allowed="test-btn"];') + }) + + it('supports allowlists defined as regular expressions', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, [/^data-test-/]) + + expect(result).toBe('DIV[data-test-custom="foo"];') + }) + }) + + describe('truncation', () => { + it('truncates the selector if it exceeds the character limit', () => { + const element = appendElementInIsolation( + '
'.repeat(1000) + ) + const result = getComposedPathSelector([element], undefined, ['data-test-allowed']) + + expect(result.length).toBeLessThanOrEqual(CHARACTER_LIMIT) + }) + }) + + describe('edge cases', () => { + it('handles elements with empty class attribute', () => { + const element = appendElementInIsolation('
') + const result = getComposedPathSelector([element], undefined, []) + + expect(result).toBe('DIV;') + }) + + it('handles elements with whitespace-only class', () => { + const wrapper = document.createElement('div') + document.body.appendChild(wrapper) + registerCleanupTask(() => wrapper.remove()) + + const element = document.createElement('div') + element.setAttribute('class', ' ') + wrapper.appendChild(element) + + const result = getComposedPathSelector([element], undefined, []) + expect(result).toBe('DIV;') + }) + + it('handles SVG elements', () => { + const wrapper = document.createElement('div') + document.body.appendChild(wrapper) + registerCleanupTask(() => wrapper.remove()) + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('data-testid', 'my-svg') + wrapper.appendChild(svg) + + const result = getComposedPathSelector([svg], undefined, []) + + // tagName for SVG in HTML document is lowercase + expect(result).toBe('svg[data-testid="my-svg"];') + }) + }) + }) +}) diff --git a/packages/rum-core/src/domain/getComposedPathSelector.ts b/packages/rum-core/src/domain/getComposedPathSelector.ts new file mode 100644 index 0000000000..bf9021171e --- /dev/null +++ b/packages/rum-core/src/domain/getComposedPathSelector.ts @@ -0,0 +1,151 @@ +import { safeTruncate, ONE_KIBI_BYTE, matchList } from '@datadog/browser-core' +import type { MatchOption } from '@datadog/browser-core' +import { STABLE_ATTRIBUTES, isGeneratedValue, getIDSelector, getTagNameSelector } from './getSelectorFromElement' + +const FILTERED_TAGNAMES = ['HTML', 'BODY'] + +/** + * arbitrary value, we want to truncate the selector if it exceeds the limit + */ +export const CHARACTER_LIMIT = 2 * ONE_KIBI_BYTE + +/** + * Safe attributes that can be collected without PII concerns. + * These are commonly used for testing, accessibility, and UI identification. + */ +export const SAFE_ATTRIBUTES = STABLE_ATTRIBUTES.concat([ + 'role', + 'type', + 'name', + 'disabled', + 'readonly', + 'checked', + 'selected', + 'tabindex', + 'draggable', + 'target', + 'rel', + 'download', + 'method', + 'action', + 'enctype', + 'autocomplete', +]) + +/** + * Extracts a selector string from a MouseEvent composedPath. + * + * This function: + * 1. Filters out non-Element items (Document, Window, ShadowRoot) + * 2. Extracts a selector string from each element + * 3. Truncates the selector string if it exceeds the character limit + * 4. Returns the selector string + * + * @param composedPath - The composedPath from a MouseEvent + * @returns A selector string + */ +export function getComposedPathSelector( + composedPath: EventTarget[], + actionNameAttribute: string | undefined, + attributesAllowList: MatchOption[] +): string { + // Filter to only include Element nodes + const elements = composedPath.filter( + (el): el is Element => el instanceof Element && !FILTERED_TAGNAMES.includes(el.tagName) + ) + + if (elements.length === 0) { + return '' + } + + const allowedAttributes = ([] as MatchOption[]).concat( + actionNameAttribute ? [actionNameAttribute] : [], + SAFE_ATTRIBUTES, + attributesAllowList + ) + + let result = '' + for (let i = 0; i < elements.length; i++) { + const part = getSelectorStringFromElement(elements[i], allowedAttributes) + const next = result + part + if (next.length >= CHARACTER_LIMIT) { + return safeTruncate(next, CHARACTER_LIMIT) + } + result = next + } + return result +} + +/** + * Extracts a selector string from an element. + */ +function getSelectorStringFromElement(element: Element, allowedAttributes: MatchOption[]): string { + const tagName = getTagNameSelector(element) + const id = getIDSelector(element) + const classes = getElementClassesString(element) + const attributes = extractSafeAttributesString(element, allowedAttributes) + const positionData = computePositionDataString(element) + + return `${tagName}${id || ''}${attributes}${classes}${positionData};` +} + +function getElementClassesString(element: Element): string { + return Array.from(element.classList) + .filter((c) => c.trim() !== '' && !isGeneratedValue(c)) + .sort() + .map((c) => `.${CSS.escape(c)}`) + .join('') +} + +/** + * Computes the nthChild and nthOfType positions for an element. + * + * @param element - The element to compute the position data for + * @returns A string of the form ":nth-child(1):nth-of-type(1)" + */ +function computePositionDataString(element: Element): string { + const parent = element.parentElement + if (!parent) { + return '' + } + + const siblings = parent.children + const n = siblings.length + if (n <= 1) { + return '' + } + + let result = '' + + const sameTypeTotal = Array.from(siblings).filter((sibling) => sibling.tagName === element.tagName).length + + for (let i = 0, j = 0; i < n; i++) { + const currentSibling = siblings[i] + if (currentSibling.tagName === element.tagName) { + j++ + } + if (currentSibling === element) { + result += `:nth-child(${i + 1})` // 1-based + if (sameTypeTotal > 1 && j > 0) { + result += `:nth-of-type(${j})` // 1-based + } + break + } + } + + return result +} + +/** + * Extracts only the safe (allowlisted) attributes from an element. + */ +function extractSafeAttributesString(element: Element, allowedAttributes: MatchOption[]): string { + let result = '' + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i] + if (matchList(allowedAttributes, attr.name)) { + result += `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]` + } + } + return result +} diff --git a/packages/rum-core/src/domain/getSelectorFromElement.ts b/packages/rum-core/src/domain/getSelectorFromElement.ts index e761b1fa77..361941a1b7 100644 --- a/packages/rum-core/src/domain/getSelectorFromElement.ts +++ b/packages/rum-core/src/domain/getSelectorFromElement.ts @@ -139,7 +139,7 @@ function getSelectorFromElementWithinSubtree( return currentSelector } -function isGeneratedValue(value: string) { +export function isGeneratedValue(value: string) { // To compute the "URL path group", the backend replaces every URL path parts as a question mark // if it thinks the part is an identifier. The condition it uses is to checks whether a digit is // present. @@ -150,7 +150,7 @@ function isGeneratedValue(value: string) { return /[0-9]/.test(value) } -function getIDSelector(element: Element): string | undefined { +export function getIDSelector(element: Element): string | undefined { if (element.id && !isGeneratedValue(element.id)) { return `#${CSS.escape(element.id)}` } @@ -171,7 +171,7 @@ function getClassSelector(element: Element): string | undefined { } } -function getTagNameSelector(element: Element): string { +export function getTagNameSelector(element: Element): string { return CSS.escape(element.tagName) } diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index 766cc33e94..e5d48d43b0 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -328,6 +328,7 @@ export interface RawRumActionEvent { selector?: string width?: number height?: number + composed_path_selector?: string } name_source?: string position?: { diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index 8a7f4fe953..dcffb9f557 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -676,15 +676,15 @@ export type RumResourceEvent = CommonProperties & */ readonly size?: number /** - * Size in octet of the resource before removing any applied content encodings + * Size in octet of the response body before removing any applied content encodings */ readonly encoded_body_size?: number /** - * Size in octet of the resource after removing any applied encoding + * Size in octet of the response body after removing any applied encoding */ readonly decoded_body_size?: number /** - * Size in octet of the fetched resource + * Size in octet of the fetched response resource */ readonly transfer_size?: number /** @@ -829,6 +829,38 @@ export type RumResourceEvent = CommonProperties & | 'video' [k: string]: unknown } + /** + * Request properties + */ + readonly request?: { + /** + * Size in octet of the request body sent over the network (after encoding) + */ + readonly encoded_body_size?: number + /** + * Size in octet of the request body before any encoding + */ + readonly decoded_body_size?: number + /** + * HTTP headers of the resource request + */ + readonly headers?: { + [k: string]: string + } + [k: string]: unknown + } + /** + * Response properties + */ + readonly response?: { + /** + * HTTP headers of the resource response + */ + readonly headers?: { + [k: string]: string + } + [k: string]: unknown + } /** * GraphQL requests parameters */ @@ -2016,6 +2048,24 @@ export interface ViewPerformanceData { * CSS selector path of the interacted element for the INP interaction */ readonly target_selector?: string + /** + * Sub-parts of the INP + */ + sub_parts?: { + /** + * Time from the start of the input event to the start of the processing of the event + */ + readonly input_delay: number + /** + * Event handler execution time + */ + readonly processing_time: number + /** + * Rendering time happening after processing + */ + readonly presentation_delay: number + [k: string]: unknown + } [k: string]: unknown } /** diff --git a/rum-events-format b/rum-events-format index 8dc61166ee..8c1cda6ac5 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 8dc61166ee818608892d13b6565ff04a3f2a7fe9 +Subproject commit 8c1cda6ac58acc090f9d4491448aa4d458783c7b diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index d654fa063f..16884a4134 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -781,3 +781,42 @@ test.describe('custom actions with startAction/stopAction', () => { expect(relatedFetch).toBeDefined() }) }) + +test.describe('action collection with composed path selector', () => { + createTest('should not return a composed_path_selector if flag is disabled') + .withRum({ trackUserInteractions: true }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('#my-button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0]._dd.action?.target?.composed_path_selector).toBeUndefined() + }) + createTest('should return a composed_path_selector if flag is enabled') + .withRum({ + trackUserInteractions: true, + enableExperimentalFeatures: ['composed_path_selector'], + allowedHtmlAttributes: ['data-test-allowed'], + }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('#my-button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0]._dd.action?.target?.composed_path_selector).toBe( + 'BUTTON#my-button[data-test-allowed="test-btn"].bar.baz.foo:nth-child(2):nth-of-type(2);' + ) + }) +})