• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

前端日志监控系统-上报SDK

武飞扬头像
华仔专业打杂
帮助45

学新通技术网

前言

首先,我们需要了解什么是前端日志监控和上报SDK。接着将介绍如何使用工具搭建SDK,以及如何在项目中进行集成和调试。最后,我们会简要介绍如何从监控数据中获得价值。

系列文章传送门

前端日志监控系统-上报SDK

前端日志监控系统-后端

为什么自建监控

自研前端日志监控上报SDK的优势主要有以下几点:

  1. 定制化程度高:自研SDK可以根据需要灵活定制,满足特定的业务需求。比如可以根据业务特点,设定各种参数、监控项和告警规则等。
  2. 安全性更高:自研的SDK可以确保监控数据的安全性,保证数据不被外泄或篡改。同时,自研的SDK也可以与业务系统进行更好的融合,更加安全可靠。
  3. 安全性更高:自研的SDK不需要第三方软件的授权,相比购买第三方的SDK开发成本更低。
  4. 可扩展性强:自研的SDK支持更多的自定义数据,可以方便的加入新的业务指标,方便扩展更新。

性能分析

Navigation Timing Level 2 是 W3C Web Performance Working Group 提出的一份规范,用于解决 Navigation Timing Level 1 的一些限制。Navigation Timing 用于测量和报告网页性能指标,包括以下内容:

  1. 重定向和缓存:Navigation Timing Level 2 增加了查找缓存和历史记录数据的支持,以便更准确地计算重定向时间。
  2. DOM 状态:Navigation Timing Level 2 引入了 DOM 正常状态的计算方式,包括解析 HTML、构建 DOM 树和加载外部资源所需的时间。
  3. 资源计时:Navigation Timing Level 2 增加了从带宽较高的 CDN 加载大型资源所需的时间计时
  4. 协议:Navigation Timing Level 2 可以更好地支持新的传输协议(如 HTTP/2)。
  5. 错误:Navigation Timing Level 2 提供了更好的错误报告,包括报告资源加载错误和 HTTP 错误等。

Navigation Timing Level 2 可以被用于 Web 应用程序的性能监测和优化,通过收集和分析网站性能数据,可以找到性能瓶颈并进行优化。

学新通技术网

以Spa页面来说,页面的加载过程大致是这样的:

学新通技术网

总体来说分为以下几个过程:

  • DNS 解析:将域名解析成 IP 地址
  • TCP 连接:TCP 三次握手
  • 发送 HTTP 请求
  • 服务器处理请求并返回 HTTP 报文
  • 浏览器解析渲染页面
  • 断开连接:TCP 四次挥手

页面性能采集分析

收集和分析网页性能指标,以便前端优化网页性能的过程。以下是页面性能采集分析的步骤:

  1. 选择性能指标:常见的性能指标包括页面加载时间、首次渲染时间、DOMReady 时间、HTTP 请求次数、资源大小等。
  2. 收集数据:通过使用前端性能采集工具或前端性能库,收集与网页性能相关的数据,比如 Navigation Timing API、Resource Timing API 或者使用开源的性能采集库等。
  3. 数据分析:将收集到的数据根据不同的指标进行分类和分析。比如在页面加载时间这一指标上,我们可以分析 DNS 解析、TCP 连接、服务器响应、首次渲染等时间分配情况,发现性能瓶颈,确定如何优化。
  4. 优化:根据分析结果,对性能瓶颈的解决方案进行策略选择,如使用 CDN、减少资源大小、代码压缩合并等,然后进行网站性能优化。
  5. 监测:网站性能的优化并非一劳永逸,随着网站的迭代与变化,需持续监测和迭代优化。

PerformanceTiming

PerformanceTiming 是 W3C 定义的在导航期间测量、记录和报告网页性能的 API,它包含了在页面加载期间发生的所有关键事件的时间戳,包括以下属性:

  1. navigationStart:浏览器开始导航的时间戳。
  2. unloadEventStart:当前文档 unload 事件开始的时间戳。
  3. unloadEventEnd:前一个文档 unload 事件结束的时间戳。
  4. redirectStart:第一个 HTTP 重定向开始时的时间戳。
  5. redirectEnd:最后一个重定向结束时的时间戳。
  6. fetchStart:浏览器发起第一个 HTTP 请求的时间戳。
  7. domainLookupStart:域名解析开始时的时间戳。
  8. domainLookupEnd:域名解析结束时的时间戳。
  9. connectStart:浏览器开始向服务器建立连接时的时间戳。
  10. connectEnd:浏览器成功建立连接时的时间戳。
  11. secureConnectionStart:安全连接建立开始的时间戳。
  12. requestStart:浏览器开始发送第一个 HTTP 请求时的时间戳。
  13. responseStart:浏览器接收到第一个字节时的时间戳。
  14. responseEnd:浏览器接收到最后一个字节时的时间戳。
  15. domLoading:浏览器正在解析 HTML 文档时的时间戳。
  16. domInteractive:HTML 文档解析完成,DOM 树构建完成的时间戳。
  17. domContentLoadedEventStart:DOMContentLoaded 事件开始时的时间戳。
  18. domContentLoadedEventEnd:DOMContentLoaded 事件结束时的时间戳。
  19. domComplete:文档解析完成并且所有资源都已准备好加载时的时间戳。
  20. loadEventStart:load 事件开始时的时间戳。
  21. loadEventEnd:load 事件结束时的时间戳。

我们可以使用 JavaScript 中的 performance.timing 获取 PerformanceTiming 对象。

/**
* 获取NT
* @return {*}  {(MPerformanceNavigationTiming | undefined)}
*/
export const getNavigationTiming = (): MPerformanceNavigationTiming | undefined => {
    const resolveNavigationTiming = (entry: PerformanceNavigationTiming): MPerformanceNavigationTiming => {
        const {
            domainLookupStart,
            domainLookupEnd,
            connectStart,
            connectEnd,
            secureConnectionStart,
            requestStart,
            responseStart,
            responseEnd,
            domInteractive,
            domContentLoadedEventEnd,
            loadEventStart,
            fetchStart,
        } = entry;

        return {
            FP: responseEnd - fetchStart, // 白屏时间	
            TTI: domInteractive - fetchStart, // 首次可交互时间
            DomReady: domContentLoadedEventEnd - fetchStart, // HTML加载完成时间也就是 DOM Ready 时间。
            Load: loadEventStart - fetchStart, // 页面完全加载时间	
            FirseByte: responseStart - domainLookupStart, // 首包时间	
            DNS: domainLookupEnd - domainLookupStart, // DNS查询耗时	
            TCP: connectEnd - connectStart, // TCP连接耗时	
            SSL: secureConnectionStart ? connectEnd - secureConnectionStart : 0, // SSL安全连接耗时	
            TTFB: responseStart - requestStart, // 请求响应耗时	
            Trans: responseEnd - responseStart, // 内容传输耗时	
            DomParse: domInteractive - responseEnd, // DOM解析耗时	
            Res: loadEventStart - domContentLoadedEventEnd, // 资源加载耗时	
        };
    };

    const navigation =
        // W3C Level2  PerformanceNavigationTiming
        // 使用了High-Resolution Time,时间精度可以达毫秒的小数点好几位。
        performance.getEntriesByType('navigation').length > 0
            ? performance.getEntriesByType('navigation')[0]
            : performance.timing; // W3C Level1  (目前兼容性高,仍然可使用,未来可能被废弃)。
    return resolveNavigationTiming(navigation as PerformanceNavigationTiming);
};

通过 PerformanceTiming 对象,我们可以了解网页的载入时间,服务器等待时间,DNS 查询时间,页面渲染时间等,帮助我们分析网页性能,并实现性能优化。

FP(白屏)、FCP(灰屏)

First Paint(首次绘制)通常指网页中的一个关键时间,即当浏览器开始呈现屏幕上第一个像素时的时间点。当网页发生白屏(即空白屏幕)的时候,通常是指在First Paint之前,页面没有显示任何内容,用户只看到了一片空白

First Paint作为一个性能指标,反映的是用户首次看到有内容呈现的时间,因此是用户体验的关键指标之一

/**
* FP
* @return {*} 
*/
export const getFP = async () => {
    return new Promise<IMetrics | undefined>((resolve, reject) => {
        if (supported.PerformanceObserver) {
            try {
                new PerformanceObserver((entryList) => {
                    for (const entry of entryList.getEntriesByName('first-paint')) {
                        resolve(entry)
                    }
                }).observe({ type: 'paint', buffered: true });
                return false
            } catch (error) {
                // 在safari下获取为空数组
                const entryList = performance.getEntriesByName('first-paint');
                resolve(entryList[0])
            }
        } else {
            const entryList = performance.getEntriesByName('first-paint');
            resolve(entryList[0])
        }
    })
}

First Contentful Paint: 首次内容渲染(fcp)(灰屏时间) ,表示浏览器首次渲染网页可见内容的时间。而灰屏通常是指在加载过程中,画面一度呈现灰色(或其他颜色),而不是直接呈现内容,持续时间可能很短或很长

