Skip to content

Commit 8fe0b89

Browse files
authored
⚗️ [RUM-13259]Track action names in shadow dom (#4044)
* ⚗️ Track action names in shadow dom * support for aria label * Add a privacy test * Edge case for aria-labelledby test * fix lint
1 parent 8c27e4b commit 8fe0b89

File tree

9 files changed

+274
-9
lines changed

9 files changed

+274
-9
lines changed

packages/rum-core/src/browser/htmlDomUtils.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isElementNode,
66
isNodeShadowRoot,
77
getParentNode,
8+
getParentElement,
89
isNodeShadowHost,
910
forEachChildNodes,
1011
hasChildNodes,
@@ -197,3 +198,23 @@ describe('getParentNode', () => {
197198
})
198199
})
199200
})
201+
202+
describe('getParentElement', () => {
203+
it('should return parentElement for normal element', () => {
204+
const parent = document.createElement('div')
205+
const child = document.createElement('span')
206+
parent.appendChild(child)
207+
208+
expect(getParentElement(child)).toBe(parent)
209+
})
210+
211+
it('should return the shadow host for element inside shadow DOM', () => {
212+
const host = document.createElement('div')
213+
const shadowRoot = host.attachShadow({ mode: 'open' })
214+
const button = document.createElement('button')
215+
shadowRoot.appendChild(button)
216+
217+
expect(button.parentElement).toBe(null)
218+
expect(getParentElement(button)).toBe(host)
219+
})
220+
})

packages/rum-core/src/browser/htmlDomUtils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,18 @@ export function forEachChildNodes(node: Node, callback: (child: Node) => void) {
4343
export function getParentNode(node: Node): Node | null {
4444
return isNodeShadowRoot(node) ? node.host : node.parentNode
4545
}
46+
47+
/**
48+
* Return the parent element, crossing shadow DOM boundaries.
49+
* If the element is a direct child of a shadow root, returns the shadow host.
50+
*/
51+
export function getParentElement(element: Element): Element | null {
52+
if (element.parentElement) {
53+
return element.parentElement
54+
}
55+
const parentNode = element.parentNode
56+
if (parentNode && isNodeShadowRoot(parentNode)) {
57+
return parentNode.host
58+
}
59+
return null
60+
}

packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,4 +930,87 @@ describe('getActionNameFromElement', () => {
930930
})
931931
})
932932
})
933+
934+
describe('shadow DOM support', () => {
935+
it('extracts text content from element inside shadow DOM', () => {
936+
const host = appendElement('<div></div>')
937+
const shadowRoot = host.attachShadow({ mode: 'open' })
938+
const button = document.createElement('button')
939+
button.textContent = 'Shadow Button'
940+
shadowRoot.appendChild(button)
941+
942+
const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
943+
expect(name).toBe('Shadow Button')
944+
expect(nameSource).toBe('text_content')
945+
})
946+
947+
it('finds data-dd-action-name on shadow host when element is inside shadow DOM', () => {
948+
const host = appendElement('<div data-dd-action-name="Custom Action"></div>')
949+
const shadowRoot = host.attachShadow({ mode: 'open' })
950+
const target = document.createElement('button')
951+
shadowRoot.appendChild(target)
952+
953+
const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
954+
expect(name).toBe('Custom Action')
955+
expect(nameSource).toBe('custom_attribute')
956+
})
957+
958+
it('traverses parent elements across shadow boundary to find action name', () => {
959+
const host = appendElement('<div></div>')
960+
const shadowRoot = host.attachShadow({ mode: 'open' })
961+
const wrapper = document.createElement('div')
962+
wrapper.setAttribute('title', 'Parent Title')
963+
const target = document.createElement('span')
964+
wrapper.appendChild(target)
965+
shadowRoot.appendChild(wrapper)
966+
967+
const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration)
968+
expect(name).toBe('Parent Title')
969+
expect(nameSource).toBe('standard_attribute')
970+
})
971+
972+
it('finds aria-labelledby element inside the same shadow DOM', () => {
973+
const host = appendElement('<div></div>')
974+
const shadowRoot = host.attachShadow({ mode: 'open' })
975+
976+
const label = document.createElement('span')
977+
label.id = 'shadow-label'
978+
label.textContent = 'Save Data'
979+
shadowRoot.appendChild(label)
980+
981+
const button = document.createElement('button')
982+
button.setAttribute('aria-labelledby', 'shadow-label')
983+
shadowRoot.appendChild(button)
984+
985+
const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
986+
expect(name).toBe('Save Data')
987+
expect(nameSource).toBe('text_content')
988+
})
989+
990+
it('falls back to document when aria-labelledby references an element outside shadow DOM', () => {
991+
appendElement('<span id="light-dom-label">External Label</span>')
992+
993+
const host = appendElement('<div></div>')
994+
const shadowRoot = host.attachShadow({ mode: 'open' })
995+
996+
const button = document.createElement('button')
997+
button.setAttribute('aria-labelledby', 'light-dom-label')
998+
shadowRoot.appendChild(button)
999+
1000+
const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration)
1001+
expect(name).toBe('External Label')
1002+
expect(nameSource).toBe('text_content')
1003+
})
1004+
1005+
it('respects privacy settings for elements inside shadow DOM', () => {
1006+
const host = appendElement('<div></div>')
1007+
const shadowRoot = host.attachShadow({ mode: 'open' })
1008+
const button = document.createElement('button')
1009+
button.textContent = 'Secret Button'
1010+
shadowRoot.appendChild(button)
1011+
1012+
const { name } = getActionNameFromElement(button, defaultConfiguration, NodePrivacyLevel.MASK)
1013+
expect(name).toBe('Masked Element')
1014+
})
1015+
})
9331016
})

