Skip to content
Draft
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
16 changes: 16 additions & 0 deletions packages/rum-core/src/browser/htmlDomUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,19 @@ export function forEachChildNodes(node: Node, callback: (child: Node) => void) {
export function getParentNode(node: Node): Node | null {
return isNodeShadowRoot(node) ? node.host : node.parentNode
}

/**
* Return the parent element, crossing shadow DOM boundaries.
* If the element is a direct child of a shadow root, returns the shadow host.
*/
export function getParentElement(element: Element): Element | null {
if (element.parentElement) {
return element.parentElement
}
// If parentElement is null, check if we're in a shadow root
const parentNode = element.parentNode
if (parentNode && isNodeShadowRoot(parentNode)) {
return parentNode.host
}
return null
}
119 changes: 119 additions & 0 deletions packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,4 +930,123 @@ describe('getActionNameFromElement', () => {
})
})
})

describe('shadow DOM support', () => {
it('extracts text content from element inside shadow DOM', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
button.textContent = 'Shadow Button'
shadowRoot.appendChild(button)

const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
expect(name).toBe('Shadow Button')
expect(nameSource).toBe('text_content')
})

it('extracts aria-label from element inside shadow DOM', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
button.setAttribute('aria-label', 'Accessible Label')
shadowRoot.appendChild(button)

const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
expect(name).toBe('Accessible Label')
expect(nameSource).toBe('standard_attribute')
})

it('traverses parent elements across shadow boundary to find action name', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const wrapper = document.createElement('div')
wrapper.setAttribute('title', 'Parent Title')
const target = document.createElement('span')
wrapper.appendChild(target)
shadowRoot.appendChild(wrapper)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Parent Title')
expect(nameSource).toBe('standard_attribute')
})

it('finds data-dd-action-name on shadow host when element is inside shadow DOM', () => {
const host = appendElement('<div data-dd-action-name="Custom Action"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const target = document.createElement('button')
shadowRoot.appendChild(target)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Custom Action')
expect(nameSource).toBe('custom_attribute')
})

it('finds data-dd-action-name on ancestor outside shadow DOM', () => {
const ancestor = appendElement('<div data-dd-action-name="Ancestor Action"><div id="host"></div></div>')
const host = ancestor.querySelector('#host')!
const shadowRoot = host.attachShadow({ mode: 'open' })
const target = document.createElement('button')
shadowRoot.appendChild(target)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Ancestor Action')
expect(nameSource).toBe('custom_attribute')
})

it('prefers data-dd-action-name closer to the element inside shadow DOM', () => {
const host = appendElement('<div data-dd-action-name="Host Action"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const wrapper = document.createElement('div')
wrapper.setAttribute('data-dd-action-name', 'Inner Action')
const target = document.createElement('button')
wrapper.appendChild(target)
shadowRoot.appendChild(wrapper)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Inner Action')
expect(nameSource).toBe('custom_attribute')
})

it('handles nested shadow DOMs', () => {
const outerHost = appendElement('<div data-dd-action-name="Outer Action"></div>')
const outerShadowRoot = outerHost.attachShadow({ mode: 'open' })

const innerHost = document.createElement('div')
outerShadowRoot.appendChild(innerHost)
const innerShadowRoot = innerHost.attachShadow({ mode: 'open' })

const target = document.createElement('button')
innerShadowRoot.appendChild(target)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Outer Action')
expect(nameSource).toBe('custom_attribute')
})

it('gets parent textual content from shadow host when element has no name', () => {
const wrapper = appendElement('<div><div id="host-container"></div></div>')
const host = wrapper.querySelector('#host-container')!
host.setAttribute('title', 'Host Title')
const shadowRoot = host.attachShadow({ mode: 'open' })
const target = document.createElement('span')
shadowRoot.appendChild(target)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('Host Title')
expect(nameSource).toBe('standard_attribute')
})

it('respects FORM boundary inside shadow DOM', () => {
const host = appendElement('<div title="Host Title"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const form = document.createElement('form')
const target = document.createElement('span')
form.appendChild(target)
shadowRoot.appendChild(form)

const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
expect(name).toBe('')
expect(nameSource).toBe('blank')
})
})
})
19 changes: 16 additions & 3 deletions packages/rum-core/src/domain/action/getActionNameFromElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants'
import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode, shouldMaskAttribute } from '../privacy'
import type { NodePrivacyLevelCache } from '../privacy'
import type { RumConfiguration } from '../configuration'
import { isElementNode } from '../../browser/htmlDomUtils'
import { isElementNode, getParentElement } from '../../browser/htmlDomUtils'
import {
ActionNameSource,
DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE,
Expand Down Expand Up @@ -49,7 +49,8 @@ export function getActionNameFromElement(
function getActionNameFromElementProgrammatically(targetElement: Element, programmaticAttribute: string) {
// We don't use getActionNameFromElementForStrategies here, because we want to consider all parents,
// without limit. It is up to the user to declare a relevant naming strategy.
const elementWithAttribute = targetElement.closest(`[${programmaticAttribute}]`)
// Use shadow-aware closest to cross shadow DOM boundaries
const elementWithAttribute = closestShadowAware(targetElement, `[${programmaticAttribute}]`)

if (!elementWithAttribute) {
return
Expand All @@ -58,6 +59,17 @@ function getActionNameFromElementProgrammatically(targetElement: Element, progra
return truncate(normalizeWhitespace(name.trim()))
}

function closestShadowAware(element: Element, selector: string): Element | null {
let current: Element | null = element
while (current) {
if (current.matches(selector)) {
return current
}
current = getParentElement(current)
}
return null
}

type NameStrategy = (
element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement,
rumConfiguration: RumConfiguration,
Expand Down Expand Up @@ -128,6 +140,7 @@ const fallbackStrategies: NameStrategy[] = [
/**
* Iterates over the target element and its parent, using the strategies list to get an action name.
* Each strategies are applied on each element, stopping as soon as a non-empty value is returned.
* Uses shadow-aware parent traversal to cross shadow DOM boundaries.
*/
const MAX_PARENTS_TO_CONSIDER = 10
function getActionNameFromElementForStrategies(
Expand Down Expand Up @@ -160,7 +173,7 @@ function getActionNameFromElementForStrategies(
if (element.nodeName === 'FORM') {
break
}
element = element.parentElement
element = getParentElement(element)
recursionCounter += 1
}
}
Expand Down
25 changes: 21 additions & 4 deletions packages/rum-core/src/domain/action/trackClickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createValueHistory,
PageExitReason,
} from '@datadog/browser-core'
import { isNodeShadowHost } from '../../browser/htmlDomUtils'
import type { FrustrationType } from '../../rawRumEvent.types'
import { ActionType } from '../../rawRumEvent.types'
import type { LifeCycle } from '../lifeCycle'
Expand Down Expand Up @@ -143,10 +144,12 @@ function processPointerDown(
pointerDownEvent: MouseEventOnElement,
windowOpenObservable: Observable<void>
) {
const target = configuration.trackActionsInShadowDom ? getEventTarget(pointerDownEvent) : pointerDownEvent.target

let nodePrivacyLevel: NodePrivacyLevel

if (configuration.enablePrivacyForActionName) {
nodePrivacyLevel = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel)
nodePrivacyLevel = getNodePrivacyLevel(target, configuration.defaultPrivacyLevel)
} else {
nodePrivacyLevel = NodePrivacyLevel.ALLOW
}
Expand Down Expand Up @@ -244,13 +247,18 @@ function computeClickActionBase(
nodePrivacyLevel: NodePrivacyLevel,
configuration: RumConfiguration
): ClickActionBase {
const rect = event.target.getBoundingClientRect()
const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute)
// Get the actual target element, handling shadow DOM if enabled
// For composed events from shadow DOM, event.target might be the shadow host,
// but we want the actual element that was clicked inside the shadow root
const target = configuration.trackActionsInShadowDom ? getEventTarget(event) : event.target

const rect = target.getBoundingClientRect()
const selector = getSelectorFromElement(target, configuration.actionNameAttribute)
if (selector) {
updateInteractionSelector(event.timeStamp, selector)
}

const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel)
const { name, nameSource } = getActionNameFromElement(target, configuration, nodePrivacyLevel)

return {
type: ActionType.CLICK,
Expand All @@ -268,6 +276,15 @@ function computeClickActionBase(
nameSource,
}
}
function getEventTarget(event: MouseEventOnElement): Element {
if (event.composed && isNodeShadowHost(event.target) && typeof event.composedPath === 'function') {
const composedPath = event.composedPath()
if (composedPath.length > 0 && composedPath[0] instanceof Element) {
return composedPath[0]
}
}
return event.target
}

const enum ClickStatus {
// Initial state, the click is still ongoing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ describe('serializeRumConfiguration', () => {
trackFeatureFlagsForEvents: ['vital'],
profilingSampleRate: 42,
propagateTraceBaggage: true,
trackActionsInShadowDom: true,
}

type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
Expand All @@ -626,7 +627,8 @@ describe('serializeRumConfiguration', () => {
: Key extends 'trackLongTasks'
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
: // The following options are not reported as telemetry. Please avoid adding more of them.
Key extends 'applicationId' | 'subdomain'
// TODO: Remove trackActionsInShadowDom from this list once rum-events-format is updated
Key extends 'applicationId' | 'subdomain' | 'trackActionsInShadowDom'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
12 changes: 12 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,15 @@ export interface RumInitConfiguration extends InitConfiguration {
*/
actionNameAttribute?: string | undefined

/**
* Enables tracking of user interactions within Shadow DOM elements.
* When enabled, click actions inside Shadow DOM will have accurate names and selectors.
*
* @category Data Collection
* @defaultValue false
*/
trackActionsInShadowDom?: boolean | undefined

// view options
/**
* Allows you to control RUM views creation. See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#override-default-rum-view-names) for further information.
Expand Down Expand Up @@ -286,6 +295,7 @@ export interface GraphQlUrlOption {
export interface RumConfiguration extends Configuration {
// Built from init configuration
actionNameAttribute: string | undefined
trackActionsInShadowDom: boolean
traceSampleRate: number
rulePsr: number | undefined
allowedTracingUrls: TracingOption[]
Expand Down Expand Up @@ -358,6 +368,7 @@ export function validateAndBuildRumConfiguration(
return {
applicationId: initConfiguration.applicationId,
actionNameAttribute: initConfiguration.actionNameAttribute,
trackActionsInShadowDom: !!initConfiguration.trackActionsInShadowDom,
sessionReplaySampleRate,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
Expand Down Expand Up @@ -533,6 +544,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
remote_configuration_id: configuration.remoteConfigurationId,
profiling_sample_rate: configuration.profilingSampleRate,
use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy,
// TODO: Add track_actions_in_shadow_dom once rum-events-format is updated
...baseSerializedConfiguration,
} satisfies RawTelemetryConfiguration
}
Loading