Author: Ling Jiang

background

There are many Content Management systems (CMS) inside cloud music, which are used to support the operation and configuration of the business. When the operation students encounter problems in the process of using cloud music, the developers are expected to give feedback and solve the problems in time. The pain point is that developers have no problem on site, and it is difficult to quickly locate the problem. The usual scenario is as follows:

  • Operation classmate Watson: “Sherlock, I in the configuration of the Mlog label prompt that the label does not exist, quick help me to see, urgent.”
  • Development classmate Sherlock: “don’t panic, I see.”(Open the operation management background of the test environment, and everything is very normal…)
  • Development classmate Sherlock: “I have a normal ah, where is your desk, I go to you to have a look”
  • Operation student Watson: “I’m in Beijing…”
  • Development classmate Sherlock: “I’m in Hangzhou…”

In order to provide timely feedback on the relevant problems encountered by operation students and locate and solve the problems encountered by CMS users as soon as possible, the one-click plug-in for problem reporting was designed and implemented to restore the problem site, mainly including recording and display:

  • ThemisRecord plug-in: report basic user information, user permissions, API request & results, error stack, screen recording
  • Listening platform to undertake display: display recording playback, user, request and error stack information

Reporting process

The following figure shows the design process of the plug-in for one-click problem reporting. During screen recording, the plug-in collects basic user information, API request data, error stack information, and screen recording information, and uploads the data to NOS cloud and listening platform.In the whole reporting process, how to achieve the operation of screen recording and playback is a difficult point, after investigation, foundrrwebOpen source libraries can serve our needs well. Rrweb library supports screen recording playback, custom events, console recording and playback, among which screen recording playback is the most commonly used scenario. For details, seeSample scenario.

This paper mainly introduces the realization principle of rrWeb library for screen recording playback.

Rrweb library

Rrweb consists of rrWeb, rrWeb-Player and rrweb-snapshot libraries:

  • Rrweb: provides both record and replay methods; The record method is used to record DOM changes on the page, and the replay method supports restoring DOM changes based on time stamps.
  • Rrweb-player: Based on the Svelte template implementation, rrWeb provides a GUI tool for playback, support pause, double speed playback, drag and drop the timeline and other functions. Methods such as replay provided by RRWeb are called internally.
  • Rrweb -snapshot: includes snapshot and REBUILDING. Snapshot serializes DOM as incremental snapshot and REBUILDING DOM as incremental snapshot.

To understand how rrWeb libraries work, you can start with the following key questions:

  • How to implement event listening
  • How to serialize DOM
  • How to implement a custom timer

How to implement event listening

To achieve screen recording based on RRWeb, events are usually recorded in the following way, and all events corresponding to DOM changes can be obtained through the emit callback method. After receiving the event, we can process it according to business requirements. For example, our one-click reporting plug-in will be uploaded to the cloud, and developers can pull the data from the cloud and play it back on the listening platform.

let events = [];

