A complete front-end monitoring platform includes three parts: data collection and reporting, data sorting and storage, data display.

This article is about the first link — data collection and reporting. The following is an outline of the content to be covered in this article. You can have a general understanding of it first:

It is difficult to understand just by looking at the theoretical knowledge, so I wrote a simple monitoring SDK combined with the technical points to be discussed in this article, which can be used to write some simple demos to help deepen the understanding. It is better to read it together with this article.

Performance Data Collection

The Chrome team came up with a number of metrics to measure web page performance:

  • First-paint (FP), the time from the start of page loading until the first pixel is drawn on the screen
  • FCP(first-Contentful-paint), the time from the time the page loads until any part of the page content completes rendering on the screen
  • LCP(largest- Contentful-paint), the time from the start of page loading until the largest text block or image element is rendered on the screen
  • CLS(layout-Shift), the cumulative score for all unexpected layout offsets that occur from the time the page is loaded and its life cycle state becomes hidden

The four performance indicators need to obtain by PerformanceObserver (but can be by performance. GetEntriesByName (), but it is not at events trigger notification). PerformanceObserver is a performance monitoring object for monitoring performance measurement events.

FP

First-paint (FP), the time from the start of page loading until the first pixel is drawn on the screen. In fact, FP is understood as white screen time is also no problem.

The measurement code is as follows:

const entryHandler = (list) = > {        
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
            observer.disconnect()
        }

       console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
The buffered property indicates whether to observe cached data, which means it doesn't matter if the code is added later than the event is triggered.
observer.observe({ type: 'paint'.buffered: true })
Copy the code

The contents of FP can be obtained from the above code:

{
    duration0.entryType"paint".name"first-paint".startTime359./ / fp time
}
Copy the code

StartTime is the drawing time we want.

FCP

FCP(first-Contentful-paint), the time from the time the page loads until any part of the page content completes rendering on the screen. For this metric, “content” refers to text, images (including background images), < SVG > elements, or non-white

elements.

To provide a good user experience, FCP scores should be limited to 1.8 seconds.

Measurement code:

const entryHandler = (list) = > {        
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
            observer.disconnect()
        }
        
        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint'.buffered: true })
Copy the code

You can get the contents of the FCP from the code above:

{
    duration0.entryType"paint".name"first-contentful-paint".startTime459./ / FCP time
}
Copy the code

StartTime is the drawing time we want.

LCP

LCP(largest- Contentful-paint), the time from the start of page loading until the largest text block or image element is rendered on the screen. The LCP metric reports the relative time to the completion of rendering of the largest visible image or text block in the visible area, based on the point at which the page first started loading.

A good LCP score should be under 2.5 seconds.

Measurement code:

const entryHandler = (list) = > {
    if (observer) {
        observer.disconnect()
    }

    for (const entry of list.getEntries()) {
        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint'.buffered: true })
Copy the code

The contents of the LCP can be obtained with the following code:

{
    duration: 0.element: p,
    entryType: "largest-contentful-paint".id: "".loadTime: 0.name: "".renderTime: 1021.299.size: 37932.startTime: 1021.299.url: "",}Copy the code

StartTime is the drawing time we want. Element refers to the DOM element drawn by LCP.

The difference between FCP and LCP is that FCP fires as soon as any content is drawn, while LCP fires as soon as the maximum content is rendered.

The types of elements examined by LCP are:

  • <img>The element
  • Embedded in<svg>Elements within the<image>The element
  • <video>Element (using cover image)
  • Elements with background images loaded through the URL () function instead of using CSS gradients
  • Block-level elements that contain text nodes or other children of inline text elements.

CLS

CLS(layout-Shift), the cumulative score for all unexpected layout offsets that occur from the time the page is loaded and its life cycle state becomes hidden.

The layout offset score is calculated as follows:

Layout offset score = Impact score * Distance scoreCopy the code

Influence the effect of fractional measurement instability on the visible area between two frames.

The distance score refers to the maximum distance (horizontal or vertical) that any unstable element can move in a frame divided by the maximum dimensional dimension (width or height, whichever is greater) of the visible area.

CLS is the sum of all the layout offset scores.

CLS is triggered when a DOM is shifted between two render frames (see figure).

The rectangle in the image above has been moved from the upper left corner to the right, which is a layout offset. Meanwhile, in CLS, there is a term called session window: one or more single layout offsets occurring in rapid succession, each offset less than 1 second apart, and the maximum duration of the entire window is 5 seconds.

For example, the second session window in the figure above has four layout offsets. Each offset must be less than 1 second apart, and the time between the first offset and the last offset must not exceed 5 seconds in order to be considered a session window. If not, consider it a new session window. Some people may ask, why is it so stipulated? Evolving the CLS Metric is the result of a number of trials and studies by the Chrome team.

CLS can be calculated in three ways:

  1. cumulative
  2. Take the average of all session Windows
  3. Take the maximum value of all session Windows

cumulative

That is, add up all the layout offset scores from the time the page loads. However, this calculation method is not friendly to pages with long life cycles. The longer the page lives, the higher the CLS score.

Take the average of all session Windows

This calculation is not based on individual layout offsets, but on session Windows. Add the values of all session Windows and take the average. But there are downsides to this calculation.

As can be seen from the figure above, the first session window produces a relatively large CLS score, while the second session window produces a relatively small CLS score. If you average them as a CLS score, you can’t tell anything about the health of the page. The original page is more early offset, less late offset, the current average does not reflect this situation.

Take the maximum value of all session Windows

This method is the optimal calculation method at present. Only the maximum value of all session Windows is taken each time to reflect the worst case of page layout deviation. Evolving the CLS Metric

Here is the measurement code for the third calculation:

let sessionValue = 0
let sessionEntries = []
const cls = {
    subType: 'layout-shift'.name: 'layout-shift'.type: 'performance'.pageURL: getPageURL(),
    value: 0,}const entryHandler = (list) = > {
    for (const entry of list.getEntries()) {
        // Only count layout shifts without recent user input.
        if(! entry.hadRecentInput) {const firstSessionEntry = sessionEntries[0]
            const lastSessionEntry = sessionEntries[sessionEntries.length - 1]

            // If the entry occurred less than 1 second after the previous entry and
            // less than 5 seconds after the first entry in the session, include the
            // entry in the current session. Otherwise, start a new session.
            if (
                sessionValue
                && entry.startTime - lastSessionEntry.startTime < 1000
                && entry.startTime - firstSessionEntry.startTime < 5000
            ) {
                sessionValue += entry.value
                sessionEntries.push(formatCLSEntry(entry))
            } else {
                sessionValue = entry.value
                sessionEntries = [formatCLSEntry(entry)]
            }

            // If the current session value is larger than the current CLS value,
            // update CLS and the entries contributing to it.
            if (sessionValue > cls.value) {
                cls.value = sessionValue
                cls.entries = sessionEntries
                cls.startTime = performance.now()
                lazyReportCache(deepCopy(cls))
            }
        }
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'layout-shift'.buffered: true })
Copy the code

After reading the above text description, look at the code to make sense. The measurement of a layout offset is as follows:

{
  duration: 0.entryType: "layout-shift".hadRecentInput: false.lastInputTime: 0.name: "".sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
  startTime: 1176.199999999255.value: 0.000005752046026677329,}Copy the code

The value field in the code is the layout offset score.

DOMContentLoaded, load events

The DOMContentLoaded event is triggered when pure HTML is fully loaded and parsed, without waiting for CSS, IMG, and iframe to finish loading.

The Load event is triggered when the entire page and all dependent resources such as stylesheets and images have finished loading.

Although these two performance metrics are older, they still tell you something about a page. It is still necessary to listen to them.

import { lazyReportCache } from '.. /utils/report'

['load'.'DOMContentLoaded'].forEach(type= > onEvent(type))

function onEvent(type) {
    function callback() {
        lazyReportCache({
            type: 'performance'.subType: type.toLocaleLowerCase(),
            startTime: performance.now(),
        })

        window.removeEventListener(type, callback, true)}window.addEventListener(type, callback, true)}Copy the code

First screen render time

In most cases, the first screen rendering time can be obtained through the Load event. Except for some special cases, such as asynchronously loaded images and DOM.

<script>
    setTimeout(() => {
        document.body.innerHTML = `
            <div>
                <! -- Omit a bunch of code... -->
            </div>`}, 3000).</script>
Copy the code

In this case there is no way to get the first screen rendering time through the Load event. In this case, we need to use MutationObserver to get the first screen rendering time. MutationObserver fires events when the attributes of the DOM elements it listens to change.

Calculation process of first screen rendering time:

  1. Use MutationObserver to listen on the Document object and fire events whenever a DOM element attribute changes.
  2. Determines whether the DOM element is in the first screen, and if so, yesrequestAnimationFrame()Called in a callback functionperformance.now()Gets the current time as its drawing time.
  3. Compare the drawing time of the last DOM element with the loading time of all images in the first screen, and use the maximum as the first screen rendering time.

Listen to the DOM

const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const ignoreDOMList = ['STYLE'.'SCRIPT'.'LINK']
    
observer = new MutationObserver(mutationList= > {
    const entry = {
        children: [],}for (const mutation of mutationList) {
        if (mutation.addedNodes.length && isInScreen(mutation.target)) {
             // ...}}if (entry.children.length) {
        entries.push(entry)
        next(() = > {
            entry.startTime = performance.now()
        })
    }
})

observer.observe(document, {
    childList: true.subtree: true,})Copy the code

The above code listens for DOM changes and filters out style, script, link, and other tags.

Check if it’s on the first screen

A page may have a lot of content, but users can only see one screen of content at most. Therefore, when calculating the rendering time of the first screen, we need to limit the scope of the rendering content to the current screen.

const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

// Whether the DOM object is in the screen
function isInScreen(dom) {
    const rectInfo = dom.getBoundingClientRect()
    if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
        return true
    }

    return false
}
Copy the code

userequestAnimationFrame()Gets the DOM drawing time

When a DOM change triggers a MutationObserver event, it simply means that the DOM content can be read, not that the DOM has been drawn to the screen.

As you can see from the figure above, when the MutationObserver event is triggered, you can read that there is already something on document.body, but nothing is actually drawn on the left screen. So call requestAnimationFrame() to get the current time as the DOM drawing time after the browser has drawn successfully.

Compare the loading times of all images on the first screen

function getRenderTime() {
    let startTime = 0
    entries.forEach(entry= > {
        if (entry.startTime > startTime) {
            startTime = entry.startTime
        }
    })

    // Need to compare with the loading time of all images on the current page
    // The image request time should be shorter than startTime, and the response end time should be longer than startTime
    performance.getEntriesByType('resource').forEach(item= > {
        if (
            item.initiatorType === 'img'
            && item.fetchStart < startTime 
            && item.responseEnd > startTime
        ) {
            startTime = item.responseEnd
        }
    })
    
    return startTime
}
Copy the code

To optimize the

Now the code is not optimized, there are two main points to note:

  1. When will the render time be reported?
  2. If compatible with asynchronous DOM addition situation?

First, the render time must not be reported until the DOM has stopped changing. Normally, after the LOAD event is triggered, the DOM will stop changing. So we can report it at this point in time.

Second, it can be reported after the LCP event is triggered. Whether the DOM is loaded synchronously or asynchronously, it needs to be drawn, so it can listen for AN LCP event and only allow it to be reported when that event is triggered.

Putting the two schemes together gives you the following code:

let isOnLoaded = false
executeAfterLoad(() = > {
    isOnLoaded = true
})


let timer
let observer
function checkDOMChange() {
    clearTimeout(timer)
    timer = setTimeout(() = > {
        // Calculate the first screen rendering time after the load and LCP events are triggered and the DOM tree does not change
        if (isOnLoaded && isLCPDone()) {
            observer && observer.disconnect()
            lazyReportCache({
                type: 'performance'.subType: 'first-screen-paint'.startTime: getRenderTime(),
                pageURL: getPageURL(),
            })

            entries = null
        } else {
            checkDOMChange()
        }
    }, 500)}Copy the code

The checkDOMChange() code is called each time a MutationObserver event is triggered, and needs to be handled with a stabilization function.

Interface Request Time

Interface request time You need to listen for XMLHttpRequest and FETCH.

Listen to the XMLHttpRequest

originalProto.open = function newOpen(. args) {
    this.url = args[1]
    this.method = args[0]
    originalOpen.apply(this, args)
}

originalProto.send = function newSend(. args) {
    this.startTime = Date.now()

    const onLoadend = () = > {
        this.endTime = Date.now()
        this.duration = this.endTime - this.startTime

        const { status, duration, startTime, endTime, url, method } = this
        const reportData = {
            status,
            duration,
            startTime,
            endTime,
            url,
            method: (method || 'GET').toUpperCase(),
            success: status >= 200 && status < 300.subType: 'xhr'.type: 'performance',
        }

        lazyReportCache(reportData)

        this.removeEventListener('loadend', onLoadend, true)}this.addEventListener('loadend', onLoadend, true)
    originalSend.apply(this, args)
}
Copy the code

How do I determine whether an XML request was successful? Depending on whether his status code is between 200 and 299. If it is, it is a success, otherwise it is a failure.

To monitor the fetch

const originalFetch = window.fetch

function overwriteFetch() {
    window.fetch = function newFetch(url, config) {
        const startTime = Date.now()
        const reportData = {
            startTime,
            url,
            method: (config? .method ||'GET').toUpperCase(),
            subType: 'fetch'.type: 'performance',}return originalFetch(url, config)
        .then(res= > {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime

            const data = res.clone()
            reportData.status = data.status
            reportData.success = data.ok

            lazyReportCache(reportData)

            return res
        })
        .catch(err= > {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime
            reportData.status = 0
            reportData.success = false

            lazyReportCache(reportData)

            throw err
        })
    }
}
Copy the code

For FETCH, the success of the request can be determined by the OK field in the returned data; if true, the request succeeds; otherwise, it fails.

Note that the listening interface request time and the time detected on Chrome DevTool may be different. This is because chrome Devtool detects the time it takes for the HTTP request to be sent and the interface to complete the process. However, XHR and FETCH are asynchronous requests, and the interface needs to call the callback function after the request succeeds. When the event is triggered, the callback function is placed on the message queue, which is then processed by the browser, with a waiting process in between.

Resource load time and cache hit ratio

PerformanceObserver allows you to listen for resource and navigation events. If the browser does not support PerformanceObserver, Can also through the performance. GetEntriesByType down-cycled (entryType).

When the Resource event is triggered, a list of resources can be obtained. Each resource object contains the following fields:

From these fields we can extract some useful information:

{
    name: entry.name, // Resource name
    subType: entryType,
    type: 'performance'.sourceType: entry.initiatorType, // Resource type
    duration: entry.duration, // Resource loading time
    dns: entry.domainLookupEnd - entry.domainLookupStart, / / DNS time-consuming
    tcp: entry.connectEnd - entry.connectStart, // Establishing a TCP connection takes time
    redirect: entry.redirectEnd - entry.redirectStart, // The redirection takes time
    ttfb: entry.responseStart, // The first byte of time
    protocol: entry.nextHopProtocol, // Request protocol
    responseBodySize: entry.encodedBodySize, // Response content size
    responseHeaderSize: entry.transferSize - entry.encodedBodySize, // Response header size
    resourceSize: entry.decodedBodySize, // The size of the decompressed resource
    isCache: isCache(entry), // Whether the cache is hit
    startTime: performance.now(),
}
Copy the code

Determines whether the resource hits the cache

There is a transferSize field in these resource objects, which represents the size of the fetched resource, including the size of the response header field and the response data. If this value is 0, it is read directly from cache (mandatory cache). If this value is not 0, but the encodedBodySize field is 0, it is going through the negotiated cache (encodedBodySize indicates the size of the body of the request response data).

function isCache(entry) {
    // Read directly from cache or 304
    return entry.transferSize === 0|| (entry.transferSize ! = =0 && entry.encodedBodySize === 0)}Copy the code

If the preceding conditions are not met, the cache is not matched. The cache hit ratio is then calculated by taking all the data/totals that hit the cache.

Browser Round-trip Cache BFC (Back/Forward Cache)

Bfcache is an in-memory cache that stores the entire page in memory. When the user returns, the entire page is immediately visible, without having to refresh again. According to bfCache, Firfox and Safari have always supported BFC, while Chrome is only supported on older mobile browsers. But I tried it out, and it’s only Safari, so maybe my Version of Firfox isn’t right.

But the BFC also has its drawbacks. When the user returns and restores the page from the BFC, the original page code is not executed again. To do this, the browser provides a PagesHow event where you can put code that needs to be executed again.

window.addEventListener('pageshow'.function(event) {
  // If this property is true, the page is recovered from the BFC
  if (event.persisted) {
    console.log('This page was restored from the bfcache.');
  } else {
    console.log('This page was loaded normally.'); }});Copy the code

For pages recovered from BFC, we also need to collect their FP, FCP, LCP and other various times.

onBFCacheRestore(event= > {
    requestAnimationFrame(() = >{['first-paint'.'first-contentful-paint'].forEach(type= > {
            lazyReportCache({
                startTime: performance.now() - event.timeStamp,
                name: type,
                subType: type,
                type: 'performance'.pageURL: getPageURL(),
                bfc: true,})})})})Copy the code

The above code is easy to understand. After the PagesHow event is triggered, subtract the event trigger time from the current time. This time difference is the drawing time of the performance indicator. Note that these performance metrics for pages recovered from the BFC are generally small, typically around 10 ms. So give them an identity field BFC: true. In this way, they can be ignored during performance statistics.

FPS

With requestAnimationFrame() we can calculate the FPS of the current page.

const next = window.requestAnimationFrame 
    ? requestAnimationFrame : (callback) = > { setTimeout(callback, 1000 / 60)}const frames = []

export default function fps() {
    let frame = 0
    let lastSecond = Date.now()

    function calculateFPS() {
        frame++
        const now = Date.now()
        if (lastSecond + 1000 <= now) {
            // Since the unit of now-lastSecond is milliseconds, the frame is 1000 times
            const fps = Math.round((frame * 1000) / (now - lastSecond))
            frames.push(fps)
                
            frame = 0
            lastSecond = now
        }
    
        // Avoid reporting too fast, cache a certain amount before reporting
        if (frames.length >= 60) {
            report(deepCopy({
                frames,
                type: 'performace'.subType: 'fps',
            }))
    
            frames.length = 0
        }

        next(calculateFPS)
    }

    calculateFPS()
}
Copy the code

The code logic is as follows:

  1. An initial time is recorded and triggered each timerequestAnimationFrame(), the number of frames is increased by 1. In one secondFrames/elapsed timeI get the current frame rate.

When there are three FPS below 20 in a row, we can tell that the page is stuttering. See how to monitor the page for stuttering.

export function isBlocking(fpsList, below = 20, last = 3) {
    let count = 0
    for (let i = 0; i < fpsList.length; i++) {
        if (fpsList[i] && fpsList[i] < below) {
            count++
        } else {
            count = 0
        }

        if (count >= last) {
            return true}}return false
}
Copy the code

Vue routing changes rendering time

We already know how to calculate the first screen rendering time, but how to calculate the page rendering time caused by the page routing switch of SPA application? This article uses Vue as an example to explain my thinking.

export default function onVueRouter(Vue, router) {
    let isFirst = true
    let startTime
    router.beforeEach((to, from, next) = > {
        // The first time you enter the page there are already other statistics of render time available
        if (isFirst) {
            isFirst = false
            return next()
        }

        // Add a field to the router to indicate whether to calculate the render time
        // Only route jumps need to be computed
        router.needCalculateRenderTime = true
        startTime = performance.now()

        next()
    })

    let timer
    Vue.mixin({
        mounted() {
            if(! router.needCalculateRenderTime)return

            this.$nextTick(() = > {
                // Code that runs only after the entire view has been rendered
                const now = performance.now()
                clearTimeout(timer)

                timer = setTimeout(() = > {
                    router.needCalculateRenderTime = false
                    lazyReportCache({
                        type: 'performance'.subType: 'vue-router-change-paint'.duration: now - startTime,
                        startTime: now,
                        pageURL: getPageURL(),
                    })
                }, 1000)})},})}Copy the code

The code logic is as follows:

  1. Listens for routing hooks that fire during route switchingrouter.beforeEach()Hook that records the current time as the render start time in its callback function.
  2. usingVue.mixin()For all componentsmounted()Inject a function. Each function performs an anti-shake function.
  3. When the last componentmounted()When triggered, it indicates that all components of the route have been mounted. Can be found inthis.$nextTick()Gets the render time in the callback function.

At the same time, there is a situation to consider. Mounted () should not be used to calculate the rendering time of components in mounted() without switching routes. So you need to add a needCalculateRenderTime field and set it to true when switching routes, which means you can calculate the rendering time.

Error data acquisition

Resource loading error

Using addEventListener() to listen for error events, you can catch resource load failure errors.

// Catch resource loading failure js CSS img...
window.addEventListener('error'.e= > {
    const target = e.target
    if(! target)return

    if (target.src || target.href) {
        const url = target.src || target.href
        lazyReportCache({
            url,
            type: 'error'.subType: 'resource'.startTime: e.timeStamp,
            html: target.outerHTML,
            resourceType: target.tagName,
            paths: e.path.map(item= > item.tagName).filter(Boolean),
            pageURL: getPageURL(),
        })
    }
}, true)
Copy the code

Js error

Use window. onError to listen for JS errors.

// Listen for js errors
window.onerror = (msg, url, line, column, error) = > {
    lazyReportCache({
        msg,
        line,
        column,
        error: error.stack,
        subType: 'js'.pageURL: url,
        type: 'error'.startTime: performance.now(),
    })
}
Copy the code

Promise error

Using addEventListener() to listen for unhandledrejection events, you can catch unhandled Promise errors.

// The downside of listening to promise errors is that no column data is retrieved
window.addEventListener('unhandledrejection'.e= > {
    lazyReportCache({
        reason: e.reason? .stack,subType: 'promise'.type: 'error'.startTime: e.timeStamp,
        pageURL: getPageURL(),
    })
})
Copy the code

sourcemap

The code in a production environment is compressed and the production environment does not upload the Sourcemap file. So the error message in the production environment is very difficult to read. Therefore, we can use source-Map to restore these compressed code error messages.

When the code fails, we can obtain the corresponding file name, number of lines, and number of columns:

{
    line: 1.column: 17.file: 'https:/www.xxx.com/bundlejs',}Copy the code

Then call the following code to restore:

async function parse(error) {
    const mapObj = JSON.parse(getMapFileContent(error.url))
    const consumer = await new sourceMap.SourceMapConsumer(mapObj)
    // Remove the./ from webpack://source-map-demo/./ SRC /index.js
    const sources = mapObj.sources.map(item= > format(item))
    // According to the compressed error information, get the number of error lines and source files before compression
    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
    // sourcesContent contains the uncompressed source code of each file
    const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
    return {
        file: originalInfo.source,
        content: originalFileContent,
        line: originalInfo.line,
        column: originalInfo.column,
        msg: error.msg,
        error: error.error
    }
}

function format(item) {
    return item.replace(/(\.\/)*/g.' ')}function getMapFileContent(url) {
    return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')}Copy the code

If sourcemap is turned on every time a project is packaged, each JS file will have a corresponding map file.

bundle.js
bundle.js.map
Copy the code

In this case, js files are stored on the static server for user access, and map files are stored on the server for restoring error information. The source-Map library can restore the uncompressed code error message from the compressed code. For example, after compression, the error location is 1 row and 47 columns. After restoration, the actual location may be 4 rows and 10 columns. In addition to location information, you can also get the source text.

The image above shows an example of code being restored with an error. Since this part of content does not belong to the scope of SDK, SO I opened another warehouse to do this matter, you can have a look.

Vue error

Window. onError does not catch Vue errors, it needs to use the API provided by Vue to listen.

Vue.config.errorHandler = (err, vm, info) = > {
    // Print the error message to the console
    console.error(err)

    lazyReportCache({
        info,
        error: err.stack,
        subType: 'vue'.type: 'error'.startTime: performance.now(),
        pageURL: getPageURL(),
    })
}
Copy the code

Behavioral data acquisition

PV, UV

PV(Page View) is the number of page views, UV(Unique visitor) user visits. PV as long as the page is visited once, UV multiple visits in the same day only once.

For the front end, as long as enter the page to report a PV on the line, UV statistics on the server to do, mainly to analyze the reported data to obtain UV statistics.

export default function pv() {
    lazyReportCache({
        type: 'behavior'.subType: 'pv'.startTime: performance.now(),
        pageURL: getPageURL(),
        referrer: document.referrer,
        uuid: getUUID(),
    })
}
Copy the code

Page duration

An initial time is recorded when the user enters the page. The initial time is deducted from the current time when the user leaves the page. This calculation logic can be done in the beforeUnload event.

export default function pageAccessDuration() {
    onBeforeunload(() = > {
        report({
            type: 'behavior'.subType: 'page-access-duration'.startTime: performance.now(),
            pageURL: getPageURL(),
            uuid: getUUID(),
        }, true)})}Copy the code

Page access depth

It is useful to keep track of page depth, such as different active pages A and B. The average access depth of A is only 50%, and that of B is 80%, indicating that B is more favored by users. According to this point, the activity page of A can be modified accordingly.

In addition, the depth of access and length of stay can be used to identify e-commerce brushing. For example, some people come to the page and pull the page to the bottom and wait for a period of time to buy, some people are slowly scroll down the page, and finally buy. Although they spend the same amount of time on the page, the first person is clearly more likely to swipe.

Page depth calculation is a little more complicated:

  1. When the user enters the page, record the current time, scrollTop value, page visible height, and total page height.
  2. The moment the user scrolls the page, it triggersscrollEvent in the callback function, using the data from point 1 to calculate the depth and duration of the page visit.
  3. When the user scrolls the page to a certain point, he stops and continues looking at the page. The current time, scrollTop value, page visual height, and total page height are recorded.
  4. Repeat number two…

Please see the specific code:

let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0

export default function pageAccessHeight() {
    window.addEventListener('scroll', onScroll)

    onBeforeunload(() = > {
        const now = performance.now()
        report({
            startTime: now,
            duration: now - startTime,
            type: 'behavior'.subType: 'page-access-height'.pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        }, true)})// Record the current access height and time after the page is loaded
    executeAfterLoad(() = > {
        startTime = performance.now()
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight
    })
}

function onScroll() {
    clearTimeout(timer)
    const now = performance.now()
    
    if(! hasReport) { hasReport =true
        lazyReportCache({
            startTime: now,
            duration: now - startTime,
            type: 'behavior'.subType: 'page-access-height'.pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        })
    }

    timer = setTimeout(() = > {
        hasReport = false
        startTime = now
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight        
    }, 500)}function toPercent(val) {
    if (val >= 1) return '100%'
    return (val * 100).toFixed(2) + The '%'
}
Copy the code

The user clicks

By using addEventListener() to listen for mouseDown and TouchStart events, we can collect information such as the size of each click area, the specific position of the click coordinate in the whole page, and the content of the click element.

export default function onClick() {['mousedown'.'touchstart'].forEach(eventType= > {
        let timer
        window.addEventListener(eventType, event= > {
            clearTimeout(timer)
            timer = setTimeout(() = > {
                const target = event.target
                const { top, left } = target.getBoundingClientRect()
                
                lazyReportCache({
                    top,
                    left,
                    eventType,
                    pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
                    scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
                    type: 'behavior'.subType: 'click'.target: target.tagName,
                    paths: event.path? .map(item= > item.tagName).filter(Boolean),
                    startTime: event.timeStamp,
                    pageURL: getPageURL(),
                    outerHTML: target.outerHTML,
                    innerHTML: target.innerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    viewport: {
                        width: window.innerWidth,
                        height: window.innerHeight,
                    },
                    uuid: getUUID(),
                })
            }, 500)})})}Copy the code

Page jump

Use addEventListener() to listen for popState and Hashchange page jump events. Note that a call to history.pushState() or history.replacEstate () does not trigger a popState event. This event is triggered only when a browser action is taken, such as when the user clicks the browser’s back button (or calls the history.back() or history.forward() methods in Javascript code). The same goes for Hashchange.

export default function pageChange() {
    let from = ' '
    window.addEventListener('popstate'.() = > {
        const to = getPageURL()

        lazyReportCache({
            from,
            to,
            type: 'behavior'.subType: 'popstate'.startTime: performance.now(),
            uuid: getUUID(),
        })

        from = to
    }, true)

    let oldURL = ' '
    window.addEventListener('hashchange'.event= > {
        const newURL = event.newURL

        lazyReportCache({
            from: oldURL,
            to: newURL,
            type: 'behavior'.subType: 'hashchange'.startTime: performance.now(),
            uuid: getUUID(),
        })

        oldURL = newURL
    }, true)}Copy the code

The Vue route changed

Vue can use the router.beforeEach hook to listen for route changes.

export default function onVueRouter(router) {
    router.beforeEach((to, from, next) = > {
        // No statistics are required when the page is loaded for the first time
        if (!from.name) {
            return next()
        }

        const data = {
            params: to.params,
            query: to.query,
        }

        lazyReportCache({
            data,
            name: to.name || to.path,
            type: 'behavior'.subType: ['vue-router-change'.'pv'].startTime: performance.now(),
            from: from.fullPath,
            to: to.fullPath,
            uuid: getUUID(),
        })

        next()
    })
}
Copy the code

The data reported

Reported methods

Data can be reported in the following ways:

  • sendBeacon
  • XMLHttpRequest
  • image

The simple SDK I wrote uses a combination of the first and second methods for reporting. The advantages of using sendBeacon for reporting are clear.

Using the sendBeacon() method allows the user agent to asynchronously send data to the server when the opportunity presents itself, without delaying the page’s unload or affecting the load performance of the next navigation. This solves all the problems of submitting analysis data: the data is reliable, the transfer is asynchronous, and the next page load is not affected.

In browsers that do not support sendBeacon we can use XMLHttpRequest to report. An HTTP request consists of two steps: send and receive. In fact, for reporting, we just have to make sure it gets delivered. It doesn’t matter if the response is received or not. To do this, I did an experiment using XMLHttpRequest to transfer 30KB of data (typically, data to be reported is rarely that large) on both beforeunload, using a different browser. Of course, this and hardware performance, network status is also related.

Report the timing

There are three types of reporting opportunities:

  1. usingrequestIdleCallback/setTimeoutDelay reporting.
  2. Reported in the beforeUnload callback function.
  3. Cache the reported data and report it when it reaches a certain amount.

It is recommended to report the following three methods together:

  1. First cache the reported data, cache to a certain amount, userequestIdleCallback/setTimeoutDelay reporting.
  2. When the page leaves, unreported data is reported.

conclusion

It is difficult to understand only by looking at the theoretical knowledge, so I wrote a simple monitoring SDK combined with the technical points mentioned in this article, which can be used to write some simple demos to help deepen the understanding. It is better to read it together with this article.

The resources

Performance monitoring

  • Performance API
  • PerformanceResourceTiming
  • Using_the_Resource_Timing_API
  • PerformanceTiming
  • Metrics
  • evolving-cls
  • custom-metrics
  • web-vitals
  • PerformanceObserver
  • Element_timing_API
  • PerformanceEventTiming
  • Timing-Allow-Origin
  • bfcache
  • MutationObserver
  • XMLHttpRequest
  • How to monitor web page lag
  • sendBeacon

Monitoring errors

  • noerror
  • source-map

Behavior surveillance

  • popstate
  • hashchange