Skip to content

Commit 1f994b9

Browse files
🎨 [PANA-5053] Separate DOM and virtual attribute serialization (#3998)
1 parent a55b1fb commit 1f994b9

File tree

4 files changed

+101
-75
lines changed

4 files changed

+101
-75
lines changed

‎packages/rum/src/domain/record/serialization/serializationUtils.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ export function getValidTagName(tagName: string): string {
9090
return processedTagName
9191
}
9292

93+
/**
94+
* Returns the tag name of the given element, normalized to ensure a consistent lowercase
95+
* representation regardless of whether the element is HTML, XHTML, or SVG.
96+
*/
97+
export function normalizedTagName(element: Element): string {
98+
return element.tagName.toLowerCase()
99+
}
100+
93101
export function censoredImageForSize(width: number, height: number) {
94102
return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}' style='background-color:silver'%3E%3C/svg%3E`
95103
}

‎packages/rum/src/domain/record/serialization/serializeAttribute.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ export function serializeAttribute(
1717
nodePrivacyLevel: NodePrivacyLevel,
1818
attributeName: string,
1919
configuration: RumConfiguration
20-
): string | number | boolean | null {
20+
): string | null {
2121
if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) {
2222
// dup condition for direct access case
2323
return null
2424
}
25+
2526
const attributeValue = element.getAttribute(attributeName)
2627
const tagName = element.tagName
2728
if (shouldMaskAttribute(tagName, attributeName, attributeValue, nodePrivacyLevel, configuration)) {
@@ -46,7 +47,7 @@ export function serializeAttribute(
4647
return CENSORED_STRING_MARK
4748
}
4849

49-
if (!attributeValue || typeof attributeValue !== 'string') {
50+
if (!attributeValue) {
5051
return attributeValue
5152
}
5253

‎packages/rum/src/domain/record/serialization/serializeAttributes.spec.ts‎

Lines changed: 31 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { appendElement } from '@datadog/browser-rum-core/test'
1212
import { createSerializationTransactionForTesting } from '../test/serialization.specHelper'
1313
import type { ScrollPositions } from '../elementsScrollPositions'
14-
import { getCssRulesString, serializeAttributes } from './serializeAttributes'
14+
import { getCssRulesString, serializeDOMAttributes, serializeVirtualAttributes } from './serializeAttributes'
1515
import { SerializationKind, type SerializationTransaction } from './serializationTransaction'
1616
import type { VirtualAttributes } from './serialization.types'
1717
import type { SerializationMetric, SerializationStats } from './serializationStats'
@@ -28,21 +28,13 @@ const PRIVACY_LEVELS = Object.keys({
2828
[NodePrivacyLevel.MASK_USER_INPUT]: true,
2929
} satisfies Record<NodePrivacyLevel, true>) as NodePrivacyLevel[]
3030

31-
describe('serializeAttributes for DOM attributes', () => {
31+
describe('serializeDOMAttributes', () => {
3232
let transaction: SerializationTransaction
3333

3434
beforeEach(() => {
3535
transaction = createSerializationTransactionForTesting()
3636
})
3737

38-
function serializeDOMAttributes(
39-
element: Element,
40-
nodePrivacyLevel: NodePrivacyLevel,
41-
transaction: SerializationTransaction
42-
): Record<string, string | number | boolean | undefined> {
43-
return serializeAttributes(element, nodePrivacyLevel, transaction)
44-
}
45-
4638
it('serializes attribute values', () => {
4739
interface TestCase {
4840
// A snippet of HTML containing an attribute we should serialize.
@@ -260,7 +252,8 @@ describe('serializeAttributes for DOM attributes', () => {
260252
element.remove()
261253

262254
for (const privacyLevel of PRIVACY_LEVELS) {
263-
const actual = serializeDOMAttributes(element, privacyLevel, transaction)[attribute.name]
255+
const attributes = serializeDOMAttributes(element, privacyLevel, transaction)
256+
const actual = attributes[attribute.name] as boolean | string | undefined
264257
const expected = expectedValueForPrivacyLevel(testCase, element, attribute, privacyLevel)
265258
expect(actual).withContext(`${testCase.html} for ${privacyLevel}`).toEqual(expected)
266259
}
@@ -342,9 +335,22 @@ describe('serializeAttributes for DOM attributes', () => {
342335
}
343336
}
344337
})
338+
339+
it('normalizes tag names', () => {
340+
// Create an <option> element in the SVG namespace. This makes it an XML element;
341+
// among other things, this results in a lowercase Element#tagName.
342+
const element = document.createElementNS('http://www.w3.org/2000/svg', 'option')
343+
expect(element.tagName).toBe('option')
344+
345+
// Check that serializeDOMAttributes() still executes <option>-specific serialization
346+
// behavior; it shouldn't behave differently because Element#tagName is lowercase.
347+
;(element as any).selected = true
348+
const attributes = serializeDOMAttributes(element, NodePrivacyLevel.ALLOW, transaction)
349+
expect(attributes['selected']).toBe(true)
350+
})
345351
})
346352

347-
describe('serializeAttributes for virtual attributes', () => {
353+
describe('serializeVirtualAttributes', () => {
348354
let stats: SerializationStats
349355
let transaction: SerializationTransaction
350356

@@ -353,36 +359,6 @@ describe('serializeAttributes for virtual attributes', () => {
353359
transaction = createSerializationTransactionForTesting({ stats })
354360
})
355361

356-
function serializeVirtualAttributes(
357-
element: Element,
358-
nodePrivacyLevel: NodePrivacyLevel,
359-
transaction: SerializationTransaction
360-
): VirtualAttributes {
361-
const attributes = serializeAttributes(element, nodePrivacyLevel, transaction)
362-
363-
const virtualAttributes: VirtualAttributes = {}
364-
if ('_cssText' in attributes) {
365-
virtualAttributes._cssText = attributes._cssText as string
366-
}
367-
if ('rr_mediaState' in attributes) {
368-
virtualAttributes.rr_mediaState = attributes.rr_mediaState as 'paused' | 'played'
369-
}
370-
if ('rr_scrollLeft' in attributes) {
371-
virtualAttributes.rr_scrollLeft = attributes.rr_scrollLeft as number
372-
}
373-
if ('rr_scrollTop' in attributes) {
374-
virtualAttributes.rr_scrollTop = attributes.rr_scrollTop as number
375-
}
376-
if ('rr_width' in attributes) {
377-
virtualAttributes.rr_width = attributes.rr_width as string
378-
}
379-
if ('rr_height' in attributes) {
380-
virtualAttributes.rr_height = attributes.rr_height as string
381-
}
382-
383-
return virtualAttributes
384-
}
385-
386362
function expectVirtualAttributes(
387363
element: Element,
388364
expectedWhenNotHidden: VirtualAttributes,
@@ -534,6 +510,19 @@ describe('serializeAttributes for virtual attributes', () => {
534510
expectVirtualAttributes(div, {}, checkElementScrollPositions)
535511
})
536512
})
513+
514+
it('normalizes tag names', () => {
515+
// Create an <audio> element in the SVG namespace. This makes it an XML element;
516+
// among other things, this results in a lowercase Element#tagName.
517+
const element = document.createElementNS('http://www.w3.org/2000/svg', 'audio')
518+
expect(element.tagName).toBe('audio')
519+
520+
// Check that serializeVirtualAttributes() still executes <audio>-specific serialization
521+
// behavior; it shouldn't behave differently because Element#tagName is lowercase.
522+
;(element as any).paused = true
523+
const attributes = serializeVirtualAttributes(element, NodePrivacyLevel.ALLOW, transaction)
524+
expect(attributes.rr_mediaState).toBe('paused')
525+
})
537526
})
538527

539528
describe('getCssRulesString', () => {

‎packages/rum/src/domain/record/serialization/serializeAttributes.ts‎

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
11
import { NodePrivacyLevel, shouldMaskNode } from '@datadog/browser-rum-core'
22
import { isSafari } from '@datadog/browser-core'
3-
import { getElementInputValue, switchToAbsoluteUrl, getValidTagName } from './serializationUtils'
3+
import { getElementInputValue, normalizedTagName, switchToAbsoluteUrl } from './serializationUtils'
44
import { serializeAttribute } from './serializeAttribute'
55
import type { SerializationTransaction } from './serializationTransaction'
66
import { SerializationKind } from './serializationTransaction'
7+
import type { VirtualAttributes } from './serialization.types'
78

89
export function serializeAttributes(
910
element: Element,
1011
nodePrivacyLevel: NodePrivacyLevel,
1112
transaction: SerializationTransaction
12-
): Record<string, string | number | boolean> {
13+
): Record<string, boolean | number | string> {
14+
return {
15+
...serializeDOMAttributes(element, nodePrivacyLevel, transaction),
16+
...serializeVirtualAttributes(element, nodePrivacyLevel, transaction),
17+
}
18+
}
19+
20+
export function serializeDOMAttributes(
21+
element: Element,
22+
nodePrivacyLevel: NodePrivacyLevel,
23+
transaction: SerializationTransaction
24+
): Record<string, boolean | string> {
1325
if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) {
1426
return {}
1527
}
16-
const safeAttrs: Record<string, string | number | boolean> = {}
17-
const tagName = getValidTagName(element.tagName)
18-
const doc = element.ownerDocument
28+
29+
const attrs: Record<string, string | boolean> = {}
30+
const tagName = normalizedTagName(element)
1931

2032
for (let i = 0; i < element.attributes.length; i += 1) {
2133
const attribute = element.attributes.item(i)!
2234
const attributeName = attribute.name
2335
const attributeValue = serializeAttribute(element, nodePrivacyLevel, attributeName, transaction.scope.configuration)
2436
if (attributeValue !== null) {
25-
safeAttrs[attributeName] = attributeValue
37+
attrs[attributeName] = attributeValue
2638
}
2739
}
2840

@@ -32,7 +44,7 @@ export function serializeAttributes(
3244
) {
3345
const formValue = getElementInputValue(element, nodePrivacyLevel)
3446
if (formValue !== undefined) {
35-
safeAttrs.value = formValue
47+
attrs.value = formValue
3648
}
3749
}
3850

@@ -43,17 +55,50 @@ export function serializeAttributes(
4355
// For privacy=`MASK`, all the values would be the same, so skip.
4456
const optionElement = element as HTMLOptionElement
4557
if (optionElement.selected) {
46-
safeAttrs.selected = optionElement.selected
58+
attrs.selected = optionElement.selected
4759
}
4860
}
4961

62+
/**
63+
* Forms: input[type=checkbox,radio]
64+
* The `checked` property for <input> is a little bit special:
65+
* 1. el.checked is a setter that returns if truthy.
66+
* 2. getAttribute returns the string value
67+
* getAttribute('checked') does not sync with `Element.checked`, so use JS property
68+
* NOTE: `checked` property exists on `HTMLInputElement`. For serializer assumptions, we check for type=radio|check.
69+
*/
70+
const inputElement = element as HTMLInputElement
71+
if (tagName === 'input' && (inputElement.type === 'radio' || inputElement.type === 'checkbox')) {
72+
if (nodePrivacyLevel === NodePrivacyLevel.ALLOW) {
73+
attrs.checked = !!inputElement.checked
74+
} else if (shouldMaskNode(inputElement, nodePrivacyLevel)) {
75+
delete attrs.checked
76+
}
77+
}
78+
79+
return attrs
80+
}
81+
82+
export function serializeVirtualAttributes(
83+
element: Element,
84+
nodePrivacyLevel: NodePrivacyLevel,
85+
transaction: SerializationTransaction
86+
): VirtualAttributes {
87+
if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) {
88+
return {}
89+
}
90+
91+
const attrs: VirtualAttributes = {}
92+
const doc = element.ownerDocument
93+
const tagName = normalizedTagName(element)
94+
5095
// remote css
5196
if (tagName === 'link') {
5297
const stylesheet = Array.from(doc.styleSheets).find((s) => s.href === (element as HTMLLinkElement).href)
5398
const cssText = getCssRulesString(stylesheet)
5499
if (cssText && stylesheet) {
55100
transaction.addMetric('cssText', cssText.length)
56-
safeAttrs._cssText = cssText
101+
attrs._cssText = cssText
57102
}
58103
}
59104

@@ -62,24 +107,7 @@ export function serializeAttributes(
62107
const cssText = getCssRulesString((element as HTMLStyleElement).sheet)
63108
if (cssText) {
64109
transaction.addMetric('cssText', cssText.length)
65-
safeAttrs._cssText = cssText
66-
}
67-
}
68-
69-
/**
70-
* Forms: input[type=checkbox,radio]
71-
* The `checked` property for <input> is a little bit special:
72-
* 1. el.checked is a setter that returns if truthy.
73-
* 2. getAttribute returns the string value
74-
* getAttribute('checked') does not sync with `Element.checked`, so use JS property
75-
* NOTE: `checked` property exists on `HTMLInputElement`. For serializer assumptions, we check for type=radio|check.
76-
*/
77-
const inputElement = element as HTMLInputElement
78-
if (tagName === 'input' && (inputElement.type === 'radio' || inputElement.type === 'checkbox')) {
79-
if (nodePrivacyLevel === NodePrivacyLevel.ALLOW) {
80-
safeAttrs.checked = !!inputElement.checked
81-
} else if (shouldMaskNode(inputElement, nodePrivacyLevel)) {
82-
delete safeAttrs.checked
110+
attrs._cssText = cssText
83111
}
84112
}
85113

@@ -88,7 +116,7 @@ export function serializeAttributes(
88116
*/
89117
if (tagName === 'audio' || tagName === 'video') {
90118
const mediaElement = element as HTMLMediaElement
91-
safeAttrs.rr_mediaState = mediaElement.paused ? 'paused' : 'played'
119+
attrs.rr_mediaState = mediaElement.paused ? 'paused' : 'played'
92120
}
93121

94122
/**
@@ -111,13 +139,13 @@ export function serializeAttributes(
111139
break
112140
}
113141
if (scrollLeft) {
114-
safeAttrs.rr_scrollLeft = scrollLeft
142+
attrs.rr_scrollLeft = scrollLeft
115143
}
116144
if (scrollTop) {
117-
safeAttrs.rr_scrollTop = scrollTop
145+
attrs.rr_scrollTop = scrollTop
118146
}
119147

120-
return safeAttrs
148+
return attrs
121149
}
122150

123151
export function getCssRulesString(cssStyleSheet: CSSStyleSheet | undefined | null): string | null {

0 commit comments

Comments
 (0)