Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

实现一套前端应用监控系统 #30

Open
yinxin630 opened this issue Nov 14, 2019 · 0 comments
Open

实现一套前端应用监控系统 #30

yinxin630 opened this issue Nov 14, 2019 · 0 comments
Labels

Comments

@yinxin630
Copy link
Owner

yinxin630 commented Nov 14, 2019

前端应用监控系统包含四部分

  1. 数据收集 SDK
  2. 数据上报网关
  3. 数据存储数据库
  4. 数据展示平台

数据收集 SDK

收集性能数据

我们通过浏览器的 performance API 获取性能数据, 其中要用到以下数据

/** 性能数据字段 */
const fields = [
    'navigationStart', // 0
    'unloadEventStart',
    'unloadEventEnd',
    'redirectStart',
    'redirectEnd',
    'fetchStart', // 5
    'domainLookupStart',
    'domainLookupEnd',
    'connectStart',
    'secureConnectionStart',
    'connectEnd', // 10
    'requestStart',
    'responseStart',
    'responseEnd',
    'domLoading',
    'domInteractive', // 15
    'domContentLoadedEventStart',
    'domContentLoadedEventEnd',
    'domComplete',
    'loadEventStart',
    'loadEventEnd', // 20
];

上面的数据是某事件触发的时刻, 下面我们定义要数据的性能指标, 这些指标都是由上述数据计算得出

/**
 * 性能数据指标
 * key: 指标
 * value: [字段1, 字段2]
 * result: key = fields[字段1] - fields[字段2]
 */
const targets: { [key: string]: [number, number] } = {
    firstbyte: [5, 12], // 首字节
    domready: [5, 17], // DOM Ready
    load: [5, 19], // load 触发

    dns: [6, 7], // DNS 查询
    tcp: [8, 10], // TCP 连接
    ssl: [9, 10], // HTTPS 连接

    ttfb: [11, 12], // TTFB
    contentdownload: [12, 13], // HTML 下载
    domparsing: [13, 15], // DOM 解析

    total: [5, 20], // 总耗时

    unload: [1, 2], // 上个页面 unload
    redirect: [3, 4], // 重定向时间
    appcache: [5, 6], // 缓存查询
};

接下来开始计算每个指标的值, 其中白屏时间是单独写的计算方法(实际运行中发现很多时候收集到的数据是0), 还做了一些数据修正工作(抛弃负数、小数点保留三位)

/**
 * 获取性能信息
 */
getPerformance() {
    /** timing api v1 */
    const timing1 = window.performance.timing;
    /** timing api v2 */
    let timing2: PerformanceEntry = {} as PerformanceEntry;

    // 优先使用 navigation v2  https://www.w3.org/TR/navigation-timing-2/
    if (SupportNavigationV2) {
        try {
            var nt2Timing = performance.getEntriesByType('navigation')[0];
            if (nt2Timing) {
                timing2 = nt2Timing;
            }
        } catch (err) {}
    }

    /** 合并 v1, v2 的数据  */
    const timing: { [key: string]: number | string; } = {};
    fields.forEach((field: string) => {
        // @ts-ignore
        timing[field] = timing2[field] || timing1[field];
    });

    /** 计算每个性能指标 */
    const times: { [key: string]: number | string; } = {};
    Object.keys(targets).forEach((key) => {
        const [fieldIndex1, fieldIndex2] = targets[key];
        times[key] = <number>timing[fields[fieldIndex2]] - <number>timing[fields[fieldIndex1]];
        // 修正负数指标
        if (times[key] < 0) {
            times[key] = 0;
        }
    });

    // 计算白屏时间
    if (SupportNavigationV2) {
        const paintTimimg = performance.getEntriesByType('paint');
        if (paintTimimg && paintTimimg.length > 0) {
            times.blank = paintTimimg[1] ? paintTimimg[1].startTime : paintTimimg[0].startTime;
        }
    } else if (window.chrome && window.chrome.loadTimes) {
        times.blank =
            window.chrome.loadTimes().firstPaintTime * 1000 -
            window.performance.timing.fetchStart;
    }
    // 修正负数时间
    if (!times.blank || times.blank < 0) {
        times.blank = 0;
    }

    // 保留三位小数
    Object.keys(times).forEach((key) => {
        if (typeof times[key] === 'number') {
            times[key] = ((times[key] as number).toFixed(3) as unknown) as number;
        }
    });

    return times;
}

收集访问数据

网络类型经常会收集不到. id / userId / assertVer 由使用 SDK 的人在初始化 SDK 时传入, 还有用户可能刚进页面时没有登录态, 后面才登录的, 所以 userId 需要支持传入 function 来在上报信息时获取 userId (代码中未体现)

/**
 * 获取公共信息
 */
getCommon() {
    const { innerWidth: width, innerHeight: height, location, document, navigator } = window;
    const { href: url } = location;
    const { title, referrer } = document;
    let net = '';
    // @ts-ignore
    if (navigator.connection && navigator.connection.effectiveType) {
        // @ts-ignore
        net = navigator.connection.effectiveType;
    }

    // url, ua, ip 解析在服务端做
    return {
        id: this.options.id, // 应用 id
        timestamp: Date.now(), // 上报时间
        url, // 页面 url
        title, // 页面标题
        referrer, // 页面 referrer
        ua: navigator.userAgent, // 用户 UA
        net, // 网络类型
        width, // 屏幕宽度
        height, // 屏幕高度
        sdkVer: version, // SDK 版本
        userId: '', // 用户 id
        assetsVer: '', // 页面版本(js 资源版本)
    };
}

