Skip to content

[RUM-10415] [alt] add privacy allowlist support treewalker#3803

Merged
cy-moi merged 23 commits into
mainfrom
congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt
Sep 4, 2025
Merged

[RUM-10415] [alt] add privacy allowlist support treewalker#3803
cy-moi merged 23 commits into
mainfrom
congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt

Conversation

@cy-moi

@cy-moi cy-moi commented Aug 26, 2025

Copy link
Copy Markdown
Contributor

Motivation

Mask action name with allowlists generated from rum privacy build plugin(WIP). This approach is purely client side and allowlist-based. We aim to mask all action names (custom & auto) OOTB with build time configuration using a build plugin.

This PR is a diverge from #3751 to add remove automatically applying on action names.

Changes

  • Add allowlist processing helpers
  • Mask all action names with the allowlist when privacy build plugin and configurations are opt-in

Test instructions

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.

Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts Outdated
Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts
Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts Outdated
Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts Outdated
Comment thread packages/rum-core/src/domain/privacy.spec.ts Outdated
Comment thread packages/rum-core/src/domain/privacy.spec.ts Outdated
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
rejectInvisibleOrMaskedElementsFilter
)
const allowlistMaskEnabled = nodePrivacyLevel === NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This isn't quite right, unfortunately. We are using the privacy level of the target node here for all the descendant nodes, but that isn't how it should work; we should instead be using the privacy level of each individual node.

Ideally we'd handle it in exactly the same way that normal masking is handled: as part of rejectInvisibleOrMaskedElementsFilter. Indeed, you are doing that, because you updated shouldMaskNode() to return true for NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED, and rejectInvisibleOrMaskedElementsFilter uses shouldMaskNode() to do its work.

That means that you should, in an ideal world, be able to revert all changes to getTextualContentWithTreeWalker(). You shouldn't need allowlistMaskEnabled since rejectInvisibleOrMaskedElementsFilter will skip MASK_UNLESS_ALLOWLISTED elements. (You'd also need to update the } else if (nodePrivacyLevel === NodePrivacyLevel.MASK) { check in getActionNameFromElement() to check for MASK_UNLESS_ALLOWLISTED as well.)

BUT: maintaining consistency here is kinda a bummer, because the current approach we use for NodePrivacyLevel.MASK is not great. By skipping all descendants of nodes with privacy level MASK, we make it impossible to apply MASK at a higher level and selectively override it. We'd be transferring that limitation to MASK_UNLESS_ALLOWLISTED if we maintain consistency. Not ideal considering we want people to adopt this feature, in most cases, by setting defaultPrivacyLevel, so we expect almost every node in the document to have privacy level MASK_UNLESS_ALLOWLISTED.

We're caught between a rock and a hard place here. Adopting the existing behavior of MASK is not good for the usability of this feature (and frankly not good for the usability of MASK), but it will be really confusing if MASK and MASK_UNLESS_ALLOWLISTED have different inheritance rules.

The best solution may be to allow overriding for both MASK and MASK_UNLESS_ALLOWLISTED when the tree walker is enabled, and gate the tree walker with a new value for enablePrivacyForActionName. We can then try to migrate everybody over to the new model in a future major. But we should discuss this; it's a bigger issue than what can be resolved in a PR review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So, after some additional discussion, we concluded that we'll ship with a version that maintains consistency with the existing treatment of NodePrivacyLevel.MASK, and then solve the other issues as a separate task.

After some thought, I think that means that the handling of NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED must be different than NodePrivacyLevel.MASK. That's because MASK_UNLESS_ALLOWLISTED just will not work right unless it's applied at the text node level.

Say you have this setup:

<div id="target" dd-privacy-level="mask-unless-allowlisted">
  <span id="a">a</span>
  <span id="b">b</span>
</div>

Say that both a and b are allowlisted strings.

What should shouldMaskNode() return for #target?

If we say "shouldMaskNode() should return true, since #target is MASK_UNLESS_ALLOWLISTED", then in the enablePrivacyForActionName: true model we will stop recursion there, and neither a or b will appear in the action name, even though they're both allowlisted.

If we say "shouldMaskNode() should return true if its text content is allowlisted", then the situation doesn't seem any better. #target not a text node, so it doesn't have its own text content. If we use its innerText, then we get ab, and ab is not allowlisted, so again we will stop recursion there and neither a nor b will appear in the action name.