/**
* 获取FCP
* @return {*}  {Promise<PerformanceEntry>}
*/
export const getFCP = (): Promise<PerformanceEntry> => {
    return new Promise<PerformanceEntry>((resolve) => {
        if (supported.PerformanceObserver) {
            new PerformanceObserver((entryList) => {
                for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
                    resolve(entry)
                }
            }).observe({ type: 'paint', buffered: true });
        } else {
            setTimeout(() => {
                if (supported.performance) {
                    const [entry] = performance.getEntriesByName('first-contentful-paint');
                    resolve(entry)
                }
            }, 50)
        }
    })
};

白屏、灰屏现象就意味着网站的性能表现较差,会降低用户体验和留存率。为了解决白屏和灰屏问题,需要优化网页性能,包括:

  1. 通过相关工具和技术优化页面加载时间,例如利用缓存、压缩文件等策略。
  2. 确保网站的代码和资源都能够被找到和访问,缩短资源加载时间,避免资源请求出现问题,如使用合适的链接和路径。
  3. 优化首次绘制的时间,如尽可能减少依赖,使用浏览器内置标准,或对页面进行懒加载,从而减少白屏、灰屏发生的概率。
  4. 优化网页资源,采用合适的图片、视频和代码加载策略,减小网页的体积;
  5. 通过前端构建工具来优化代码,如代码分割、懒加载等。

FMP(首次有效绘制)

首次绘制出对用户有意义的内容,比如主要的文本、图片、自定义字体、短视频等,能够给用户带来直观的视觉反馈

1、在代码初始化中添加一个开始时间

constructor(data: IProps) {
    super(data)
    this.startTime = Date.now();
    this.initResourceFlow();
    this.initFMP()
    afterLoad(() => {
        this.initFCP();
        this.initFP();
        this.initNavigationTiming();
    });
}

2、在FCP(灰屏)中计算两者的时间差

/**
* 灰屏
* @memberof WebVitals
*/
initFCP = async () => {
    try {
        const entry = await getFCP() as IMetrics
        const time = Date.now() - this.startTime
        if (entry) {
            this.diffTime = Number((entry.startTime - time).toFixed(2))
            const metrics = normalizePerformanceRecord(entry)
            this.set(MetricsName.FCP, { fcpTime: metrics, reportsType: MetricsName.FCP, category: TransportCategory.PREF, })
        }
    } catch (error) {
    }
};

3、通过MutationObserver、IntersectionObserver监听首次绘制完成内容后,计算FMP时间。

export const mOberver = (callback: MutationObserverHandler): MutationObserver => {
    const mOb = new MutationObserver(function (mutationsList: MutationRecord[]) {
        mutationsList.forEach(callback);
    })
    mOb.observe(document.body, {
        attributes: true,
        characterData: true,
        childList: true,
        subtree: true,
        attributeOldValue: true,
        characterDataOldValue: true,
    })
    return mOb
}

/**
* 首次有效绘制
* @memberof WebVitals
*/
initFMP = () => {
    try {
        let isOnce = false
        const time = this.startTime;
        const iOb = new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
                    if (!isOnce) {
                        const t = Date.now() - time   this.diffTime
                        this.set(MetricsName.FMP, { fmpTime: t, reportsType: MetricsName.FMP, category: TransportCategory.PREF })
                        isOnce = true
                    }
                }
            });
        }, {
            root: null,
            rootMargin: '0px',
            threshold: [0.1, 0.85]
        })
        const mO = mOberver(function (mutation: MutationRecord) {
            const addedNodes = mutation.addedNodes
            addedNodes?.forEach((node: any) => {
                if (node instanceof HTMLElement) {
                    iOb.observe(node)
                    mO.disconnect()
                }
            })
        })
    } catch (error) {
    }
}

如果页面报错导致没有触发首次绘制,就监听不到FMP。

FMP的优化可以通过优化代码、减少资源请求数量和大小、使用懒加载以及优化服务器响应时间等方式来实现

静态资源

学新通技术网

performance.getEntriesByType('resource') 是一种用于获取网页性能信息的方法,在这里返回的是一个数组,其中包含了网页已加载的所有资源。这些资源包括图片、脚本、样式表、视频等等。

/**
* 获取资源resource加载性能
* new PerformanceObserver获取资源有部分获取不了
* @return {*}  {Array<ResourceFlowTiming>}
*/
export const getPerformanceResourceFlow = (): Array<ResourceFlowTiming> => {
    if (supported.performance) {
        const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
        let list = entries.filter((entry) => {
            return ['fetch', 'xmlhttprequest', 'beacon'].indexOf(entry.initiatorType) === -1;
        });
        const metrics = list.map((entry) => {
            const {
                name,
                transferSize,
                initiatorType,
                startTime,
                responseEnd,
                domainLookupEnd,
                domainLookupStart,
                connectStart,
                connectEnd,
                secureConnectionStart,
                responseStart,
                requestStart,
                duration
            } = entry;
            const dnsLookup = domainLookupEnd - domainLookupStart
            const initialConnect = connectEnd - connectStart
            const isCache = (duration == 0 && transferSize !== 0) || (!dnsLookup && !initialConnect)
            return ({
                // name 资源地址
                name,
                // transferSize 传输大小
                transferSize,
                // initiatorType 资源类型
                initiatorType,
                // startTime 开始时间
                startTime,
                // responseEnd 结束时间
                responseEnd,
                //消耗时间
                time: duration,
                dnsLookup: dnsLookup,
                initialConnect: initialConnect,
                ssl: connectEnd - secureConnectionStart,
                request: responseStart - requestStart,
                ttfb: responseStart - requestStart,
                contentDownload: responseStart - requestStart,
                // 是否命中缓存
                isCache: !!isCache,
            })
        })
        return metrics
    }
    return []
}

当页面 pageshow 触发时,中止监听。

/**
* 初始化 RF 
* @memberof WebVitals
*/
initResourceFlow = (): void => {
    if (supported.performance) {
        try {
            const resourceFlow: Array<ResourceFlowTiming> = [];
            const resObserve = getResourceFlow(resourceFlow);
            const stopListening = () => {
                if (resObserve) {
                    resObserve.disconnect();
                }
                const metrics = getPerformanceResourceFlow() as Array<ResourceFlowTiming>;
                console.log(metrics, resourceFlow)
                const list = metrics.map((item: IMetrics) => normalizePerformanceRecord(item))
                const cacheQuantity = metrics.filter(item => item.isCache)?.length
                this.set(MetricsName.RF, { resourcePrefs: list, reportsType: MetricsName.RF, category: TransportCategory.PREF, cacheRate: ((cacheQuantity / metrics.length) * 100).toFixed(2) })
            };
            // 当页面 pageshow 触发时,中止。 使用load 返回的结果也是一样。
            window.addEventListener('pageshow', stopListening, { once: true, capture: true });
        } catch (error) {
        }
    }
};

在使用performance.getEntriesByType('resource')PerformanceObserver监听资源对比时,返回的静态资源加载的数不一致。导致这样的原因可能是:PerformanceObserver 是异步监听资源性能相关数据的方法。获取的时机不一样可能导致数据不一致

学新通技术网

学新通技术网

静态资源加载是指在Web页面中加载静态文件,如CSS、JavaScript、图片、视频等。这些静态资源不会随着用户与页面的交互而改变,并且通常会保存在服务器上,通过HTTP请求来获取。在Web开发中,优化静态资源加载可以提高页面的性能。

  1. name: 资源的URL。
  2. entryType: 资源的类型,值为resource
  3. startTime: 资源加载的开始时间,单位为毫秒。
  4. duration: 资源加载完成的时间,单位为毫秒。
  5. initiatorType: 指示资源加载的来源,例如是img还是script等。
  6. nextHopProtocol: 指示资源下载的协议类型,例如HTTP或HTTPS等。
  7. workerStart: 指示浏览器开始处理资源时,Service Worker开始处理的时间,单位为毫秒。
  8. redirectStart: 如果资源是一个重定向,那么它指示开始执行重定向的时间,否则该值为0,单位为毫秒。
  9. redirectEnd: 如果资源是一个重定向,那么它指示重定向完成的时间,否则该值为0,单位为毫秒。
  10. fetchStart: 指示浏览器开始下载资源的时间,单位为毫秒。
  11. domainLookupStart: 指示域名解析开始的时间,单位为毫秒。
  12. domainLookupEnd: 指示浏览器完成域名解析的时间,单位为毫秒。
  13. connectStart: 指示浏览器开始建立连接的时间,如果使用HTTP/2或HTTPS协议,则始终为0,单位为毫秒。
  14. connectEnd: 指示浏览器成功建立连接的时间,如果使用HTTP/2或HTTPS协议,则始终为0,单位为毫秒。
  15. secureConnectionStart: 如果使用HTTPS协议,则表示开始建立安全连接的时间,否则该值为0,单位为毫秒。
  16. requestStart: 指示浏览器发送资源请求的时间,单位为毫秒。
  17. responseStart: 指示浏览器开始接收资源响应的时间,单位为毫秒。
  18. responseEnd: 指示浏览器完成接收资源响应的时间,单位为毫秒。

