Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ docs/
!.yarn/releases
!.yarn/sdks
!.yarn/versions
/test-results/
/playwright-report/
test-results/
playwright-report/
/blob-report/
/playwright/.cache/
.vscode
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
11 changes: 11 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
91 changes: 91 additions & 0 deletions packages/rum-core/src/domain/getSelectorFromElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,94 @@ describe('isSelectorUniqueAmongSiblings', () => {
expect(isSelectorUniqueAmongSiblings(element, 'DIV', 'HR')).toBeTrue()
})
})

describe('getSelectorFromElement with shadow DOM', () => {
it('should generate selector for element inside shadow DOM', () => {
const host = appendElement('<div id="shadow-host"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
button.classList.add('shadow-button')
shadowRoot.appendChild(button)

const selector = getSelectorFromElement(button, undefined)
expect(selector).toBeDefined()
expect(selector).toContain('BUTTON')
})

it('should use stable attribute for element inside shadow DOM', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
button.setAttribute('data-testid', 'shadow-test')
shadowRoot.appendChild(button)

const selector = getSelectorFromElement(button, undefined)
expect(selector).toContain('data-testid="shadow-test"')
})

it('should traverse shadow boundary to build selector path', () => {
const host = appendElement('<div id="my-host"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
shadowRoot.appendChild(button)

const selector = getSelectorFromElement(button, undefined)
expect(selector).toBeDefined()
expect(selector).toContain('DIV')
expect(selector).toContain('BUTTON')
})

it('should handle nested shadow DOMs', () => {
const outerHost = appendElement('<div data-testid="outer-host"></div>')
const outerShadowRoot = outerHost.attachShadow({ mode: 'open' })

const innerHost = document.createElement('div')
innerHost.setAttribute('data-testid', 'inner-host')
outerShadowRoot.appendChild(innerHost)
const innerShadowRoot = innerHost.attachShadow({ mode: 'open' })

const button = document.createElement('button')
button.setAttribute('data-testid', 'deep-button')
innerShadowRoot.appendChild(button)

const selector = getSelectorFromElement(button, undefined)
expect(selector).toBeDefined()
expect(selector).toContain('data-testid="deep-button"')
})

it('should use position selector inside shadow DOM when no unique identifier', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })

const div1 = document.createElement('div')
const div2 = document.createElement('div')
const target = document.createElement('span')
div2.appendChild(target)
shadowRoot.appendChild(div1)
shadowRoot.appendChild(div2)

const selector = getSelectorFromElement(target, undefined)
expect(selector).toBeDefined()
expect(selector).toContain('SPAN')
})

it('should generate unique selector when siblings exist inside shadow DOM', () => {
const host = appendElement('<div id="host"></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })

const button1 = document.createElement('button')
button1.classList.add('first')
const button2 = document.createElement('button')
button2.classList.add('second')

shadowRoot.appendChild(button1)
shadowRoot.appendChild(button2)

const selector1 = getSelectorFromElement(button1, undefined)
const selector2 = getSelectorFromElement(button2, undefined)

expect(selector1).toBeDefined()
expect(selector2).toBeDefined()
expect(selector1).not.toBe(selector2)
})
})
Loading