Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
21 changes: 21 additions & 0 deletions packages/rum-core/src/browser/htmlDomUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isElementNode,
isNodeShadowRoot,
getParentNode,
getParentElement,
isNodeShadowHost,
forEachChildNodes,
hasChildNodes,
Expand Down Expand Up @@ -197,3 +198,23 @@ describe('getParentNode', () => {
})
})
})

describe('getParentElement', () => {
it('should return parentElement for normal element', () => {
const parent = document.createElement('div')
const child = document.createElement('span')
parent.appendChild(child)

expect(getParentElement(child)).toBe(parent)
})

it('should return the shadow host for element inside shadow DOM', () => {
const host = document.createElement('div')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
shadowRoot.appendChild(button)

expect(button.parentElement).toBe(null)
expect(getParentElement(button)).toBe(host)
})
})
15 changes: 15 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,18 @@ 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
}
const parentNode = element.parentNode
if (parentNode && isNodeShadowRoot(parentNode)) {
return parentNode.host
}
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -930,4 +930,72 @@ describe('getActionNameFromElement', () => {
})
})
})

describe('shadow DOM support', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 suggestion: ‏Could be nice to also test privacy settings with shadow dom?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea ! I added one 👍

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('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('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 aria-labelledby element inside the same shadow DOM', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })

const label = document.createElement('span')
label.id = 'shadow-label'
label.textContent = 'Save Data'
shadowRoot.appendChild(label)

const button = document.createElement('button')
button.setAttribute('aria-labelledby', 'shadow-label')
shadowRoot.appendChild(button)

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

it('respects privacy settings for elements inside shadow DOM', () => {
const host = appendElement('<div></div>')
const shadowRoot = host.attachShadow({ mode: 'open' })
const button = document.createElement('button')
button.textContent = 'Secret Button'
shadowRoot.appendChild(button)

const { name } = getActionNameFromElement(button, defaultConfiguration, NodePrivacyLevel.MASK)
expect(name).toBe('Masked Element')
})
})
})
27 changes: 23 additions & 4 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,7 @@ 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}]`)
const elementWithAttribute = closestShadowAware(targetElement, `[${programmaticAttribute}]`)

if (!elementWithAttribute) {
return
Expand All @@ -58,6 +58,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 @@ -160,7 +171,7 @@ function getActionNameFromElementForStrategies(
if (element.nodeName === 'FORM') {
break
}
element = element.parentElement
element = getParentElement(element)
recursionCounter += 1
}
}
Expand All @@ -173,7 +184,15 @@ function truncate(s: string) {
return s.length > 100 ? `${safeTruncate(s, 100)} [...]` : s
}

function getElementById(refElement: Element, id: string) {
function getElementById(refElement: Element, id: string): HTMLElement | null {
const rootNode = refElement.getRootNode()
if (rootNode instanceof ShadowRoot) {
const shadowElement = rootNode.getElementById(id)
if (shadowElement) {
return shadowElement
}
}

// Use the element ownerDocument here, because tests are executed in an iframe, so
// document.getElementById won't work.
return refElement.ownerDocument ? refElement.ownerDocument.getElementById(id) : null
Expand Down
39 changes: 39 additions & 0 deletions packages/rum-core/src/domain/action/trackClickActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,45 @@ describe('trackClickActions', () => {
function createFakeErrorEvent() {
return { type: RumEventType.ERROR, action: { id: findActionId() } } as AssembledRumEvent
}

describe('shadow DOM support', () => {
let shadowHost: HTMLElement
let shadowButton: HTMLButtonElement

beforeEach(() => {
shadowHost = document.createElement('div')
shadowHost.id = 'shadow-host'
shadowHost.style.width = '100px'
shadowHost.style.height = '100px'
document.body.appendChild(shadowHost)

const shadowRoot = shadowHost.attachShadow({ mode: 'open' })
shadowButton = document.createElement('button')
shadowButton.textContent = 'Shadow Button'
shadowRoot.appendChild(shadowButton)
})

afterEach(() => {
shadowHost.remove()
})

it('with betaTrackActionsInShadowDom, gets action name from composedPath', () => {
startClickActionsTracking({ betaTrackActionsInShadowDom: true })

emulateClick({
target: shadowHost,
activity: {},
eventProperty: {
composed: true,
composedPath: () => [shadowButton, shadowHost.shadowRoot, shadowHost, document.body, document],
},
})
clock.tick(EXPIRE_DELAY)

expect(events.length).toBe(1)
expect(events[0].name).toBe('Shadow Button')
})
})
})

describe('finalizeClicks', () => {
Expand Down
26 changes: 22 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 {
PageExitReason,
relativeToClocks,
} 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 @@ -147,10 +148,14 @@ function processPointerDown(
pointerDownEvent: MouseEventOnElement,
windowOpenObservable: Observable<void>
) {
const targetForPrivacy = configuration.betaTrackActionsInShadowDom
? getEventTarget(pointerDownEvent)
: pointerDownEvent.target

let nodePrivacyLevel: NodePrivacyLevel

if (configuration.enablePrivacyForActionName) {
nodePrivacyLevel = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel)
nodePrivacyLevel = getNodePrivacyLevel(targetForPrivacy, configuration.defaultPrivacyLevel)
} else {
nodePrivacyLevel = NodePrivacyLevel.ALLOW
}
Expand Down Expand Up @@ -248,13 +253,16 @@ function computeClickActionBase(
nodePrivacyLevel: NodePrivacyLevel,
configuration: RumConfiguration
): ClickActionBase {
const rect = event.target.getBoundingClientRect()
const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute)
const selectorTarget = event.target
const rect = selectorTarget.getBoundingClientRect()
const selector = getSelectorFromElement(selectorTarget, configuration.actionNameAttribute)

if (selector) {
updateInteractionSelector(event.timeStamp, selector)
}

const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel)
const nameTarget = configuration.betaTrackActionsInShadowDom ? getEventTarget(event) : event.target
const { name, nameSource } = getActionNameFromElement(nameTarget, configuration, nodePrivacyLevel)

return {
type: ActionType.CLICK,
Expand All @@ -273,6 +281,16 @@ function computeClickActionBase(
}
}

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.
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,
betaTrackActionsInShadowDom: 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: Add betaTrackActionsInShadowDom to rum-events-format and remove from this exclusion
Key extends 'applicationId' | 'subdomain' | 'betaTrackActionsInShadowDom'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
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 accurate action names for clicks inside Shadow DOM elements.
* ⚠️ This is a beta feature and will be updated in the future with selector tracking.
*
* @category Beta
* @defaultValue false
*/
betaTrackActionsInShadowDom?: boolean | undefined
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 thought: I don’t think we need the beta prefix. IMO, the implementation is not too complex to justify it.
I recommend putting the feature behind a feature flag so we can easily enable or disable it during dogfooding.


// 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
betaTrackActionsInShadowDom: 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,
betaTrackActionsInShadowDom: !!initConfiguration.betaTrackActionsInShadowDom,
sessionReplaySampleRate,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
Expand Down
Loading