packages/rum-core/src/domain/action/getActionNameFromElement.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants'
33
import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode, shouldMaskAttribute } from '../privacy'
44
import type { NodePrivacyLevelCache } from '../privacy'
55
import type { RumConfiguration } from '../configuration'
6-
import { isElementNode } from '../../browser/htmlDomUtils'
6+
import { isElementNode, getParentElement } from '../../browser/htmlDomUtils'
77
import {
88
ActionNameSource,
99
DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE,
@@ -49,7 +49,7 @@ export function getActionNameFromElement(
4949
function getActionNameFromElementProgrammatically(targetElement: Element, programmaticAttribute: string) {
5050
// We don't use getActionNameFromElementForStrategies here, because we want to consider all parents,
5151
// without limit. It is up to the user to declare a relevant naming strategy.
52-
const elementWithAttribute = targetElement.closest(`[${programmaticAttribute}]`)
52+
const elementWithAttribute = closestShadowAware(targetElement, `[${programmaticAttribute}]`)
5353

5454
if (!elementWithAttribute) {
5555
return
@@ -58,6 +58,17 @@ function getActionNameFromElementProgrammatically(targetElement: Element, progra
5858
return truncate(normalizeWhitespace(name.trim()))
5959
}
6060

61+
function closestShadowAware(element: Element, selector: string): Element | null {
62+
let current: Element | null = element
63+
while (current) {
64+
if (current.matches(selector)) {
65+
return current
66+
}
67+
current = getParentElement(current)
68+
}
69+
return null
70+
}
71+
6172
type NameStrategy = (
6273
element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement,
6374
rumConfiguration: RumConfiguration,
@@ -160,7 +171,7 @@ function getActionNameFromElementForStrategies(
160171
if (element.nodeName === 'FORM') {
161172
break
162173
}
163-
element = element.parentElement
174+
element = getParentElement(element)
164175
recursionCounter += 1
165176
}
166177
}
@@ -173,7 +184,15 @@ function truncate(s: string) {
173184
return s.length > 100 ? `${safeTruncate(s, 100)} [...]` : s
174185
}
175186

176-
function getElementById(refElement: Element, id: string) {
187+
function getElementById(refElement: Element, id: string): HTMLElement | null {
188+
const rootNode = refElement.getRootNode()
189+
if (rootNode instanceof ShadowRoot) {
190+
const shadowElement = rootNode.getElementById(id)
191+
if (shadowElement) {
192+
return shadowElement
193+
}
194+
}
195+
177196
// Use the element ownerDocument here, because tests are executed in an iframe, so
178197
// document.getElementById won't work.
179198
return refElement.ownerDocument ? refElement.ownerDocument.getElementById(id) : null

packages/rum-core/src/domain/action/trackClickActions.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,45 @@ describe('trackClickActions', () => {
619619
function createFakeErrorEvent() {
620620
return { type: RumEventType.ERROR, action: { id: findActionId() } } as AssembledRumEvent
621621
}
622+
623+
describe('shadow DOM support', () => {
624+
let shadowHost: HTMLElement
625+
let shadowButton: HTMLButtonElement
626+
627+
beforeEach(() => {
628+
shadowHost = document.createElement('div')
629+
shadowHost.id = 'shadow-host'
630+
shadowHost.style.width = '100px'
631+
shadowHost.style.height = '100px'
632+
document.body.appendChild(shadowHost)
633+
634+
const shadowRoot = shadowHost.attachShadow({ mode: 'open' })
635+
shadowButton = document.createElement('button')
636+
shadowButton.textContent = 'Shadow Button'
637+
shadowRoot.appendChild(shadowButton)
638+
})
639+
640+
afterEach(() => {
641+
shadowHost.remove()
642+
})
643+
644+
it('with betaTrackActionsInShadowDom, gets action name from composedPath', () => {
645+
startClickActionsTracking({ betaTrackActionsInShadowDom: true })
646+
647+
emulateClick({
648+
target: shadowHost,
649+
activity: {},
650+
eventProperty: {
651+
composed: true,
652+
composedPath: () => [shadowButton, shadowHost.shadowRoot, shadowHost, document.body, document],
653+
},
654+
})
655+
clock.tick(EXPIRE_DELAY)
656+
657+
expect(events.length).toBe(1)
658+
expect(events[0].name).toBe('Shadow Button')
659+
})
660+
})
622661
})
623662

624663
describe('finalizeClicks', () => {

packages/rum-core/src/domain/action/trackClickActions.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createValueHistory,
1010
relativeToClocks,
1111
} from '@datadog/browser-core'
12+
import { isNodeShadowHost } from '../../browser/htmlDomUtils'
1213
import type { FrustrationType } from '../../rawRumEvent.types'
1314
import { ActionType } from '../../rawRumEvent.types'
1415
import type { LifeCycle } from '../lifeCycle'
@@ -142,10 +143,14 @@ function processPointerDown(
142143
pointerDownEvent: MouseEventOnElement,
143144
windowOpenObservable: Observable<void>
144145
) {
146+
const targetForPrivacy = configuration.betaTrackActionsInShadowDom
147+
? getEventTarget(pointerDownEvent)
148+
: pointerDownEvent.target
149+
145150
let nodePrivacyLevel: NodePrivacyLevel
146151

147152
if (configuration.enablePrivacyForActionName) {
148-
nodePrivacyLevel = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel)
153+
nodePrivacyLevel = getNodePrivacyLevel(targetForPrivacy, configuration.defaultPrivacyLevel)
149154
} else {
150155
nodePrivacyLevel = NodePrivacyLevel.ALLOW
151156
}
@@ -248,13 +253,16 @@ function computeClickActionBase(
248253
nodePrivacyLevel: NodePrivacyLevel,
249254
configuration: RumConfiguration
250255
): ClickActionBase {
251-
const rect = event.target.getBoundingClientRect()
252-
const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute)
256+
const selectorTarget = event.target
257+
const rect = selectorTarget.getBoundingClientRect()
258+
const selector = getSelectorFromElement(selectorTarget, configuration.actionNameAttribute)
259+
253260
if (selector) {
254261
updateInteractionSelector(event.timeStamp, selector)
255262
}
256263

257-
const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel)
264+
const nameTarget = configuration.betaTrackActionsInShadowDom ? getEventTarget(event) : event.target
265+
const { name, nameSource } = getActionNameFromElement(nameTarget, configuration, nodePrivacyLevel)
258266

259267
return {
260268
type: ActionType.CLICK,
@@ -273,6 +281,16 @@ function computeClickActionBase(
273281
}
274282
}
275283

284+
function getEventTarget(event: MouseEventOnElement): Element {
285+
if (event.composed && isNodeShadowHost(event.target) && typeof event.composedPath === 'function') {
286+
const composedPath = event.composedPath()
287+
if (composedPath.length > 0 && composedPath[0] instanceof Element) {
288+
return composedPath[0]
289+
}
290+
}
291+
return event.target
292+
}
293+
276294
const enum ClickStatus {
277295
// Initial state, the click is still ongoing.
278296
ONGOING,

packages/rum-core/src/domain/configuration/configuration.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ describe('serializeRumConfiguration', () => {
630630
trackFeatureFlagsForEvents: ['vital'],
631631
profilingSampleRate: 42,
632632
propagateTraceBaggage: true,
633+
betaTrackActionsInShadowDom: true,
633634
}
634635

635636
type MapRumInitConfigurationKey<Key extends string> = Key extends keyof InitConfiguration
@@ -644,7 +645,8 @@ describe('serializeRumConfiguration', () => {
644645
: Key extends 'trackLongTasks'
645646
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
646647
: // The following options are not reported as telemetry. Please avoid adding more of them.
647-
Key extends 'applicationId' | 'subdomain'
648+
// TODO: Add betaTrackActionsInShadowDom to rum-events-format and remove from this exclusion
649+
Key extends 'applicationId' | 'subdomain' | 'betaTrackActionsInShadowDom'
648650
? never
649651
: CamelToSnakeCase<Key>
650652
// By specifying the type here, we can ensure that serializeConfiguration is returning an

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,15 @@ export interface RumInitConfiguration extends InitConfiguration {
204204
*/
205205
actionNameAttribute?: string | undefined
206206

207+
/**
208+
* Enables accurate action names for clicks inside Shadow DOM elements.
209+
* ⚠️ This is a beta feature and will be updated in the future with selector tracking.
210+
*
211+
* @category Beta
212+
* @defaultValue false
213+
*/
214+
betaTrackActionsInShadowDom?: boolean | undefined
215+
207216
// view options
208217
/**
209218
* 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.
@@ -287,6 +296,7 @@ export interface GraphQlUrlOption {
287296
export interface RumConfiguration extends Configuration {
288297
// Built from init configuration
289298
actionNameAttribute: string | undefined
299+
betaTrackActionsInShadowDom: boolean
290300
traceSampleRate: number
291301
rulePsr: number | undefined
292302
allowedTracingUrls: TracingOption[]
@@ -359,6 +369,7 @@ export function validateAndBuildRumConfiguration(
359369
return {
360370
applicationId: initConfiguration.applicationId,
361371
actionNameAttribute: initConfiguration.actionNameAttribute,
372+
betaTrackActionsInShadowDom: !!initConfiguration.betaTrackActionsInShadowDom,
362373
sessionReplaySampleRate,
363374
startSessionReplayRecordingManually:
364375
initConfiguration.startSessionReplayRecordingManually !== undefined

0 commit comments

Comments
 (0)