import { assign, startsWith } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from '@datadog/browser-rum-core' import { NodePrivacyLevel, PRIVACY_ATTR_NAME, PRIVACY_ATTR_VALUE_HIDDEN, CENSORED_STRING_MARK, CENSORED_IMG_MARK, } from '../../constants' import type { SerializedNode, SerializedNodeWithId, DocumentNode, DocumentTypeNode, ElementNode, TextNode, CDataNode, } from '../../types' import { NodeType } from '../../types' import { getTextContent, shouldMaskNode, reducePrivacyLevel, getNodeSelfPrivacyLevel, MAX_ATTRIBUTE_VALUE_CHAR_LENGTH, } from './privacy' import { getSerializedNodeId, setSerializedNodeId, getElementInputValue } from './serializationUtils' import { forEach } from './utils' import type { ElementsScrollPositions } from './elementsScrollPositions' // Those values are the only one that can be used when inheriting privacy levels from parent to // children during serialization, since HIDDEN and IGNORE shouldn't serialize their children. This // ensures that no children are serialized when they shouldn't. type ParentNodePrivacyLevel = | typeof NodePrivacyLevel.ALLOW | typeof NodePrivacyLevel.MASK | typeof NodePrivacyLevel.MASK_USER_INPUT export const enum SerializationContextStatus { INITIAL_FULL_SNAPSHOT, SUBSEQUENT_FULL_SNAPSHOT, MUTATION, } export type SerializationContext = | { status: SerializationContextStatus.MUTATION } | { status: SerializationContextStatus.INITIAL_FULL_SNAPSHOT elementsScrollPositions: ElementsScrollPositions } | { status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT elementsScrollPositions: ElementsScrollPositions } export interface SerializeOptions { serializedNodeIds?: Set ignoreWhiteSpace?: boolean parentNodePrivacyLevel: ParentNodePrivacyLevel serializationContext: SerializationContext configuration: RumConfiguration } export function serializeDocument( document: Document, configuration: RumConfiguration, serializationContext: SerializationContext ): SerializedNodeWithId { // We are sure that Documents are never ignored, so this function never returns null return serializeNodeWithId(document, { serializationContext, parentNodePrivacyLevel: configuration.defaultPrivacyLevel, configuration, })! } export function serializeNodeWithId(node: Node, options: SerializeOptions): SerializedNodeWithId | null { const serializedNode = serializeNode(node, options) if (!serializedNode) { return null } // Try to reuse the previous id const id = getSerializedNodeId(node) || generateNextId() const serializedNodeWithId = serializedNode as SerializedNodeWithId serializedNodeWithId.id = id setSerializedNodeId(node, id) if (options.serializedNodeIds) { options.serializedNodeIds.add(id) } return serializedNodeWithId } function serializeNode(node: Node, options: SerializeOptions): SerializedNode | undefined { switch (node.nodeType) { case node.DOCUMENT_NODE: return serializeDocumentNode(node as Document, options) case node.DOCUMENT_TYPE_NODE: return serializeDocumentTypeNode(node as DocumentType) case node.ELEMENT_NODE: return serializeElementNode(node as Element, options) case node.TEXT_NODE: return serializeTextNode(node as Text, options) case node.CDATA_SECTION_NODE: return serializeCDataNode() } } export function serializeDocumentNode(document: Document, options: SerializeOptions): DocumentNode { return { type: NodeType.Document, childNodes: serializeChildNodes(document, options), } } function serializeDocumentTypeNode(documentType: DocumentType): DocumentTypeNode { return { type: NodeType.DocumentType, name: documentType.name, publicId: documentType.publicId, systemId: documentType.systemId, } } /** * Serializing Element nodes involves capturing: * 1. HTML ATTRIBUTES: * 2. JS STATE: * - scroll offsets * - Form fields (input value, checkbox checked, otpion selection, range) * - Canvas state, * - Media (video/audio) play mode + currentTime * - iframe contents * - webcomponents * 3. CUSTOM PROPERTIES: * - height+width for when `hidden` to cover the element * 4. EXCLUDED INTERACTION STATE: * - focus (possible, but not worth perf impact) * - hover (tracked only via mouse activity) * - fullscreen mode */ export function serializeElementNode(element: Element, options: SerializeOptions): ElementNode | undefined { const tagName = getValidTagName(element.tagName) const isSVG = isSVGElement(element) || undefined // For performance reason, we don't use getNodePrivacyLevel directly: we leverage the // parentNodePrivacyLevel option to avoid iterating over all parents const nodePrivacyLevel = reducePrivacyLevel(getNodeSelfPrivacyLevel(element), options.parentNodePrivacyLevel) if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { const { width, height } = element.getBoundingClientRect() return { type: NodeType.Element, tagName, attributes: { rr_width: `${width}px`, rr_height: `${height}px`, [PRIVACY_ATTR_NAME]: PRIVACY_ATTR_VALUE_HIDDEN, }, childNodes: [], isSVG, } } // Ignore Elements like Script and some Link, Metas if (nodePrivacyLevel === NodePrivacyLevel.IGNORE) { return } const attributes = getAttributesForPrivacyLevel(element, nodePrivacyLevel, options) let childNodes: SerializedNodeWithId[] = [] if (element.childNodes.length) { // OBJECT POOLING OPTIMIZATION: // We should not create a new object systematically as it could impact performances. Try to reuse // the same object as much as possible, and clone it only if we need to. let childNodesSerializationOptions if (options.parentNodePrivacyLevel === nodePrivacyLevel && options.ignoreWhiteSpace === (tagName === 'head')) { childNodesSerializationOptions = options } else { childNodesSerializationOptions = assign({}, options, { parentNodePrivacyLevel: nodePrivacyLevel, ignoreWhiteSpace: tagName === 'head', }) } childNodes = serializeChildNodes(element, childNodesSerializationOptions) } return { type: NodeType.Element, tagName, attributes, childNodes, isSVG, } } /** * Text Nodes are dependant on Element nodes * Privacy levels are set on elements so we check the parentElement of a text node * for privacy level. */ function serializeTextNode(textNode: Text, options: SerializeOptions): TextNode | undefined { // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = textNode.parentElement?.tagName const textContent = getTextContent(textNode, options.ignoreWhiteSpace || false, options.parentNodePrivacyLevel) if (!textContent) { return } return { type: NodeType.Text, textContent, isStyle: parentTagName === 'STYLE' ? true : undefined, } } function serializeCDataNode(): CDataNode { return { type: NodeType.CDATA, textContent: '', } } export function serializeChildNodes(node: Node, options: SerializeOptions): SerializedNodeWithId[] { const result: SerializedNodeWithId[] = [] forEach(node.childNodes, (childNode) => { const serializedChildNode = serializeNodeWithId(childNode, options) if (serializedChildNode) { result.push(serializedChildNode) } }) return result } export function serializeAttribute( element: Element, nodePrivacyLevel: NodePrivacyLevel, attributeName: string, configuration: RumConfiguration ): string | number | boolean | null { if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { // dup condition for direct access case return null } const attributeValue = element.getAttribute(attributeName) if ( nodePrivacyLevel === NodePrivacyLevel.MASK && attributeName !== PRIVACY_ATTR_NAME && attributeName !== DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE && attributeName !== configuration.actionNameAttribute ) { const tagName = element.tagName switch (attributeName) { // Mask Attribute text content case 'title': case 'alt': case 'placeholder': return CENSORED_STRING_MARK } // mask image URLs if (tagName === 'IMG' || tagName === 'SOURCE') { if (attributeName === 'src' || attributeName === 'srcset') { return CENSORED_IMG_MARK } } // mask URLs if (tagName === 'A' && attributeName === 'href') { return CENSORED_STRING_MARK } // mask data-* attributes if (attributeValue && startsWith(attributeName, 'data-')) { // Exception: it's safe to reveal the `${PRIVACY_ATTR_NAME}` attr return CENSORED_STRING_MARK } } if (!attributeValue || typeof attributeValue !== 'string') { return attributeValue } // Minimum Fix for customer. if (attributeValue.length > MAX_ATTRIBUTE_VALUE_CHAR_LENGTH && attributeValue.slice(0, 5) === 'data:') { return 'data:truncated' } return attributeValue } let _nextId = 1 function generateNextId(): number { return _nextId++ } const TAG_NAME_REGEX = /[^a-z1-6-_]/ function getValidTagName(tagName: string): string { const processedTagName = tagName.toLowerCase().trim() if (TAG_NAME_REGEX.test(processedTagName)) { // if the tag name is odd and we cannot extract // anything from the string, then we return a // generic div return 'div' } return processedTagName } function getCssRulesString(s: CSSStyleSheet): string | null { try { const rules = s.rules || s.cssRules return rules ? Array.from(rules).map(getCssRuleString).join('') : null } catch (error) { return null } } function getCssRuleString(rule: CSSRule): string { return isCSSImportRule(rule) ? getCssRulesString(rule.styleSheet) || '' : rule.cssText } function isCSSImportRule(rule: CSSRule): rule is CSSImportRule { return 'styleSheet' in rule } function isSVGElement(el: Element): boolean { return el.tagName === 'svg' || el instanceof SVGElement } function getAttributesForPrivacyLevel( element: Element, nodePrivacyLevel: NodePrivacyLevel, options: SerializeOptions ): Record { if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { return {} } const safeAttrs: Record = {} const tagName = getValidTagName(element.tagName) const doc = element.ownerDocument type HtmlAttribute = { name: string; value: string } for (let i = 0; i < element.attributes.length; i += 1) { const attribute = element.attributes.item(i) as HtmlAttribute const attributeName = attribute.name const attributeValue = serializeAttribute(element, nodePrivacyLevel, attributeName, options.configuration) if (attributeValue !== null) { safeAttrs[attributeName] = attributeValue } } if ( (element as HTMLInputElement).value && (tagName === 'textarea' || tagName === 'select' || tagName === 'option' || tagName === 'input') ) { const formValue = getElementInputValue(element, nodePrivacyLevel) if (formValue !== undefined) { safeAttrs.value = formValue } } /** *