那么,如何判断用户的资源是否命中了缓存呢? ,其实很简单,如果静态资源被缓存了,它具有以下两个特征:

  • 静态资源的 duration 为0;
  • 静态资源的 transferSize 不为0;

获取页面资源时间详情时,有跨域的限制。默认情况下,跨域资源以下属性会被设置为 0

  • redirectStart
  • redirectEnd
  • domainLookupStart
  • domainLookupEnd
  • connectStart
  • connectEnd
  • secureConnectionStart
  • requestStart
  • responseStart
  • transferSize

如果想获取资源的具体时间,跨域资源需要设置响应头 Timing-Allow-Origin

  • 对于可控跨域资源例如自家 CDNTiming-Allow-Origin 的响应头 origins 至少得设置了主页面的域名,允许获取资源时间
  • 一般对外公共资源设置为 Timing-Allow-Origin: *

目前LCP、CLS、FID 等性能指标做上报处理,后续添加。

用户行为

采集用户行为,简单来说就是通过 JavaScript 实现对用户在页面上的行为进行收集、记录和分析,以便更好地理解用户需求和行为特征,从而实现产品和服务的优化。常见的行为采集有以下:

  • PV、UV量,日同比、周同比等。能清晰的明白流量变化。
  • 用户热点页面、高访问量
  • 设备、浏览器语言、浏览器、活跃时间段等的用户特征
  • 用户的行为追踪:某个用户,进入了网站后的一系列操作或者跳转行为;
  • 用户自定义埋点上报用户行为、曝光行为等;为在线广告、产品曝光度等效果进行评估。

在采集用户行为之前,我们要创建BehaviorStore类,用于缓存用户行为操作数据。为后续错误上报时,用户错误流程溯源时提供数据。

采集了以下用户行为信息:

  • 路由跳转行为
  • 点击行为
  • ajax 请求行为
  • 用户自定义事件
  • 曝光行为
/**
* 记录用户行为
* @export
* @class BehaviorStore
*/
export default class BehaviorStore {
    private state: Array<BehaviorStack>
    private max: number
    constructor({ maxBehaviorRecords }: BehaviorRecordsOptions) {
        this.max = maxBehaviorRecords || 100
        this.state = []
    }
    push(value: BehaviorStack) {
        if (this.length() === this.max) {
            this.shift();
        }
        this.state.push(value);
    }

    shift() {
        return this.state.shift();
    }

    length() {
        return this.state.length;
    }

    get() {
        return this.state;
    }

    clear() {
        this.state = [];
    }
}

用户行为包含页面一些基础信息,这些基本信息用于后续问题排查和检索

export interface PageInfo {
    // 浏览器的语种 (eg:zh) 
    lang?: string
    // 屏幕宽高 
    winScreen?: string
    // 文档宽高 
    docScreen?: string
    // 用户ID
    userId?: string
    // 标题
    title?: string
    // 路由
    path?: string
    // href
    href?: string
    // referrer
    referrer?: string
    // prevHref
    prevHref?: string
    // 跳转方式
    jumpType?: string
    // 用户来源方式
    type?: number
    // 网络状况
    effectiveType?: string
    // 环境
    mode?: string
    // 站点ID
    siteId?: string
}

UserVitals类中用户行为数据采集,如PV、事件、曝光时间、http请求、自定义上报事件等,并且缓存用户行为数据。

/**
* 用户行为
* @export
* @class UserVitals
* @extends {SendLog}
*/
export default class UserVitals extends CommonExtend {
    // 最大行为追踪记录数
    public behaviorTracking: BehaviorStore
    private events: Array<string> = ['click', 'touchstart']
    constructor({ isExposure, maxBehaviorRecords, ...data }: IProps) {
        super(data)
        this.behaviorTracking = new BehaviorStore({ maxBehaviorRecords: maxBehaviorRecords });
        this.initClickHandler();
        this.initHttpHandler();
        isExposure && this.initExposure();
    }
}

路由

常见的前端路由主要有两种模式:history模式hash模式。相比于hash模式,history模式的URL更加美观,并且对SEO更加友好。

hash模式

hash模式是将URL中的#符号作为路由的切换标志,通过监听URL中#号后面的变化,从而实现页面的切换。在单页应用中广泛使用,常常配合window.location.hash属性和onhashchang、 onpopstate事件使用来监听和控制路由的变换。

/**
* 添加对 hashchange,  popstate 的监听
* @param {FN1} handler
*/
export const proxyHash = (handler: FN1): void => {
    // hash 变化除了触发 hashchange ,也会触发 popstate 事件,而且会先触发 popstate 事件,我们可以统一监听 popstate
    // 这里可以考虑是否需要监听 hashchange
    window.addEventListener('hashchange', (e) => handler(e), true);
    // 添加对 popstate 的监听
    // 浏览器回退、前进行为触发的 可以自己判断是否要添加监听
    window.addEventListener('popstate', (e) => handler(e), true);
};

history模式

window.history 属性指向 History 对象,它表示当前窗口的浏览历史。当发生改变时,只会改变页面的路径,不会刷新页面。 History 对象保存了当前窗口访问过的所有页面网址。通过 history.length 可以得出当前窗口一共访问过几个网址。 由于安全原因,浏览器不允许脚本读取这些地址,但是允许在地址之间导航。 浏览器工具栏的“前进”和“后退”按钮,其实就是对History 对象进行操作。

  • History.back():移动到上一个网址,等同于点击浏览器的后退键。对于第一个访问的网址,该方法无效果。
  • History.forward():移动到下一个网址,等同于点击浏览器的前进键。对于最后一个访问的网址,该方法无效果。
  • History.go():接受一个整数作为参数,以当前网址为基准,移动到参数指定的网址。如果参数超过实际存在的网址范围,该方法无效果;如果不指定参数,默认参数为0,相当于刷新当前页面。
  • History.pushState(): 该方法用于在历史中添加一条记录。pushState()方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有变化。
  • History.replaceState(): 该方法用来修改 History 对象的当前记录,用法与 pushState() 方法一样。
/**
* 重现监听pushState replaceState 事件
* @param {keyof History} type
* @return {*} 
*/
const wr = (type: keyof History) => {
    const orig = history[type];
    return function (this: unknown) {
        // eslint-disable-next-line prefer-rest-params
        const rv = orig.apply(this, arguments);
        const e = new Event(type);
        window.dispatchEvent(e);
        return rv;
    };
};

/**
* 添加 pushState replaceState 事件
*/
export const wrHistory = (): void => {
    history.pushState = wr('pushState');
    history.replaceState = wr('replaceState');
};

/**
* 为 pushState 以及 replaceState 方法添加 Event 事件
* @param {FN1} handler
*/
export const proxyHistory = (handler: FN1): void => {
    // 添加对 replaceState 的监听
    window.addEventListener('replaceState', (e) => handler(e), true);
    // 添加对 pushState 的监听
    window.addEventListener('pushState', (e) => handler(e), true);
};

PV、UV

PV(Page Views,页面浏览量)UV(Unique Visitors,独立访客数)是互联网网站或应用程序中常用的两个访问量指标。它们的统计方式如下:

PV(页面浏览量):指用户在网站或应用程序中访问的页面数量。当用户打开一个页面时,就会增加该页面的 PV 值。PV 可以衡量网站或应用程序的受欢迎程度,反映了用户的访问情况。PV 值通常用于衡量网站或应用程序的流量。

UV(独立访客数):指一段时间内访问某个网站或应用程序的不同用户数量。UV 是根据用户的 IP 地址和浏览器等信息来确定用户的身份的。如果同一个用户在一天内多次访问网站或应用程序,只计算为一个 UV。UV 可以衡量网站或应用程序的独立用户数量,反映了用户的规模和影响力。UV 值通常用于衡量网站或应用程序的用户数量。

需要注意的是,PV 和 UV 是两个不同的概念,不能混淆使用。PV 值可以高于 UV 值,因为同一个用户在一次访问中可以浏览多个页面,是根据用户一天时间进行统计,并且记为一次。

/**
* 上报pv
* @memberof UserVitals
*/
initPV = () => {
    const metrice = {
        reportsType: MetricsName.RCR,
        category: TransportCategory.PV
    }
    this.add(MetricsName.RCR, metrice)
    this.behaviorTracking.push(metrice)
}

/**
* 用于监听路由的变化
* @param {FN1} [cb]
* @memberof SendLog
*/
initRouterChange = (cb?: FN1) => {
    wrHistory()
    const handler = (e: Event) => {
        this.dynamicInfo(e);
        setTimeout(() => {
            this.handleRoutineReport()
        }, 100)
        this.isLoaded = true
        cb?.(e)
    };
    window.addEventListener('pageshow', handler, { once: true, capture: true });
    proxyHash(handler);
    proxyHistory(handler);
}