The only reasonable answer is that shouldMaskNode() should return false for #target. This implies that we can't use the same strategy we use for today's version of MASK; there's no easy way for us to stop traversal when we see MASK_UNLESS_ALLOWLISTED, because we don't know whether it actually means "mask" or "don't mask" until we go deeper in the tree. (And the answer may be different for different descendant elements.) shouldMaskNode() can only reasonably return true based on MASK_UNLESS_ALLOWLISTED for text nodes.

Handling this would be easier if you switched to a recursive implementation, as @BenoitZugmeyer suggested, but we should be able to make it work with the tree walker as well. (Though with less ideal performance.) I'll add some comments.

@sethfowler-datadog sethfowler-datadog Aug 27, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In rejectInvisibleOrMaskedElementsfilter(), you should switch from getNodeSelfPrivacyLevel() to getNodePrivacyLevel(). This is part that's not ideal from a performance perspective. You can reduce the impact by providing a NodePrivacyLevelCache argument, so that at least we won't compute the answer over and over for the same nodes.

You will also need to update rejectInvisibleOrMaskedElementsFilter() to call getNodePrivacyLevel() even for text nodes. (Right now text nodes are always accepted, but now we need to reject them sometimes.)

NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
rejectInvisibleOrMaskedElementsFilter
)
const allowlistMaskEnabled = nodePrivacyLevel === NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So, after some additional discussion, we concluded that we'll ship with a version that maintains consistency with the existing treatment of NodePrivacyLevel.MASK, and then solve the other issues as a separate task.

After some thought, I think that means that the handling of NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED must be different than NodePrivacyLevel.MASK. That's because MASK_UNLESS_ALLOWLISTED just will not work right unless it's applied at the text node level.

Say you have this setup:

<div id="target" dd-privacy-level="mask-unless-allowlisted">
  <span id="a">a</span>
  <span id="b">b</span>
</div>

Say that both a and b are allowlisted strings.

What should shouldMaskNode() return for #target?

If we say "shouldMaskNode() should return true, since #target is MASK_UNLESS_ALLOWLISTED", then in the enablePrivacyForActionName: true model we will stop recursion there, and neither a or b will appear in the action name, even though they're both allowlisted.

If we say "shouldMaskNode() should return true if its text content is allowlisted", then the situation doesn't seem any better. #target not a text node, so it doesn't have its own text content. If we use its innerText, then we get ab, and ab is not allowlisted, so again we will stop recursion there and neither a nor b will appear in the action name.

The only reasonable answer is that shouldMaskNode() should return false for #target. This implies that we can't use the same strategy we use for today's version of MASK; there's no easy way for us to stop traversal when we see MASK_UNLESS_ALLOWLISTED, because we don't know whether it actually means "mask" or "don't mask" until we go deeper in the tree. (And the answer may be different for different descendant elements.) shouldMaskNode() can only reasonably return true based on MASK_UNLESS_ALLOWLISTED for text nodes.

Handling this would be easier if you switched to a recursive implementation, as @BenoitZugmeyer suggested, but we should be able to make it work with the tree walker as well. (Though with less ideal performance.) I'll add some comments.

Comment on lines +285 to +277
text += node.textContent || ''
text += allowlistMaskEnabled
? maskDisallowedTextContent(node.textContent || '', ACTION_NAME_MASK)
: node.textContent || ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can revert this change, since we'll rely on rejectInvisibleOrMaskedElementsFilter() to exclude MASK_UNLESS_ALLOWLISTED elements that need to be masked.

NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
rejectInvisibleOrMaskedElementsFilter
)
const allowlistMaskEnabled = nodePrivacyLevel === NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED

@sethfowler-datadog sethfowler-datadog Aug 27, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In rejectInvisibleOrMaskedElementsfilter(), you should switch from getNodeSelfPrivacyLevel() to getNodePrivacyLevel(). This is part that's not ideal from a performance perspective. You can reduce the impact by providing a NodePrivacyLevelCache argument, so that at least we won't compute the answer over and over for the same nodes.

You will also need to update rejectInvisibleOrMaskedElementsFilter() to call getNodePrivacyLevel() even for text nodes. (Right now text nodes are always accepted, but now we need to reject them sometimes.)

Comment thread packages/rum-core/src/domain/privacy.ts Outdated
case NodePrivacyLevel.MASK:
case NodePrivacyLevel.HIDDEN:
case NodePrivacyLevel.IGNORE:
case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED:

@sethfowler-datadog sethfowler-datadog Aug 27, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of unconditionally returning true here, you should use an implementation like this:

case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED:
  return isTextNode() ? !isAllowlisted(node.textContent) : false