上报数据

上报数据有两种方式, sendBeaconimg.src

sendBeacon 是 POST 请求, 数据通过 body 传输, 了解sendBeacon

img.src 是 GET 请求, 数据通过 url params 传输, string 数据需要编码

/**
 * 上报数据
 * @param measurement influxDB Measurement
 * @param data 上报数据
 */
log(measurement: Measurement, data: any) {
    // rollup 注入的值
    // @ts-ignore
    const host = TRACKER_HOST;
    try {
        // throw Error('use img');
        // 使用 sendBeacon 上报, 参考: https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon
        data.sendMode = 'sendBeacon';
        window.navigator.sendBeacon(`${host}/${measurement}`, JSON.stringify(data));
    } catch {
        // 降级为 img.src 上报
        data.sendMode = 'img';
        const params = Object.keys(data)
            .map((field) => `${field}=${encodeURIComponent(data[field])}`)
            .join('&');
        const img = document.createElement('img');
        img.src = `${host}/${measurement}?${params}`;
    }
}

代码中考虑了功能拓展, 第一个参数是上报数据类型(对应数据库表)

数据上报网关

网关要做的事情是, 解析数据并存储到数据库中

async function parseCommonInfo(params: Object) {
    const result: { [key: string]: any } = Object.assign({}, params);

    const { protocol, host, pathname, query, hash } = url.parse(result.url);
    Object.assign(result, {
        protocol,
        page: host + pathname,
        query,
        hash,
    });

    const parsedIpv4 = result.ip.match(/\d+.\d+.\d+.\d+/);
    result.ipv4 = parsedIpv4 ? parsedIpv4[0] : '';

    // @ts-ignore
    const uaParseResult = uaParser(params.ua);
    const { browser, os, device } = uaParseResult;
    const mfwAppRegex = /mfwappver\/(\d+)\.\d+\.\d+/;
    // @ts-ignore
    const appParseResult = mfwAppRegex.exec(params.ua);
    if (appParseResult) {
        browser.name = 'Mfw APP';
        browser.major = appParseResult[1];
    }

    const osMajorRegex = /^\d+/;
    const osParseResult = osMajorRegex.exec(os.version);
    if (osParseResult) {
        os.version = osParseResult[0];
    }

    Object.assign(result, {
        browser: browser.name + ' ' + browser.major,
        os: os.name + ' ' + os.version,
        device: device.vendor && device.model ? device.vendor + ' ' + device.model : '',
    });

    let ipInfo = {
        country: '',
        region: '',
        county: '',
        city: '',
        isp: '',
    };
    if (result.ipv4) {
        // 淘宝的服务是有限流的
        const url = `https://ip.taobao.com/service/getIpInfo.php?ip=${result.ipv4}`;
        try {
            const { status, data } = await axios.get(url);
            if (status === 200 && data.code === 0 && data.data.isp !== '内网IP') {
                function getIpInfo(field: string) {
                    return data.data[field] === 'XX' ? '' : data.data[field];
                }
                ipInfo = {
                    country: getIpInfo('country'),
                    region: getIpInfo('region'),
                    county: getIpInfo('county'),
                    city: getIpInfo('city'),
                    isp: getIpInfo('isp'),
                };
            }
        } catch (err){
            console.error('获取 ip 信息失败', url, err.message);
        }
    }
    Object.assign(result, ipInfo);

    return result;
}

router.get('/performance', async (ctx) => {
    const { query } = url.parse(ctx.request.url);
    const params = querystring.parse(query);
    params.ip = ctx.request.ip;
    const performance = await parseCommonInfo(params);
    await logPerformance(performance);
    ctx.status = 204;
});

router.post('/performance', async (ctx) => {
    const params = ctx.request.body;
    params.ip = ctx.request.ip;
    const performance = await parseCommonInfo(params);
    await logPerformance(performance);
    ctx.status = 204;
});

数据存储数据库

因为存的是日志数据, 适合使用时序数据库, 经过简单对比(star数量, 相关文章, 口碑)后选择了 influxDB

中文文档
node API

使用起来没什么难度, 和其它 SQL 数据库差不多

数据展示平台

有了数据之后, 需要将其展示出来, 我没有自己去实现页面, 直接选择了使用 Grafana

Grafana 是一款开源的可视化仪表盘, 支持通过配置或者写 SQL 的方式直接生成展示图表, 并且配置报警项

文档
中文文档
配置文档
内置图表使用文档

启动 Grafana 后, 打开 web 端登录管理员账号, 先配置 influxDB 数据源, 然后创建图表配置数据查询就 ok 了

结语

文章写得比较简单, 没有具体说一些细节, 如果大家很感兴趣的话, 可以直接看源码 https://github.com/yinxin630/fe-tracker, 还可以按照 README 中的步骤自己跑一下看看

@yinxin630 yinxin630 changed the title 搭建自己的前端监控系统 实现一套前端应用监控系统 Dec 30, 2019
@yinxin630 yinxin630 removed the todo label Dec 30, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant