From 489bf1764e5f27e77763f86dddc79c34e8e86fa1 Mon Sep 17 00:00:00 2001 From: zcy Date: Mon, 23 Jun 2025 15:06:03 +0200 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8Add=20support=20for=20privacy=20?= =?UTF-8?q?plugin=20extracted=20data=20for=20masking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/action/actionCollection.spec.ts | 40 +++++++ .../src/domain/action/actionCollection.ts | 35 ++++-- .../domain/action/getActionNameFromElement.ts | 3 +- .../action/privacy/allowedDictionary.spec.ts | 105 +++++++++++++++++ .../action/privacy/allowedDictionary.ts | 107 ++++++++++++++++++ .../src/domain/action/trackClickActions.ts | 7 +- 6 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts create mode 100644 packages/rum-core/src/domain/action/privacy/allowedDictionary.ts diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 6caeeb03e3..91b55814f8 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -188,4 +188,44 @@ describe('actionCollection', () => { }) }) }) + + describe('maskActionName', () => { + beforeEach(() => { + window.$DD_ALLOW = new Set(['foo-bar']) + // the listeners should have been registered successfully + window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) + }) + it('should mask custom action with the action name dictionary', () => { + addAction({ + name: 'foo bar baz', + startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, + type: ActionType.CUSTOM, + }) + + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar MASKED') + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') + }) + + it('should mask auto name with the action name dictionary', () => { + const event = createNewEvent('pointerup', { target: document.createElement('button') }) + lifeCycle.notify(LifeCycleEventType.AUTO_ACTION_COMPLETED, { + counts: { + errorCount: 0, + longTaskCount: 0, + resourceCount: 0, + }, + duration: -10 as Duration, + event, + events: [event], + frustrationTypes: [], + id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + name: 'foo bar baz', + nameSource: 'text_content', + startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, + type: ActionType.CLICK, + }) + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar MASKED') + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') + }) + }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index f5e865737e..7fa1b2b45f 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,6 +11,9 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts, ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' +import { addAllowlistObserver, createActionAllowList, maskActionName } from './privacy/allowedDictionary' +import type { AllowedDictionary } from './privacy/allowedDictionary' +import { ActionNameSource } from './getActionNameFromElement' export type { ActionContexts } @@ -31,9 +34,12 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { - lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) - ) + const actionNameDictionary = createActionAllowList() + addAllowlistObserver(actionNameDictionary) + + lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) + }) hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { if ( @@ -69,14 +75,19 @@ export function startActionCollection( return { addAction: (action: CustomAction) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) }, actionContexts, stop, } } -function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { +function processAction( + action: AutoAction | CustomAction, + actionNameDictionary: AllowedDictionary +): RawRumEventCollectedData { + const { name: updatedName, masked } = maskActionName(action.name, actionNameDictionary.allowlist) + const autoActionProperties = isAutoAction(action) ? { action: { @@ -99,16 +110,26 @@ function processAction(action: AutoAction | CustomAction): RawRumEventCollectedD action: { target: action.target, position: action.position, - name_source: action.nameSource, + name_source: masked ? ActionNameSource.MASK_DISALLOWED : action.nameSource, }, }, } : undefined + const actionEvent: RawRumActionEvent = combine( { - action: { id: generateUUID(), target: { name: action.name }, type: action.type }, + action: { id: generateUUID(), target: { name: updatedName }, type: action.type }, date: action.startClocks.timeStamp, type: RumEventType.ACTION as const, + ...(masked + ? { + _dd: { + action: { + name_source: ActionNameSource.MASK_DISALLOWED, + }, + }, + } + : {}), }, autoActionProperties ) diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.ts index ad54f187dc..3e53555541 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.ts @@ -14,8 +14,9 @@ export const enum ActionNameSource { TEXT_CONTENT = 'text_content', STANDARD_ATTRIBUTE = 'standard_attribute', BLANK = 'blank', + MASK_DISALLOWED = 'mask_disallowed', } -type ActionName = { +export type ActionName = { name: string nameSource: ActionNameSource } diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts new file mode 100644 index 0000000000..18f50d580f --- /dev/null +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -0,0 +1,105 @@ +import { + addAllowlistObserver, + createActionAllowList, + getMatchRegex, + processRawAllowList, + maskActionName, +} from './allowedDictionary' + +const TEST_STRINGS = { + EMOJI: '💥', + EMOJI_WITH_NUMBERS: '💥123', + SPECIAL_CHARS: '$$$', + SPECIAL_CHARS_WITH_NUMBERS: '$$$123', + HYPHENATED_SPECIAL_CHARS: '$$$-123', + COMPLEX_MIXED: 'test-<$>-123 hello>=42@world?', +} + +describe('allowedDictionary', () => { + beforeEach(() => { + window.$DD_ALLOW = new Set([ + TEST_STRINGS.EMOJI, + TEST_STRINGS.EMOJI_WITH_NUMBERS, + TEST_STRINGS.SPECIAL_CHARS, + TEST_STRINGS.SPECIAL_CHARS_WITH_NUMBERS, + TEST_STRINGS.HYPHENATED_SPECIAL_CHARS, + TEST_STRINGS.COMPLEX_MIXED, + ]) + }) + + afterEach(() => { + window.$DD_ALLOW_OBSERVERS = undefined + }) + + it('should not initialize if $DD_ALLOW is not defined', () => { + window.$DD_ALLOW = undefined as any + const dict = createActionAllowList() + expect(dict.lastRawString).toBeUndefined() + }) + + it('MATCH_REGEX matches words and symbols in TEST_STRINGS', () => { + expect(TEST_STRINGS.EMOJI.match(getMatchRegex())).toEqual(['💥']) + expect(TEST_STRINGS.EMOJI_WITH_NUMBERS.match(getMatchRegex())).toEqual(['💥123']) + expect(TEST_STRINGS.SPECIAL_CHARS.match(getMatchRegex())).toEqual(['$$$']) + expect(TEST_STRINGS.SPECIAL_CHARS_WITH_NUMBERS.match(getMatchRegex())).toEqual(['$$$123']) + expect(TEST_STRINGS.HYPHENATED_SPECIAL_CHARS.match(getMatchRegex())).toEqual(['$$$', '123']) + expect(TEST_STRINGS.COMPLEX_MIXED.match(getMatchRegex())).toEqual(['test', '<$>', '123', 'hello', '>=42', 'world']) + }) + + it('initializes allowlist with normalized words from $DD_ALLOW', () => { + const dict = createActionAllowList() + // EMOJI and EMOJI_WITH_NUMBERS + expect(dict.allowlist.has('123')).toBeTrue() + // COMPLEX_MIXED + expect(dict.allowlist.has('test')).toBeTrue() + expect(dict.allowlist.has('hello')).toBeTrue() + expect(dict.allowlist.has('>=42')).toBeTrue() + expect(dict.allowlist.has('world')).toBeTrue() + }) + + describe('maskAutoActionName', () => { + it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { + const dict = createActionAllowList() + const testString1 = maskActionName('test-💥-$>=123-pii', dict.allowlist) + expect(testString1.masked).toBeTrue() + expect(testString1.name).toBe('test-💥-MASKED-MASKED') + const testString2 = maskActionName('test-💥+123*hello wild', dict.allowlist) + expect(testString2.masked).toBeTrue() + expect(testString2.name).toBe('test-MASKED*hello MASKED') + }) + + it('handles empty string', () => { + const dict = createActionAllowList() + const result = maskActionName('', dict.allowlist) + expect(result.masked).toBeFalse() + expect(result.name).toBe('') + }) + }) + + it('updates dictionary when $DD_ALLOW changes', () => { + const dict = createActionAllowList() + expect(dict.allowlist.size).toBe(10) + + // Simulate a change in $DD_ALLOW + window.$DD_ALLOW?.add('new-Word') + window.$DD_ALLOW?.add('another-Word') + // Trigger the observer manually + processRawAllowList(window.$DD_ALLOW, dict) + + // Verify dictionary is updated with new words + expect(dict.allowlist.has('word')).toBeTrue() + expect(dict.allowlist.has('new')).toBeTrue() + expect(dict.allowlist.has('another')).toBeTrue() + // Old words should still be present + expect(dict.allowlist.size).toBe(13) + }) + + describe('addAllowlistObserver', () => { + it('creates a new set and add an observer if it does not exist', () => { + const dict = createActionAllowList() + window.$DD_ALLOW_OBSERVERS = undefined as any + addAllowlistObserver(dict) + expect(window.$DD_ALLOW_OBSERVERS?.size).toBe(1) + }) + }) +}) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts new file mode 100644 index 0000000000..9614009e5b --- /dev/null +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -0,0 +1,107 @@ +declare global { + interface Window { + $DD_ALLOW?: Set + $DD_ALLOW_OBSERVERS?: Set<() => void> + } +} + +export function getMatchRegex(): RegExp { + try { + new RegExp('\\p{Letter}', 'u') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + // Fallback to support european letters and apostrophes + return /(?:(?![×Þß÷þø])[a-zÀ-ÿ’])+|(?:(?!(?:(?![×Þß÷þø])[a-zÀ-ÿ’]))[^\s])+/gi + } + return /[\p{Letter}]+|[\p{Symbol}\p{Number}]+/gu +} + +const MAX_WORD_LENGTH = 20 +export type AllowedDictionary = { + updatedCounter: number + allowlist: Set + initializeAllowlist: () => void + lastRawString: SetIterator | undefined +} + +export function createActionAllowList(): AllowedDictionary { + const actionNameDictionary: AllowedDictionary = { + updatedCounter: 0, + allowlist: new Set(), + initializeAllowlist: () => { + if (!actionNameDictionary.allowlist || actionNameDictionary.allowlist.size === 0) { + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + } + }, + lastRawString: window.$DD_ALLOW?.values(), + } + actionNameDictionary.initializeAllowlist() + return actionNameDictionary +} + +export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary) { + if (!rawAllowlist) { + return + } + if (!dictionary.lastRawString) { + dictionary.lastRawString = rawAllowlist.values() + } + const size = rawAllowlist.size + let nextItem = dictionary.lastRawString.next() + while (dictionary.updatedCounter < size && nextItem.value) { + processRawString(nextItem.value, dictionary) + if (dictionary.updatedCounter !== size - 1) { + nextItem = dictionary.lastRawString.next() + } + dictionary.updatedCounter++ + } +} + +function processRawString(str: string, dictionary: AllowedDictionary) { + const words: string[] | null = str.match(getMatchRegex()) + if (words) { + for (const word of words) { + if (word.length > MAX_WORD_LENGTH) { + continue + } + const normalizeWord = word.toLocaleLowerCase() + if (!dictionary.allowlist.has(normalizeWord)) { + dictionary.allowlist.add(normalizeWord) + } + } + } +} + +export function addAllowlistObserver(dictionary: AllowedDictionary) { + if (!window.$DD_ALLOW_OBSERVERS) { + window.$DD_ALLOW_OBSERVERS = new Set<() => void>() + } + window.$DD_ALLOW_OBSERVERS.add(() => processRawAllowList(window.$DD_ALLOW, dictionary)) +} + +export function maskActionName( + name: string, + processedAllowlist: Set +): { + masked: boolean + name: string +} { + if (!window.$DD_ALLOW) { + return { + name, + masked: false, + } + } + + let masked = false + return { + name: name.replace(getMatchRegex(), (word: string) => { + if (!processedAllowlist.has(word.toLowerCase())) { + masked = true + return 'MASKED' + } + return word + }), + masked, + } +} diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 58bc50341d..7bf3e75d20 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -243,7 +243,8 @@ function computeClickActionBase( if (selector) { updateInteractionSelector(event.timeStamp, selector) } - const actionName = getActionNameFromElement(event.target, configuration, nodePrivacyLevel) + + const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel) return { type: ActionType.CLICK, @@ -257,8 +258,8 @@ function computeClickActionBase( x: Math.round(event.clientX - rect.left), y: Math.round(event.clientY - rect.top), }, - name: actionName.name, - nameSource: actionName.nameSource, + name, + nameSource, } } From c1e6461099e2ed2a262b3744df4429dbb3367706 Mon Sep 17 00:00:00 2001 From: zcy Date: Mon, 23 Jun 2025 15:29:20 +0200 Subject: [PATCH 02/16] fix: flaky test due to uncleared global var --- .../domain/action/privacy/allowedDictionary.spec.ts | 11 ++++++++++- .../src/domain/action/privacy/allowedDictionary.ts | 4 ---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index 18f50d580f..af6f135484 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -28,6 +28,7 @@ describe('allowedDictionary', () => { }) afterEach(() => { + window.$DD_ALLOW = undefined as any window.$DD_ALLOW_OBSERVERS = undefined }) @@ -57,7 +58,15 @@ describe('allowedDictionary', () => { expect(dict.allowlist.has('world')).toBeTrue() }) - describe('maskAutoActionName', () => { + describe('maskActionName', () => { + it('should not run if $DD_ALLOW is not defined', () => { + window.$DD_ALLOW = undefined as any + const dict = createActionAllowList() + const testString = maskActionName('mask-feature-off', dict.allowlist) + expect(testString.masked).toBeFalse() + expect(testString.name).toBe('mask-feature-off') + }) + it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { const dict = createActionAllowList() const testString1 = maskActionName('test-💥-$>=123-pii', dict.allowlist) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 9614009e5b..4611f7aad3 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -16,7 +16,6 @@ export function getMatchRegex(): RegExp { return /[\p{Letter}]+|[\p{Symbol}\p{Number}]+/gu } -const MAX_WORD_LENGTH = 20 export type AllowedDictionary = { updatedCounter: number allowlist: Set @@ -61,9 +60,6 @@ function processRawString(str: string, dictionary: AllowedDictionary) { const words: string[] | null = str.match(getMatchRegex()) if (words) { for (const word of words) { - if (word.length > MAX_WORD_LENGTH) { - continue - } const normalizeWord = word.toLocaleLowerCase() if (!dictionary.allowlist.has(normalizeWord)) { dictionary.allowlist.add(normalizeWord) From 052a04b350d756c4b68d8bbf5dd7b11b34ce7af2 Mon Sep 17 00:00:00 2001 From: zcy Date: Wed, 25 Jun 2025 09:31:16 +0200 Subject: [PATCH 03/16] fix: clear up dictionary and listeners; improve code; fix tests --- .../domain/action/actionCollection.spec.ts | 13 +- .../src/domain/action/actionCollection.ts | 20 +- .../action/privacy/allowedDictionary.spec.ts | 190 ++++++++++-------- .../action/privacy/allowedDictionary.ts | 79 +++++--- 4 files changed, 176 insertions(+), 126 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 91b55814f8..34d719b60a 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -190,11 +190,16 @@ describe('actionCollection', () => { }) describe('maskActionName', () => { - beforeEach(() => { + beforeAll(() => { window.$DD_ALLOW = new Set(['foo-bar']) - // the listeners should have been registered successfully + // notify the observer to process the allowlist window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) }) + + afterAll(() => { + window.$DD_ALLOW = undefined + }) + it('should mask custom action with the action name dictionary', () => { addAction({ name: 'foo bar baz', @@ -202,7 +207,7 @@ describe('actionCollection', () => { type: ActionType.CUSTOM, }) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar MASKED') + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar ***') expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') }) @@ -224,7 +229,7 @@ describe('actionCollection', () => { startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, type: ActionType.CLICK, }) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar MASKED') + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar ***') expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 7fa1b2b45f..1e7ac5b958 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,7 +11,7 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts, ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' -import { addAllowlistObserver, createActionAllowList, maskActionName } from './privacy/allowedDictionary' +import { createActionAllowList, maskActionName } from './privacy/allowedDictionary' import type { AllowedDictionary } from './privacy/allowedDictionary' import { ActionNameSource } from './getActionNameFromElement' @@ -35,11 +35,14 @@ export function startActionCollection( configuration: RumConfiguration ) { const actionNameDictionary = createActionAllowList() - addAllowlistObserver(actionNameDictionary) + const clearActionNameDictionary: () => void = actionNameDictionary.clear - lifeCycle.subscribe(LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) - }) + const { unsubscribe: unsubscribeAutoActionCompleted } = lifeCycle.subscribe( + LifeCycleEventType.AUTO_ACTION_COMPLETED, + (action) => { + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) + } + ) hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { if ( @@ -78,7 +81,11 @@ export function startActionCollection( lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) }, actionContexts, - stop, + stop: () => { + clearActionNameDictionary() + unsubscribeAutoActionCompleted() + stop() + }, } } @@ -87,7 +94,6 @@ function processAction( actionNameDictionary: AllowedDictionary ): RawRumEventCollectedData { const { name: updatedName, masked } = maskActionName(action.name, actionNameDictionary.allowlist) - const autoActionProperties = isAutoAction(action) ? { action: { diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index af6f135484..f7d9373769 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -1,114 +1,138 @@ -import { - addAllowlistObserver, - createActionAllowList, - getMatchRegex, - processRawAllowList, - maskActionName, -} from './allowedDictionary' +import { createActionAllowList, getMatchRegex, processRawAllowList, maskActionName } from './allowedDictionary' +import type { AllowedDictionary } from './allowedDictionary' const TEST_STRINGS = { - EMOJI: '💥', - EMOJI_WITH_NUMBERS: '💥123', - SPECIAL_CHARS: '$$$', - SPECIAL_CHARS_WITH_NUMBERS: '$$$123', - HYPHENATED_SPECIAL_CHARS: '$$$-123', - COMPLEX_MIXED: 'test-<$>-123 hello>=42@world?', + COMPLEX_MIXED: 'test-user-name:💥$$$, test-user-id:hello>=42@world?', + PARAGRAPH_MIXED: 'This is a test paragraph with various symbols: 💥, $$$, 123, and more.', } -describe('allowedDictionary', () => { - beforeEach(() => { - window.$DD_ALLOW = new Set([ - TEST_STRINGS.EMOJI, - TEST_STRINGS.EMOJI_WITH_NUMBERS, - TEST_STRINGS.SPECIAL_CHARS, - TEST_STRINGS.SPECIAL_CHARS_WITH_NUMBERS, - TEST_STRINGS.HYPHENATED_SPECIAL_CHARS, - TEST_STRINGS.COMPLEX_MIXED, - ]) +describe('createActionAllowList', () => { + beforeAll(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) }) - afterEach(() => { - window.$DD_ALLOW = undefined as any - window.$DD_ALLOW_OBSERVERS = undefined + afterAll(() => { + window.$DD_ALLOW = undefined }) - it('should not initialize if $DD_ALLOW is not defined', () => { - window.$DD_ALLOW = undefined as any - const dict = createActionAllowList() - expect(dict.lastRawString).toBeUndefined() + it('should create an action name dictionary', () => { + const actionNameDictionary = createActionAllowList() + expect(actionNameDictionary.allowlist.size).toBe(20) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + }) + + it('should handle when $DD_ALLOW is undefined and redefined later', () => { + window.$DD_ALLOW = undefined + const actionNameDictionary = createActionAllowList() + expect(actionNameDictionary.rawStringIterator).toBeUndefined() + + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + // Trigger the observer manually + window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + }) +}) + +describe('actionNameDictionary processing', () => { + let actionNameDictionary: AllowedDictionary + let clearActionNameDictionary: () => void + + beforeEach(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + actionNameDictionary = createActionAllowList() + clearActionNameDictionary = actionNameDictionary.clear + }) + + afterEach(() => { + window.$DD_ALLOW = undefined + clearActionNameDictionary() }) it('MATCH_REGEX matches words and symbols in TEST_STRINGS', () => { - expect(TEST_STRINGS.EMOJI.match(getMatchRegex())).toEqual(['💥']) - expect(TEST_STRINGS.EMOJI_WITH_NUMBERS.match(getMatchRegex())).toEqual(['💥123']) - expect(TEST_STRINGS.SPECIAL_CHARS.match(getMatchRegex())).toEqual(['$$$']) - expect(TEST_STRINGS.SPECIAL_CHARS_WITH_NUMBERS.match(getMatchRegex())).toEqual(['$$$123']) - expect(TEST_STRINGS.HYPHENATED_SPECIAL_CHARS.match(getMatchRegex())).toEqual(['$$$', '123']) - expect(TEST_STRINGS.COMPLEX_MIXED.match(getMatchRegex())).toEqual(['test', '<$>', '123', 'hello', '>=42', 'world']) + expect(TEST_STRINGS.COMPLEX_MIXED.match(getMatchRegex())).toEqual( + jasmine.arrayContaining(['test', 'user', 'name', '💥$$$', 'test', 'user', 'id', 'hello', '>=42', 'world']) + ) + expect(TEST_STRINGS.PARAGRAPH_MIXED.match(getMatchRegex())).toEqual( + jasmine.arrayContaining([ + 'This', + 'is', + 'a', + 'test', + 'paragraph', + 'with', + 'various', + 'symbols', + '💥', + '$$$', + '123', + 'and', + 'more', + ]) + ) }) it('initializes allowlist with normalized words from $DD_ALLOW', () => { - const dict = createActionAllowList() // EMOJI and EMOJI_WITH_NUMBERS - expect(dict.allowlist.has('123')).toBeTrue() + expect(actionNameDictionary.allowlist.has('123')).toBeTrue() // COMPLEX_MIXED - expect(dict.allowlist.has('test')).toBeTrue() - expect(dict.allowlist.has('hello')).toBeTrue() - expect(dict.allowlist.has('>=42')).toBeTrue() - expect(dict.allowlist.has('world')).toBeTrue() - }) - - describe('maskActionName', () => { - it('should not run if $DD_ALLOW is not defined', () => { - window.$DD_ALLOW = undefined as any - const dict = createActionAllowList() - const testString = maskActionName('mask-feature-off', dict.allowlist) - expect(testString.masked).toBeFalse() - expect(testString.name).toBe('mask-feature-off') - }) - - it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { - const dict = createActionAllowList() - const testString1 = maskActionName('test-💥-$>=123-pii', dict.allowlist) - expect(testString1.masked).toBeTrue() - expect(testString1.name).toBe('test-💥-MASKED-MASKED') - const testString2 = maskActionName('test-💥+123*hello wild', dict.allowlist) - expect(testString2.masked).toBeTrue() - expect(testString2.name).toBe('test-MASKED*hello MASKED') - }) - - it('handles empty string', () => { - const dict = createActionAllowList() - const result = maskActionName('', dict.allowlist) - expect(result.masked).toBeFalse() - expect(result.name).toBe('') - }) + expect(actionNameDictionary.allowlist.has('test')).toBeTrue() + expect(actionNameDictionary.allowlist.has('hello')).toBeTrue() + expect(actionNameDictionary.allowlist.has('>=42')).toBeTrue() + expect(actionNameDictionary.allowlist.has('world')).toBeTrue() }) it('updates dictionary when $DD_ALLOW changes', () => { - const dict = createActionAllowList() - expect(dict.allowlist.size).toBe(10) + expect(actionNameDictionary.allowlist.size).toBe(20) // Simulate a change in $DD_ALLOW window.$DD_ALLOW?.add('new-Word') window.$DD_ALLOW?.add('another-Word') // Trigger the observer manually - processRawAllowList(window.$DD_ALLOW, dict) + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) // Verify dictionary is updated with new words - expect(dict.allowlist.has('word')).toBeTrue() - expect(dict.allowlist.has('new')).toBeTrue() - expect(dict.allowlist.has('another')).toBeTrue() + expect(actionNameDictionary.allowlist.has('word')).toBeTrue() + expect(actionNameDictionary.allowlist.has('new')).toBeTrue() + expect(actionNameDictionary.allowlist.has('another')).toBeTrue() // Old words should still be present - expect(dict.allowlist.size).toBe(13) + expect(actionNameDictionary.allowlist.size).toBe(23) + }) +}) + +describe('maskActionName', () => { + let actionNameDictionary: AllowedDictionary + let clearActionNameDictionary: () => void + + beforeEach(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + actionNameDictionary = createActionAllowList() + clearActionNameDictionary = actionNameDictionary.clear + }) + + afterEach(() => { + window.$DD_ALLOW = undefined + clearActionNameDictionary() + }) + + it('should not run if $DD_ALLOW is not defined', () => { + window.$DD_ALLOW = undefined as any + const testString = maskActionName('mask-feature-off', actionNameDictionary.allowlist) + expect(testString.masked).toBeFalse() + expect(testString.name).toBe('mask-feature-off') + }) + + it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { + const testString1 = maskActionName('test-💥-$>=123-pii', actionNameDictionary.allowlist) + expect(testString1.masked).toBeTrue() + expect(testString1.name).toBe('test-💥-***-***') + const testString2 = maskActionName('test-💥+123*hello wild', actionNameDictionary.allowlist) + expect(testString2.masked).toBeTrue() + expect(testString2.name).toBe('test-****hello ***') }) - describe('addAllowlistObserver', () => { - it('creates a new set and add an observer if it does not exist', () => { - const dict = createActionAllowList() - window.$DD_ALLOW_OBSERVERS = undefined as any - addAllowlistObserver(dict) - expect(window.$DD_ALLOW_OBSERVERS?.size).toBe(1) - }) + it('handles empty string', () => { + const result = maskActionName('', actionNameDictionary.allowlist) + expect(result.masked).toBeFalse() + expect(result.name).toBe('') }) }) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 4611f7aad3..1d7adf24d5 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -1,58 +1,75 @@ +import { CENSORED_STRING_MARK } from '../../privacy' + declare global { interface Window { $DD_ALLOW?: Set $DD_ALLOW_OBSERVERS?: Set<() => void> } } +let matchRegex: RegExp | undefined export function getMatchRegex(): RegExp { - try { - new RegExp('\\p{Letter}', 'u') - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - // Fallback to support european letters and apostrophes - return /(?:(?![×Þß÷þø])[a-zÀ-ÿ’])+|(?:(?!(?:(?![×Þß÷þø])[a-zÀ-ÿ’]))[^\s])+/gi + if (!matchRegex) { + try { + matchRegex = new RegExp('\\p{Letter}+|[\\p{Symbol}\\p{Number}]+', 'gu') + } catch { + // Fallback to support european letters and apostrophes + matchRegex = /(?:(?![×Þß÷þø])[a-zÀ-ÿ’])+|(?:(?!(?:(?![×Þß÷þø])[a-zÀ-ÿ’]))[^\s])+/gi + } } - return /[\p{Letter}]+|[\p{Symbol}\p{Number}]+/gu + return matchRegex } export type AllowedDictionary = { - updatedCounter: number + rawStringCounter: number allowlist: Set - initializeAllowlist: () => void - lastRawString: SetIterator | undefined + rawStringIterator: SetIterator | undefined + clear: () => void } export function createActionAllowList(): AllowedDictionary { const actionNameDictionary: AllowedDictionary = { - updatedCounter: 0, + rawStringCounter: 0, allowlist: new Set(), - initializeAllowlist: () => { - if (!actionNameDictionary.allowlist || actionNameDictionary.allowlist.size === 0) { - processRawAllowList(window.$DD_ALLOW, actionNameDictionary) - } + rawStringIterator: window.$DD_ALLOW?.values(), + clear: () => { + clearActionNameDictionary(actionNameDictionary, observer) }, - lastRawString: window.$DD_ALLOW?.values(), } - actionNameDictionary.initializeAllowlist() + const observer = () => processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + initializeAllowlist(actionNameDictionary) + addAllowlistObserver(observer) + return actionNameDictionary } +export function clearActionNameDictionary(dictionary: AllowedDictionary, observer: () => void): void { + dictionary.allowlist.clear() + dictionary.rawStringCounter = 0 + dictionary.rawStringIterator = undefined + window.$DD_ALLOW_OBSERVERS?.delete(observer) +} + +function initializeAllowlist(actionNameDictionary: AllowedDictionary): void { + if (actionNameDictionary.allowlist.size === 0) { + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) + } +} + export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary) { if (!rawAllowlist) { return } - if (!dictionary.lastRawString) { - dictionary.lastRawString = rawAllowlist.values() + if (!dictionary.rawStringIterator) { + dictionary.rawStringIterator = rawAllowlist.values() } const size = rawAllowlist.size - let nextItem = dictionary.lastRawString.next() - while (dictionary.updatedCounter < size && nextItem.value) { - processRawString(nextItem.value, dictionary) - if (dictionary.updatedCounter !== size - 1) { - nextItem = dictionary.lastRawString.next() + while (dictionary.rawStringCounter < size) { + const nextItem = dictionary.rawStringIterator.next() + dictionary.rawStringCounter++ + if (nextItem.value) { + processRawString(nextItem.value, dictionary) } - dictionary.updatedCounter++ } } @@ -61,18 +78,16 @@ function processRawString(str: string, dictionary: AllowedDictionary) { if (words) { for (const word of words) { const normalizeWord = word.toLocaleLowerCase() - if (!dictionary.allowlist.has(normalizeWord)) { - dictionary.allowlist.add(normalizeWord) - } + dictionary.allowlist.add(normalizeWord) } } } -export function addAllowlistObserver(dictionary: AllowedDictionary) { +export function addAllowlistObserver(observer: () => void): void { if (!window.$DD_ALLOW_OBSERVERS) { window.$DD_ALLOW_OBSERVERS = new Set<() => void>() } - window.$DD_ALLOW_OBSERVERS.add(() => processRawAllowList(window.$DD_ALLOW, dictionary)) + window.$DD_ALLOW_OBSERVERS.add(observer) } export function maskActionName( @@ -92,9 +107,9 @@ export function maskActionName( let masked = false return { name: name.replace(getMatchRegex(), (word: string) => { - if (!processedAllowlist.has(word.toLowerCase())) { + if (!processedAllowlist.has(word.toLocaleLowerCase())) { masked = true - return 'MASKED' + return CENSORED_STRING_MARK } return word }), From 9fe8b89e0c20b24b9ab0616d6c799f54652f6234 Mon Sep 17 00:00:00 2001 From: zcy Date: Fri, 27 Jun 2025 16:21:13 +0200 Subject: [PATCH 04/16] fix: remove fallback for older version browsers --- .../domain/action/actionCollection.spec.ts | 22 +- .../src/domain/action/actionCollection.ts | 5 +- .../action/privacy/allowedDictionary.spec.ts | 255 +++++++++++++----- .../action/privacy/allowedDictionary.ts | 151 +++++++---- .../domain/action/trackClickActions.spec.ts | 3 +- .../src/domain/action/trackClickActions.ts | 4 +- packages/rum-core/src/domain/privacy.ts | 2 +- 7 files changed, 320 insertions(+), 122 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index 34d719b60a..cf7628fb65 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -11,6 +11,8 @@ import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts } from './actionCollection' import { startActionCollection } from './actionCollection' +import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from './getActionNameFromElement' +import { isBrowserSupported } from './privacy/allowedDictionary' describe('actionCollection', () => { const lifeCycle = new LifeCycle() @@ -50,7 +52,7 @@ describe('actionCollection', () => { duration: 100 as Duration, id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'foo', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CLICK, event, @@ -142,7 +144,7 @@ describe('actionCollection', () => { frustrationTypes: [], id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'foo', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, type: ActionType.CLICK, }) @@ -207,7 +209,12 @@ describe('actionCollection', () => { type: ActionType.CUSTOM, }) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar ***') + let expectedName = 'foo bar xxx' + if (!isBrowserSupported()) { + expectedName = ACTION_NAME_PLACEHOLDER + } + + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe(expectedName) expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') }) @@ -225,11 +232,16 @@ describe('actionCollection', () => { frustrationTypes: [], id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: 'foo bar baz', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, type: ActionType.CLICK, }) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe('foo bar ***') + + let expectedName = 'foo bar xxx' + if (!isBrowserSupported()) { + expectedName = ACTION_NAME_PLACEHOLDER + } + expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe(expectedName) expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 1e7ac5b958..b6aaf45d71 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -93,7 +93,10 @@ function processAction( action: AutoAction | CustomAction, actionNameDictionary: AllowedDictionary ): RawRumEventCollectedData { - const { name: updatedName, masked } = maskActionName(action.name, actionNameDictionary.allowlist) + const { name: updatedName, masked } = + isAutoAction(action) && action.nameSource === ActionNameSource.MASK_PLACEHOLDER + ? { name: action.name, masked: false } + : maskActionName(action.name, actionNameDictionary.allowlist) const autoActionProperties = isAutoAction(action) ? { action: { diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index f7d9373769..9ce7e6a6f5 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -1,4 +1,11 @@ -import { createActionAllowList, getMatchRegex, processRawAllowList, maskActionName } from './allowedDictionary' +import { ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' +import { + createActionAllowList, + processRawAllowList, + maskActionName, + tokenize, + isBrowserSupported, +} from './allowedDictionary' import type { AllowedDictionary } from './allowedDictionary' const TEST_STRINGS = { @@ -6,6 +13,132 @@ const TEST_STRINGS = { PARAGRAPH_MIXED: 'This is a test paragraph with various symbols: 💥, $$$, 123, and more.', } +const LANGUAGES_TEST_STRINGS = { + FRENCH_MIXED_SENTENCE: "C'est un test avec des mots français et des symboles: 💥, $$$, 123, et plus. Bonjour!", + SPANISH_MIXED_SENTENCE: 'Este es un test con palabras en español y símbolos: 💥, $$$, 123, y más. ¡Hola!', + GERMAN_MIXED_SENTENCE: 'Das ist ein Test mit deutschen Wörtern und Symbolen: 💥, $$$, 123, und mehr. Hallo!', + ITALIAN_MIXED_SENTENCE: 'Questo è un test con parole in italiano e simboli: 💥, $$$, 123, e altro. Ciao!', + PORTUGUESE_MIXED_SENTENCE: 'Este é um teste com palavras em português e símbolos: 💥, $$$, 123, e mais. Olá!', +} +if (isBrowserSupported()) { + describe('Test tokenize', () => { + it('should handle emojis when Browser supports unicode regex', () => { + const paragraphMixedTokens = tokenize(TEST_STRINGS.PARAGRAPH_MIXED) + expect(paragraphMixedTokens).toContain('💥') + expect(paragraphMixedTokens).not.toContain('$$$') + expect(paragraphMixedTokens).not.toContain('123') + }) + + /** + * This test is to ensure that the match regex is working as expected in all browsers. + * With unicode regex, we can support symbols and emojis OOTB. + * But in older versions of browsers, we need to use a minimal fallback regex which does + * not support many symbols, to avoid bloating the bundle size. + * + * Only European languages (Except Russian) are tested here. + * We can't test Russian because it's not supported by the fallback regex. + * Asian languages are not supported by our current tokenizer strategy. + */ + it('Tokenized results matches words and symbols in TEST_STRINGS', () => { + const paragraphMixedTokens = tokenize(TEST_STRINGS.PARAGRAPH_MIXED) + const expectedParagraphMixed = [ + 'This', + 'is', + 'a', + 'test', + 'paragraph', + 'with', + 'various', + 'symbols', + 'and', + 'more', + ] + expectedParagraphMixed.forEach((expected) => { + expect(paragraphMixedTokens).toContain(expected) + }) + const frenchTokens = tokenize(LANGUAGES_TEST_STRINGS.FRENCH_MIXED_SENTENCE) + const expectedFrench = [ + 'C', + 'est', + 'un', + 'test', + 'avec', + 'des', + 'mots', + 'français', + 'et', + 'des', + 'symboles', + 'et', + 'plus', + 'Bonjour', + ] + expectedFrench.forEach((expected) => { + expect(frenchTokens).toContain(expected) + }) + + const spanishTokens = tokenize(LANGUAGES_TEST_STRINGS.SPANISH_MIXED_SENTENCE) + const expectedSpanish = [ + 'Este', + 'es', + 'un', + 'test', + 'con', + 'palabras', + 'en', + 'español', + 'y', + 'símbolos', + 'y', + 'más', + 'Hola', + ] + expectedSpanish.forEach((expected) => { + expect(spanishTokens).toContain(expected) + }) + + const germanTokens = tokenize(LANGUAGES_TEST_STRINGS.GERMAN_MIXED_SENTENCE) + const expectedGerman = [ + 'Das', + 'ist', + 'ein', + 'Test', + 'mit', + 'deutschen', + 'Wörtern', + 'und', + 'Symbolen', + 'und', + 'mehr', + 'Hallo', + ] + expectedGerman.forEach((expected) => { + expect(germanTokens).toContain(expected) + }) + + const portugueseTokens = tokenize(LANGUAGES_TEST_STRINGS.PORTUGUESE_MIXED_SENTENCE) + const expectedPortuguese = [ + 'Este', + 'é', + 'um', + 'teste', + 'com', + 'palavras', + 'em', + 'português', + 'e', + 'símbolos', + 'e', + 'mais', + 'Olá', + ] + expectedPortuguese.forEach((expected) => { + expect(portugueseTokens).toContain(expected) + }) + }) + }) +} + describe('createActionAllowList', () => { beforeAll(() => { window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) @@ -15,10 +148,18 @@ describe('createActionAllowList', () => { window.$DD_ALLOW = undefined }) - it('should create an action name dictionary', () => { + it('should create an action name dictionary and clear it', () => { const actionNameDictionary = createActionAllowList() - expect(actionNameDictionary.allowlist.size).toBe(20) + if (!isBrowserSupported()) { + expect(actionNameDictionary.allowlist.size).toBe(0) + expect(actionNameDictionary.rawStringIterator).toBeDefined() + return + } + expect(actionNameDictionary.allowlist.size).toBeGreaterThan(0) expect(actionNameDictionary.rawStringIterator).toBeDefined() + actionNameDictionary.clear() + expect(actionNameDictionary.allowlist.size).toBe(0) + expect(actionNameDictionary.rawStringIterator).toBeUndefined() }) it('should handle when $DD_ALLOW is undefined and redefined later', () => { @@ -30,76 +171,52 @@ describe('createActionAllowList', () => { // Trigger the observer manually window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) expect(actionNameDictionary.rawStringIterator).toBeDefined() + actionNameDictionary.clear() }) }) -describe('actionNameDictionary processing', () => { - let actionNameDictionary: AllowedDictionary - let clearActionNameDictionary: () => void +if (isBrowserSupported()) { + describe('actionNameDictionary processing', () => { + let actionNameDictionary: AllowedDictionary + let clearActionNameDictionary: () => void - beforeEach(() => { - window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) - actionNameDictionary = createActionAllowList() - clearActionNameDictionary = actionNameDictionary.clear - }) + beforeEach(() => { + window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + actionNameDictionary = createActionAllowList() + clearActionNameDictionary = actionNameDictionary.clear + }) - afterEach(() => { - window.$DD_ALLOW = undefined - clearActionNameDictionary() - }) + afterEach(() => { + window.$DD_ALLOW = undefined + clearActionNameDictionary() + }) - it('MATCH_REGEX matches words and symbols in TEST_STRINGS', () => { - expect(TEST_STRINGS.COMPLEX_MIXED.match(getMatchRegex())).toEqual( - jasmine.arrayContaining(['test', 'user', 'name', '💥$$$', 'test', 'user', 'id', 'hello', '>=42', 'world']) - ) - expect(TEST_STRINGS.PARAGRAPH_MIXED.match(getMatchRegex())).toEqual( - jasmine.arrayContaining([ - 'This', - 'is', - 'a', - 'test', - 'paragraph', - 'with', - 'various', - 'symbols', - '💥', - '$$$', - '123', - 'and', - 'more', - ]) - ) - }) + it('initializes allowlist with normalized words from $DD_ALLOW', () => { + expect(actionNameDictionary.allowlist.has('test')).toBeTrue() + expect(actionNameDictionary.allowlist.has('hello')).toBeTrue() + expect(actionNameDictionary.allowlist.has('world')).toBeTrue() + }) - it('initializes allowlist with normalized words from $DD_ALLOW', () => { - // EMOJI and EMOJI_WITH_NUMBERS - expect(actionNameDictionary.allowlist.has('123')).toBeTrue() - // COMPLEX_MIXED - expect(actionNameDictionary.allowlist.has('test')).toBeTrue() - expect(actionNameDictionary.allowlist.has('hello')).toBeTrue() - expect(actionNameDictionary.allowlist.has('>=42')).toBeTrue() - expect(actionNameDictionary.allowlist.has('world')).toBeTrue() - }) + it('updates dictionary when $DD_ALLOW changes', () => { + const initialAllowlistSize = actionNameDictionary.allowlist.size - it('updates dictionary when $DD_ALLOW changes', () => { - expect(actionNameDictionary.allowlist.size).toBe(20) + // Simulate a change in $DD_ALLOW + window.$DD_ALLOW?.add('new-Word') + window.$DD_ALLOW?.add('another-Word') + // Trigger the observer manually + processRawAllowList(window.$DD_ALLOW, actionNameDictionary) - // Simulate a change in $DD_ALLOW - window.$DD_ALLOW?.add('new-Word') - window.$DD_ALLOW?.add('another-Word') - // Trigger the observer manually - processRawAllowList(window.$DD_ALLOW, actionNameDictionary) - - // Verify dictionary is updated with new words - expect(actionNameDictionary.allowlist.has('word')).toBeTrue() - expect(actionNameDictionary.allowlist.has('new')).toBeTrue() - expect(actionNameDictionary.allowlist.has('another')).toBeTrue() - // Old words should still be present - expect(actionNameDictionary.allowlist.size).toBe(23) + // Verify dictionary is updated with new words + expect(actionNameDictionary.allowlist.has('word')).toBeTrue() + expect(actionNameDictionary.allowlist.has('new')).toBeTrue() + expect(actionNameDictionary.allowlist.has('another')).toBeTrue() + // Old words should still be present + expect(actionNameDictionary.allowlist.size).toBe(initialAllowlistSize + 3) + }) }) -}) +} -describe('maskActionName', () => { +describe('createActionNameDictionary and maskActionName', () => { let actionNameDictionary: AllowedDictionary let clearActionNameDictionary: () => void @@ -122,12 +239,22 @@ describe('maskActionName', () => { }) it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { + let expected = 'test-💥-xxxxxx-xxx' + if (!isBrowserSupported()) { + expected = ACTION_NAME_PLACEHOLDER + } + const testString1 = maskActionName('test-💥-$>=123-pii', actionNameDictionary.allowlist) expect(testString1.masked).toBeTrue() - expect(testString1.name).toBe('test-💥-***-***') + expect(testString1.name).toBe(expected) + + expected = 'test-xxxxxx*hello xxxx' + if (!isBrowserSupported()) { + expected = ACTION_NAME_PLACEHOLDER + } const testString2 = maskActionName('test-💥+123*hello wild', actionNameDictionary.allowlist) expect(testString2.masked).toBeTrue() - expect(testString2.name).toBe('test-****hello ***') + expect(testString2.name).toBe(expected) }) it('handles empty string', () => { diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 1d7adf24d5..fb48e27d13 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -1,4 +1,5 @@ -import { CENSORED_STRING_MARK } from '../../privacy' +import { TEXT_MASKING_CHAR } from '../../privacy' +import { ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' declare global { interface Window { @@ -6,18 +7,66 @@ declare global { $DD_ALLOW_OBSERVERS?: Set<() => void> } } -let matchRegex: RegExp | undefined - -export function getMatchRegex(): RegExp { - if (!matchRegex) { - try { - matchRegex = new RegExp('\\p{Letter}+|[\\p{Symbol}\\p{Number}]+', 'gu') - } catch { - // Fallback to support european letters and apostrophes - matchRegex = /(?:(?![×Þß÷þø])[a-zÀ-ÿ’])+|(?:(?!(?:(?![×Þß÷þø])[a-zÀ-ÿ’]))[^\s])+/gi + +type UnicodeRegexes = { + splitRegex: RegExp + matchRegex: RegExp +} + +// Cache regex compilation and browser support detection +let cachedRegexes: UnicodeRegexes | undefined +let supportsUnicodeRegex: boolean | undefined + +/** + * Tests and initializes Unicode regex support for the current browser + * @returns true if Unicode property escapes are supported, false otherwise + */ +function initializeUnicodeSupport(): boolean { + if (supportsUnicodeRegex !== undefined) { + return supportsUnicodeRegex + } + + try { + cachedRegexes = { + // Split on punctuation, separators, and control characters + splitRegex: new RegExp('[^\\p{Punctuation}\\p{Separator}\\p{Cc}]+', 'gu'), + // Match letters (including apostrophes), emojis, and mathematical symbols + matchRegex: new RegExp("[\\p{Letter}']+|[\\p{Emoji_Presentation}]+|[\\p{Sm}]+", 'gu'), } + supportsUnicodeRegex = true + } catch { + supportsUnicodeRegex = false + } + + return supportsUnicodeRegex +} + +/** + * Tokenizes a string into meaningful words using Unicode-aware regex + * @param str - The string to tokenize + * @returns Array of tokens, empty array if not supported or no tokens found + */ +export function tokenize(str: string): string[] { + if (!str?.trim()) { + return [] } - return matchRegex + + if (!initializeUnicodeSupport() || !cachedRegexes) { + return [] + } + + const { splitRegex, matchRegex } = cachedRegexes + const segments = str.match(splitRegex) || [] + + return segments.flatMap((segment) => segment.match(matchRegex) || []) +} + +/** + * Checks if the current browser supports Unicode property escapes in regex + * @returns true if supported, false otherwise + */ +export function isBrowserSupported(): boolean { + return initializeUnicodeSupport() } export type AllowedDictionary = { @@ -28,58 +77,62 @@ export type AllowedDictionary = { } export function createActionAllowList(): AllowedDictionary { - const actionNameDictionary: AllowedDictionary = { + const dictionary: AllowedDictionary = { rawStringCounter: 0, allowlist: new Set(), rawStringIterator: window.$DD_ALLOW?.values(), clear: () => { - clearActionNameDictionary(actionNameDictionary, observer) + clearActionNameDictionary(dictionary, observer) }, } - const observer = () => processRawAllowList(window.$DD_ALLOW, actionNameDictionary) - initializeAllowlist(actionNameDictionary) + + const observer = () => processRawAllowList(window.$DD_ALLOW, dictionary) + initializeAllowlist(dictionary) addAllowlistObserver(observer) - return actionNameDictionary + return dictionary } export function clearActionNameDictionary(dictionary: AllowedDictionary, observer: () => void): void { dictionary.allowlist.clear() dictionary.rawStringCounter = 0 dictionary.rawStringIterator = undefined + supportsUnicodeRegex = undefined window.$DD_ALLOW_OBSERVERS?.delete(observer) } -function initializeAllowlist(actionNameDictionary: AllowedDictionary): void { - if (actionNameDictionary.allowlist.size === 0) { - processRawAllowList(window.$DD_ALLOW, actionNameDictionary) +function initializeAllowlist(dictionary: AllowedDictionary): void { + if (dictionary.allowlist.size === 0) { + processRawAllowList(window.$DD_ALLOW, dictionary) } } -export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary) { - if (!rawAllowlist) { +export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary): void { + if (!rawAllowlist?.size) { return } + if (!dictionary.rawStringIterator) { dictionary.rawStringIterator = rawAllowlist.values() } - const size = rawAllowlist.size - while (dictionary.rawStringCounter < size) { + + const targetSize = rawAllowlist.size + while (dictionary.rawStringCounter < targetSize) { const nextItem = dictionary.rawStringIterator.next() dictionary.rawStringCounter++ + if (nextItem.value) { processRawString(nextItem.value, dictionary) } } } -function processRawString(str: string, dictionary: AllowedDictionary) { - const words: string[] | null = str.match(getMatchRegex()) - if (words) { - for (const word of words) { - const normalizeWord = word.toLocaleLowerCase() - dictionary.allowlist.add(normalizeWord) - } +function processRawString(str: string, dictionary: AllowedDictionary): void { + const tokens = tokenize(str) + + for (const token of tokens) { + const normalizedToken = token.toLowerCase() + dictionary.allowlist.add(normalizedToken) } } @@ -90,29 +143,31 @@ export function addAllowlistObserver(observer: () => void): void { window.$DD_ALLOW_OBSERVERS.add(observer) } -export function maskActionName( - name: string, - processedAllowlist: Set -): { - masked: boolean - name: string -} { +export function maskActionName(name: string, processedAllowlist: Set): { masked: boolean; name: string } { if (!window.$DD_ALLOW) { + return { name, masked: false } + } + + if (!isBrowserSupported()) { return { - name, - masked: false, + name: name ? ACTION_NAME_PLACEHOLDER : '', + masked: !!name, } } - let masked = false + const { splitRegex } = cachedRegexes! + let hasBeenMasked = false + + const maskedName = name.replace(splitRegex, (segment: string) => { + if (!processedAllowlist.has(segment.toLowerCase())) { + hasBeenMasked = true + return TEXT_MASKING_CHAR.repeat(segment.length) + } + return segment + }) + return { - name: name.replace(getMatchRegex(), (word: string) => { - if (!processedAllowlist.has(word.toLocaleLowerCase())) { - masked = true - return CENSORED_STRING_MARK - } - return word - }), - masked, + name: maskedName, + masked: hasBeenMasked, } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 582e88dd8d..3f3d55d0f4 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -21,6 +21,7 @@ import type { ClickAction } from './trackClickActions' import { finalizeClicks, trackClickActions } from './trackClickActions' import { MAX_DURATION_BETWEEN_CLICKS } from './clickChain' import { getInteractionSelector, CLICK_ACTION_MAX_DURATION } from './interactionSelectorCache' +import { ActionNameSource } from './getActionNameFromElement' // Used to wait some time after the creation of an action const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = PAGE_ACTIVITY_VALIDATION_DELAY * 0.8 @@ -117,7 +118,7 @@ describe('trackClickActions', () => { duration: BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY as Duration, id: jasmine.any(String), name: 'Click me', - nameSource: 'text_content', + nameSource: ActionNameSource.TEXT_CONTENT, startClocks: { relative: addDuration(pointerDownClocks.relative, EMULATED_CLICK_DURATION), timeStamp: addDuration(pointerDownClocks.timeStamp, EMULATED_CLICK_DURATION), diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 7bf3e75d20..8d649c29dc 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -22,7 +22,7 @@ import type { RumConfiguration } from '../configuration' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ClickChain } from './clickChain' import { createClickChain } from './clickChain' -import { getActionNameFromElement } from './getActionNameFromElement' +import { getActionNameFromElement, ActionNameSource } from './getActionNameFromElement' import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' @@ -38,7 +38,7 @@ export interface ClickAction { type: ActionType.CLICK id: string name: string - nameSource: string + nameSource: ActionNameSource target?: { selector: string | undefined width: number diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index ef90843fbe..075bba3511 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -35,7 +35,7 @@ export const FORM_PRIVATE_TAG_NAMES: { [tagName: string]: true } = { OPTGROUP: true, } -const TEXT_MASKING_CHAR = 'x' +export const TEXT_MASKING_CHAR = 'x' export type NodePrivacyLevelCache = Map From 549159faed5d5dd3bd92a7c96f261484e54cb0f6 Mon Sep 17 00:00:00 2001 From: zcy Date: Fri, 27 Jun 2025 16:55:51 +0200 Subject: [PATCH 05/16] fix: type --- packages/rum-core/src/domain/action/trackClickActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 8d649c29dc..ceac474294 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -22,7 +22,8 @@ import type { RumConfiguration } from '../configuration' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ClickChain } from './clickChain' import { createClickChain } from './clickChain' -import { getActionNameFromElement, ActionNameSource } from './getActionNameFromElement' +import { getActionNameFromElement } from './getActionNameFromElement' +import type { ActionNameSource } from './getActionNameFromElement' import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' From 6f20b2ba7852be86aa23108ae75ad0edb900b2c7 Mon Sep 17 00:00:00 2001 From: zcy Date: Fri, 27 Jun 2025 17:48:57 +0200 Subject: [PATCH 06/16] refactor: reduce bundle size a bit --- .../src/domain/action/actionCollection.ts | 6 +- .../action/privacy/allowedDictionary.spec.ts | 9 +++ .../action/privacy/allowedDictionary.ts | 74 ++++++------------- 3 files changed, 35 insertions(+), 54 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index b6aaf45d71..9626df597d 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -93,10 +93,8 @@ function processAction( action: AutoAction | CustomAction, actionNameDictionary: AllowedDictionary ): RawRumEventCollectedData { - const { name: updatedName, masked } = - isAutoAction(action) && action.nameSource === ActionNameSource.MASK_PLACEHOLDER - ? { name: action.name, masked: false } - : maskActionName(action.name, actionNameDictionary.allowlist) + const { name: updatedName, masked } = maskActionName(action.name, actionNameDictionary.allowlist) + const autoActionProperties = isAutoAction(action) ? { action: { diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index 9ce7e6a6f5..5cab8460ad 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -29,6 +29,15 @@ if (isBrowserSupported()) { expect(paragraphMixedTokens).not.toContain('123') }) + it('should return empty array for whitespace-only strings', () => { + expect(tokenize(' ')).toEqual([]) + expect(tokenize(' ')).toEqual([]) + expect(tokenize('\t')).toEqual([]) + expect(tokenize('\n')).toEqual([]) + expect(tokenize('\r')).toEqual([]) + expect(tokenize(' \t\n\r ')).toEqual([]) + }) + /** * This test is to ensure that the match regex is working as expected in all browsers. * With unicode regex, we can support symbols and emojis OOTB. diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index fb48e27d13..ffc604e1a8 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -17,10 +17,6 @@ type UnicodeRegexes = { let cachedRegexes: UnicodeRegexes | undefined let supportsUnicodeRegex: boolean | undefined -/** - * Tests and initializes Unicode regex support for the current browser - * @returns true if Unicode property escapes are supported, false otherwise - */ function initializeUnicodeSupport(): boolean { if (supportsUnicodeRegex !== undefined) { return supportsUnicodeRegex @@ -41,13 +37,8 @@ function initializeUnicodeSupport(): boolean { return supportsUnicodeRegex } -/** - * Tokenizes a string into meaningful words using Unicode-aware regex - * @param str - The string to tokenize - * @returns Array of tokens, empty array if not supported or no tokens found - */ export function tokenize(str: string): string[] { - if (!str?.trim()) { + if (!str.trim()) { return [] } @@ -61,10 +52,6 @@ export function tokenize(str: string): string[] { return segments.flatMap((segment) => segment.match(matchRegex) || []) } -/** - * Checks if the current browser supports Unicode property escapes in regex - * @returns true if supported, false otherwise - */ export function isBrowserSupported(): boolean { return initializeUnicodeSupport() } @@ -82,29 +69,28 @@ export function createActionAllowList(): AllowedDictionary { allowlist: new Set(), rawStringIterator: window.$DD_ALLOW?.values(), clear: () => { - clearActionNameDictionary(dictionary, observer) + dictionary.allowlist.clear() + dictionary.rawStringCounter = 0 + dictionary.rawStringIterator = undefined + supportsUnicodeRegex = undefined + window.$DD_ALLOW_OBSERVERS?.delete(observer) }, } const observer = () => processRawAllowList(window.$DD_ALLOW, dictionary) - initializeAllowlist(dictionary) - addAllowlistObserver(observer) - return dictionary -} - -export function clearActionNameDictionary(dictionary: AllowedDictionary, observer: () => void): void { - dictionary.allowlist.clear() - dictionary.rawStringCounter = 0 - dictionary.rawStringIterator = undefined - supportsUnicodeRegex = undefined - window.$DD_ALLOW_OBSERVERS?.delete(observer) -} - -function initializeAllowlist(dictionary: AllowedDictionary): void { + // Initialize allowlist if needed if (dictionary.allowlist.size === 0) { processRawAllowList(window.$DD_ALLOW, dictionary) } + + // Add observer + if (!window.$DD_ALLOW_OBSERVERS) { + window.$DD_ALLOW_OBSERVERS = new Set<() => void>() + } + window.$DD_ALLOW_OBSERVERS.add(observer) + + return dictionary } export function processRawAllowList(rawAllowlist: Set | undefined, dictionary: AllowedDictionary): void { @@ -116,39 +102,27 @@ export function processRawAllowList(rawAllowlist: Set | undefined, dicti dictionary.rawStringIterator = rawAllowlist.values() } - const targetSize = rawAllowlist.size - while (dictionary.rawStringCounter < targetSize) { + const currentSize = rawAllowlist.size + while (dictionary.rawStringCounter < currentSize) { const nextItem = dictionary.rawStringIterator.next() dictionary.rawStringCounter++ if (nextItem.value) { - processRawString(nextItem.value, dictionary) + // Process raw string tokens directly + const tokens = tokenize(nextItem.value) + for (const token of tokens) { + dictionary.allowlist.add(token.toLowerCase()) + } } } } -function processRawString(str: string, dictionary: AllowedDictionary): void { - const tokens = tokenize(str) - - for (const token of tokens) { - const normalizedToken = token.toLowerCase() - dictionary.allowlist.add(normalizedToken) - } -} - -export function addAllowlistObserver(observer: () => void): void { - if (!window.$DD_ALLOW_OBSERVERS) { - window.$DD_ALLOW_OBSERVERS = new Set<() => void>() - } - window.$DD_ALLOW_OBSERVERS.add(observer) -} - export function maskActionName(name: string, processedAllowlist: Set): { masked: boolean; name: string } { - if (!window.$DD_ALLOW) { + if (!window.$DD_ALLOW || name === ACTION_NAME_PLACEHOLDER) { return { name, masked: false } } - if (!isBrowserSupported()) { + if (!initializeUnicodeSupport()) { return { name: name ? ACTION_NAME_PLACEHOLDER : '', masked: !!name, From 6e5063b44869e68625be342dc2c1c8e121ccc246 Mon Sep 17 00:00:00 2001 From: zcy Date: Mon, 30 Jun 2025 14:45:23 +0200 Subject: [PATCH 07/16] refactor: make allowlist masking compatible with dd-privacy tags --- .../domain/action/actionCollection.spec.ts | 55 ------------------ .../src/domain/action/actionCollection.ts | 31 +++------- .../action/privacy/allowedDictionary.spec.ts | 37 ++++++++---- .../action/privacy/allowedDictionary.ts | 29 +++++++--- .../domain/action/trackClickActions.spec.ts | 57 ++++++++++++++++++- .../src/domain/action/trackClickActions.ts | 27 +++++++-- 6 files changed, 134 insertions(+), 102 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index cf7628fb65..a83061faa6 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -190,59 +190,4 @@ describe('actionCollection', () => { }) }) }) - - describe('maskActionName', () => { - beforeAll(() => { - window.$DD_ALLOW = new Set(['foo-bar']) - // notify the observer to process the allowlist - window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) - }) - - afterAll(() => { - window.$DD_ALLOW = undefined - }) - - it('should mask custom action with the action name dictionary', () => { - addAction({ - name: 'foo bar baz', - startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, - type: ActionType.CUSTOM, - }) - - let expectedName = 'foo bar xxx' - if (!isBrowserSupported()) { - expectedName = ACTION_NAME_PLACEHOLDER - } - - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe(expectedName) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') - }) - - it('should mask auto name with the action name dictionary', () => { - const event = createNewEvent('pointerup', { target: document.createElement('button') }) - lifeCycle.notify(LifeCycleEventType.AUTO_ACTION_COMPLETED, { - counts: { - errorCount: 0, - longTaskCount: 0, - resourceCount: 0, - }, - duration: -10 as Duration, - event, - events: [event], - frustrationTypes: [], - id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', - name: 'foo bar baz', - nameSource: ActionNameSource.TEXT_CONTENT, - startClocks: { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, - type: ActionType.CLICK, - }) - - let expectedName = 'foo bar xxx' - if (!isBrowserSupported()) { - expectedName = ACTION_NAME_PLACEHOLDER - } - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent).action.target.name).toBe(expectedName) - expect((rawRumEvents[0].rawRumEvent as RawRumActionEvent)._dd?.action?.name_source).toBe('mask_disallowed') - }) - }) }) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 9626df597d..6e51bfe82a 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -11,9 +11,7 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts, ClickAction } from './trackClickActions' import { trackClickActions } from './trackClickActions' -import { createActionAllowList, maskActionName } from './privacy/allowedDictionary' -import type { AllowedDictionary } from './privacy/allowedDictionary' -import { ActionNameSource } from './getActionNameFromElement' +import { createActionAllowList } from './privacy/allowedDictionary' export type { ActionContexts } @@ -40,7 +38,7 @@ export function startActionCollection( const { unsubscribe: unsubscribeAutoActionCompleted } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, (action) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) } ) @@ -72,13 +70,14 @@ export function startActionCollection( lifeCycle, domMutationObservable, windowOpenObservable, - configuration + configuration, + actionNameDictionary )) } return { addAction: (action: CustomAction) => { - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action, actionNameDictionary)) + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processAction(action)) }, actionContexts, stop: () => { @@ -89,12 +88,7 @@ export function startActionCollection( } } -function processAction( - action: AutoAction | CustomAction, - actionNameDictionary: AllowedDictionary -): RawRumEventCollectedData { - const { name: updatedName, masked } = maskActionName(action.name, actionNameDictionary.allowlist) - +function processAction(action: AutoAction | CustomAction): RawRumEventCollectedData { const autoActionProperties = isAutoAction(action) ? { action: { @@ -117,7 +111,7 @@ function processAction( action: { target: action.target, position: action.position, - name_source: masked ? ActionNameSource.MASK_DISALLOWED : action.nameSource, + name_source: action.nameSource, }, }, } @@ -125,18 +119,9 @@ function processAction( const actionEvent: RawRumActionEvent = combine( { - action: { id: generateUUID(), target: { name: updatedName }, type: action.type }, + action: { id: generateUUID(), target: { name: action.name }, type: action.type }, date: action.startClocks.timeStamp, type: RumEventType.ACTION as const, - ...(masked - ? { - _dd: { - action: { - name_source: ActionNameSource.MASK_DISALLOWED, - }, - }, - } - : {}), }, autoActionProperties ) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index 5cab8460ad..c6ddc52ed6 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -1,4 +1,6 @@ -import { ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' +import { ActionType } from 'packages/rum-core/src/rawRumEvent.types' +import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from '../getActionNameFromElement' +import { ClickActionBase } from '../trackClickActions' import { createActionAllowList, processRawAllowList, @@ -7,6 +9,7 @@ import { isBrowserSupported, } from './allowedDictionary' import type { AllowedDictionary } from './allowedDictionary' +import { NodePrivacyLevel } from '../../privacy' const TEST_STRINGS = { COMPLEX_MIXED: 'test-user-name:💥$$$, test-user-id:hello>=42@world?', @@ -228,6 +231,17 @@ if (isBrowserSupported()) { describe('createActionNameDictionary and maskActionName', () => { let actionNameDictionary: AllowedDictionary let clearActionNameDictionary: () => void + const clickActionBase: ClickActionBase = { + type: ActionType.CLICK, + name: 'test-💥-xxxxxx-xxx', + nameSource: ActionNameSource.MASK_DISALLOWED, + target: { + selector: 'button', + width: 100, + height: 100, + }, + position: { x: 0, y: 0 }, + } beforeEach(() => { window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) @@ -242,33 +256,36 @@ describe('createActionNameDictionary and maskActionName', () => { it('should not run if $DD_ALLOW is not defined', () => { window.$DD_ALLOW = undefined as any - const testString = maskActionName('mask-feature-off', actionNameDictionary.allowlist) - expect(testString.masked).toBeFalse() + clickActionBase.name = 'mask-feature-off' + const testString = maskActionName(clickActionBase, NodePrivacyLevel.ALLOW, actionNameDictionary.allowlist) expect(testString.name).toBe('mask-feature-off') + expect(testString.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) }) it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { + clickActionBase.name = 'test-💥-$>=123-pii' let expected = 'test-💥-xxxxxx-xxx' if (!isBrowserSupported()) { expected = ACTION_NAME_PLACEHOLDER } - - const testString1 = maskActionName('test-💥-$>=123-pii', actionNameDictionary.allowlist) - expect(testString1.masked).toBeTrue() + const testString1 = maskActionName(clickActionBase, NodePrivacyLevel.MASK, actionNameDictionary.allowlist) expect(testString1.name).toBe(expected) + expect(testString1.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + clickActionBase.name = 'test-💥+123*hello wild' expected = 'test-xxxxxx*hello xxxx' if (!isBrowserSupported()) { expected = ACTION_NAME_PLACEHOLDER } - const testString2 = maskActionName('test-💥+123*hello wild', actionNameDictionary.allowlist) - expect(testString2.masked).toBeTrue() + const testString2 = maskActionName(clickActionBase, NodePrivacyLevel.MASK, actionNameDictionary.allowlist) expect(testString2.name).toBe(expected) + expect(testString2.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) }) it('handles empty string', () => { - const result = maskActionName('', actionNameDictionary.allowlist) - expect(result.masked).toBeFalse() + clickActionBase.name = '' + const result = maskActionName(clickActionBase, NodePrivacyLevel.ALLOW, actionNameDictionary.allowlist) expect(result.name).toBe('') + expect(result.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) }) }) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index ffc604e1a8..0fa23b932d 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -1,5 +1,6 @@ -import { TEXT_MASKING_CHAR } from '../../privacy' -import { ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' +import { NodePrivacyLevel, TEXT_MASKING_CHAR } from '../../privacy' +import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from '../getActionNameFromElement' +import type { ClickActionBase } from '../trackClickActions' declare global { interface Window { @@ -59,7 +60,7 @@ export function isBrowserSupported(): boolean { export type AllowedDictionary = { rawStringCounter: number allowlist: Set - rawStringIterator: SetIterator | undefined + rawStringIterator: IterableIterator | undefined clear: () => void } @@ -117,15 +118,26 @@ export function processRawAllowList(rawAllowlist: Set | undefined, dicti } } -export function maskActionName(name: string, processedAllowlist: Set): { masked: boolean; name: string } { - if (!window.$DD_ALLOW || name === ACTION_NAME_PLACEHOLDER) { - return { name, masked: false } +export function maskActionName( + actionName: ClickActionBase, + nodeSelfPrivacy: NodePrivacyLevel, + processedAllowlist: Set +): ClickActionBase { + if (!window.$DD_ALLOW) { + return actionName } + if (nodeSelfPrivacy === NodePrivacyLevel.ALLOW) { + return actionName + } + + const { name, nameSource } = actionName + if (!initializeUnicodeSupport()) { return { + ...actionName, name: name ? ACTION_NAME_PLACEHOLDER : '', - masked: !!name, + nameSource: name ? ActionNameSource.MASK_DISALLOWED : nameSource, } } @@ -141,7 +153,8 @@ export function maskActionName(name: string, processedAllowlist: Set): { }) return { + ...actionName, name: maskedName, - masked: hasBeenMasked, + nameSource: hasBeenMasked ? ActionNameSource.MASK_DISALLOWED : nameSource, } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 3f3d55d0f4..127901359a 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -22,6 +22,7 @@ import { finalizeClicks, trackClickActions } from './trackClickActions' import { MAX_DURATION_BETWEEN_CLICKS } from './clickChain' import { getInteractionSelector, CLICK_ACTION_MAX_DURATION } from './interactionSelectorCache' import { ActionNameSource } from './getActionNameFromElement' +import { createActionAllowList } from './privacy/allowedDictionary' // Used to wait some time after the creation of an action const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = PAGE_ACTIVITY_VALIDATION_DELAY * 0.8 @@ -62,7 +63,8 @@ describe('trackClickActions', () => { lifeCycle, domMutationObservable, windowOpenObservable, - mockRumConfiguration(partialConfig) + mockRumConfiguration(partialConfig), + createActionAllowList() ) findActionId = trackClickActionsResult.actionContexts.findActionId @@ -448,6 +450,59 @@ describe('trackClickActions', () => { }) }) + describe('maskActionName', () => { + beforeAll(() => { + window.$DD_ALLOW = new Set(['foo-bar']) + // notify the observer to process the allowlist + window.$DD_ALLOW_OBSERVERS?.forEach((observer) => observer()) + }) + + afterAll(() => { + window.$DD_ALLOW = undefined + }) + + it('should mask action name when defaultPrivacyLevel is mask and not in allowlist', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('xxxxx xx') + expect(events[0].nameSource).toBe(ActionNameSource.MASK_DISALLOWED) + }) + + it('should not mask action name when defaultPrivacyLevel is allow', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Click me') + }) + + it('should not use allowlist masking when enablePrivacyForActionName is true', () => { + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + enablePrivacyForActionName: true, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Masked Element') + }) + }) + function emulateClick({ target = button, activity, diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index ceac474294..153ab90565 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -28,6 +28,8 @@ import type { MouseEventOnElement, UserActivity } from './listenActionEvents' import { listenActionEvents } from './listenActionEvents' import { computeFrustration } from './computeFrustration' import { CLICK_ACTION_MAX_DURATION, updateInteractionSelector } from './interactionSelectorCache' +import { maskActionName } from './privacy/allowedDictionary' +import type { AllowedDictionary } from './privacy/allowedDictionary' interface ActionCounts { errorCount: number @@ -66,7 +68,8 @@ export function trackClickActions( lifeCycle: LifeCycle, domMutationObservable: Observable, windowOpenObservable: Observable, - configuration: RumConfiguration + configuration: RumConfiguration, + actionNameDictionary: AllowedDictionary ) { const history: ClickActionIdHistory = createValueHistory({ expireDelay: ACTION_CONTEXT_TIME_OUT_DELAY }) const stopObservable = new Observable() @@ -88,7 +91,14 @@ export function trackClickActions( hadActivityOnPointerDown: () => boolean }>(configuration, { onPointerDown: (pointerDownEvent) => - processPointerDown(configuration, lifeCycle, domMutationObservable, pointerDownEvent, windowOpenObservable), + processPointerDown( + configuration, + lifeCycle, + domMutationObservable, + pointerDownEvent, + windowOpenObservable, + actionNameDictionary + ), onPointerUp: ({ clickActionBase, hadActivityOnPointerDown }, startEvent, getUserActivity) => { startClickAction( configuration, @@ -140,8 +150,10 @@ function processPointerDown( lifeCycle: LifeCycle, domMutationObservable: Observable, pointerDownEvent: MouseEventOnElement, - windowOpenObservable: Observable + windowOpenObservable: Observable, + actionNameDictionary: AllowedDictionary ) { + const nodeSelfPrivacy = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) const nodePrivacyLevel = configuration.enablePrivacyForActionName ? getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) : NodePrivacyLevel.ALLOW @@ -150,7 +162,12 @@ function processPointerDown( return undefined } - const clickActionBase = computeClickActionBase(pointerDownEvent, nodePrivacyLevel, configuration) + let clickActionBase = computeClickActionBase(pointerDownEvent, nodePrivacyLevel, configuration) + + // mask with allowlist when enablePrivacyForActionName is not set to true + if (!configuration.enablePrivacyForActionName) { + clickActionBase = maskActionName(clickActionBase, nodeSelfPrivacy, actionNameDictionary.allowlist) + } let hadActivityOnPointerDown = false @@ -232,7 +249,7 @@ function startClickAction( }) } -type ClickActionBase = Pick +export type ClickActionBase = Pick function computeClickActionBase( event: MouseEventOnElement, From 69607f6bdd8fe456495a7ab26dce44dc641c93b8 Mon Sep 17 00:00:00 2001 From: zcy Date: Mon, 30 Jun 2025 14:55:03 +0200 Subject: [PATCH 08/16] fix:lint --- .../rum-core/src/domain/action/actionCollection.spec.ts | 3 +-- .../src/domain/action/privacy/allowedDictionary.spec.ts | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.spec.ts b/packages/rum-core/src/domain/action/actionCollection.spec.ts index a83061faa6..b5b3488756 100644 --- a/packages/rum-core/src/domain/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/action/actionCollection.spec.ts @@ -11,8 +11,7 @@ import { createHooks } from '../hooks' import type { RumMutationRecord } from '../../browser/domMutationObservable' import type { ActionContexts } from './actionCollection' import { startActionCollection } from './actionCollection' -import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from './getActionNameFromElement' -import { isBrowserSupported } from './privacy/allowedDictionary' +import { ActionNameSource } from './getActionNameFromElement' describe('actionCollection', () => { const lifeCycle = new LifeCycle() diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index c6ddc52ed6..f3e93aee90 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -1,6 +1,7 @@ -import { ActionType } from 'packages/rum-core/src/rawRumEvent.types' -import { ACTION_NAME_PLACEHOLDER, ActionNameSource } from '../getActionNameFromElement' -import { ClickActionBase } from '../trackClickActions' +import { ActionType } from '../../../rawRumEvent.types' +import { NodePrivacyLevel } from '../../privacy' +import { ActionNameSource, ACTION_NAME_PLACEHOLDER } from '../getActionNameFromElement' +import type { ClickActionBase } from '../trackClickActions' import { createActionAllowList, processRawAllowList, @@ -9,7 +10,6 @@ import { isBrowserSupported, } from './allowedDictionary' import type { AllowedDictionary } from './allowedDictionary' -import { NodePrivacyLevel } from '../../privacy' const TEST_STRINGS = { COMPLEX_MIXED: 'test-user-name:💥$$$, test-user-id:hello>=42@world?', From 5105112a2dc0e1b6463011524dcb2a8319722706 Mon Sep 17 00:00:00 2001 From: zcy Date: Tue, 1 Jul 2025 16:59:18 +0200 Subject: [PATCH 09/16] fix:add test case --- .../src/domain/action/trackClickActions.spec.ts | 14 ++++++++++++++ .../src/domain/action/trackClickActions.ts | 4 +--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 127901359a..65c83bbe62 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -501,6 +501,20 @@ describe('trackClickActions', () => { expect(events.length).toBe(1) expect(events[0].name).toBe('Masked Element') }) + + it('should not mask action name when defaultPrivacyLevel is mask but dd-privacy is allow', () => { + button.setAttribute('data-dd-privacy', 'allow') + startClickActionsTracking({ + defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + }) + + emulateClick({ activity: {} }) + expect(findActionId()).not.toBeUndefined() + clock.tick(EXPIRE_DELAY) + + expect(events.length).toBe(1) + expect(events[0].name).toBe('Click me') + }) }) function emulateClick({ diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index 153ab90565..6f4c4a0741 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -154,9 +154,7 @@ function processPointerDown( actionNameDictionary: AllowedDictionary ) { const nodeSelfPrivacy = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) - const nodePrivacyLevel = configuration.enablePrivacyForActionName - ? getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) - : NodePrivacyLevel.ALLOW + const nodePrivacyLevel = configuration.enablePrivacyForActionName ? nodeSelfPrivacy : NodePrivacyLevel.ALLOW if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { return undefined From da92015fef8b2469af0f81e126e9b22b63817c3d Mon Sep 17 00:00:00 2001 From: zcy Date: Wed, 9 Jul 2025 13:35:54 +0200 Subject: [PATCH 10/16] fix: nits and regexes --- .../src/domain/action/actionCollection.ts | 3 +- .../action/privacy/allowedDictionary.spec.ts | 127 ++++-------------- .../action/privacy/allowedDictionary.ts | 45 +++---- packages/rum-core/src/domain/privacy.ts | 1 + 4 files changed, 44 insertions(+), 132 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 6e51bfe82a..593e067bff 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -33,7 +33,6 @@ export function startActionCollection( configuration: RumConfiguration ) { const actionNameDictionary = createActionAllowList() - const clearActionNameDictionary: () => void = actionNameDictionary.clear const { unsubscribe: unsubscribeAutoActionCompleted } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, @@ -81,7 +80,7 @@ export function startActionCollection( }, actionContexts, stop: () => { - clearActionNameDictionary() + actionNameDictionary.clear() unsubscribeAutoActionCompleted() stop() }, diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index f3e93aee90..3a4fa55d84 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -13,15 +13,15 @@ import type { AllowedDictionary } from './allowedDictionary' const TEST_STRINGS = { COMPLEX_MIXED: 'test-user-name:💥$$$, test-user-id:hello>=42@world?', - PARAGRAPH_MIXED: 'This is a test paragraph with various symbols: 💥, $$$, 123, and more.', + PARAGRAPH_MIXED: "This isn't a sentence, it's RUM's test: 💥, $$$ = 1 + 2 + 3, and more.", } const LANGUAGES_TEST_STRINGS = { - FRENCH_MIXED_SENTENCE: "C'est un test avec des mots français et des symboles: 💥, $$$, 123, et plus. Bonjour!", - SPANISH_MIXED_SENTENCE: 'Este es un test con palabras en español y símbolos: 💥, $$$, 123, y más. ¡Hola!', - GERMAN_MIXED_SENTENCE: 'Das ist ein Test mit deutschen Wörtern und Symbolen: 💥, $$$, 123, und mehr. Hallo!', - ITALIAN_MIXED_SENTENCE: 'Questo è un test con parole in italiano e simboli: 💥, $$$, 123, e altro. Ciao!', - PORTUGUESE_MIXED_SENTENCE: 'Este é um teste com palavras em português e símbolos: 💥, $$$, 123, e mais. Olá!', + FRENCH_MIXED_SENTENCE: "C'est pas un test, c'est RUM's test: 💥, $$$ = 1 + 2 + 3, et plus.", + SPANISH_MIXED_SENTENCE: "Este no es un test, es RUM's test: 💥, $$$ = 1 + 2 + 3, y más.", + GERMAN_MIXED_SENTENCE: "Das ist kein Test, das ist RUM's Test: 💥, $$$ = 1 + 2 + 3, und mehr.", + ITALIAN_MIXED_SENTENCE: "Questo non è un test, questo è RUM's test: 💥, $$$ = 1 + 2 + 3, e altro.", + PORTUGUESE_MIXED_SENTENCE: "Este não é um teste, este é RUM's test: 💥, $$$ = 1 + 2 + 3, e mais.", } if (isBrowserSupported()) { describe('Test tokenize', () => { @@ -29,7 +29,9 @@ if (isBrowserSupported()) { const paragraphMixedTokens = tokenize(TEST_STRINGS.PARAGRAPH_MIXED) expect(paragraphMixedTokens).toContain('💥') expect(paragraphMixedTokens).not.toContain('$$$') - expect(paragraphMixedTokens).not.toContain('123') + expect(paragraphMixedTokens).not.toContain('1') + expect(paragraphMixedTokens).not.toContain('2') + expect(paragraphMixedTokens).not.toContain('3') }) it('should return empty array for whitespace-only strings', () => { @@ -52,101 +54,20 @@ if (isBrowserSupported()) { * Asian languages are not supported by our current tokenizer strategy. */ it('Tokenized results matches words and symbols in TEST_STRINGS', () => { - const paragraphMixedTokens = tokenize(TEST_STRINGS.PARAGRAPH_MIXED) - const expectedParagraphMixed = [ - 'This', - 'is', - 'a', - 'test', - 'paragraph', - 'with', - 'various', - 'symbols', - 'and', - 'more', - ] - expectedParagraphMixed.forEach((expected) => { - expect(paragraphMixedTokens).toContain(expected) - }) - const frenchTokens = tokenize(LANGUAGES_TEST_STRINGS.FRENCH_MIXED_SENTENCE) - const expectedFrench = [ - 'C', - 'est', - 'un', - 'test', - 'avec', - 'des', - 'mots', - 'français', - 'et', - 'des', - 'symboles', - 'et', - 'plus', - 'Bonjour', - ] - expectedFrench.forEach((expected) => { - expect(frenchTokens).toContain(expected) - }) + const expectedParagraphMixed = ['This', "isn't", 'a', 'sentence', "it's", "RUM's", 'test', '💥', '=', '+', '+', 'and', 'more'] + expect(tokenize(TEST_STRINGS.PARAGRAPH_MIXED).sort()).toEqual(expectedParagraphMixed.sort()) + + const expectedFrench = ["C'est", 'pas', 'un', 'test', "c'est", "RUM's", 'test', '💥', '=', '+', '+', 'et', 'plus'] + expect(tokenize(LANGUAGES_TEST_STRINGS.FRENCH_MIXED_SENTENCE).sort()).toEqual(expectedFrench.sort()) - const spanishTokens = tokenize(LANGUAGES_TEST_STRINGS.SPANISH_MIXED_SENTENCE) - const expectedSpanish = [ - 'Este', - 'es', - 'un', - 'test', - 'con', - 'palabras', - 'en', - 'español', - 'y', - 'símbolos', - 'y', - 'más', - 'Hola', - ] - expectedSpanish.forEach((expected) => { - expect(spanishTokens).toContain(expected) - }) + const expectedSpanish = ['Este', 'no', 'es', 'un', 'test', 'es', "RUM's", 'test', '💥', '=', '+', '+', 'y', 'más'] + expect(tokenize(LANGUAGES_TEST_STRINGS.SPANISH_MIXED_SENTENCE).sort()).toEqual(expectedSpanish.sort()) - const germanTokens = tokenize(LANGUAGES_TEST_STRINGS.GERMAN_MIXED_SENTENCE) - const expectedGerman = [ - 'Das', - 'ist', - 'ein', - 'Test', - 'mit', - 'deutschen', - 'Wörtern', - 'und', - 'Symbolen', - 'und', - 'mehr', - 'Hallo', - ] - expectedGerman.forEach((expected) => { - expect(germanTokens).toContain(expected) - }) + const expectedGerman = ['Das', 'ist', 'kein', 'Test', 'das', 'ist', "RUM's", 'Test', '💥', '=', '+', '+', 'und', 'mehr'] + expect(tokenize(LANGUAGES_TEST_STRINGS.GERMAN_MIXED_SENTENCE).sort()).toEqual(expectedGerman.sort()) - const portugueseTokens = tokenize(LANGUAGES_TEST_STRINGS.PORTUGUESE_MIXED_SENTENCE) - const expectedPortuguese = [ - 'Este', - 'é', - 'um', - 'teste', - 'com', - 'palavras', - 'em', - 'português', - 'e', - 'símbolos', - 'e', - 'mais', - 'Olá', - ] - expectedPortuguese.forEach((expected) => { - expect(portugueseTokens).toContain(expected) - }) + const expectedPortuguese = ['Este', 'não', 'é', 'um', 'teste', 'este', 'é', "RUM's", 'test', '💥', '=', '+', '+', 'e', 'mais'] + expect(tokenize(LANGUAGES_TEST_STRINGS.PORTUGUESE_MIXED_SENTENCE).sort()).toEqual(expectedPortuguese.sort()) }) }) } @@ -244,7 +165,7 @@ describe('createActionNameDictionary and maskActionName', () => { } beforeEach(() => { - window.$DD_ALLOW = new Set([TEST_STRINGS.COMPLEX_MIXED, TEST_STRINGS.PARAGRAPH_MIXED]) + window.$DD_ALLOW = new Set([TEST_STRINGS.PARAGRAPH_MIXED]) actionNameDictionary = createActionAllowList() clearActionNameDictionary = actionNameDictionary.clear }) @@ -263,8 +184,8 @@ describe('createActionNameDictionary and maskActionName', () => { }) it('masks words not in allowlist (with dictionary from $DD_ALLOW)', () => { - clickActionBase.name = 'test-💥-$>=123-pii' - let expected = 'test-💥-xxxxxx-xxx' + clickActionBase.name = "test this: if 💥 isn't pii" + let expected = "test this: xx 💥 isn't xxx" if (!isBrowserSupported()) { expected = ACTION_NAME_PLACEHOLDER } @@ -273,7 +194,7 @@ describe('createActionNameDictionary and maskActionName', () => { expect(testString1.nameSource).toBe(ActionNameSource.MASK_DISALLOWED) clickActionBase.name = 'test-💥+123*hello wild' - expected = 'test-xxxxxx*hello xxxx' + expected = 'test-💥+xxxxxxxxx xxxx' if (!isBrowserSupported()) { expected = ACTION_NAME_PLACEHOLDER } diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 0fa23b932d..1598421ac1 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -10,32 +10,30 @@ declare global { } type UnicodeRegexes = { - splitRegex: RegExp matchRegex: RegExp + splitRegex: RegExp } // Cache regex compilation and browser support detection let cachedRegexes: UnicodeRegexes | undefined -let supportsUnicodeRegex: boolean | undefined -function initializeUnicodeSupport(): boolean { - if (supportsUnicodeRegex !== undefined) { - return supportsUnicodeRegex +function getOrInitRegexes(): UnicodeRegexes | undefined { + if (cachedRegexes !== undefined) { + return cachedRegexes } try { cachedRegexes = { // Split on punctuation, separators, and control characters - splitRegex: new RegExp('[^\\p{Punctuation}\\p{Separator}\\p{Cc}]+', 'gu'), + splitRegex: new RegExp(`[^\\p{Separator}\\p{Cc}\\p{Sm}!"(),-./:;?[\\]\`_{|}]+`, 'gu'), // Match letters (including apostrophes), emojis, and mathematical symbols - matchRegex: new RegExp("[\\p{Letter}']+|[\\p{Emoji_Presentation}]+|[\\p{Sm}]+", 'gu'), + matchRegex: new RegExp("[\\p{Letter}’']+|[\\p{Emoji_Presentation}]+|[\\p{Sm}]+", 'gu'), } - supportsUnicodeRegex = true - } catch { - supportsUnicodeRegex = false + } catch { + cachedRegexes = undefined } - return supportsUnicodeRegex + return cachedRegexes } export function tokenize(str: string): string[] { @@ -43,18 +41,16 @@ export function tokenize(str: string): string[] { return [] } - if (!initializeUnicodeSupport() || !cachedRegexes) { + if (!getOrInitRegexes() || !cachedRegexes) { return [] } - const { splitRegex, matchRegex } = cachedRegexes - const segments = str.match(splitRegex) || [] - - return segments.flatMap((segment) => segment.match(matchRegex) || []) + const { matchRegex } = cachedRegexes + return str.match(matchRegex) || [] } export function isBrowserSupported(): boolean { - return initializeUnicodeSupport() + return getOrInitRegexes() !== undefined } export type AllowedDictionary = { @@ -68,22 +64,17 @@ export function createActionAllowList(): AllowedDictionary { const dictionary: AllowedDictionary = { rawStringCounter: 0, allowlist: new Set(), - rawStringIterator: window.$DD_ALLOW?.values(), + rawStringIterator: undefined, clear: () => { dictionary.allowlist.clear() dictionary.rawStringCounter = 0 dictionary.rawStringIterator = undefined - supportsUnicodeRegex = undefined window.$DD_ALLOW_OBSERVERS?.delete(observer) }, } const observer = () => processRawAllowList(window.$DD_ALLOW, dictionary) - - // Initialize allowlist if needed - if (dictionary.allowlist.size === 0) { - processRawAllowList(window.$DD_ALLOW, dictionary) - } + processRawAllowList(window.$DD_ALLOW, dictionary) // Add observer if (!window.$DD_ALLOW_OBSERVERS) { @@ -132,8 +123,9 @@ export function maskActionName( } const { name, nameSource } = actionName + const regexes = getOrInitRegexes() - if (!initializeUnicodeSupport()) { + if (!regexes) { return { ...actionName, name: name ? ACTION_NAME_PLACEHOLDER : '', @@ -141,10 +133,9 @@ export function maskActionName( } } - const { splitRegex } = cachedRegexes! let hasBeenMasked = false - const maskedName = name.replace(splitRegex, (segment: string) => { + const maskedName = name.replace(regexes.splitRegex, (segment: string) => { if (!processedAllowlist.has(segment.toLowerCase())) { hasBeenMasked = true return TEXT_MASKING_CHAR.repeat(segment.length) diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index 075bba3511..bcb5680bc4 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -36,6 +36,7 @@ export const FORM_PRIVATE_TAG_NAMES: { [tagName: string]: true } = { } export const TEXT_MASKING_CHAR = 'x' +export const TEXT_MASKING_STR= 'xxx' export type NodePrivacyLevelCache = Map From 045d430590409e53f6768f858fa2e870ffbe3cf1 Mon Sep 17 00:00:00 2001 From: zcy Date: Wed, 9 Jul 2025 14:16:48 +0200 Subject: [PATCH 11/16] fix: lint and format --- .../action/privacy/allowedDictionary.spec.ts | 53 +++++++++++++++++-- .../action/privacy/allowedDictionary.ts | 6 +-- packages/rum-core/src/domain/privacy.ts | 1 - 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts index 3a4fa55d84..5259f4cc6a 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.spec.ts @@ -54,19 +54,64 @@ if (isBrowserSupported()) { * Asian languages are not supported by our current tokenizer strategy. */ it('Tokenized results matches words and symbols in TEST_STRINGS', () => { - const expectedParagraphMixed = ['This', "isn't", 'a', 'sentence', "it's", "RUM's", 'test', '💥', '=', '+', '+', 'and', 'more'] + const expectedParagraphMixed = [ + 'This', + "isn't", + 'a', + 'sentence', + "it's", + "RUM's", + 'test', + '💥', + '=', + '+', + '+', + 'and', + 'more', + ] expect(tokenize(TEST_STRINGS.PARAGRAPH_MIXED).sort()).toEqual(expectedParagraphMixed.sort()) - const expectedFrench = ["C'est", 'pas', 'un', 'test', "c'est", "RUM's", 'test', '💥', '=', '+', '+', 'et', 'plus'] + const expectedFrench = ["C'est", 'pas', 'un', 'test', "c'est", "RUM's", 'test', '💥', '=', '+', '+', 'et', 'plus'] expect(tokenize(LANGUAGES_TEST_STRINGS.FRENCH_MIXED_SENTENCE).sort()).toEqual(expectedFrench.sort()) const expectedSpanish = ['Este', 'no', 'es', 'un', 'test', 'es', "RUM's", 'test', '💥', '=', '+', '+', 'y', 'más'] expect(tokenize(LANGUAGES_TEST_STRINGS.SPANISH_MIXED_SENTENCE).sort()).toEqual(expectedSpanish.sort()) - const expectedGerman = ['Das', 'ist', 'kein', 'Test', 'das', 'ist', "RUM's", 'Test', '💥', '=', '+', '+', 'und', 'mehr'] + const expectedGerman = [ + 'Das', + 'ist', + 'kein', + 'Test', + 'das', + 'ist', + "RUM's", + 'Test', + '💥', + '=', + '+', + '+', + 'und', + 'mehr', + ] expect(tokenize(LANGUAGES_TEST_STRINGS.GERMAN_MIXED_SENTENCE).sort()).toEqual(expectedGerman.sort()) - const expectedPortuguese = ['Este', 'não', 'é', 'um', 'teste', 'este', 'é', "RUM's", 'test', '💥', '=', '+', '+', 'e', 'mais'] + const expectedPortuguese = [ + 'Este', + 'não', + 'é', + 'um', + 'teste', + 'este', + 'é', + "RUM's", + 'test', + '💥', + '=', + '+', + '+', + 'e', + 'mais', + ] expect(tokenize(LANGUAGES_TEST_STRINGS.PORTUGUESE_MIXED_SENTENCE).sort()).toEqual(expectedPortuguese.sort()) }) }) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 1598421ac1..d4b7e96720 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -24,12 +24,12 @@ function getOrInitRegexes(): UnicodeRegexes | undefined { try { cachedRegexes = { - // Split on punctuation, separators, and control characters - splitRegex: new RegExp(`[^\\p{Separator}\\p{Cc}\\p{Sm}!"(),-./:;?[\\]\`_{|}]+`, 'gu'), + // Split on separators, control characters, and selected punctuation + splitRegex: new RegExp('[^\\p{Separator}\\p{Cc}\\p{Sm}!"(),-./:;?[\\]`_{|}]+', 'gu'), // Match letters (including apostrophes), emojis, and mathematical symbols matchRegex: new RegExp("[\\p{Letter}’']+|[\\p{Emoji_Presentation}]+|[\\p{Sm}]+", 'gu'), } - } catch { + } catch { cachedRegexes = undefined } diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index bcb5680bc4..075bba3511 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -36,7 +36,6 @@ export const FORM_PRIVATE_TAG_NAMES: { [tagName: string]: true } = { } export const TEXT_MASKING_CHAR = 'x' -export const TEXT_MASKING_STR= 'xxx' export type NodePrivacyLevelCache = Map From 71dfbe0187e5ad05fa3e6879b8010ca8f9aacb79 Mon Sep 17 00:00:00 2001 From: zcy Date: Fri, 11 Jul 2025 18:26:51 +0200 Subject: [PATCH 12/16] fix: check when tokens are not strings --- .../rum-core/src/domain/action/privacy/allowedDictionary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index d4b7e96720..06eacdacca 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -37,7 +37,7 @@ function getOrInitRegexes(): UnicodeRegexes | undefined { } export function tokenize(str: string): string[] { - if (!str.trim()) { + if (typeof str !== 'string' || !str.trim()) { return [] } From b220eaeb8926f3b4883b91f0414cad62a0c519be Mon Sep 17 00:00:00 2001 From: zcy Date: Fri, 11 Jul 2025 18:32:01 +0200 Subject: [PATCH 13/16] add privacy level --- packages/core/src/domain/configuration/configuration.ts | 1 + .../src/domain/action/privacy/allowedDictionary.ts | 8 +++----- .../rum-core/src/domain/action/trackClickActions.spec.ts | 4 ++-- packages/rum-core/src/domain/privacy.ts | 7 +++++++ 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 7a92c71a1b..e13eb0662f 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -20,6 +20,7 @@ export const DefaultPrivacyLevel = { ALLOW: 'allow', MASK: 'mask', MASK_USER_INPUT: 'mask-user-input', + MASK_UNLESS_ALLOWLISTED: 'mask-unless-allowlisted', } as const export type DefaultPrivacyLevel = (typeof DefaultPrivacyLevel)[keyof typeof DefaultPrivacyLevel] diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index 06eacdacca..c01dc4b47a 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -114,18 +114,16 @@ export function maskActionName( nodeSelfPrivacy: NodePrivacyLevel, processedAllowlist: Set ): ClickActionBase { - if (!window.$DD_ALLOW) { - return actionName - } - if (nodeSelfPrivacy === NodePrivacyLevel.ALLOW) { return actionName + } else if (nodeSelfPrivacy !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED && (!window.$DD_ALLOW || !window.$DD_ALLOW.size)) { + return actionName } const { name, nameSource } = actionName const regexes = getOrInitRegexes() - if (!regexes) { + if (!regexes || !window.$DD_ALLOW || !window.$DD_ALLOW.size) { return { ...actionName, name: name ? ACTION_NAME_PLACEHOLDER : '', diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index 65c83bbe62..047540bb32 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -461,9 +461,9 @@ describe('trackClickActions', () => { window.$DD_ALLOW = undefined }) - it('should mask action name when defaultPrivacyLevel is mask and not in allowlist', () => { + it('should mask action name when defaultPrivacyLevel is mask_unless_allowlisted and not in allowlist', () => { startClickActionsTracking({ - defaultPrivacyLevel: DefaultPrivacyLevel.MASK, + defaultPrivacyLevel: DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED, }) emulateClick({ activity: {} }) diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index 075bba3511..d08ba14bd6 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -7,6 +7,7 @@ export const NodePrivacyLevel = { ALLOW: DefaultPrivacyLevel.ALLOW, MASK: DefaultPrivacyLevel.MASK, MASK_USER_INPUT: DefaultPrivacyLevel.MASK_USER_INPUT, + MASK_UNLESS_ALLOWLISTED: DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED, } as const export type NodePrivacyLevel = (typeof NodePrivacyLevel)[keyof typeof NodePrivacyLevel] @@ -82,6 +83,7 @@ export function reducePrivacyLevel( case NodePrivacyLevel.ALLOW: case NodePrivacyLevel.MASK: case NodePrivacyLevel.MASK_USER_INPUT: + case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: return childPrivacyLevel @@ -133,6 +135,10 @@ export function getNodeSelfPrivacyLevel(node: Node): NodePrivacyLevel | undefine return NodePrivacyLevel.MASK_USER_INPUT } + if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED))) { + return NodePrivacyLevel.MASK + } + if (node.matches(getPrivacySelector(NodePrivacyLevel.ALLOW))) { return NodePrivacyLevel.ALLOW } @@ -158,6 +164,7 @@ export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel) { case NodePrivacyLevel.MASK: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: + case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: return true case NodePrivacyLevel.MASK_USER_INPUT: return isTextNode(node) ? isFormElement(node.parentNode) : isFormElement(node) From a7234cebab8382530d358a8cea875b59ed9e1f95 Mon Sep 17 00:00:00 2001 From: zcy Date: Tue, 15 Jul 2025 16:37:23 +0200 Subject: [PATCH 14/16] feat:add masking in session replay --- .../src/domain/action/actionCollection.ts | 2 +- .../action/privacy/allowedDictionary.ts | 33 +++++++++++-------- packages/rum-core/src/domain/privacy.ts | 9 ++++- .../serialization/serialization.types.ts | 1 + 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/rum-core/src/domain/action/actionCollection.ts b/packages/rum-core/src/domain/action/actionCollection.ts index 593e067bff..857e2c7f90 100644 --- a/packages/rum-core/src/domain/action/actionCollection.ts +++ b/packages/rum-core/src/domain/action/actionCollection.ts @@ -24,6 +24,7 @@ export interface CustomAction { } export type AutoAction = ClickAction +export const actionNameDictionary = createActionAllowList() export function startActionCollection( lifeCycle: LifeCycle, @@ -32,7 +33,6 @@ export function startActionCollection( windowOpenObservable: Observable, configuration: RumConfiguration ) { - const actionNameDictionary = createActionAllowList() const { unsubscribe: unsubscribeAutoActionCompleted } = lifeCycle.subscribe( LifeCycleEventType.AUTO_ACTION_COMPLETED, diff --git a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts index c01dc4b47a..d53fbfb15a 100644 --- a/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts +++ b/packages/rum-core/src/domain/action/privacy/allowedDictionary.ts @@ -17,7 +17,7 @@ type UnicodeRegexes = { // Cache regex compilation and browser support detection let cachedRegexes: UnicodeRegexes | undefined -function getOrInitRegexes(): UnicodeRegexes | undefined { +export function getOrInitRegexes(): UnicodeRegexes | undefined { if (cachedRegexes !== undefined) { return cachedRegexes } @@ -109,6 +109,20 @@ export function processRawAllowList(rawAllowlist: Set | undefined, dicti } } +export function maskTextContent(text: string, processedAllowlist: Set, regexes: UnicodeRegexes): {maskedText: string, hasBeenMasked: boolean} { + let hasBeenMasked = false + + const maskedText = text.replace(regexes.splitRegex, (segment: string) => { + if (!processedAllowlist.has(segment.toLowerCase())) { + hasBeenMasked = true + return TEXT_MASKING_CHAR.repeat(segment.length) + } + return segment + }) + + return {maskedText, hasBeenMasked} +} + export function maskActionName( actionName: ClickActionBase, nodeSelfPrivacy: NodePrivacyLevel, @@ -118,7 +132,8 @@ export function maskActionName( return actionName } else if (nodeSelfPrivacy !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED && (!window.$DD_ALLOW || !window.$DD_ALLOW.size)) { return actionName - } + } // if the privacy level is MASK or MASK_USER_INPUT and the allowlist is present, we continue of masking the action name + const { name, nameSource } = actionName const regexes = getOrInitRegexes() @@ -131,19 +146,11 @@ export function maskActionName( } } - let hasBeenMasked = false - - const maskedName = name.replace(regexes.splitRegex, (segment: string) => { - if (!processedAllowlist.has(segment.toLowerCase())) { - hasBeenMasked = true - return TEXT_MASKING_CHAR.repeat(segment.length) - } - return segment - }) + const maskedName = maskTextContent(name, processedAllowlist, regexes) return { ...actionName, - name: maskedName, - nameSource: hasBeenMasked ? ActionNameSource.MASK_DISALLOWED : nameSource, + name: maskedName.maskedText, + nameSource: maskedName.hasBeenMasked ? ActionNameSource.MASK_DISALLOWED : nameSource, } } diff --git a/packages/rum-core/src/domain/privacy.ts b/packages/rum-core/src/domain/privacy.ts index d08ba14bd6..96ba554eee 100644 --- a/packages/rum-core/src/domain/privacy.ts +++ b/packages/rum-core/src/domain/privacy.ts @@ -1,5 +1,7 @@ import { DefaultPrivacyLevel } from '@datadog/browser-core' import { isElementNode, getParentNode, isTextNode } from '../browser/htmlDomUtils' +import { getOrInitRegexes, maskTextContent } from './action/privacy/allowedDictionary' +import { actionNameDictionary } from './action/actionCollection' export const NodePrivacyLevel = { IGNORE: 'ignore', @@ -136,7 +138,7 @@ export function getNodeSelfPrivacyLevel(node: Node): NodePrivacyLevel | undefine } if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED))) { - return NodePrivacyLevel.MASK + return NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED } if (node.matches(getPrivacySelector(NodePrivacyLevel.ALLOW))) { @@ -233,6 +235,11 @@ export function getTextContent( } else if (parentTagName === 'OPTION') { //