rrweb.record({
  // emit option is required
  emit(event) {
    // push event into the events arrayevents.push(event); }});Copy the code

Record method will be based on the event type to initialize the event monitoring, such as DOM element changes, mouse movement, mouse interaction, rolling, etc., have their own event monitoring methods, this article is mainly concerned with the DOM element changes monitoring and processing process.

Listening for CHANGES to DOM elements depends on the browser-provided MutationObserver API, which triggers callbacks asynchronously in batches after a series of DOM changes and passes the DOM changes to callback methods through a MutationRecord array. A detailed introduction to MutationObserver can be viewed at MDN.

Rrweb also implements listening based on this API internally, and the callback method is processMutations method provided by the MutationBuffer class:

  const observer = new MutationObserver(
    mutationBuffer.processMutations.bind(mutationBuffer),
  );
Copy the code

MutationBuffer. ProcessMutations method based on MutationRecord. The type value do different processing:

  • type === 'attributes': represents DOM attribute changes. All node attribute changes are recorded inthis.attributesIn the array, the structure is{ node: Node, attributes: {} }, attributes records only the attributes involved in this change;
  • type === 'characterData': indicates that the characterData node change is recorded in thethis.textsIn the array, the structure is{ node: Node, value: string }, value is the latest value of the characterData node.
  • type === 'childList': represents changes to the child node tree childList, which is more complex than the previous two types.

ChildList Incremental snapshot

Rrweb uses incremental snapshots to handle childList changes because it would be too large to record the entire DOM tree each time.

There are three key sets: addedSet, movedSet, and droppedSet, which correspond to three node operations: add, move, and delete. This is similar to the React Diff mechanism. Set structure is used here to realize the DOM node reprocessing.

New nodes

Traverse MutationRecord addedNodes node, will not be serialized nodes are added to the addedSet, and if the node is set to be deleted droppedSet, was removed from the droppedSet.

Example: Create nodes N1 and n2, append n2 to N1, and append N1 to body.

body
  n1
    n2
Copy the code

The node will only generate a MutationRecord record operation, which increase the n1, n2 “append to n1” process will not generate MutationRecord records, so in the traversal MutationRecord. AddedNodes nodes, The children need to be traversed, or the N2 node will be missed.

After traversing all array of MutationRecord records, the nodes in addedSet will be serialized uniformly. The result of serialization of each node is as follows:

export type addedNodeMutation = {
  parentId: number;
  nextId: number | null;
  node: serializedNodeWithId;
}
Copy the code

The DOM association is throughparentIdnextIdIf the parent node or the next sibling node of the DOM node has not been serialized, the node cannot be accurately located, so it needs to be stored first and processed finally.

Rrweb uses a bidirectional linked list addList to store nodes whose parents have not yet been added. When inserting a node into addList:

  1. If the previousSibling of the DOM node already exists in the list, insert innode.previousSiblingAfter the node
  2. If the DOM node’s nextSibling already exists in the list, insert innode.nextSiblingBefore the node
  3. If neither is present, insert the head of the list

In this way, the order of sibling nodes is guaranteed, the DOM node’s nextSibling must be followed by that node, and the previousSibling must be preceded by that node. After the serialization of addedSet, the addList is traversed in reverse order to ensure that the nextSibling of the DOM node must be serialized before the DOM node. Next time the DOM node is serialized, the nextId can be obtained.

Mobile node

Traverse MutationRecord addedNodes node, if records have __sn properties of nodes, is added to the movedSet. A __sn attribute represents a DOM node that has been serialized, which means a movement of the node.

Before serializing a node in movedSet, it determines whether its parent has been removed:

  1. If the parent node is removed, no action is required.
  2. The parent node is not removed. Serialize the node.

The node to delete

Traverse MutationRecord. RemovedNodes node:

  1. If the node is newly added, the node is ignored and theaddedSetTo remove the node and record thedroppedSet, is needed when dealing with new nodes: although we remove the node, its children may still existaddedSetIn, processingaddedSetWhen a node is displayed, it determines whether its ancestor node has been removed.
  2. The node to be deleted is recorded in thethis.removesIn, parentId and node ID are recorded.

How to serialize DOM

The MutationBuffer instance calls the serializeNodeWithId method of snapshot to serialize the DOM node. SerializeNodeWithId internally calls the serializeNode method, Document, Doctype, Element, Text, CDATASection, Comment and other different types of node are serialized according to nodeType. The key is to serialize Element:

  • Traverses the elementattributesProperty, and calltransformAttributeMethod treats the resource path as an absolute path.
    for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
        attributes[name] = transformAttribute(doc, tagName, name, value);
    }
Copy the code
  • By checking if the element containsblockClassClass name, or whether it matchesblockSelectorSelector to determine if elements need to be hidden; To ensure that element hiding does not affect the page layout, an empty element of the same width and height is returned.
    const needBlock = _isBlockedElement(
        n as HTMLElement,
        blockClass,
        blockSelector,
    );
Copy the code
  • Distinguish the outer chain style file from the inline style, serialize the CSS style, and convert the relative path of the resource referenced in the CSS style to the absolute path; For the external linked file, read all the styles through CSSStyleSheet instance cssRules, concatenate them into a string, and place them in_cssTextAttribute;
    if (tagName === 'link' && inlineStylesheet) {
        Getstylesheets getstylesheets getstylesheets getstylesheets getstylesheets
        const stylesheet = Array.from(doc.styleSheets).find((s) = > {
            return s.href === (n as HTMLLinkElement).href;
        });
        // Get the strings of all rules corresponding to the CSS file
        const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
        if (cssText) {
            delete attributes.rel;
            delete attributes.href;
            // Convert resource paths in CSS files to absolute paths
            attributes._cssText = absoluteToStylesheet( 
                cssText,
                stylesheet!.href!,
            );
        }
    }
Copy the code
  • Call to user input datamaskInputValueMethod for encryption processing;
  • Convert Canvas into Base64 image to save, record the current playing time of media, the scrolling position of elements, etc.
  • Returns a serialized objectserializedNodeThe key to serialization is that each node has a unique ID, whererootIdRepresents the id of the owning document, which helps us identify the root node during playback.
    return {
        type: NodeType.Element,
        tagName,
        attributes,
        childNodes: [],
        isSVG,
        needBlock,
        rootId,
    };
Copy the code

The Event time stamp

After retrieving the serialized DOM node, the wrapEvent method is called to add a timestamp to the event, which is needed for playback.

function wrapEvent(e: event) :eventWithTime {
  return {
    ...e,
    timestamp: Date.now(),
  };
}
Copy the code

Serialization id

The serializeNodeWithId method reads the ID from the DOM node’s __sn.id attribute during serialization. If it does not exist, the genId method generates a new ID and assigns it to the __sn.id attribute, which uniquely identifies the DOM node. The mapping relation of ID -> DOM was established through ID to help us find the corresponding DOM node during playback.

function genId() :number {
  return _id++;
}

const serializedNode = Object.assign(_serializedNode, { id });
Copy the code

If the DOM node has child nodes, the serializeNodeWithId method is recursively called, which returns a tree data structure like the following:

{
    type: NodeType.Document,
    childNodes: [{{type: NodeType.Element,
            tagName,
            attributes,
            childNodes: [{
                / /...
            }],
            isSVG,
            needBlock,
            rootId,
        }
    }],
    rootId,
};
Copy the code

How to implement a custom timer

In the playback process, in order to support arbitrary dragging of the progress bar and setting of playback speed (as shown in the figure above), a high-precision Timer is customized. Key attributes and methods are as follows:

export declare class Timer {
    // Playback initial position, corresponding to the progress bar drag to any point in time
    timeOffset: number;
    // Playback speed
    speed: number;
    // Play back the Action queue
    private actions;
    // Add a playback Action queue
    addActions(actions: actionWithDelay[]): void;
    // Start playback
    start(): void;
    // Set playback speed
    setSpeed(speed: number): void;
}
Copy the code

Playback entrance

The event recorded above can be played back in the iframe through the play method provided by Replayer.

const replayer = new rrweb.Replayer(events);
replayer.play();
Copy the code

The first step is to initialize the rrWeb. Replayer instance by creating an iframe as a container for event playback, and then calling two services respectively: CreatePlayerService handles the logic for event playback and createSpeedService controls the playback speed.

In the second step, the replayer.play() method is called to trigger the play event type and begin the event playback process.

// this.service Playback control service instance created for createPlayerService
// timeOffset is the timeOffset after mouse dragging
this.service.send({ type: 'PLAY'.payload: { timeOffset } });
Copy the code

Baseline timestamp generation

The key to playback support for drag and drop is passing in the timeOffset parameter:

  • Timestamp = events[n].timestamp – events[0].timestamp,nIs the total length of the event queue minus one;
  • The total duration of the time axis is the total playback duration, and the starting position of the mouse drag corresponds to the coordinate of the time axistimeOffset;
  • According to the initial eventtimestamptimeOffsetSo let’s figure out what the drag isbaselineTime;
  • And then from all the event queues according to the eventtimestampThe interceptionbaselineTimeAfter the event queue, that is, the event queue to be played back.

Playback Action queue conversion

After getting the event queue, you need to traverse the event queue, convert it into the corresponding playback Action according to the event type, and add it to the Action queue of the user-defined Timer.

actions.push({
    doAction: () = > {
        castFn();
    },
    delay: event.delay! });Copy the code
  • doActionThe method to call for playback will vary depending onEventTypeTo do playback processing, such as DOM element changes corresponding to incremental eventsEventType.IncrementalSnapshot. If the event type is incremental, the playback Action is calledapplyIncrementalMethod to apply the incremental snapshot, build the actual DOM node based on the serialized node data, the reverse of the previous DOM serialization process, and add it to the iframe container.
  • delayTimestamp – baselineTime, is the timestamp of the current event relative toBaseline timestampThe difference between the

RequestAnimationFrame Timing playback

Timer custom Timer is a high precision Timer, mainly because the start method uses requestAnimationFrame to handle timed playback of queues asynchronously; In contrast to the browser’s native setTimeout and setInterval, requestAnimationFrame is not blocked by the main thread, as setTimeout and setInterval may be blocked.

Second, the performance. Now () time function is used to calculate the current playing time. Perform.now () returns a timestamp in floating point with a precision of microseconds, higher than other available timelike functions, such as date.now (), which returns only milliseconds.

 public start() {
    this.timeOffset = 0;
    / / performance. Timing. NavigationStart + performance. Now () is approximately equal to the Date, now ()
    let lastTimestamp = performance.now();
    / / the Action queue
    const { actions } = this;
    const self = this;
    function check() {
      const time = performance.now();
      // self.timeOffset Indicates the current playing duration. The value is the sum of played duration * speed
      // It is cumulative because the speed may change several times during playback
      self.timeOffset += (time - lastTimestamp) * self.speed;
      lastTimestamp = time;
      // Iterate through the Action queue
      while (actions.length) {
        const action = actions[0];
        // The offset is relative to the baseline timestamp. {timeOffset}ms is currently played
        // Play all actions with "difference <= current playback duration"
        if (self.timeOffset >= action.delay) {
          actions.shift();
          action.doAction();
        } else {
          break; }}if (actions.length > 0|| self.liveMode) { self.raf = requestAnimationFrame(check); }}this.raf = requestAnimationFrame(check);
  }
Copy the code

After the playback Action queue conversion is complete, the timer.start() method is called to perform the playback at the correct intervals. In each requestAnimationFrame callback, the Action queue is traversed in order. If the difference between the current Action and the baseline timestamp is less than the current playback duration, the Action needs to be triggered in this asynchronous callback. The action. DoAction method is called to perform playback of the incremental snapshot. The replayed Action is removed from the queue to ensure that the next requestAnimationFrame callback will not be re-executed.

conclusion

In the understanding of “how to achieve event monitoring”, “how to serialize DOM”, “how to achieve a custom timer” these several key issues, we have a basic grasp of rrWeb workflow, in addition, RRWeb in playback time also use iframe sandbox mode, to achieve some JS behavior restrictions. Those of you who are interested can go further.

In short, rrWeb can conveniently help us to achieve screen playback function, such as now in CMS business landing use of one-key report function, through the combination of API request, error stack information and screen playback function, can help the development of the problem for positioning and solving, let you also become a Sherlock.

This article is published from NetEase Cloud Music big front end team, the article is prohibited to be reproduced in any form without authorization. Grp.music – Fe (at) Corp.Netease.com We recruit front-end, iOS and Android all year long. If you are ready to change your job and you like cloud music, join us!