import { elapsed, monitor, noop, timeStampNow } from '@openobserve/browser-core' import type { RumConfiguration, NodePrivacyLevelCache, RumMutationRecord, RumChildListMutationRecord, RumCharacterDataMutationRecord, RumAttributesMutationRecord, } from '@openobserve/browser-rum-core' import { isNodeShadowHost, getMutationObserverConstructor, getParentNode, forEachChildNodes, getNodePrivacyLevel, getTextContent, NodePrivacyLevel, } from '@openobserve/browser-rum-core' import { IncrementalSource } from '../../../types' import type { BrowserMutationData, AddedNodeMutation, AttributeMutation, RemovedNodeMutation, TextMutation, BrowserIncrementalSnapshotRecord, } from '../../../types' import type { SerializationContext, SerializationScope, SerializationStats } from '../serialization' import { getElementInputValue, serializeNodeWithId, SerializationContextStatus, serializeAttribute, createSerializationStats, updateSerializationStats, } from '../serialization' import { createMutationBatch } from '../mutationBatch' import type { ShadowRootCallBack, ShadowRootsController } from '../shadowRootsController' import { assembleIncrementalSnapshot } from '../assembly' import type { NodeWithSerializedNode } from '../nodeIds' import type { Tracker } from './tracker.types' export type MutationCallBack = ( incrementalSnapshotRecord: BrowserIncrementalSnapshotRecord, stats?: SerializationStats ) => void type WithSerializedTarget = T & { target: NodeWithSerializedNode } export type MutationTracker = Tracker & { flush: () => void } /** * Buffers and aggregate mutations generated by a MutationObserver into MutationPayload */ export function trackMutation( mutationCallback: MutationCallBack, configuration: RumConfiguration, scope: SerializationScope, shadowRootsController: ShadowRootsController, target: Node ): MutationTracker { const MutationObserver = getMutationObserverConstructor() if (!MutationObserver) { return { stop: noop, flush: noop } } const mutationBatch = createMutationBatch((mutations) => { processMutations( mutations.concat(observer.takeRecords() as RumMutationRecord[]), mutationCallback, configuration, scope, shadowRootsController ) }) const observer = new MutationObserver(monitor(mutationBatch.addMutations) as (callback: RumMutationRecord[]) => void) observer.observe(target, { attributeOldValue: true, attributes: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true, }) return { stop: () => { observer.disconnect() mutationBatch.stop() }, flush: () => { mutationBatch.flush() }, } } function processMutations( mutations: RumMutationRecord[], mutationCallback: MutationCallBack, configuration: RumConfiguration, scope: SerializationScope, shadowRootsController: ShadowRootsController ) { const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map() mutations .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList') .forEach((mutation) => { mutation.removedNodes.forEach((removedNode) => { traverseRemovedShadowDom(removedNode, shadowRootsController.removeShadowRoot) }) }) // Discard any mutation with a 'target' node that: // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely // part of a mutation occurring in a parent Node // * should be hidden or ignored const filteredMutations = mutations.filter( (mutation): mutation is WithSerializedTarget => mutation.target.isConnected && scope.nodeIds.areAssignedForNodeAndAncestors(mutation.target) && getNodePrivacyLevel(mutation.target, configuration.defaultPrivacyLevel, nodePrivacyLevelCache) !== NodePrivacyLevel.HIDDEN ) const serializationStats = createSerializationStats() const { adds, removes, hasBeenSerialized } = processChildListMutations( filteredMutations.filter( (mutation): mutation is WithSerializedTarget => mutation.type === 'childList' ), configuration, scope, serializationStats, shadowRootsController, nodePrivacyLevelCache ) const texts = processCharacterDataMutations( filteredMutations.filter( (mutation): mutation is WithSerializedTarget => mutation.type === 'characterData' && !hasBeenSerialized(mutation.target) ), configuration, scope, nodePrivacyLevelCache ) const attributes = processAttributesMutations( filteredMutations.filter( (mutation): mutation is WithSerializedTarget => mutation.type === 'attributes' && !hasBeenSerialized(mutation.target) ), configuration, scope, nodePrivacyLevelCache ) if (!texts.length && !attributes.length && !removes.length && !adds.length) { return } mutationCallback( assembleIncrementalSnapshot(IncrementalSource.Mutation, { adds, removes, texts, attributes }), serializationStats ) } function processChildListMutations( mutations: Array>, configuration: RumConfiguration, scope: SerializationScope, serializationStats: SerializationStats, shadowRootsController: ShadowRootsController, nodePrivacyLevelCache: NodePrivacyLevelCache ) { // First, we iterate over mutations to collect: // // * nodes that have been added in the document and not removed by a subsequent mutation // * nodes that have been removed from the document but were not added in a previous mutation // // For this second category, we also collect their previous parent (mutation.target) because we'll // need it to emit a 'remove' mutation. // // Those two categories may overlap: if a node moved from a position to another, it is reported as // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case, // the node will be in both sets. const addedAndMovedNodes = new Set() const removedNodes = new Map() for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { addedAndMovedNodes.add(node) }) mutation.removedNodes.forEach((node) => { if (!addedAndMovedNodes.has(node)) { removedNodes.set(node, mutation.target) } addedAndMovedNodes.delete(node) }) } // Then, we sort nodes that are still in the document by topological order, for two reasons: // // * We will serialize each added nodes with their descendants. We don't want to serialize a node // twice, so we need to iterate over the parent nodes first and skip any node that is contained in // a precedent node. // // * To emit "add" mutations, we need references to the parent and potential next sibling of each // added node. So we need to iterate over the parent nodes first, and when multiple nodes are // siblings, we want to iterate from last to first. This will ensure that any "next" node is // already serialized and have an id. const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes) sortAddedAndMovedNodes(sortedAddedAndMovedNodes) // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized // node ids in a set to be able to skip subsequent related mutations. const serializedNodeIds = new Set() const serializationContext: SerializationContext = { status: SerializationContextStatus.MUTATION, serializationStats, shadowRootsController, } const addedNodeMutations: AddedNodeMutation[] = [] for (const node of sortedAddedAndMovedNodes) { if (hasBeenSerialized(node)) { continue } const parentNodePrivacyLevel = getNodePrivacyLevel( node.parentNode!, configuration.defaultPrivacyLevel, nodePrivacyLevelCache ) if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { continue } const serializationStart = timeStampNow() const serializedNode = serializeNodeWithId(node, parentNodePrivacyLevel, { serializedNodeIds, serializationContext, configuration, scope, }) updateSerializationStats(serializationStats, 'serializationDuration', elapsed(serializationStart, timeStampNow())) if (!serializedNode) { continue } const parentNode = getParentNode(node)! addedNodeMutations.push({ nextId: getNextSibling(node), parentId: scope.nodeIds.get(parentNode)!, node: serializedNode, }) } // Finally, we emit remove mutations. const removedNodeMutations: RemovedNodeMutation[] = [] removedNodes.forEach((parent, node) => { const parentId = scope.nodeIds.get(parent) const id = scope.nodeIds.get(node) if (parentId !== undefined && id !== undefined) { removedNodeMutations.push({ parentId, id }) } }) return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized } function hasBeenSerialized(node: Node) { const id = scope.nodeIds.get(node) return id !== undefined && serializedNodeIds.has(id) } function getNextSibling(node: Node): null | number { let nextSibling = node.nextSibling while (nextSibling) { const id = scope.nodeIds.get(nextSibling) if (id !== undefined) { return id } nextSibling = nextSibling.nextSibling } return null } } function processCharacterDataMutations( mutations: Array>, configuration: RumConfiguration, scope: SerializationScope, nodePrivacyLevelCache: NodePrivacyLevelCache ) { const textMutations: TextMutation[] = [] // Deduplicate mutations based on their target node const handledNodes = new Set() const filteredMutations = mutations.filter((mutation) => { if (handledNodes.has(mutation.target)) { return false } handledNodes.add(mutation.target) return true }) // Emit mutations for (const mutation of filteredMutations) { const value = mutation.target.textContent if (value === mutation.oldValue) { continue } const id = scope.nodeIds.get(mutation.target) if (id === undefined) { continue } const parentNodePrivacyLevel = getNodePrivacyLevel( getParentNode(mutation.target)!, configuration.defaultPrivacyLevel, nodePrivacyLevelCache ) if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { continue } textMutations.push({ id, value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null, }) } return textMutations } function processAttributesMutations( mutations: Array>, configuration: RumConfiguration, scope: SerializationScope, nodePrivacyLevelCache: NodePrivacyLevelCache ) { const attributeMutations: AttributeMutation[] = [] // Deduplicate mutations based on their target node and changed attribute const handledElements = new Map>() const filteredMutations = mutations.filter((mutation) => { const handledAttributes = handledElements.get(mutation.target) if (handledAttributes && handledAttributes.has(mutation.attributeName!)) { return false } if (!handledAttributes) { handledElements.set(mutation.target, new Set([mutation.attributeName!])) } else { handledAttributes.add(mutation.attributeName!) } return true }) // Emit mutations const emittedMutations = new Map() for (const mutation of filteredMutations) { const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!) if (uncensoredValue === mutation.oldValue) { continue } const id = scope.nodeIds.get(mutation.target) if (id === undefined) { continue } const privacyLevel = getNodePrivacyLevel(mutation.target, configuration.defaultPrivacyLevel, nodePrivacyLevelCache) const attributeValue = serializeAttribute(mutation.target, privacyLevel, mutation.attributeName!, configuration) let transformedValue: string | null if (mutation.attributeName === 'value') { const inputValue = getElementInputValue(mutation.target, privacyLevel) if (inputValue === undefined) { continue } transformedValue = inputValue } else if (typeof attributeValue === 'string') { transformedValue = attributeValue } else { transformedValue = null } let emittedMutation = emittedMutations.get(mutation.target) if (!emittedMutation) { emittedMutation = { id, attributes: {} } attributeMutations.push(emittedMutation) emittedMutations.set(mutation.target, emittedMutation) } emittedMutation.attributes[mutation.attributeName!] = transformedValue } return attributeMutations } export function sortAddedAndMovedNodes(nodes: Node[]) { nodes.sort((a, b) => { const position = a.compareDocumentPosition(b) /* eslint-disable no-bitwise */ if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { return -1 } else if (position & Node.DOCUMENT_POSITION_CONTAINS) { return 1 } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return 1 } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { return -1 } /* eslint-enable no-bitwise */ return 0 }) } function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: ShadowRootCallBack) { if (isNodeShadowHost(removedNode)) { shadowDomRemovedCallback(removedNode.shadowRoot) } forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback)) }