@cy-moi cy-moi Aug 28, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think this works with Session Replay serializeAttributes which censors attribtutes depending on the element privacy level. So I moved back this logic inside rejectInvisibleOrMaskedElementsfilter() again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Per discussion on slack, this is fixed by handling form elements like mask-user-input does (cr. @sethfowler-datadog 's solution)

@datadog-official

datadog-official Bot commented Aug 27, 2025

Copy link
Copy Markdown

⚠️ Tests

⚠️ Warnings

❄️ 2 New flaky tests detected

domMutationObservable collects DOM mutation when a text node is added from Chrome 137.0.0.0 (Android 10) (Datadog)
Error: Expected 2 to be 1.
    at <Jasmine>
    at webpack:///packages/rum-core/src/browser/domMutationObservable.spec.ts:26:25 <- /tmp/_karma_webpack_74325/commons.js:82783:33
startRecorderInitTelemetry should not collect recorder init metrics telemetry when telemetry is disabled from Firefox 67.0 (Windows 10) (Datadog)
Expected true to be false.
<Jasmine>
./packages/rum/src/domain/startRecorderInitTelemetry.spec.ts/</<@webpack:///packages/rum/src/domain/startRecorderInitTelemetry.spec.ts:150:41 <- /tmp/_karma_webpack_730007/commons.js:117764:45

ℹ️ Info

🧪 All tests passed

🎯 Code Coverage
Patch Coverage: 100.00%
Total Coverage: 92.61% (+0.04%)

View detailed report

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 7b6f97a | Docs | Was this helpful? Give us feedback!

@cy-moi
cy-moi force-pushed the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch from 45cff17 to 66155c2 Compare September 1, 2025 11:44
@cy-moi
cy-moi marked this pull request as ready for review September 2, 2025 08:35
@cy-moi
cy-moi requested a review from a team as a code owner September 2, 2025 08:35
@cit-pr-commenter

cit-pr-commenter Bot commented Sep 3, 2025

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 155.93 KiB 156.51 KiB 598 B 0.37%
Rum Recorder 19.53 KiB 19.53 KiB 0 B 0.00%
Rum Profiler 5.18 KiB 5.18 KiB 0 B 0.00%
Logs 55.12 KiB 55.12 KiB 0 B 0.00%
Flagging N/A 931 B 931 B N/A%
Rum Slim 113.80 KiB 114.29 KiB 503 B 0.43%
Worker 23.60 KiB 23.60 KiB 0 B 0.00%
🚀 CPU Performance
Action Name Base Average Cpu Time (ms) Local Average Cpu Time (ms) 𝚫
addglobalcontext 0.005 0.008 0.002
addaction 0.015 0.024 0.009
addtiming 0.004 0.005 0.001
adderror 0.015 0.023 0.007
startstopsessionreplayrecording 0.001 0.001 0.000
startview 0.004 0.007 0.002
logmessage 0.019 0.026 0.006
🧠 Memory Performance
Action Name Base Consumption Memory (bytes) Local Consumption Memory (bytes) 𝚫 (bytes)
addglobalcontext 24.77 KiB 25.19 KiB 431 B
addaction 43.47 KiB 45.14 KiB 1.67 KiB
addtiming 25.22 KiB 25.07 KiB -147 B
adderror 48.54 KiB 50.79 KiB 2.25 KiB
startstopsessionreplayrecording 24.52 KiB 24.43 KiB -93 B
startview 421.01 KiB 428.03 KiB 7.02 KiB
logmessage 42.04 KiB 42.44 KiB 409 B

🔗 RealWorld

Comment thread packages/rum-core/src/domain/privacy.ts
Comment thread packages/rum-core/src/domain/privacy.ts Outdated
})

