Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,27 @@ describe('getActionNameFromElement', () => {
expectedName: 'foo bar baz',
expectedNameSource: 'text_content',
},
{
html: `
<div data-dd-privacy="mask-unless-allowlisted" aria-label="bar" target>
<span>bar</span>
</div>
`,
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
expectedName: 'Masked Element',
expectedNameSource: 'standard_attribute',
},
{
html: `
<div data-dd-privacy="mask-unless-allowlisted" aria-label="foo" target>
<span>bar</span>
</div>
`,
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
allowlist: ['foo'],
expectedName: 'foo',
expectedNameSource: 'standard_attribute',
},
]
testCases.forEach(({ html, defaultPrivacyLevel, allowlist, expectedName, expectedNameSource }) => {
mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
Expand Down
26 changes: 18 additions & 8 deletions packages/rum-core/src/domain/action/getActionNameFromElement.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ExperimentalFeature, isExperimentalFeatureEnabled, safeTruncate } from '@datadog/browser-core'
import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants'
import { getNodePrivacyLevel, shouldMaskNode } from '../privacy'
import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode } from '../privacy'
import type { NodePrivacyLevelCache } from '../privacy'
import type { RumConfiguration } from '../configuration'
import { isElementNode } from '../../browser/htmlDomUtils'
Expand Down Expand Up @@ -84,7 +84,7 @@ const priorityStrategies: NameStrategy[] = [
return getActionNameFromTextualContent(element, rumConfiguration)
}
},
(element) => getActionNameFromStandardAttribute(element, 'aria-label'),
(element, rumConfiguration) => getActionNameFromStandardAttribute(element, 'aria-label', rumConfiguration),
// associated element text designated by the aria-labelledby attribute
(element, rumConfiguration) => {
const labelledByAttribute = element.getAttribute('aria-labelledby')
Expand All @@ -100,10 +100,10 @@ const priorityStrategies: NameStrategy[] = [
}
}
},
(element) => getActionNameFromStandardAttribute(element, 'alt'),
(element) => getActionNameFromStandardAttribute(element, 'name'),
(element) => getActionNameFromStandardAttribute(element, 'title'),
(element) => getActionNameFromStandardAttribute(element, 'placeholder'),
(element, rumConfiguration) => getActionNameFromStandardAttribute(element, 'alt', rumConfiguration),
(element, rumConfiguration) => getActionNameFromStandardAttribute(element, 'name', rumConfiguration),
(element, rumConfiguration) => getActionNameFromStandardAttribute(element, 'title', rumConfiguration),
(element, rumConfiguration) => getActionNameFromStandardAttribute(element, 'placeholder', rumConfiguration),
// SELECT first OPTION text
(element, rumConfiguration) => {
if ('options' in element && element.options.length > 0) {
Expand Down Expand Up @@ -169,9 +169,19 @@ function getElementById(refElement: Element, id: string) {
return refElement.ownerDocument ? refElement.ownerDocument.getElementById(id) : null
}

function getActionNameFromStandardAttribute(element: Element | HTMLElement, attribute: string): ActionName {
function getActionNameFromStandardAttribute(
element: Element | HTMLElement,
attribute: string,
rumConfiguration: RumConfiguration
): ActionName {
const { enablePrivacyForActionName, defaultPrivacyLevel } = rumConfiguration
const nodeSelfPrivacyLevel = getNodePrivacyLevel(element, defaultPrivacyLevel)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I would avoid calling this nodeSelfPrivacyLevel, because that phrase has a particular meaning in the code (it's the privacy level of a node ignoring its ancestors), and this variable has a different meaning (it contains the privacy level of element considering its ancestors).

Suggested change
const nodeSelfPrivacyLevel = getNodePrivacyLevel(element, defaultPrivacyLevel)
const nodePrivacyLevel = getNodePrivacyLevel(element, defaultPrivacyLevel)

Copy link
Member

Choose a reason for hiding this comment

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

🔨 warning: ‏It's very inefficient to call getNodePrivacyLevel over and over again for each attribute / element ancestors. You could use the cache argument to make it faster.

const attributeValue = element.getAttribute(attribute)
return {
name: element.getAttribute(attribute) || '',
name:
enablePrivacyForActionName && nodeSelfPrivacyLevel && shouldMaskNode(element, nodeSelfPrivacyLevel, false)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think it makes sense to check if nodeSelfPrivacyLevel is truthy, because getNodePrivacyLevel always returns a valid privacy level:

Suggested change
enablePrivacyForActionName && nodeSelfPrivacyLevel && shouldMaskNode(element, nodeSelfPrivacyLevel, false)
enablePrivacyForActionName && shouldMaskNode(element, nodeSelfPrivacyLevel, false)

Looking at the bigger picture, it's unfortunate that we do all of the work of calling getNodePrivacyLevel() (which can be a bit expensive) and then ignore the result if enablePrivacyForActionName is false. It would be better to restructure this function so that you check enablePrivacyForActionName first, and then only call getNodePrivacyLevel() if enablePrivacyForActionName is true.

? maskDisallowedTextContent(attributeValue || '', ACTION_NAME_PLACEHOLDER)
: attributeValue || '',
Copy link
Member

Choose a reason for hiding this comment

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

💬 suggestion: ‏maybe don't do the expensive operations if the attribute is missing

let attributeValue = element.getAttribute(attribute)
if (attributeValue) {
  const nodePrivacyLevel = ...
  etc.
} else {
  attributeValue = ''
}
return { name: attributeValue, nameSource: ActionNameSource.STANDARD_ATTRIBUTE }

nameSource: ActionNameSource.STANDARD_ATTRIBUTE,
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/rum-core/src/domain/privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,16 @@ export function getNodeSelfPrivacyLevel(node: Node): NodePrivacyLevel | undefine
* Other `shouldMaskNode` cases are edge cases that should not matter too much (ex: should we mask a
* node if it is ignored or hidden? it doesn't matter since it won't be serialized).
*/
export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel) {
export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel, isTextContent: boolean = true) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of adding this extra argument to shouldMaskNode(), I would suggest creating a new shouldMaskAttribute() function in this file and migrating as much of the logic as you can from serializeAttribute() in serializeAttribute.ts into shouldMaskAttribute(). This would give us a central place to control privacy for attributes, similar to what we already have for nodes, instead of spreading the logic between multiple places in the code.

I think that you should be able to restructure serializeAttribute() so that it more or less looks like this:

export function serializeAttribute() {
  if (/* HIDDEN */) {
    // Handle HIDDEN case.
    return ...
  }
  if (shouldMaskAttribute(...)) {
    if (/* is one of the image cases */) {
      // Handle image cases.
      return ...
    }
    // We're in a non-image case.
    return CENSORED_STRING_MARK 
  }
  // Handle non-HIDDEN, non-masked cases...
  return
}

With this setup, we'll ensure that action names and session replay handle attribute masking in a consistent way.

switch (privacyLevel) {
case NodePrivacyLevel.MASK:
case NodePrivacyLevel.HIDDEN:
case NodePrivacyLevel.IGNORE:
return true
case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED:
if (!isTextContent) {
return true
}
if (isTextNode(node)) {
// Always return true if our parent is a form element, like MASK_USER_INPUT.
// Otherwise, decide whether to mask based on the allowlist.
Expand Down