当监听到路由变化时,会触发PV上报。

上报事件

事件上报是记录用户在网页中点击行为,这些信息是非常有价值:

  1. 了解用户行为:通过分析用户的点击行为,可以了解他们的偏好和行为模式。这有助于更好地了解受众,制定更有针对性的营销策略和增强用户参与度。
  2. 提高用户体验:点击事件上报可以帮助发现用户在使用网站或应用程序时遇到的问题,以便能够及时优化用户体验。例如,如果发现用户在某个按钮上多次点击而未能实现他们的目标,可以在那里增加一些反馈提示,以减少用户的困惑。
  3. 跟踪效果:点击事件上报可以帮助跟踪广告、播放量、点击支付量等效果。可以记录点击率和转化率,以便更好地了解运营效果。
  4. 改进产品设计:通过分析点击事件,可以了解用户对产品设计的反应,并进行相应的调整。例如,如果发现用户在使用某个功能时遇到困难,可以对该功能进行优化,以提高用户的满意度。
// 上报名称
@IsString()
@prop({ type: String, default: null, validate: /\S /, text: true })
logName: string | null

// 上报数据
@IsString()
@prop({ type: String, default: null })
logData: string | null
// 上报位置
@IsString()
@prop({ type: String, default: null })
logPos: string | null
// 上报ID
@IsString()
@prop({ type: String, default: null })
logId: string | null

针对上报事件,只有在标签节点上添加data-logId才会统计上报,但是在行为中还是会缓存起来,用于错误上报;为排查问题用户操作溯源提供数据。

/**
* 上报事件
* @memberof UserVitals
*/
initClickHandler = () => {
    const handle = (e: MouseEvent | any) => {
        const target = e.target
        const tagName = target.tagName.toLowerCase();
        if (tagName === "body" || tagName === 'html') {
            return null;
        }
        const data = target.dataset || {} // 点击事件上报参数
        let classNames = target.classList.value;
        classNames = classNames !== "" ? ` class="${classNames}"` : "";
        const id = target.id ? ` id="${target.id}"` : "";
        const innerText = target.innerText;
        // 获取包含id、class、innerTextde字符串的标签
        let nodeDom = `<${tagName} ${id} ${classNames !== "" ? classNames : ""}>${innerText}</${tagName}>`;
        const metrice: BehaviorStack = {
            reportsType: MetricsName.CBR,
            nodeId: target.id,
            classList: Array.from(target.classList),
            tagName: tagName,
            tagText: innerText || target.textContent,
            category: TransportCategory.EVENT,
            nodeDom: nodeDom,
            ...data,
        }
        // 只有标签节点上添加上报参数,data-logId=""才会上报
        if (data.logId) {
            this.add(MetricsName.CBR, metrice)
        }
        this.behaviorTracking.push({
            ...metrice
        })
    }
    this.events.forEach((event) => {
        window.addEventListener(event, handle, true)
    })
}

曝光上报

曝光和上面的点击事件有着同样的价值;曝光上报能够提供产品展示的数量和频率等数据。

曝光事件使用了MutationObserverIntersectionObserverAPI来观察 DOM 树的更改以及观察一个元素与其祖先元素或视口之间的交叉情况。当目标元素与交叉区域有所改变时,IntersectionObserver 可以检测到并触发相应的回调。

MutationObserver API 可以观察三种类型的变化:

  • attributes: 监听指定节点的所有属性变化
  • childList: 监听指定节点的子节点变化
  • subtree: 监听指定节点下的所有子孙节点变化

IntersectionObserver 可以观察多种类型的交叉情况,可以通过 options 对象的属性来设置:

  • root: 观察器所观察的HTML元素。默认为null,表示浏览器视口。
  • rootMargin: 格式为"{top, right, bottom, left}",表示target元素与root交叉区域的外边距,可以用像素值或百分比。默认值是0px。
  • threshold: 一个触发回调的值数组,表示交叉区域和目标元素的比率。0就是不可见,1就是完全可见。默认为0。

如果需要观察目标元素与交叉区域的交叉情况,通过observe方法用来添加观察的DOM节点;disconnect方法可以用来停止观察DOM节点。

IntersectionObserverMutationObserver都存在兼容性问题,所以需要安装相应的填充包

// 用于观察body节点的变化
export const mOberver = (callback: MutationObserverHandler): MutationObserver => {
    const mOb = new MutationObserver(function (mutationsList: MutationRecord[]) {
        mutationsList.forEach(callback);
    })
    mOb.observe(document.body, {
        attributes: true,
        characterData: true,
        childList: true,
        subtree: true,
        attributeOldValue: true,
        characterDataOldValue: true,
    })
    return mOb
}

通过MutationObserver观察DOM节点变化,判断当前节点以及子节点中是否存在class="on-visible";如果存在需要曝光的DOM节点,可以通过IntersectionObserverobserve中观察相应的节点是否进入可视区域进行上报;上报后通过disconnect停止观察DOM节点,只观察一次。上报参数和点击事件参数一致

学新通技术网

/**
* 上报曝光
* @memberof UserVitals
*/
initExposure = () => {
    // 针对曝光监控
    const itOberser = new IntersectionObserver((entries, observer: IntersectionObserverInit) => {
        entries.forEach((entry) => {
            // 检查元素发生了碰撞
            const nodeRef = entry.target as HTMLElement
            const att = nodeRef.getAttribute('data-visible')
            if (entry.isIntersecting && entry.intersectionRatio >= 0.55 && !att) {
                const data: any = nodeRef.dataset || {} // 曝光埋点日志数据
                const metrice: BehaviorStack = {
                    reportsType: MetricsName.CE,
                    classList: Array.from(nodeRef.classList),
                    tagName: nodeRef.tagName,
                    text: nodeRef.textContent,
                    category: TransportCategory.EVENT,
                    ...data,
                }
                this.add(MetricsName.CE, metrice)
                // 曝光不是用户行为,可以不作为采集信息
                nodeRef.setAttribute('data-visible', 'y')
            }
        });
    }, {
        root: null,
        rootMargin: '0px',
        threshold: [0.1, 0.55]
    })

    const nodes = (document as Document).querySelectorAll('.on-visible')
    nodes.forEach((child) => {
        itOberser?.observe(child)
    });

    mOberver(function (mutation: MutationRecord) {
        const addedNodes = mutation.addedNodes
        if (!!addedNodes.length) {
            addedNodes?.forEach((node: any) => {
                const isS = node.classList.contains('on-visible')
                isS && itOberser.observe(node)
                const nodes = node.querySelectorAll('.on-visible')
                nodes?.forEach((child: HTMLElement) => {
                    itOberser.observe(child)
                });
            })
        }
    })
}

HTTP 行为

HTTP 请求捕获通常用于开发和调试 Web 应用程序时,用于检查和分析浏览器和服务器之间传输的请求和响应,以便进行故障排除和优化。采集 HTTP请求 的各种信息:包括 请求地址方法耗时请求时间响应时间响应结果等等

这里要封装了针对XMLHttpRequest、fetch的劫持。

import { FN1, HttpMetrics } from "./interfaces";

/**
* 解析query 参数
* @param {string} searchPath
* @return {*} 
*/
const parseUrlParameter = (searchPath: string) => {
    try {
        let search = (searchPath || window.location.search).match(/\?.*(?=\b|#)/) as any
        search && (search = search[0].replace(/^\?/, ''))
        if (!search) return {}
        const queries = {} as any,
            params = search?.split('&')

        for (const i in params) {
            const param = params?.[i]?.split('=')
            queries[param[0]] = param[1] ? decodeURIComponent(param[1]) : ''
        }
        return queries
    } catch (error) {
        return {}
    }
}

/**
* 调用 proxyXmlHttp 即可完成全局监听 XMLHttpRequest
* @param {(FN1 | null | undefined)} sendHandler
* @param {FN1} loadHandler
*/
export const proxyXmlHttp = (sendHandler: FN1 | null | undefined, loadHandler: FN1) => {
    if ('XMLHttpRequest' in window && typeof window.XMLHttpRequest === 'function') {
        const oXMLHttpRequest = window.XMLHttpRequest;
        if (!(window as any).oXMLHttpRequest) {
            (window as any).oXMLHttpRequest = oXMLHttpRequest;
        }
        // oXMLHttpRequest 为原生的 XMLHttpRequest,可以用以 SDK 进行数据上报,区分业务
        (window as any).XMLHttpRequest = () => {
            // 覆写 window.XMLHttpRequest
            try {
                const xhr = new oXMLHttpRequest();
                const { open, send } = xhr;
                let metrics = {} as HttpMetrics;

                xhr.open = (method, url: string) => {
                    metrics.method = method;
                    metrics.queryUrl = url
                    metrics.url = url.split('?')[0];
                    open.call(xhr, method, url, true);
                };

                xhr.send = (body) => {
                    const urlParmas = parseUrlParameter(metrics.queryUrl as string)
                    metrics.body = body || undefined
                    metrics.params = JSON.stringify(urlParmas)
                    metrics.requestTime = new Date().getTime();
                    // sendHandler 可以在发送 Ajax 请求之前,挂载一些信息,比如 header 请求头
                    // setRequestHeader 设置请求header,用来传输关键参数等
                    // xhr.setRequestHeader('xxx-id', 'VQVE-QEBQ');
                    if (typeof sendHandler === 'function') sendHandler(xhr);
                    send.call(xhr, body);
                };
                // 请求结束
                xhr.addEventListener('loadend', () => {
                    const { status, statusText, response } = xhr;
                    let res: any
                    try {
                        const { status: s, message, result } = JSON.parse(response)
                        res = { status: s, message, }
                    } catch (error) {
                        res = response
                    }
                    metrics = {
                        ...metrics,
                        status,
                        statusText,
                        response: res,
                        responseTime: new Date().getTime(),
                    };
                    if (typeof loadHandler === 'function') loadHandler(metrics);
                    // xhr.status 状态码
                    // 如果 xhr.status 为 500\404等,就是有异常,调用异常上报即可
                });
                return xhr;
            } catch (error) {

            }
        };
    }
};

/**
* 调用 proxyFetch 即可完成全局监听 fetch
* @param {(FN1 | null | undefined)} sendHandler
* @param {FN1} loadHandler
*/
export const proxyFetch = (sendHandler: FN1 | null | undefined, loadHandler: FN1) => {
    if ('fetch' in window && typeof window.fetch === 'function') {
        const oFetch = window.fetch;
        if (!(window as any).oFetch) {
            (window as any).oFetch = oFetch;
        }
        (window as any).fetch = async (input: any, init: RequestInit) => {
            // init 是用户手动传入的 fetch 请求互数据,包括了 method、body、headers,要做统一拦截数据修改,直接改init即可
            if (typeof sendHandler === 'function') sendHandler(init);
            let metrics = {} as HttpMetrics;

            metrics.method = init?.method || '';
            const url = (input && typeof input !== 'string' ? input?.url : input) || ''; // 请求的url
            metrics.url = url.split('?')[0];
            const urlParmas = parseUrlParameter(url as string)
            metrics.body = init?.body || undefined
            metrics.params = JSON.stringify(urlParmas)
            metrics.requestTime = new Date().getTime();

            return oFetch.call(window, input, init).then(async (response) => {
                // clone 出一个新的 response,再用其做.text(),避免 body stream already read 问题
                const res = response.clone();
                const result = await res.text()
                let resData: any
                try {
                    resData = JSON.parse(result || "{}")
                } catch (error) {
                    resData = { result: resData }
                }
                metrics = {
                    ...metrics,
                    status: res.status,
                    statusText: res.statusText,
                    // response: JSON.stringify({ statue: resData?.statue, message: resData?.message, result: resData?.result }),
                    response: { statue: resData?.statue, message: resData?.message, result: resData?.result },
                    responseTime: new Date().getTime(),
                };
                if (typeof loadHandler === 'function') loadHandler(metrics);
                return response;
            });
        };
    }
};

初始化监听http请求。

/**
* http请求上报
* @memberof UserVitals
*/
initHttpHandler = (): void => {
    const handler = (metrics: HttpMetrics) => {
        const metrice = {
            reportsType: MetricsName.HT,
            category: TransportCategory.API,
            ...metrics,
        }
        this.add(MetricsName.HT, metrice)
        // 记录到用户行为追踪队列
        this.behaviorTracking.push(metrice);
    }
    proxyXmlHttp(null, handler)
    proxyFetch(null, handler)
}

User Agent 信息

User Agent:在Node层处理, 通过ua-parser-js来解析UA信息并保存到数据库中。

 // 获取UA
 const ua = request.headers['user-agent'] || ''

/**
 * 解析UA
 * @export
 * @param {string} ua
 * @return {*}  {IResult}
 */
export function getUaInfo(ua: string): IResult {
    const parser = new UAParser()
    parser.setUA(ua)
    return parser.getResult()
}

IP信息

IP:同样也是在Node层处理,它通过多个来源尝试获取客户端 IP 地址,并返回一个字符串类型的 IP 地址或 undefined。函数中使用了以下逻辑来获取 IP 地址

export function getClientIp(req: Request): string | undefined {
    const ip = (req.headers['x-forwarded-for'] as string) ||
        (req.headers['x-real-ip'] as string) ||
        req.socket.remoteAddress ||
        req.ip ||
        req.ips[0]
    return ip.replace('::ffff:', '').replace('::1', '') || undefined
}

获取到相关IP后我们要解析IP相关的信息。解析后IP相关信息也保存到数据库中。

import logger from "@app/utils/logger";
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";

type IP = string
export interface IPLocation {
    country: string
    country_code: string
    region: string
    region_code: string
    city: string
    zip: string
    [key: string]: any
}

@Injectable()
export class HelperServiceIp {
    constructor(private readonly httpService: HttpService) {}

    /**
     * https://ip-api.com/docs/api:json 获取ip 信息
     * @private
     * @param {string} ip
     * @return {*} 
     * @memberof HelperServiceIp
     */
    private queryLocationApi(ip: IP): Promise<IPLocation> {
        return this.httpService.axiosRef
            .get<any>(`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,zip`)
            .then((response) => {
                return response.data?.status !== 'success'
                    ? Promise.reject(response.data.message)
                    : Promise.resolve({
                        country: response.data.country,
                        country_code: response.data.countryCode,
                        region: response.data.regionName,
                        region_code: response.data.region,
                        city: response.data.city,
                        zip: response.data.zip,
                    })
            })
            .catch((error) => {
                const message = error?.response?.data || error?.message || error
                logger.warn('queryLocationByIPAPI failed!', message)
                return Promise.reject(message)
            })
    }

    /**
     * https://ipapi.co/api/#introduction ipapi获取ip 信息
     * @private
     * @param {IP} ip
     * @return {*}  {Promise<IPLocation>}
     * @memberof HelperServiceIp
     */
    private queryLocationByAPICo(ip: IP): Promise<IPLocation> {
        return this.httpService.axiosRef
            .get<any>(`https://ipapi.co/${ip}/json/`)
            .then((response) => {
                return response.data?.error
                    ? Promise.reject(response.data.reason)
                    : Promise.resolve({
                        country: response.data.country_name,
                        country_code: response.data.country_code,
                        region: response.data.region,
                        region_code: response.data.region_code,
                        city: response.data.city,
                        zip: response.data.postal,
                    })
            })
            .catch((error) => {
                const message = error?.response?.data || error?.message || error
                logger.warn('queryLocationByAPICo failed!', message)
                return Promise.reject(message)
            })
    }

    /**
     * 获取IP
     * @param {IP} ip
     * @return {*}  {(Promise<IPLocation | null>)}
     * @memberof HelperServiceIp
     */
    public queryLocation(ip: IP): Promise<IPLocation | null> {
        return this.queryLocationApi(ip)
            .catch(() => this.queryLocationByAPICo(ip))
            .catch(() => null)
    }
}

通过调用两个不同的第三方 API,分别为 ip-api.comipapi.co,获取指定 IP 地址的国家、省份、城市、邮编等信息,并返回一个包含这些信息的对象,如果获取失败则返回 null。

错误上报

错误信息是最基础也是最重要的数据,错误信息主要分为下面几类:

  • JS 代码运行错误、语法错误等
  • 异步错误
  • 静态资源加载错误
  • 接口请求报错

JavaScript错误堆栈(Call Stack)是一个数据结构,用于跟踪执行上下文中的函数调用。当JS代码执行出现错误时,错误堆栈将展示错误在代码中的位置及发生错误时的调用堆栈信息。

学新通技术网 上图就是抛出的 Error对象 里的 Stack错误堆栈,里面包含了很多信息:包括调用链文件名调用地址行列信息等等;接下来我们细讲下各个错误上报捕获方法。

try-catch 捕获

一般情况下,try-catch语句块并不会触发浏览器的错误捕获事件,因为语言本身提供了异常处理机制。

// 不上报 
try {
    i
} catch (err) {
    console.log(err)
}

为了解决这个问题,想到使用babel;熟悉babel的朋友都知道babel编译步骤通常包括以下几个步骤:

  1. 解析:Babel首先将输入的代码解析成AST(抽象语法树),这个阶段会将最新的ES6或ES7代码转换成可以被Babel理解的JavaScript AST。(包含了词法解析和语法解析
  2. 转换:Babel接下来会应用一组或多组插件转换AST树,将新的语法转换成ES5浏览器环境可以执行的代码。例如,类、箭头函数和模板字面量等语言特性就需要被转换。
  3. 生成机器代码:转换后,Babel需要将AST转换为目标浏览器可以理解和执行的机器代码。Babel使用babel-generator库来完成这个任务。在这个阶段,优化和压缩代码也可以被应用。

了解了babel基本的原理,我们开始在babel-转换步骤中,写个babel自定义插件,处理try-catch错误无法上报的问题。

大概流程如下:通过向catch中注入Promise.reject(error),把错误抛给Promise,然后通过监听unhandledrejection监听Promise错误进行上报。这里要保持catch中原有的代码。

// 处理 try-catch 块
TryStatement(path) {
    const {
        block,
        handler
    } = path.node;
    if (handler) {
        const oldBody = handler.body.body;
        const errorHandler = t.functionExpression(null, [handler.param], t.blockStatement([
            t.expressionStatement(
                t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('reject')), [
                    handler.param,
                ]),
            ),
            ...oldBody,
        ]));

        path.node.handler.body = errorHandler.body;
    }
},

编译后的文件中保存原来的代码逻辑,不影响catch的业务处理。

学新通技术网

onerror和window.addEventListener('error')

都是用来捕获JavaScript运行时错误的方法,但是它们有一些区别:

  • onerror 是一个全局事件处理程序,用于捕获未经处理的错误。可以使用它来捕获同一文档中发生的所有 JavaScript 错误,并将其发送到指定的错误处理函数中。但是无法捕获静态资源加载失败时的错误。
  • window.addEventListener('error') 是一个更细粒度的事件处理程序,可以使用它来捕获指定元素中的 JavaScript 错误。可以捕获静态资源加载失败时的错误
/**
* 初始化监听JS异常
* @memberof ErrorVitals
*/
initJsError = () => {
    const handler = (event: ErrorEvent) => {
        event.preventDefault()
        // 这里只搜集js 错误
        if (getErrorKey(event) !== MechanismType.JS) return false
        const value = event.message
        if (/ResizeObserver/.test(value)) {
            return false;
        }
        const errUid = getErrorUid(`${MechanismType.JS}-${event.message}-${event.filename}`)
        const errInfo = {
            // 上报错误归类
            reportsType: MechanismType.JS,
            // 错误信息
            value: event.message,
            // 错误类型
            errorType: event?.error?.name || 'UnKnowun',
            // 解析后的错误堆栈
            stackTrace: parseStackFrames(event.error),
            // 用户行为追踪 breadcrumbs 在 errorSendHandler 中统一封装
            // 错误的标识码
            errorUid: errUid,
            // 其他信息
            meta: {
                // file 错误所处的文件地址
                file: event.filename,
                // col 错误列号
                col: event.colno,
                // row 错误行号
                row: event.lineno,
            },
        } as ExceptionMetrics
        this.errorSendHandler(errInfo)
    }
    window.addEventListener('error', (e) => handler(e), true)
}

/**
* 初始化监听静态资源异常上报
* @memberof ErrorVitals
*/
initResourceError = () => {
    const handler = (e: Event) => {
        e.preventDefault()
        // 只采集静态资源错误信息
        if (getErrorKey(e) !== MechanismType.RS) return false
        const target = e.target as any
        const errUid = getErrorUid(`${MechanismType.RS}-${target.src}-${target.tagName}`)
        const errInfo = {
            // 上报错误归类
            reportsType: MechanismType.RS,
            // 错误信息
            value: '',
            // 错误类型
            errorType: 'ResourceError',
            // 用户行为追踪 breadcrumbs 在 errorSendHandler 中统一封装
            // 错误的标识码
            errorUid: errUid,
            // 其他信息
            meta: {
                url: target.src,
                html: target.outerHTML,
                type: target.tagName,
            },
        } as ExceptionMetrics
        this.errorSendHandler(errInfo)
    }
    window.addEventListener('error', (e) => handler(e), true)
}

/**
* 监听跨域报错
* @memberof ErrorVitals
*/
initCorsError = (): void => {
    const handler = (event: ErrorEvent) => {
        // 阻止向上抛出控制台报错
        event.preventDefault();
        // 如果不是跨域脚本异常,就结束
        if (getErrorKey(event) !== MechanismType.CS) return;
        const exception = {
            // 上报错误归类
            reportsType: MechanismType.CS,
            // 错误信息
            value: event.message,
            // 错误类型
            errorType: 'CorsError',
            // 错误的标识码
            errorUid: getErrorUid(`${MechanismType.CS}-${event.message}`),
            // 附带信息
            meta: {},
        } as ExceptionMetrics;
        this.errorSendHandler(exception);
    };
    window.addEventListener('error', (event) => handler(event), true);
};

Promise错误

Promise 面临三种错误:未捕获异常拒绝(Rejected)Promise 异常(Thrown)Promise

  1. 未捕获异常

如果一个 Promise 链中没有 catch() 方法,那么任何错误都将以未捕获异常的形式抛出,如下所示:

// 可以捕获
Promise.resolve().then(() => {
    throw new Error('Oops!');
    // 或者 Promise.reject(new Error('Oops!'));
});
  1. 拒绝(Rejected)Promise

如果一个 Promise 对象在执行过程中发生了错误,它就会从未处理的 Promise(Pending Promise)变为拒绝的 Promise(Rejected Promise),并将错误作为拒绝原因传递到 Promise 链中的 catch() 方法中:

// 无法被unhandledrejection捕获
Promise.resolve().then(() => {
    return Promise.reject(new Error('Oops!'));
    }).catch((error) => {
    console.log(error.message);
});

  1. 异常(Thrown)Promise

当 Promise 链中的任何操作发生错误时(例如使用不存在的对象属性),它将抛出一个异常(threw an exception),该异常会被 Promise 的执行器(executor)捕获,并将其作为拒绝的 Promise 返回:

// 无法被unhandledrejection捕获
Promise.resolve().then(() => {
    return someNonExistentObject.someMethod();
}).catch((error) => {
    console.log(error.message);
});

Promise 被拒绝但未被 catch 处理时,会触发 unhandledrejection 事件。 可以使用 window.addEventListener('unhandledrejection', handler) 来全局监听并捕获此类错误。但是一旦被catch掉就无法被捕获上报。为了解决这个可以使用babel处理,大致步骤和处理try-catch一样。

// 处理 promise-catch 块
CallExpression(path) {

    if (
        t.isMemberExpression(path.node.callee) &&
        t.isIdentifier(path.node.callee.property, {
            name: "catch"
        })
    ) {
        const fnPath = path.get("arguments")[0];

        const hasErrorParam = fnPath.node.params.length > 0;

        const errorIdentifier = path.scope.generateUidIdentifier("error");

        fnPath.get("body").pushContainer(
            "body",
            t.expressionStatement(
                t.callExpression(t.memberExpression(t.identifier("Promise"), t.identifier("reject")), [
                    hasErrorParam ? fnPath.node.params[0] : errorIdentifier,
                ])
            )
        );
        if (!hasErrorParam) {
            fnPath.node.params.push(errorIdentifier);
        }
    }
},

处理后的效果如下:

学新通技术网 已经触发错误上报

学新通技术网

监听promise错误上报

/**
* 初始化监听promise 错误
* @memberof ErrorVitals
*/
initPromiseError = () => {
    const handler = (e: PromiseRejectionEvent) => {
        e.preventDefault()
        let value = e.reason.message || e.reason;
        if (Object.prototype.toString.call(value) === '[Object Object]') {
            value = JSON.stringify(value)
        }
        if (/Minified React/.test(value)) {
            return;
        }
        const type = e.reason.name || 'UnKnowun';
        const errUid = getErrorUid(`${MechanismType.UJ}-${value}-${type}`)
        const errorInfo = {
            // 上报错误归类
            reportsType: MechanismType.UJ,
            // 错误信息
            value: value,
            // 错误类型
            errorType: type,
            // 解析后的错误堆栈
            stackTrace: parseStackFrames(e.reason),
            // 用户行为追踪 breadcrumbs 在 errorSendHandler 中统一封装
            // 错误的标识码
            errorUid: errUid,
            // 附带信息
            meta: {},
        } as ExceptionMetrics;
        this.errorSendHandler(errorInfo)
    }
    window.addEventListener('unhandledrejection', (e) => handler(e), true)
}

http 错误

http错误上报使用和http上报行为监听同样的逻辑处理,这里就不过多细讲。

/**
* 用于处理promise 错误中,无法获取是那个接口报错
* @memberof ErrorVitals
*/
initHttpError = () => {
    const loadHandler = (metrics: HttpMetrics) => {
        let res: any
        res = metrics.response

        if (metrics.status < 400 && res?.status >= 'success') return false
        const value = metrics.response
        const errUid = getErrorUid(`${MechanismType.HP}-${value}-${metrics.statusText}`)
        const errorInfo = {
            // 上报错误归类
            reportsType: MechanismType.HP,
            // 错误信息
            value,
            // 错误类型
            errorType: 'HttpError',
            // 用户行为追踪 breadcrumbs 在 errorSendHandler 中统一封装
            // 错误的标识码
            errorUid: errUid,
            // 附带信息
            meta: {
                ...metrics,
            },
        } as ExceptionMetrics
        this.errorSendHandler(errorInfo)
    }
    proxyXmlHttp(null, loadHandler)
    proxyFetch(null, loadHandler)
}

React组件错误

引用官网的一句话: A JavaScript error in a part of the UI shouldn’t break the whole app. To solve this problem for React users, React 16 introduces a new concept of an “error boundary”.(针对React用户,UI的某个部分出现JavaScript错误不应该破坏整个应用程序。为了解决这个问题,React 16引入了一个新的概念,即“错误边界”(error boundary))。

Error Boundaries是一种能够捕捉组件树中JavaScript错误的方式,并且不会导致整个应用程序崩溃的方式。这些错误一般是由于JavaScript代码中的异常或未处理的错误引起的。

在React版本16及更高版本中,我们可以使用Error Boundaries来处理以下类型的错误:

  1. 组件生命周期方法内的错误
  2. 渲染方法内的错误
  3. 子节点树内的错误
/**
* 用于处理React 组件 错误,并上报错误
* @template T
* @param {*} WrappedComponent
* @param {string} name 组件名称, 在webpack打包后无法获取组件name,传递组件名称、能够快速定位问题
* @return {*} 
*/
function ErrorBoundaryHoc<T extends object>(WrappedComponent: React.FC<T>, name: string) {
    return class extends Component<T, IState> {
        constructor(props: T) {
            super(props);
            this.state = { hasError: false };
        }

        static getDerivedStateFromError(error: any) {
            // 更新 state 使下一次渲染能够显示降级后的 UI
            return { hasError: true };
        }

        componentDidCatch(error: Error, errorInfo: ErrorInfo) {
            // 错误日志上报
            window.monitor?.initReactError(error, { ...errorInfo, componentName: name })
        }

        render() {
            if (this.state.hasError) {
                // 你可以自定义降级后的 UI 并渲染
                return <h1>功能出错了,已上报!</h1>;
            }

            return <WrappedComponent {...this.props as T} />;
        }
    }
}

export default ErrorBoundaryHoc

提供对外的用于React组件上报

/**
* react 组件错误上报
* @param {*} error
* @memberof ErrorVitals
*/
initReactError = (error: Error, errorInfo: ErrorInfo) => {
    const errUid = getErrorUid(`${MechanismType.REACT}-${error.name}-${error.message}`)
    const errInfo = {
        // 上报错误归类
        reportsType: MechanismType.REACT,
        // 错误信息
        value: error.message,
        // 错误类型
        errorType: error?.name || 'UnKnowun',
        // 解析后的错误堆栈
        stackTrace: parseStackFrames(error),
        // 用户行为追踪 breadcrumbs 在 errorSendHandler 中统一封装
        // 错误的标识码
        errorUid: errUid,
        // 其他信息
        meta: {
            // 错误所在的组件
            file: parseStackFrames({ stack: errorInfo.componentStack } as any),
            // 组件名称
            conponentName: errorInfo.componentName
        },
    } as ExceptionMetrics
    this.errorSendHandler(errInfo)
}

rrweb录屏

rrweb 是使用typescript 开发的web 操作录制以及回放框架,包含了比较完整的系统组件

  • rrweb-snapshot: 进行dom 与操作实践的关联处理
  • rrweb: 主要包含了record 以及replay
  • rrweb-player: rrweb 的UI 提供了方便的基于UI的操作,比如暂停,时间段选择

首先我们来看一张图,这是rrweb录制后回放效果。

学新通技术网

针对错误录制流程如下:

  1. 在错误触发后生成一个唯一的UUID, 并添加pushErrorUidList数组中。
  2. 通过emit事件获取当前录制的数据,并且配置checkoutEveryNms:为10s重新录制。
  3. 通过监听emit事件返回的isCheckout来判断重新制作了快照,如果为true,并且判断录制这个阶段是否存在错误上报。如果存在就上报录制,更新到相应的错误UUID中。
/**
*错误采集上报
* @export
* @class ErrorVitals
* @extends {CommonExtend}
*/
export default class ErrorVitals extends CommonExtend {
    private behaviorTracking: BehaviorStore
    private errorUids: Array<string>
    private eventsMatrix: Array<any> = []
    private errorUUidList: Array<string> = []
    constructor({ behaviorTracking, mode, ...data }: IProps & ErrorExtend) {
        super({ ...data, mode })
        this.behaviorTracking = behaviorTracking
        this.errorUids = []
        this.startRecord()
        this.initJsError()
        this.initResourceError()
        this.initPromiseError()
        this.initHttpError()
        this.initCorsError()
    }

    /**
     * 所有的错误信息上报
     * @param {ExceptionMetrics} error
     * @memberof ErrorVitals
     */
    errorSendHandler = ({ meta, stackTrace, ...error }: ExceptionMetrics) => {
        const list = this.behaviorTracking?.get()
        const errorInfo = {
            ...error,
            // 上报归类
            category: TransportCategory.ERROR,
            breadcrumbs: list?.slice(0, 50), // 获取行为操作最后50个,也可以外传
        }
        const hasStatus = this.errorUids.includes(errorInfo.errorUid)
        if (hasStatus) return false
        // 保存上报错误uid, 防止同一个用户重复上报
        this.errorUids.push(errorInfo.errorUid)
        // 生成唯一UUID, 用于录制
        const uuid = generateUUID()
        // 添加到
        this.pushErrorUidList(uuid)
        // 立即上报错误
        this.sendLog({ ...errorInfo, meta: meta, stackTrace: stackTrace, errorUUid: uuid })
    }

    /**
     * 开始录制
     * @memberof RecordUtil
     */
    startRecord = () => {
        const self = this
        record({
            emit(event, isCheckout) {
                // isCheckout 是一个标识,告诉你重新制作了快照
                if (isCheckout) {
                    if (!!self.errorUUidList.length) {
                        // 上传录制
                        self.sendLog({
                            category: TransportCategory.RV,
                            errorUUidList: self.errorUUidList,
                            events: JSON.stringify(self.eventsMatrix)
                        })
                    }
                    self.eventsMatrix = []
                    self.clearErrorUid()
                }
                self.eventsMatrix.push(event)
            },
            recordCanvas: true,
            checkoutEveryNms: 10 * 1000, // 每10秒重新制作快照 太长上报就有丢失
        })
    }

    /**
     * 新增错误UUID
     * @param {string} errorUid
     * @memberof RecordUtil
     */
    pushErrorUidList = (errorUid: string) => {
        this.errorUUidList.push(errorUid)
    }

    /**
     * 清除所有的UUID
     * @memberof RecordUtil
     */
    clearErrorUid = () => {
        this.errorUUidList = []
    }
}

rrweb无法在Web Worker 中运行,因为rrweb实现的原理是,将所有的 DOM 变更序列化并记录下来。在回放的时候,rrweb 会将序列化好的 DOM 变更逐条执行,还原出原始的展示效果,从而实现了重放用户操作的功能。 由于 Web Worker 只能操作与 DOM 和 BOM 无关的运行环境(例如 JavaScript 代码),因此不能直接访问和操作 DOM 和 BOM 对象

所有的错误上报都会进行去重, 防止同一个用户重复上报,每次上报错误都会产生一个UUID,为后续录制错误,更新到相应的错误信息中;错误上报是立即发送。

上报方式

首先我们来了解下上报的几种方式:信标(Beacon API)Ajax(XMLHttpRequest 和 fetch)Image(GIF、PNG);它们是现代前端中用于网络请求的三种常见方式。它们各自有不同的使用场景和优缺点。

  1. 信标(Beacon API)

Beacon API 是浏览器提供的一种网络请求机制,主要用于在页面卸载时进行异步传输统计或其他非关键性任务的通知。这种方式的主要优势在于可以始终保持异步,不会对浏览器的性能造成影响。同时,由于 Beacons 在页面卸载时能够保证发送成功,因此它非常适合用于网页统计等场景。但是该方式的缺点是,它只适用于发送少量且不需要返回值的数据,同时不支持跨域请求。

  1. Ajax(XMLHttpRequest 和 fetch)

Ajax 技术主要通过 Ajax 来实现异步请求。它的主要优势在于可以方便地发送各种类型的数据和请求,同时也支持跨域请求。在实际开发中,Ajax 技术广泛地应用于网页的交互及数据请求。然而,该方式的缺点在于无法保证每个请求都能被及时响应,而且可能会阻塞其他请求,影响浏览器的性能。

  1. Image(GIF、PNG)

使用 Image 来进行网络请求,在实际开发中往往用于上报日志、统计、广告等应用场景。它的主要优势在于能够确保所有的请求都能够被执行,而且请求发送过程不会阻塞其他操作。无需关心返回值,在一些特定场景下,Image 作为一种简单且有效的方法进行异步数据处理,很受欢迎。但是,该方式仅适用于发送少量的数据,而且能够通过 URL 的长度来限制数据量的大小。

看了上面的三种上报方式,最终采用 sendBeacon xmlHttpRequest 降级上报的方式,当浏览器不支持 sendBeacon 或者 传输的数据量超过了 sendBeacon 的限制,我们就降级采用 xmlHttpRequest 进行上报数据;

/**
* 发送日志
* @param {*} params
* @memberof SendLog
*/
sendLog = ({ category, ...data }: IMetrics) => {
    if (!this.isLoaded) {
        // 防止错误优先触发
        this.dynamicInfo()
    }
    let params = {
        ...data,
        category,
    }
    if (category !== TransportCategory.RV) {
        params = { ...this.pageInfo, ...params }
    }
    if (typeof navigator.sendBeacon === 'function') {
        const isSuccess = window.navigator?.sendBeacon(this.url, JSON.stringify(params))
        !isSuccess && this.xmlTransport(params)
    } else {
        this.xmlTransport(params)
    }
}

/**
* oXMLHttpRequest 上报
* @memberof SendLog
*/
xmlTransport = (params: IMetrics) => {
    const xhr = new (window as any).oXMLHttpRequest();
    xhr.open('POST', this.url, true);
    xhr.send(JSON.stringify(params));
};

目前上报方式,错误是立即上报,其他信息上报是等pageshow事件触发后再上报。

/**
 * 通用的方法
 * @param {(MetricsName | string)} key
 * @memberof SendLog
 */
handlerCommon = (key: MetricsName | string) => {
    if (!this.state.has(key)) {
        this.keys.push(key)
    }
    if (!this.isOver) {
        this.isOver = true
        this.handleRoutineReport()
    }
}

/**
 * 处理常规上报, 首次延迟100ms上报,防止抢占网络资源 ,此处可以优化使用requestidlecallback、requestanimationframe触发上报。  后续修改
 * @memberof SendLog
 */
handleRoutineReport = () => {
    const loop = () => {
        const key = this.keys[0]
        if (key) {
            const metrics = this.get(key)
            if (Array.isArray(metrics)) {
                const l = (i = 0) => {
                    const value = metrics[i];
                    if (value) {
                        this.sendLog(value)
                        setTimeout(() => {
                            l(  i)
                        }, 60)
                    } else {
                        loop()
                    }
                }
                l()
            } else {
                metrics && this.sendLog(metrics)
            }
            setTimeout(() => {
                loop()
            }, 60)
        } else {
            this.isOver = false
        }
    }
    loop()
}

封装后,提供对外配置参数。

import 'intersection-observer'
import 'mutationobserver-polyfill'
import '@fastly/performance-observer-polyfill/polyfill'
import { ErrorInfo, FN1, FN2 } from "./interfaces";
import UserVitals from "./userVitals";
import WebVitals from "./webVitals";
import ErrorVitals from './errorVitals'
import SendLog from "./send";

interface MonitorProps {
    url: string // 上报Url
    isExposure?: boolean // 是否支持曝光埋点
    appKey: string //上报map 存储位置
    mode: string // 环境
    maxBehaviorRecords?: number // 最大行为跟踪数 // 默认100
}

class Monitor {
    private webVitals: WebVitals
    private userVitals: UserVitals
    private errorVitals: ErrorVitals
    private send: SendLog
    public customHandler: FN1
    public initReactError: FN2<Error, ErrorInfo>
    // 对参数进行校验
    constructor({ url, isExposure, appKey, mode, maxBehaviorRecords }: MonitorProps) {
        if (!url) {
            throw Error('上报url为空')
        }
        if (!appKey) {
            throw Error('上报map 存储位置为空')
        }
        this.send = new SendLog(url, mode, appKey)
        this.webVitals = new WebVitals({ ...this.send })
        this.userVitals = new UserVitals({ ...this.send, maxBehaviorRecords: maxBehaviorRecords })
        this.errorVitals = new ErrorVitals({
            behaviorTracking: this.userVitals.behaviorTracking,
            ...this.send
        })
        this.customHandler = this.userVitals.initCustomerHandler()
        this.initReactError = this.errorVitals.initReactError
        this.send.initRouterChange(() => {
            this.userVitals.initPV()
            isExposure && this.userVitals.initExposure();
        })
    }
}
export default Monitor

前端-日志上报管理后台

目前前端日志上报管理后台使用Emp2 React Mobx架构。这里不做说明了。构建流程请看这个篇文章Nest Emp2 构建BFF层

封装了 axios 库

封装了 axios 库的 http 请求工具函数。它定义了四个函数 getpostdeleteIdput 来发送 GET、POST、DELETE 、 PUT 请求。这些函数都是通过调用 httpCommon 函数实现的,该函数接受一个 method 参数和一个包含请求信息的对象作为参数,并返回一个 Promise 对象。

import axios, { AxiosRequestConfig as _AxiosRequestConfig, Method } from 'axios'
import { ResponseData } from '@src/interfaces/response.iterface'
import config from '@src/config'
import { pickBy } from 'lodash'

export interface AxiosRequestConfig extends _AxiosRequestConfig {
    startTime?: Date
}

export type HttpParams = Record<string, any>

export type GetParmas = Omit<HttpParams, 'data' | 'otherConfig' | 'apiUrl'> & {
    transferData: any
}

enum HTTPERROR {
    LOGICERROR,
    TIMEOUTERROR,
    NETWORKERROR
}

interface HttpReq {
    data?: HttpParams
    otherConfig?: AxiosRequestConfig
    apiUrl: string
}

// 判断请求是否成功
const isSuccess = (res: any) => (Object.is(res.status, 'success'))
// 格式化返回结果
const resFormat = (res: any) => res

function httpCommon<T>(method: Method, { data, otherConfig, apiUrl }: HttpReq): Promise<ResponseData<T> | any> {
    let axiosConfig: AxiosRequestConfig = {
        method,
        url: apiUrl,
        baseURL: config.apiHost,
    }
    const instance = axios.create()

    // 请求拦截
    instance.interceptors.request.use(
        cfg => {
            cfg.params = { ...cfg.params, ts: Date.now() / 1000 }
            return cfg
        },
        error => Promise.reject(error)
    )

    // 响应拦截
    instance.interceptors.response.use(
        response => {
            const rdata = response.data
            if (!isSuccess(rdata)) {
                return Promise.reject(rdata)
            }
            return resFormat(rdata)
        },
        error => {
            return Promise.reject({
                message: error.response.data.error || error.response.statusText || error.message || 'network error',
                result: /^timeout of/.test(error.message) ? HTTPERROR[HTTPERROR.TIMEOUTERROR] : HTTPERROR[HTTPERROR.NETWORKERROR],
                status: 'error'
            })
        }
    )
    data = pickBy({ ...data })
    if (method === 'get') {
        axiosConfig.params = data
    } else {
        axiosConfig.data = data
    }

    axiosConfig.startTime = new Date()
    if (otherConfig) {
        axiosConfig = Object.assign(axiosConfig, otherConfig)
    }
    return instance
        .request(axiosConfig)
        .then(res => res)
        .catch(err => {
            return err
        })
}

function get<T>(apiUrl: string, data: HttpParams, otherConfig: AxiosRequestConfig = {}) {
    return httpCommon<T>('get', { data, apiUrl, otherConfig })
}

function post<T>(apiUrl: string, data: HttpParams, otherConfig: AxiosRequestConfig = {}) {
    return httpCommon<T>('post', { data, apiUrl, otherConfig })
}

function deleteId<T>(apiUrl: string, data: HttpParams, otherConfig: AxiosRequestConfig = {}) {
    return httpCommon<T>('delete', { data, apiUrl, otherConfig })
}

function put<T>(apiUrl: string, data: HttpParams, otherConfig: AxiosRequestConfig = {}) {
    return httpCommon<T>('put', { data, apiUrl, otherConfig })
}

export default {
    put,
    get,
    post,
    deleteId,
}

httpCommon 函数会根据传入的 method 值创建一个 AxiosRequestConfig 对象,并添加一些请求拦截器和响应拦截器,用于处理请求和响应。 AxiosRequestConfig 接口继承自 axios 库中的 AxiosRequestConfig 接口,并添加了一个startTime 属性,用于记录请求的开始时间。HTTPERROR 枚举定义了三种可能的错误类型,分别是逻辑错误、超时错误和网络错误。isSuccess 函数用于判断返回结果是否成功,resFormat 函数用于格式化返回结果。HttpReq 接口定义了一个请求对象,其中包含请求数据、其他配置和 API 地址。HttpParamsGetParams 类型分别定义了一个通用的 HTTP 参数类型和一个 GET 请求的参数类型。

前端页面相关功能就不做解释。

后续优化

  1. 新增站点配置,目前站点配置功能不多,后续支持各种告警配置。如果404、500告警;告警次数;资源加载告警等等。
  2. 完成前端页面相关功能开发。如果有好的UI或者原型可以提供下,目前前端功能交互和展示比较耗时,要一边做一边想怎么实现好的UI效果。
  3. 完善SDK上报。

文笔有限、才疏学浅,文中如有不正之处,万望告知。

参考链接

一文摸清前端监控自研实践

以往相关文章链接

Nest Emp2 构建BFF层

Nest Emp2 实现BFF能做什么?

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tangjacif
系列文章
更多 icon
同类精品
更多 icon
继续加载