it('returns true if the privacy level is not ALLOW nor MASK_USER_INPUT', () => {
it('returns true if the privacy level is not ALLOW nor MASK_USER_INPUT or MASK_UNLESS_ALLOWLISTED', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test no longer checks MASK_UNLESS_ALLOWLISTED (which is correct, since shouldMaskNode() doesn't necessarily return true for MASK_UNLESS_ALLOWLISTED now!) but the test name still includes MASK_UNLESS_ALLOWLISTED. So, you'll want to revert this change to the test name.

In addition, you should add some new tests in this describe() block that cover the different cases for MASK_UNLESS_ALLOWLISTED. Off the top of my head, you should check:

  1. Text nodes which have allowlisted textContent.
  2. Text nodes with whitespace-only textContent.
  3. Text nodes which have non-allowlisted, non-whitespace textContent.
  4. Non-text nodes (e.g. normal HTML elements).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The test name is return true if ... is not ..., so I assumed we should add MASK_UNLESS_ALLOWLISTED there?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah yes, you're absolutely right; the is not threw me off! Current test name LGTM in that case.

Comment thread packages/rum-core/src/domain/privacy.spec.ts Outdated
Comment thread packages/rum-core/src/domain/privacy.spec.ts
Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts Outdated
Comment thread packages/rum-core/src/domain/action/getActionNameFromElement.ts Outdated
Comment thread packages/rum-core/src/domain/action/getActionNameFromElement.ts Outdated
Comment thread packages/rum-core/src/domain/action/trackClickActions.spec.ts Outdated
Comment thread packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts Outdated
@cy-moi
cy-moi force-pushed the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch from 033ba89 to a126bc8 Compare September 4, 2025 12:11
@cy-moi
cy-moi force-pushed the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch from a126bc8 to 3940be6 Compare September 4, 2025 12:24
const serializedDoc = generateLeanSerializedDoc(HTML, 'mask-unless-allowlisted')
const textContents = getAllTextContents(serializedDoc)
for (const textContent of textContents) {
if (textContent && isAllowlisted(textContent)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this fail if you don't include the textContent && part here? If so, don't we have a bug? isAllowlisted() should be treating all whitespace-only strings as allowlisted. So, I think this is all you need:

Suggested change
if (textContent && isAllowlisted(textContent)) {
if (isAllowlisted(textContent)) {

If the test fails without the textContent && part, it'd be good to understand why.

Comment on lines +913 to +918
const textContents = getAllTextContents(serializedDoc)
for (const textContent of textContents) {
if (textContent.trim() && !isAllowlisted(textContent)) {
expect(textContent).toEqual(jasmine.stringMatching(/^[x\s*]*$/))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You marked this one as resolved but it seems like you didn't change the test... 😃

I'm realizing now that this test doesn't test the thing it says it's testing anyway. The test name suggests this test is a test about both attributes and text content, but the test expectations involve only text content. It seems to me that you have text content pretty well covered by the other tests, so maybe remove text content from the test name; let's make this test focus on attributes and ensure that we have test coverage for them.

So, first, we need the body of the test to be like this:

Suggested change
const textContents = getAllTextContents(serializedDoc)
for (const textContent of textContents) {
if (textContent.trim() && !isAllowlisted(textContent)) {
expect(textContent).toEqual(jasmine.stringMatching(/^[x\s*]*$/))
}
}
const attributeValues = getAllAttributeValues(serializedDoc)
for (const attributeValue of attributeValues) {
if (isAllowlisted(attributeValue)) {
expect(attributeValue).not.toEqual(jasmine.stringMatching(/^[x\s*]*$/))
} else {
expect(attributeValue).toEqual(jasmine.stringMatching(/^[x\s*]*$/))
}
}

Then you just need to implement getAllAttributeValues(), following the pattern established by getAllTextContents().

Comment thread packages/rum/src/domain/record/serialization/serializeNode.spec.ts Outdated
textNode = document.createTextNode('')
})

it('returns false for allowlisted text content', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, having these thorough tests will give us a lot of peace of mind down the road. Thank you!

userProgrammaticAttribute: string | undefined,
privacyEnabledActionName: boolean
rumConfiguration: RumConfiguration,
nodePrivacyLevel: NodePrivacyLevel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: It's a bit tricky to tell from the diff, but it seems like none of the strategies use the nodePrivacyLevel parameter defined in this type anymore, so you can probably just delete this line. (But only if that's true!)

Suggested change
nodePrivacyLevel: NodePrivacyLevel

You'll also need to remove the nodePrivacyLevel parameter from getActionNameFromElementForStrategies().

@cy-moi
cy-moi force-pushed the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch from 325361d to fa6b2bd Compare September 4, 2025 14:33
@cy-moi
cy-moi force-pushed the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch from fa6b2bd to fa26d8c Compare September 4, 2025 14:39

@sethfowler-datadog sethfowler-datadog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, with one final nit:

;(window as BrowserWindow).$DD_ALLOW = undefined
})

it('obfuscates text content not in allowlist', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So now this test checks attributes, but the test name says obfuscates text content not in allowlist...

expect(JSON.stringify(serializedDoc)).toContain('***')
})

it('obfuscates attributes and non-allowlisted text content', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

...while this one checks text contents, but the test name says obfuscates attributes and non-allowlisted text content.

So the names are backwards. But the actual tests look good! Please swap the bodies of these two tests, and then I think we're good to go.

@cy-moi
cy-moi merged commit dc19986 into main Sep 4, 2025
20 checks passed
@cy-moi
cy-moi deleted the congyao/RUM-10415-add-privacy-allowlist-support-treewalker-alt branch September 4, 2025 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants