├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── index.html ├── local-cache.html └── saveCache.js ├── karma.conf.js ├── package.json ├── rollup.config.js ├── src ├── head │ ├── base.ts │ ├── error.ts │ ├── observer.ts │ ├── readme.md │ └── whitescreen.ts ├── lib │ ├── data.ts │ ├── huffman.ts │ ├── interface.ts │ ├── spyHeadInterface.ts │ └── util.ts ├── module │ ├── fid.ts │ ├── layoutShift.ts │ ├── lcp.ts │ ├── longtask.ts │ ├── memory.ts │ ├── navigatorInfo.ts │ ├── resource.ts │ ├── timing.ts │ └── tti.ts ├── spy-client-basic.ts ├── spy-client.ts ├── spy-head.ts ├── spy-local-cache.ts └── types │ └── globals.d.ts ├── test └── spec │ ├── basicSpec.ts │ ├── checkSpec.ts │ ├── headSpec.ts │ ├── markSpec.ts │ ├── metricSpec.ts │ └── types │ └── globals.d.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | output/ 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file eslintrc 3 | * @author kaivean 4 | */ 5 | 6 | // reference to https://github.com/ecomfe/eslint-config 7 | module.exports = { 8 | extends: [ 9 | '@ecomfe/eslint-config', 10 | // 注意这些规则会要求使用 ES6 的 import 来引入依赖, 11 | // 如果使用的是 require 则会出现检查错误,可禁用 import/no-commonjs 和 import/unambiguous 来解决。 12 | '@ecomfe/eslint-config/import', 13 | '@ecomfe/eslint-config/typescript' 14 | ], 15 | env: { 16 | 'jasmine': true, 17 | 'es6': true, 18 | 'browser': true, 19 | // 'node': true 20 | }, 21 | rules: { 22 | 'no-unreachable-loop': 'off', 23 | 'no-console': ['error', {allow: ['warn', 'error']}], 24 | 'import/no-commonjs': 'off', 25 | 'import/unambiguous': 'off', 26 | 'import/extensions': 'off', 27 | 'import/no-unresolved': 'off', 28 | // for of 编译出来要多不少代码 29 | '@typescript-eslint/prefer-for-of': 'off', 30 | // 还是得写空函数得 31 | '@typescript-eslint/no-empty-function': 'off', 32 | // 数组includes方法,在浏览器需要polyfill,少用 33 | '@typescript-eslint/prefer-includes': 'off', 34 | // 字符串ends-with ,在浏览器需要polyfill,少用 35 | '@typescript-eslint/prefer-string-starts-ends-with': 'off', 36 | '@typescript-eslint/prefer-regexp-exec': 'off', 37 | '@typescript-eslint/restrict-plus-operands': 'off', 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Referenced from https://github.com/github/gitignore/blob/master/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | # other stuff 66 | .DS_Store 67 | Thumbs.db 68 | 69 | .editorconfig 70 | .npmrc 71 | 72 | # IDE configurations 73 | .idea 74 | .vscode 75 | 76 | # build assets 77 | /output 78 | /dist 79 | /dll 80 | 81 | build-dep 82 | .cache-loader 83 | 84 | package-lock.json 85 | 86 | spy-client.d.ts 87 | spy-client.mjs -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.settings 3 | /.project 4 | /.gitignore 5 | /node_modules 6 | /test 7 | /.tmp 8 | /.vscode 9 | 10 | .DS_Store 11 | *.db 12 | *.bak 13 | *.tmp 14 | *.cmd 15 | ~* 16 | package-lock.json 17 | tsconfig.json 18 | tsconfig.src.json 19 | tslint.json 20 | 21 | webpack.*.config.ts 22 | karma.conf.ts 23 | 24 | rollup.config.js 25 | .eslint* 26 | coverage 27 | src -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '12' 5 | 6 | env: 7 | - TRAVIS=true 8 | 9 | addons: 10 | chrome: stable 11 | 12 | before_script: 13 | - "npm install" 14 | - "npm run build" 15 | 16 | script: 17 | - "npm run test" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The ISC License 2 | 3 | Copyright (c) kaivean 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 15 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spy-client [![Build Status](https://travis-ci.com/kaivean/spy-client.svg?branch=master)](https://travis-ci.com/kaivean/spy-client) 2 | 3 | ## 介绍 4 | 日志采集模块,提供一系列方便的api供使用 5 | 6 | 1. 新版2.x部分API不再兼容1.x 7 | 2. 从2.1.0版本开始,不再兼容IE8及以下IE浏览器 8 | 3. 从2.1.8版本开始,兼容小程序环境(new Image类发送);通过继承类,覆盖request方法,可以支持Node.js/跨端框架/小程序环境 9 | 10 | 11 | ## 安装 12 | 13 | ``` 14 | npm install spy-client --save 15 | ``` 16 | 17 | CDN方式 18 | 19 | 不是一次性3个JS都引入,具体往下看 20 | 21 | ```html 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ``` 32 | 33 | > 如果对于一些指标想理解更准确,看源码是最佳方式 [SDK源码](https://github.com/kaivean/spy-client) 34 | 35 | 36 | > SDK的指标采集请酌情选用,不要一股脑全用上,如果只用了一项采集功能,但SDK体积太大,可以考虑自行编译,看文档最后 37 | 38 | 39 | ## 快速使用 40 | 41 | 初始化 42 | ```javascript 43 | const SpyClient = require('spy-client'); 44 | const spy = new SpyClient({ 45 | pid: '1_1000', // 必须 46 | lid: '', // 可选,页面的logid 47 | sample: 1 // 可选,默认为1, 全局抽样,取值:[0-1], 所有发送接口都受到该抽样,单个发送接口的sample配置会覆盖该抽样。 48 | }); 49 | ``` 50 | 51 | 发送性能日志 52 | ```javascript 53 | // 发送性能日志 54 | spy.sendPerf({ 55 | // 可选, 分组,默认common,用户自定义 56 | group: 'test', 57 | // 必须, 指标信息,每个字段为一个指标,由用户自定义,这里的fisrtScreen、whiteScreen等都是业务自己定义,后续会在平台上配置好,平台会从该字段取对应指标信息。 58 | // 这些指标需要你自行计算好时间再发送,不能带单位 59 | info: { 60 | tcp: 1200, 61 | domReady: 600 62 | }, 63 | // 可选,维度信息,每个字段为一个维度,由用户自定义,这里的netType、pageType都是业务自己定义,后续会在平台上配置好,平台会从该字段取对应维度信息。 64 | dim: { 65 | os: 'ios', 66 | netType: 'wifi' 67 | } 68 | }); 69 | ``` 70 | 71 | ## SDK说明 72 | SDK分两种 73 | 74 | * 基础版SDK:提供最基础和最简单的功能,如果这些功能能满足你,那么直接使用该SDK即可,因为体积较小 75 | * 增强版SDK:除了基础版SDK功能外,集合了丰富的常用的性能和异常指标统计 76 | 77 | 接下来分别介绍 78 | 79 | ## 基础版SDK 80 | 提供最基础和最简单的功能,如果这些功能能满足你,那么直接使用该SDK即可 81 | 82 | ```javascript 83 | // basic spy-client 基本用法,最简单功能 84 | const SpyClient = require('spy-client/dist/spy-client-basic'); 85 | const spy = new SpyClient({ 86 | pid: '1_1000', // 必须 87 | lid: '', // 可选,页面的logid 88 | sample: 1 // 可选,默认为1, 全局抽样,取值:[0-1], 所有发送接口都受到该抽样,单个发送接口的sample配置会覆盖该抽样。 89 | }); 90 | ``` 91 | 92 | 以下先简单列举所有可用API示例 93 | ```javascript 94 | 95 | // 发生性能日志,本质是数值型的metric数据 96 | spy.sendPerf({ 97 | // 可选, 分组,默认common,用户自定义 98 | group: 'test', 99 | // 必须, 指标信息,每个字段为一个指标,由用户自定义,这里的fisrtScreen、whiteScreen等都是业务自己定义,后续会在平台上配置好,平台会从该字段取对应指标信息。 100 | // 这些指标需要你自行计算好时间再发送,不能带单位 101 | info: { 102 | tcp: 1200, 103 | domReady: 600 104 | }, 105 | // 可选,维度信息,每个字段为一个维度,由用户自定义,这里的netType、pageType都是业务自己定义,后续会在平台上配置好,平台会从该字段取对应维度信息。 106 | dim: { 107 | os: 'ios', 108 | netType: 'wifi' 109 | } 110 | }); 111 | 112 | 113 | // 发送异常日志 114 | spy.sendExcept({ 115 | // 必须, 异常信息,msg字段是必须的,是异常唯一标识。其他字段作为补充信息,由用户自定义 116 | info: { 117 | msg: 'abc is not undefined', // msg字段是必须的,必须的,必须的,会统计相同msg的总量 118 | stack: 'xxxxx', 119 | file: 'xxxxxxx' 120 | }, 121 | // 可选, 分组,默认common,用户自定义 122 | group: 'test', 123 | // 可选,维度信息,每个字段为一个维度,由用户自定义 124 | dim: { 125 | os: 'ios' 126 | } 127 | }); 128 | 129 | // 发送分布日志 130 | spy.sendDist({ 131 | info: { 132 | from: 'hao123' 133 | }, 134 | dim: { 135 | os: 'ios' 136 | } 137 | }); 138 | 139 | // 发送计数日志 140 | spy.sendCount({ 141 | info: { 142 | from: 'hao123' 143 | }, 144 | dim: { 145 | os: 'ios' 146 | } 147 | }); 148 | 149 | // 如果能拿到error实例,通过该方法快速上报异常,默认会获取stack等信息 150 | spy.sendExceptForError(new Error('error'), { 151 | dim: { 152 | os: 'ios' 153 | } 154 | }); 155 | 156 | // 最基础的API,需要自行指定type字段 157 | spy.send({ 158 | type: 'perf' 159 | info: { 160 | domReady: 1000 161 | }, 162 | dim: {} 163 | }); 164 | 165 | 166 | // 统计辅助方法 167 | spy.startMark('playTime'); 168 | let time = spy.endMark('playTime'); 169 | console.log(time); // output: 1000 170 | 171 | spy.startMark('pauseTime'); 172 | spy.endMark('pauseTime'); // 假设中间执行花费1s 173 | console.log(spy.getAllMark()); 174 | // output 175 | // { 176 | // playTime: 1000, 177 | // pauseTime: 1000 178 | // } 179 | 180 | spy.clearMark('pauseTime'); // 清除pauseTime 181 | spy.clearAllMark(); // 清除所有mark的信息 182 | 183 | ``` 184 | 185 | 基础版可支持小程序/Node.js/跨端框架环境 186 | 187 | 1 . 小程序 188 | 不用做任何修改,就支持采用new Image发送日志。 189 | 190 | 2 . Node.js/跨端框架环境 191 | 192 | Node.js,跨端框架,以及小程序环境中若采用`spy.send(xxx, true)`方式,则需要继承SpyClient类,覆盖request方法. 193 | 如果是Node.js,需要服务器有外网权限 194 | 195 | ```javascript 196 | const SpyClient = require('spy-client/dist/spy-client-basic'); 197 | // 若环境编译不支持umd,则可以导入es module 198 | // const SpyClient = require('spy-client/dist/spy-client-basic.esm'); 199 | 200 | class SpyClientNode from SpyClient { 201 | request(url: string, data?: any) { 202 | axios({ 203 | method: data ? 'post' : 'get', 204 | url, 205 | data: data ? JSON.stringify(data) : data, 206 | }); 207 | } 208 | } 209 | 210 | const spy = new SpyClientNode({ 211 | pid: '1_1000', // 必须 212 | lid: '', // 可选,页面的logid 213 | sample: 1 // 可选,默认为1, 全局抽样,取值:[0-1], 所有发送接口都受到该抽样,单个发送接口的sample配置会覆盖该抽样。 214 | }); 215 | spy.sendPerf({ 216 | info: { 217 | responseTime: 200 218 | } 219 | }); 220 | ``` 221 | 222 | ## 增强版SDK 223 | 224 | 增强版SDK分成了2部分 225 | 226 | 1. spy-head:有些功能我们希望越早生效越好,比如全局JS报错监控。因此把这些功能最小集抽成一个单独JS,以便可以插入head标签内,也不会全量引入整个SDK在头部。当然,放到任何地方都是可以,开发者自行决策即可。此部分包含的功能有 227 | * 异常:全局JS报错监控、资源加载失败监控、白屏异常监控 228 | * 性能:Longtask等信息采集,真正的统计是在spy-client里,只是越早采集,能获取更多的longtask 229 | 230 | 2. spy-client:此部分提供了丰富的性能和异常的指标统计,其中部分功能依赖于spy-head,包含的功能有 231 | * 性能指标采集:包含体积、卡顿、速度等60+个性能指标采集方法 232 | * 异常:包含大于150K的大图片采集、HTTPS环境下HTTP资源采集 233 | * 辅助方式: mark系列辅助方法 234 | 235 | > 增强版SDK仅支持浏览器环境 236 | 237 | ### spy-head使用 238 | 239 | spy-head JS可以视情况通过script内联或嵌入其他JS里 240 | 241 | > 如果要启用一项异常监控功能,需要设置其抽样sample不为0 242 | 243 | ```html 244 | 9 | 43 | 44 | 45 | 46 |
47 |
open devtool
48 | 49 |
50 | 51 | 52 |

 53 |         

54 | TypeScriptJavaScriptJavaScript 的超集用于解决大型项目的代码复杂性一种脚本语言,用于创建动态网页。可以在编译期间发现并纠正错误作为一种解释型语言,只能在运行时发现错误强类型,支持静态和动态类型弱类型,没有静态类型选项最终被编译成 JavaScript 代码,使浏览器可以理解可以直接在浏览器中使用支持模块、泛型和接口不支持模块,泛型或接口支持 ES3,ES4,ES5 和 ES6 等不支持编译其他 ES3,ES4,ES5 或 ES6 功能社区的支持仍在增长,而且还不是很大大量的社区支持以及大量文档和解决问题的支持 55 |

56 | 57 | 58 | 59 |
60 | 61 | 62 | 63 | 338 | 339 | -------------------------------------------------------------------------------- /example/local-cache.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SPY Client Test 5 | 6 | 7 | 8 | 42 | 43 | 44 | 45 |
46 |
open
47 | 48 |

49 | TypeScriptJavaScriptJavaScript 的超集用于解决大型项目的代码复杂性一种脚本语言,用于创建动态网页。可以在编译期间发现并纠正错误作为一种解释型语言,只能在运行时发现错误强类型,支持静态和动态类型弱类型,没有静态类型选项最终被编译成 JavaScript 代码,使浏览器可以理解可以直接在浏览器中使用支持模块、泛型和接口不支持模块,泛型或接口支持 ES3,ES4,ES5 和 ES6 等不支持编译其他 ES3,ES4,ES5 或 ES6 功能社区的支持仍在增长,而且还不是很大大量的社区支持以及大量文档和解决问题的支持 50 |

51 | 52 |
53 | 54 | 55 | 56 | 57 | 158 | 159 | -------------------------------------------------------------------------------- /example/saveCache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file saveCache.js 3 | * @author chaiyanchen(chaiyanchen@baidu.com) 4 | * 起一个简单的本地服务,可用于接收spy-local-cache中缓存的数据 5 | */ 6 | 7 | const fs = require('fs'); 8 | 9 | const fastify = require('fastify')({ 10 | logger: true, 11 | }); 12 | 13 | fastify.register(require('fastify-cors'), {}); 14 | 15 | // 声明路由 16 | fastify.post('/saveData', function (request, reply) { 17 | try { 18 | const jsonData = JSON.stringify(request.body); 19 | const filePath = `jsonData_${new Date().getTime()}.json`; 20 | fs.writeFile(filePath, jsonData, 'utf8', err => { 21 | if (err) { 22 | console.error(err); 23 | return; 24 | } 25 | console.log('写入成功!'); 26 | }); 27 | } 28 | catch (error) { 29 | console.log('写入失败:', error); 30 | } 31 | 32 | reply.send({hello: 'world'}); 33 | }); 34 | 35 | fastify.listen(3000, function (err, address) { 36 | if (err) { 37 | fastify.log.error(err); 38 | process.exit(1); 39 | } 40 | fastify.log.info(`server listening on ${address}`); 41 | }); 42 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file karma测试配置,放弃了karma-typescript,不支持相对导入,采用了webpack 3 | * @author kaivean 4 | */ 5 | 6 | 7 | // const webpack = require('webpack'); 8 | 9 | module.exports = config => { 10 | config.set({ 11 | 12 | // base path that will be used to resolve all patterns (eg. files, exclude) 13 | basePath: './', 14 | 15 | // frameworks to use 16 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 17 | frameworks: ['jasmine', 'karma-typescript'], 18 | 19 | // plugins: ['karma-webpack'], 20 | client: { 21 | jasmine: { 22 | random: false, 23 | }, 24 | }, 25 | 26 | // list of files / patterns to load in the browser 27 | files: [ 28 | 'dist/spy-head.js', // 先加载 29 | // 'dist/spy-client.js', // 先加载 30 | // 'src/index.ts', 31 | // 'test/**/basicSpec.ts', 32 | // 'test/**/headSpec.ts', 33 | // 'test/**/metricSpec.ts', 34 | // 'test/**/*Spec.ts', 35 | { 36 | pattern: 'test/**/*Spec.ts', 37 | watched: false, // 监听变化 38 | included: true, // script加载到页面里 39 | served: true, // be served by Karma's webserver 40 | nocache: false, // 为了保证测试到一些case,比如lcp等 41 | }, 42 | ], 43 | 44 | // list of files / patterns to exclude 45 | exclude: [ 46 | 47 | ], 48 | 49 | // preprocess matching files before serving them to the browser 50 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 51 | preprocessors: { 52 | 'test/**/*.ts': ['karma-typescript'], 53 | }, 54 | 55 | karmaTypescriptConfig: { 56 | exclude: ['node_modules'], 57 | include: ['test/**/*.ts'], 58 | compilerOptions: { 59 | baseUrl: '.', 60 | paths: { 61 | 'spy-client': ['dist/spy-client.js'], 62 | }, 63 | }, 64 | transformPath(filepath) { 65 | return filepath.replace(/\.(ts|tsx)$/, '.js'); 66 | }, 67 | // tsconfig: './tsconfig.json', 68 | }, 69 | 70 | reporters: ['progress', 'kjhtml', 'karma-typescript'], 71 | 72 | // web server port 73 | port: 9876, 74 | 75 | // enable / disable colors in the output (reporters and logs) 76 | colors: true, 77 | 78 | // level of logging 79 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 80 | logLevel: config.LOG_INFO, 81 | 82 | // enable / disable watching file and executing tests whenever any file changes 83 | autoWatch: true, 84 | 85 | // start these browsers 86 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 87 | browsers: ['Chrome'], 88 | 89 | // you can define custom flags 90 | customLaunchers: { 91 | ChromeHeadlessNoSandbox: { 92 | base: 'ChromeHeadless', 93 | flags: ['--no-sandbox'], 94 | }, 95 | }, 96 | 97 | // Continuous Integration mode 98 | // if true, Karma captures browsers, runs the tests and exits 99 | singleRun: true, 100 | 101 | // Concurrency level 102 | // how many browser should be started simultaneous 103 | concurrency: Infinity, 104 | }); 105 | 106 | if (process.env.TRAVIS) { 107 | config.set({ 108 | browsers: ['ChromeHeadless', 'ChromeHeadlessNoSandbox'], 109 | }); 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spy-client", 3 | "version": "2.1.12", 4 | "description": "spy client", 5 | "main": "dist/spy-client.js", 6 | "module": "dist/spy-client.esm.js", 7 | "typings": "dist/spy-client.d.ts", 8 | "scripts": { 9 | "w_dev": "rm -fr dist && NODE_ENV=development webpack --config webpack.dev.config.ts", 10 | "w_watch": "rm -fr dist && NODE_ENV=development webpack -w --config webpack.dev.config.ts", 11 | "w_build": "rm -fr dist && NODE_ENV=production webpack --config webpack.prod.config.ts", 12 | "dev": "rollup -c --environment NODE_ENV:development", 13 | "watch": "NODE_OPTIONS='--max-old-space-size=4096' rollup -cw --environment NODE_ENV:development", 14 | "build": "rollup -c --environment NODE_ENV:production", 15 | "lint": "eslint src/**/*.ts", 16 | "example": "npm run dev && echo 'open url http://localhost:8000/example' && python -m SimpleHTTPServer 8000 ", 17 | "test": "karma start karma.conf.js", 18 | "release_pre": "rm -fr dist && npm run build && npm run lint && npm run test", 19 | "release": "npm version patch && npm publish --registry=https://registry.npmjs.org", 20 | "release_post": "git push origin master && git push origin --tags" 21 | }, 22 | "directories": { 23 | "example": "example", 24 | "test": "test" 25 | }, 26 | "dependencies": { 27 | "core-js": "^3.6.4" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.15.0", 31 | "@babel/eslint-parser": "^7.15.0", 32 | "@babel/eslint-plugin": "^7.14.5", 33 | "@babel/preset-env": "^7.5.5", 34 | "@ecomfe/eslint-config": "^7.2.1", 35 | "@rollup/plugin-commonjs": "^11.0.2", 36 | "@rollup/plugin-node-resolve": "^7.1.1", 37 | "@rollup/plugin-typescript": "^8.2.5", 38 | "@types/glob": "^7.1.1", 39 | "@types/jasmine": "^3.5.10", 40 | "@types/node": "^12.6.8", 41 | "@types/uglifyjs-webpack-plugin": "^1.1.0", 42 | "@types/webpack": "^4.4.35", 43 | "@types/webpack-merge": "^4.1.5", 44 | "@typescript-eslint/eslint-plugin": "^4.29.2", 45 | "@typescript-eslint/parser": "^4.29.2", 46 | "babel-eslint": "^10.1.0", 47 | "babel-loader": "^8.0.6", 48 | "cache-loader": "^4.1.0", 49 | "css-loader": "^3.1.0", 50 | "eslint": "^6.8.0", 51 | "eslint-plugin-babel": "^5.3.0", 52 | "eslint-plugin-import": "^2.24.1", 53 | "fastify": "^3.21.0", 54 | "fastify-cors": "^6.0.2", 55 | "fecs": "^1.6.4", 56 | "file-loader": "^4.1.0", 57 | "jasmine-core": "^3.4.0", 58 | "karma": "^6.3.4", 59 | "karma-chrome-launcher": "^3.1.0", 60 | "karma-jasmine": "^2.0.1", 61 | "karma-jasmine-html-reporter": "^1.4.2", 62 | "karma-typescript": "^5.5.2", 63 | "less": "^3.9.0", 64 | "less-loader": "^5.0.0", 65 | "rollup": "^2.56.2", 66 | "rollup-plugin-babel": "^4.4.0", 67 | "rollup-plugin-replace": "^2.2.0", 68 | "rollup-plugin-typescript2": "^0.30.0", 69 | "rollup-plugin-uglify": "^6.0.4", 70 | "stylus": "^0.54.5", 71 | "stylus-loader": "^3.0.2", 72 | "ts-loader": "^6.0.4", 73 | "ts-node": "^8.3.0", 74 | "typescript": "^4.3.5", 75 | "uglifyjs-webpack-plugin": "^2.1.3", 76 | "url-loader": "^2.1.0" 77 | }, 78 | "keywords": [ 79 | "spy", 80 | "client", 81 | "Longtask", 82 | "frontend monitor", 83 | "performance monitor", 84 | "exception monitor", 85 | "jsError", 86 | "whiteScreenError", 87 | "resourceError", 88 | "performance timing", 89 | "Largest Contentful Paint", 90 | "FID", 91 | "TTI", 92 | "Cumulative Layout Shift", 93 | "Memory", 94 | "Resource Timing" 95 | ], 96 | "repository": { 97 | "type": "git", 98 | "url": "" 99 | }, 100 | "author": "kaivean", 101 | "license": "ISC", 102 | "babel": { 103 | "comments": false, 104 | "presets": [], 105 | "env": { 106 | "test": { 107 | "plugins": [ 108 | "istanbul" 109 | ] 110 | } 111 | } 112 | }, 113 | "browserslist": [ 114 | "> 1%", 115 | "last 2 versions", 116 | "ie >= 9", 117 | "android >= 4", 118 | "ios >= 9" 119 | ] 120 | } 121 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file webpack 4不支持旧浏览器,采用rollup支持旧浏览器打包配置 3 | * @author kaivean 4 | */ 5 | 6 | // import babel from 'rollup-plugin-babel'; 7 | import {uglify} from 'rollup-plugin-uglify'; 8 | import replace from 'rollup-plugin-replace'; 9 | import resolve from '@rollup/plugin-node-resolve'; 10 | import typescript from 'rollup-plugin-typescript2'; 11 | // 这个官方正式插件有bug,不输出声明文件 12 | // import typescript from '@rollup/plugin-typescript'; 13 | import commonjs from '@rollup/plugin-commonjs'; 14 | 15 | const isDevelopment = process.env.NODE_ENV === 'development'; 16 | 17 | function genPlugins(opt = {}) { 18 | const plugins = [ 19 | resolve({ 20 | node: false, 21 | browser: true, 22 | }), 23 | ]; 24 | 25 | plugins.push(commonjs()); 26 | plugins.push(typescript({ 27 | outDir: './dist', 28 | tsconfig: 'tsconfig.json', 29 | })); 30 | 31 | plugins.push(replace({ 32 | 'process.env.NODE_ENV': JSON.stringify(isDevelopment ? 'development' : 'production'), 33 | })); 34 | 35 | if (opt.isMin) { 36 | plugins.push(uglify()); 37 | } 38 | 39 | return plugins; 40 | } 41 | 42 | export default [ 43 | // ********************* 44 | // spyClient 45 | // ********************* 46 | 47 | // umd风格全兼容 48 | { 49 | input: 'src/spy-client.ts', 50 | output: { 51 | file: 'dist/spy-client.js', 52 | format: 'umd', 53 | name: 'SpyClient', 54 | }, 55 | plugins: genPlugins(), 56 | }, 57 | { 58 | input: 'src/spy-client.ts', 59 | output: { 60 | file: 'dist/spy-client.min.js', 61 | format: 'umd', 62 | name: 'SpyClient', 63 | }, 64 | plugins: genPlugins({isMin: true}), 65 | }, 66 | 67 | // iife风格全兼容 一些构建模块,比如很老的fis版本无法构建umd格式模块,因此仅提供全局变量iife模式 68 | { 69 | input: 'src/spy-client.ts', 70 | output: { 71 | file: 'dist/spy-client.iife.js', 72 | format: 'iife', 73 | name: 'SpyClient', 74 | }, 75 | plugins: genPlugins(), 76 | }, 77 | { 78 | input: 'src/spy-client.ts', 79 | output: { 80 | file: 'dist/spy-client.iife.min.js', 81 | format: 'iife', 82 | name: 'SpyClient', 83 | }, 84 | plugins: genPlugins({isMin: true}), 85 | }, 86 | 87 | // es module风格全兼容 仅仅是es module的风格,语法和polyfill还是经过编译的 88 | { 89 | input: 'src/spy-client.ts', 90 | output: { 91 | file: 'dist/spy-client.esm.js', 92 | format: 'esm', 93 | name: 'SpyClient', 94 | }, 95 | plugins: genPlugins(), 96 | }, 97 | 98 | 99 | // es6代码 仅仅编译ts成es6,对语法和polyfill不做处理 100 | { 101 | input: 'src/spy-client.ts', 102 | output: { 103 | file: 'dist/spy-client.mjs', 104 | format: 'esm', 105 | name: 'SpyClient', 106 | }, 107 | plugins: genPlugins({es6: true}), 108 | }, 109 | 110 | 111 | // ********************* 112 | // spy-client-basic 113 | // ********************* 114 | 115 | // umd风格全兼容 116 | { 117 | input: 'src/spy-client-basic.ts', 118 | output: { 119 | file: 'dist/spy-client-basic.js', 120 | format: 'umd', 121 | name: 'SpyClient', 122 | }, 123 | plugins: genPlugins(), 124 | }, 125 | { 126 | input: 'src/spy-client-basic.ts', 127 | output: { 128 | file: 'dist/spy-client-basic.min.js', 129 | format: 'umd', 130 | name: 'SpyClient', 131 | }, 132 | plugins: genPlugins({isMin: true}), 133 | }, 134 | 135 | // iife风格全兼容 一些构建模块,比如很老的fis版本无法构建umd格式模块,因此仅提供全局变量iife模式 136 | { 137 | input: 'src/spy-client-basic.ts', 138 | output: { 139 | file: 'dist/spy-client-basic.iife.js', 140 | format: 'iife', 141 | name: 'SpyClient', 142 | }, 143 | plugins: genPlugins(), 144 | }, 145 | { 146 | input: 'src/spy-client-basic.ts', 147 | output: { 148 | file: 'dist/spy-client-basic.iife.min.js', 149 | format: 'iife', 150 | name: 'SpyClient', 151 | }, 152 | plugins: genPlugins({isMin: true}), 153 | }, 154 | 155 | // es module风格全兼容 仅仅是es module的风格,语法和polyfill还是经过编译的 156 | { 157 | input: 'src/spy-client-basic.ts', 158 | output: { 159 | file: 'dist/spy-client-basic.esm.js', 160 | format: 'esm', 161 | name: 'SpyClient', 162 | }, 163 | plugins: genPlugins(), 164 | }, 165 | 166 | 167 | // es6代码 仅仅编译ts成es6,对语法和polyfill不做处理 168 | { 169 | input: 'src/spy-client-basic.ts', 170 | output: { 171 | file: 'dist/spy-client-basic.mjs', 172 | format: 'esm', 173 | name: 'SpyClient', 174 | }, 175 | plugins: genPlugins({es6: true}), 176 | }, 177 | 178 | 179 | // ********************* 180 | // spyHead 181 | // ********************* 182 | // 头部JS,iife风格全兼容 183 | { 184 | input: 'src/spy-head.ts', 185 | output: { 186 | file: 'dist/spy-head.js', 187 | format: 'umd', 188 | name: '__spyHead', 189 | }, 190 | plugins: genPlugins({head: true}), 191 | }, 192 | { 193 | input: 'src/spy-head.ts', 194 | output: { 195 | file: 'dist/spy-head.min.js', 196 | format: 'umd', 197 | name: '__spyHead', 198 | }, 199 | plugins: genPlugins({isMin: true, head: true}), 200 | }, 201 | 202 | // ********************* 203 | // spy-local-cache 204 | // ********************* 205 | // 头部JS,iife风格全兼容 206 | { 207 | input: 'src/spy-local-cache.ts', 208 | output: { 209 | file: 'dist/spy-local-cache.js', 210 | format: 'umd', 211 | name: 'SpyLocalCache', 212 | }, 213 | plugins: genPlugins({head: true}), 214 | }, 215 | { 216 | input: 'src/spy-local-cache.ts', 217 | output: { 218 | file: 'dist/spy-local-cache.min.js', 219 | format: 'umd', 220 | name: 'SpyLocalCache', 221 | }, 222 | plugins: genPlugins({isMin: true, head: true}), 223 | }, 224 | ]; 225 | -------------------------------------------------------------------------------- /src/head/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 资源、JS、白屏监控的基础 3 | * @author kaivean 4 | */ 5 | 6 | import { 7 | SpyHeadConf, 8 | } from '../lib/spyHeadInterface'; 9 | 10 | interface SendObj { 11 | type?: 'perf'| 'except' | 'dist' | 'count'; 12 | group: string; 13 | info: any; 14 | dim?: any; 15 | lid?: string; 16 | pid?: string; 17 | ts?: number; 18 | } 19 | 20 | export default { 21 | conf: {} as SpyHeadConf, 22 | winerrors: [] as SendObj[], 23 | errorDestroy() {}, 24 | observerDestroy() {}, 25 | entryMap: {} as any, 26 | init(conf: SpyHeadConf) { 27 | this.conf = conf; 28 | }, 29 | addError(obj: SendObj) { 30 | // 有些错误一下出现很多次,都聚合都一个错误,加上次数 31 | if (this.winerrors.length > 0) { 32 | const lastObj = this.winerrors[this.winerrors.length - 1]; 33 | if (obj.info.msg === lastObj.info.msg) { 34 | lastObj.info.count += (lastObj.info.count || 0); 35 | return; 36 | } 37 | } 38 | if (this.winerrors.length < 1000) { 39 | this.winerrors.push(obj); 40 | } 41 | }, 42 | send(obj: SendObj, isSend?: boolean, logServer?: string) { 43 | const conf = this.conf; 44 | obj.type = obj.type || 'except'; 45 | obj.pid = conf.pid; 46 | obj.lid = conf.lid; 47 | obj.ts = Date.now(); 48 | 49 | this.addError(obj); 50 | this.interceptor && this.interceptor(obj); 51 | if (isSend === false) { 52 | return; 53 | } 54 | 55 | logServer = logServer || conf.logServer; 56 | let logUrl = `${logServer}?pid=${obj.pid}&lid=${obj.lid}&ts=${obj.ts}` 57 | + `&type=${obj.type}&group=${obj.group}&info=${encodeURIComponent(JSON.stringify(obj.info))}`; 58 | 59 | if (obj.dim) { 60 | logUrl += '&dim=' + encodeURIComponent(JSON.stringify(obj.dim)); 61 | } 62 | 63 | let img: (HTMLImageElement | null) = new Image(); 64 | img.src = logUrl; 65 | img.onload = img.onerror = function () { 66 | img = null; 67 | }; 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /src/head/error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 资源和JS错误监控 3 | * @author kaivean 4 | */ 5 | 6 | import { 7 | ErrorHandlerData, 8 | SpyHeadConf, 9 | ErrorConf, 10 | } from '../lib/spyHeadInterface'; 11 | import {getUrlInfo, getxpath} from '../lib/util'; 12 | 13 | import spyHead from './base'; 14 | 15 | export function init(conf: SpyHeadConf) { 16 | const resourceError = conf.resourceError || {} as ErrorConf; 17 | const jsError = conf.jsError || {} as ErrorConf; 18 | const isSendJserror = Math.random() < (jsError.sample ? jsError.sample : 0); 19 | const isSendResource = Math.random() < (resourceError.sample ? resourceError.sample : 0); 20 | 21 | const winerrors = spyHead.winerrors; 22 | let resourceErrorCount = 0; 23 | 24 | function spyListenError(event: Event) { 25 | try { 26 | const el = event.target; 27 | const obj = {info: {}, dim: {}, group: ''} as ErrorHandlerData; 28 | const info = obj.info; 29 | const srcElement = event.srcElement as (HTMLElement | Window); 30 | 31 | // 设备信息 32 | const dataConnection = navigator.connection || {}; 33 | info.downlink = dataConnection.downlink; // 网站下载速度 M/s 34 | info.effectiveType = dataConnection.effectiveType; // 网络类型 35 | info.rtt = dataConnection.rtt; // 网络往返时间 ms 36 | info.deviceMemory = navigator.deviceMemory || 0; 37 | info.hardwareConcurrency = navigator.hardwareConcurrency || 0; 38 | 39 | // JS错误 40 | if (srcElement === window) { 41 | obj.group = jsError.group; 42 | 43 | // 异常信息 44 | // promise错误从reason取 45 | const error = (event.error || (event as PromiseRejectionEvent).reason) || {}; 46 | // promise错误从error.message取 47 | info.msg = event.message || error.message || ''; 48 | info.file = event.filename; 49 | info.ln = event.lineno; 50 | info.col = event.colno; 51 | info.stack = (error.stack || '').split('\n').slice(0, 3).join('\n'); 52 | 53 | // 针对esl的MODULE_TIMEOUT处理 54 | if (info.msg.indexOf('MODULE_TIMEOUT') !== -1) { 55 | const matches = info.msg.match(/^.*Hang:(.*); Miss:(.*)/); 56 | if (matches && matches[2]) { 57 | info.msg = 'MODULE_TIMEOUT for miss:' + (matches[2]); 58 | } 59 | } 60 | 61 | // 历史错误 62 | const historys = []; 63 | for (let index = 0; index < winerrors.length; index++) { 64 | const item = winerrors[index]; 65 | const prefix = item.info.count > 1 ? `(${item.info.count})` : ''; 66 | historys.push(prefix + (item.info.msg as string)); 67 | } 68 | 69 | info.hisErrors = historys.join('----'); 70 | 71 | let allow: boolean | undefined | void = true; 72 | if (jsError.handler) { 73 | allow = jsError.handler(obj); 74 | } 75 | 76 | if (allow !== false) { 77 | spyHead.send(obj, isSendJserror); 78 | } 79 | } 80 | // 资源错误 81 | else { 82 | obj.group = resourceError.group; 83 | (obj.dim as any).type = (srcElement as HTMLElement).tagName.toLowerCase(); 84 | 85 | const url = (srcElement as HTMLScriptElement).src || (srcElement as HTMLLinkElement).href || ''; 86 | // 日志本身失败,要忽略 87 | if (url.indexOf('/mwb2.gif?') > -1) { 88 | return; 89 | } 90 | 91 | info.msg = url || 'unknown load eror'; 92 | 93 | (obj.dim as any).host = getUrlInfo(url).host; 94 | 95 | if (el && (el as HTMLElement).tagName === 'IMG') { 96 | info.xpath = getxpath(el as HTMLElement).xpath; 97 | } 98 | 99 | if (resourceErrorCount) { 100 | info.hisErrCount = resourceErrorCount; 101 | } 102 | 103 | let allow: boolean | undefined | void = true; 104 | if (resourceError.handler) { 105 | allow = resourceError.handler(obj); 106 | } 107 | 108 | if (allow !== false) { 109 | spyHead.send(obj, isSendResource); 110 | } 111 | 112 | resourceErrorCount++; 113 | } 114 | } 115 | catch (e) { 116 | console.error(e); 117 | } 118 | } 119 | window.addEventListener('error', spyListenError, true); 120 | 121 | // Promise未处理拒绝监控 122 | window.addEventListener('unhandledrejection', spyListenError, true); 123 | 124 | spyHead.errorDestroy = function () { 125 | window.removeEventListener('error', spyListenError, true); 126 | window.removeEventListener('unhandledrejection', spyListenError, true); 127 | spyHead.winerrors = []; 128 | }; 129 | } 130 | 131 | -------------------------------------------------------------------------------- /src/head/observer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file PerformanceObserver指标采集 3 | * @author kaivean 4 | */ 5 | 6 | import spyHead from './base'; 7 | 8 | export function init() { 9 | // Longtask监控 10 | if (window.PerformanceObserver) { 11 | const observer = new window.PerformanceObserver( 12 | function spyObserveLongtask(list: PerformanceObserverEntryList) { 13 | const entryMap = spyHead.entryMap; 14 | const entries = list.getEntries(); 15 | for (let i = 0; i < entries.length; i++) { 16 | const entry = entries[i]; 17 | if (!entryMap[entry.entryType]) { 18 | entryMap[entry.entryType] = []; 19 | } 20 | entryMap[entry.entryType].push(entry); 21 | } 22 | } 23 | ); 24 | 25 | spyHead.observerDestroy = function () { 26 | observer.disconnect(); 27 | }; 28 | 29 | // 在ios下,没有一个类似的监控项是支持的,就抛错,chrome会console warn 30 | try { 31 | observer.observe({entryTypes: [ 32 | 'longtask', 33 | 'layout-shift', 34 | 'first-input', 35 | 'largest-contentful-paint', 36 | ]}); 37 | } 38 | catch (e) {} 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/head/readme.md: -------------------------------------------------------------------------------- 1 | head目录代码一般是要插入head标签内的,为了保持体积最优,请不要使用需要polyfill和高级语法 -------------------------------------------------------------------------------- /src/head/whitescreen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 白屏监控 3 | * @author kaivean 4 | */ 5 | 6 | import { 7 | ErrorHandlerData, 8 | SpyHeadConf, 9 | WhiteScreenErrorConf, 10 | } from '../lib/spyHeadInterface'; 11 | import {getResTiming} from '../lib/util'; 12 | 13 | import spyHead from './base'; 14 | 15 | export function init(conf: SpyHeadConf) { 16 | const whiteScreenError = conf.whiteScreenError || {} as WhiteScreenErrorConf; 17 | const handler = whiteScreenError.handler; 18 | const selector = whiteScreenError.selector; 19 | const subSelector = whiteScreenError.subSelector; 20 | const timeout = whiteScreenError.timeout || 6000; 21 | const isSend = Math.random() < (whiteScreenError.sample ? whiteScreenError.sample : 0); 22 | 23 | // 补充白屏信息:期间的网络时间 24 | function getNetTime() { 25 | if (!window.performance) { 26 | return false; 27 | } 28 | const pf = getResTiming(window.performance.timing); 29 | const netStr = `&wait=${pf.wait}` 30 | + `&dns=${pf.dns}` 31 | + `&connect=${pf.connect}` 32 | + `&requestTime=${pf.req}` 33 | + `&resoneTime=${pf.res}`; 34 | return netStr; 35 | } 36 | 37 | // 补充白屏信息:期间发生的JS Error 和 资源 Error 38 | function getHisError() { 39 | if (!(spyHead.winerrors)) { 40 | return false; 41 | } 42 | const errors = spyHead.winerrors; 43 | const historys = []; 44 | for (let i = 0; i < errors.length; i++) { 45 | const stack = (errors[i].info.stack || '').split('\n')[0]; 46 | historys.push(`(${i })${stack || errors[i].info.msg}`); 47 | } 48 | return historys.join(';;'); 49 | } 50 | 51 | // 补充白屏信息: 设备信息 52 | function getDeviceInfo() { 53 | const ret = {} as any; 54 | // 设备信息 55 | const dataConnection = navigator.connection || {}; 56 | ret.downlink = dataConnection.downlink; // 网站下载速度 M/s 57 | ret.effectiveType = dataConnection.effectiveType; // 网络类型 58 | ret.rtt = dataConnection.rtt; // 网络往返时间 ms 59 | ret.deviceMemory = navigator.deviceMemory || 0; 60 | ret.hardwareConcurrency = navigator.hardwareConcurrency || 0; 61 | return ret; 62 | } 63 | 64 | function isWhiteScreen() { 65 | const ele = document.querySelector(selector); 66 | if (!ele) { 67 | return true; 68 | } 69 | const sub = ele.querySelector(subSelector); 70 | if (!sub) { 71 | return true; 72 | } 73 | if (ele.clientHeight < (window.innerHeight * 2 / 3)) { 74 | return true; 75 | } 76 | return false; 77 | } 78 | 79 | if (selector) { 80 | setTimeout(function () { 81 | if (isWhiteScreen()) { 82 | const obj = { 83 | group: whiteScreenError.group, 84 | info: { 85 | msg: '', 86 | netTime: getNetTime(), 87 | hisErrors: getHisError(), 88 | deviceInfo: getDeviceInfo(), 89 | }, 90 | } as ErrorHandlerData; 91 | 92 | obj.info.msg = 'WhiteScren Error'; 93 | 94 | let allow: boolean | undefined | void = true; 95 | if (handler) { 96 | allow = handler(obj); 97 | } 98 | 99 | if (allow !== false && obj.info.msg) { 100 | spyHead && spyHead.send(obj, isSend); 101 | } 102 | } 103 | }, timeout); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 简单的公共数据模块,内部模块之间数据交换 3 | * @author kaivean 4 | */ 5 | 6 | const data = {} as any; 7 | 8 | export function setData(key: string, value: any) { 9 | data[key] = value; 10 | } 11 | 12 | export function getData(key: string) { 13 | return data[key]; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/huffman.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Huffman 3 | * @author kaivean 4 | */ 5 | 6 | /** 7 | * 8 | 赫夫曼编码 9 | 基本介绍 10 | 11 | 1.赫夫曼编码也翻译为 哈夫曼编码(Huffman Coding),又称霍夫曼编码,是一种编码方式, 属于一种程序算法 12 | 赫夫曼编码是赫哈夫曼树在电讯通信中的经典的应用之一。 13 | 14 | 2.赫夫曼编码广泛地用于数据文件压缩。其压缩率通常在20%~90%之间 15 | 赫夫曼码是可变字长编码(VLC)的一种。Huffman于1952年提出一种编码方法,称之为最佳编码 16 | 17 | * 18 | * 19 | */ 20 | 21 | class HuffmanNode { 22 | data;// 存放数据(字符本身),比如 'a' => 97, ' '=>32 23 | weight;// 权值,表示字符串出现的次数 24 | left: HuffmanNode; 25 | right: HuffmanNode; 26 | constructor(data: any, weight: number) { 27 | this.data = data; 28 | this.weight = weight; 29 | } 30 | 31 | // 前序遍历 32 | preOrder(arr: any) { 33 | arr.push(this); 34 | if (this.left) { 35 | this.left.preOrder(arr); 36 | } 37 | if (this.right) { 38 | this.right.preOrder(arr); 39 | } 40 | } 41 | } 42 | 43 | 44 | /** 45 | * 46 | * @param {接受字符数组} bytes 47 | * @return 返回的就是list形式 48 | */ 49 | function getNodes(bytes: any[]): HuffmanNode[] { 50 | // 创建一个list 51 | let list: HuffmanNode[] = []; 52 | // counts 统计每一个byte出现的次数 53 | let counts: any = {}; 54 | for (let b of bytes) { 55 | let count = counts[b]; // map还没有这个字符数据 56 | if (count == null) { 57 | counts[b] = 1; 58 | } 59 | else { 60 | counts[b]++; 61 | } 62 | } 63 | 64 | for (const [key, value] of Object.entries(counts)) { 65 | list.push(new HuffmanNode(key, value as number)); 66 | } 67 | return list; 68 | } 69 | 70 | 71 | // 通过list创建赫夫曼树 72 | function createHuffmanTree(nodes: HuffmanNode[]): HuffmanNode | undefined { 73 | const compareFun = function (a: HuffmanNode, b: HuffmanNode) { 74 | return a.weight - b.weight; 75 | }; 76 | while (nodes.length > 1) { 77 | // 排序,从小到大 78 | nodes.sort(compareFun); 79 | // 取出第一颗最小的二叉树 80 | let leftNode = nodes.shift(); 81 | let rightNode = nodes.shift(); 82 | if (leftNode && rightNode) { 83 | // 创建一个新的二叉树,它的根节点,没有data,只有权值 84 | let parent = new HuffmanNode(null, leftNode.weight + rightNode.weight); 85 | parent.left = leftNode; 86 | parent.right = rightNode; 87 | 88 | // 将新的二叉树,加入到nodes 89 | nodes.unshift(parent); 90 | } 91 | 92 | } 93 | // nodes最后的节点,就是根节点 94 | return nodes.shift(); 95 | } 96 | 97 | // 生成赫夫曼树对应的赫夫曼编码表 98 | function getHuffmanCodes(root: any) { 99 | if (root == null) { 100 | return null; 101 | } 102 | // 生成赫夫曼树对应的赫夫曼编码表 103 | // 思路 104 | // 1.将赫夫曼编码表存放在map里面 105 | // 2.在生成赫夫曼编码表时,需要拼接路径,定义一个数组string,存储某个叶子节点的路径 106 | 107 | let huffmanCodes: any = {}; 108 | let strings: any[] = []; 109 | 110 | /** 111 | * 将传入的node节点的所有叶子节点的赫夫曼编码得到,并放入到huffmanCodes集合中 112 | * @param {传入节点} node 113 | * @param {路径:左子节点是0,右子节点是1} code 114 | * @param {用于拼接路径} string 115 | */ 116 | function getCodes(node: HuffmanNode, code: string, strs: string[]) { 117 | let string2 = ([] as string[]).concat(strs); 118 | // 将code加入到string中 119 | string2.push(code); 120 | if (node != null) { // 如果node == null不处理 121 | // 判断当前node是叶子节点还是非叶子节点 122 | if (node.data == null) { // 非叶子节点 123 | // 递归处理 124 | // 向左递归 125 | getCodes(node.left, '0', string2); 126 | // 向右递归 127 | getCodes(node.right, '1', string2); 128 | } 129 | else { // 说明是一个叶子节点 130 | // 就表示找到了某个叶子节点的最后 131 | huffmanCodes[node.data] = string2.join(''); 132 | } 133 | } 134 | } 135 | getCodes(root, '', strings); 136 | return huffmanCodes; 137 | } 138 | 139 | 140 | /** 141 | * 编写一个方法,将字符串对应的bytes数组,通过生成的赫夫曼编码表,返回一个赫夫曼编码压缩后的byte数组 142 | * @param {原始的字符串对应的bytes数组} bytes 143 | * @param {生成的赫夫曼编码表} huffmanCodes 144 | * @return 返回的是字符串对应的一个byte数组 145 | */ 146 | function zip(bytes: number[], huffmanCodes: any) { 147 | // 1.利用huffmanCodes将bytes转成赫夫曼编码对应的字符串 148 | let string = []; 149 | // 遍历数组 150 | for (let b of bytes) { 151 | string.push(huffmanCodes[b]); 152 | } 153 | return string; 154 | } 155 | 156 | function huffStringToByte(strs: string[]) { 157 | // 计算赫夫曼编码字符串的长度 158 | let str = strs.join(''); 159 | let len = Math.ceil(str.length / 8); 160 | // 创建存储压缩后的byte数组 161 | let huffmanCodeByte = new Array(len + 1); 162 | let index = 0; 163 | let strByte: string = ''; // 记录是第几个byte 164 | for (let i = 0; i < str.length; i += 8) { 165 | strByte = str.substring(i, i + 8); 166 | // 将strByte转成一个byte,放入huffmanCodeByte 167 | huffmanCodeByte[index] = parseInt(strByte, 2); 168 | index++; 169 | } 170 | // 记录最后一位二进制码的长度,因为,比如最后一位二进制strByte为00101时, 171 | // parseInt(strByte, 2)后等于5,前面的两个00已经丢失,所以必须记录长度,以便解码时补足前面的0 172 | huffmanCodeByte[index] = strByte.length; 173 | return huffmanCodeByte; 174 | } 175 | 176 | // 使用一个方法,封装前面的方法,便于调用 177 | /** 178 | * 179 | * @param {原始的字符串对应的字节数组} bytes 180 | * @returns 是经过赫夫曼编码处理后,压缩后的字节数组 181 | * 182 | */ 183 | function huffmanZip(bytes: any[]) { 184 | // 1.生成节点数组 185 | let nodes = getNodes(bytes); 186 | // 2.根据节点数组创建赫夫曼树 187 | let root = createHuffmanTree(nodes); 188 | // 3.根据赫夫曼树生成赫夫曼编码 189 | let hufumanCodes = getHuffmanCodes(root); 190 | // 4.根据生成的赫夫曼编码生成压缩后的赫夫曼编码字节数组 191 | let hufumanStrArr = zip(bytes, hufumanCodes); 192 | let hufumanByteArr = huffStringToByte(hufumanStrArr); 193 | 194 | return {result: hufumanByteArr, codes: hufumanCodes}; 195 | } 196 | 197 | // 完成数据的解压 198 | // 思路 199 | // 1.将huffmanBytesArr先转成赫夫曼编码对应的二进制字符串 200 | // 2.将赫夫曼编码对应的二进制的字符串转成赫夫曼编码字符串 201 | 202 | /** 203 | * 204 | * @param {表示是否需要补高位,如果是true,表示需要,如果是false,表示不需要,如果是最后一个字节不需要补高位} flag 205 | * @param {传入的byte} byte 206 | * @returns 是byte对应的二进制字符串 207 | */ 208 | function huffByteToString(flag: boolean, byte: number) { 209 | // 如果是 210 | if (flag) { 211 | byte |= 256; 212 | } 213 | let str = Number(byte).toString(2); 214 | if (flag) { 215 | return str.substring(str.length - 8); 216 | } 217 | return str; 218 | } 219 | 220 | 221 | /** 222 | * 编写一份方法,完成对压缩数据的解码 223 | * @param {赫夫曼编码表} huffmanCodes 224 | * @param {赫夫曼编码得到的二进制数组} huffmanBytes 225 | */ 226 | function decode(huffmanCodes: {[key: string]: string}, huffmanBytes: number[]) { 227 | // 1.先得到二进制字符串 形式11001111111011...... 228 | let heffmanStrArr = []; 229 | for (let i = 0; i < huffmanBytes.length - 1; i++) { 230 | // 判断是不是最后一个字节 231 | let flag = (i !== huffmanBytes.length - 2); 232 | heffmanStrArr.push(huffByteToString(flag, huffmanBytes[i])); 233 | } 234 | // 最后一位记录的是最后一位二进制字符串的长度,该长度主要用于补足最后一位丢失的0,所以要单独处理, 235 | let lastByteStr = heffmanStrArr[heffmanStrArr.length - 1]; 236 | let lastByteLength = huffmanBytes[huffmanBytes.length - 1]; 237 | lastByteStr = '00000000'.substring(8 - (lastByteLength - lastByteStr.length)) + lastByteStr; 238 | heffmanStrArr[heffmanStrArr.length - 1] = lastByteStr; 239 | 240 | // 把赫夫曼编码表进行调换 241 | let map: {[key: string]: string} = {}; 242 | for (const [key, value] of Object.entries(huffmanCodes)) { 243 | map[value] = key; 244 | } 245 | let heffmanStr = heffmanStrArr.join(''); 246 | let list = []; 247 | let len = heffmanStr.length; 248 | for (let i = 0; i < len;) { 249 | let count = 1; 250 | let flag = true; 251 | let b: string | null = null; 252 | while (flag && i + count <= len) { 253 | // 取出一个1或0 254 | // i不动,count移动,直到匹配到一个字符 255 | let key = heffmanStr.substring(i, i + count); 256 | if (key === '') { 257 | break; 258 | } 259 | b = map[key]; 260 | if (!b) { // 没有匹配到 261 | count++; 262 | } 263 | else { 264 | // 匹配到 265 | flag = false; 266 | } 267 | } 268 | list.push(parseInt(b as string, 10)); 269 | i += count; 270 | } 271 | // 当for循环结束后,list中就存放了所有的字符 272 | 273 | return list; 274 | } 275 | 276 | 277 | // js byte[] 和string 相互转换 UTF-8 278 | function stringToByte(str: string): number[] { 279 | let bytes = []; 280 | for (let index = 0; index < str.length; index++) { 281 | bytes.push(str.charCodeAt(index)); 282 | } 283 | return bytes; 284 | } 285 | 286 | function byteToString(arr: number[]): string { 287 | let data = ''; 288 | for (const code of arr) { 289 | data += String.fromCharCode(code); 290 | } 291 | return data; 292 | } 293 | 294 | export function huffmanEncode(str: string) { 295 | let bytes = stringToByte(str); 296 | let {result, codes} = huffmanZip(bytes); 297 | return { 298 | codes, 299 | result: byteToString(result), 300 | }; 301 | } 302 | 303 | export function huffmanDecode(codes: any, str: string) { 304 | let bytes = stringToByte(str); 305 | const data = decode(codes, bytes); 306 | return byteToString(data); 307 | } 308 | -------------------------------------------------------------------------------- /src/lib/interface.ts: -------------------------------------------------------------------------------- 1 | export interface SpyClientOption { 2 | /** 3 | * 模块ID 4 | */ 5 | pid: string; 6 | 7 | // 日志ID 8 | /** 9 | * 日志ID 10 | */ 11 | lid?: string; 12 | 13 | /** 14 | * 全局抽样配置,默认是 1,取值从[0, 1] 15 | */ 16 | sample?: number; 17 | 18 | /** 19 | * 是否校验发送字段符合规范,默认是 true 20 | */ 21 | check?: boolean; 22 | 23 | /** 24 | * 日志服务器,默认是webb服务器,尾部需要加? 25 | */ 26 | logServer?: string; 27 | 28 | /** 29 | * 本地日志cache对象,是指独立的spy-local-cache模块实例 30 | */ 31 | localCache?: any; 32 | } 33 | 34 | export class Module { 35 | // public constructor() {} 36 | // [propName: string]: listenCallback[], 37 | // init: () => void; 38 | load?: () => void; 39 | leave?: () => void; 40 | destroy?: () => void; 41 | } 42 | 43 | export interface FIDMetric { 44 | // First Input Delay https://web.dev/fid/ 45 | // 首次输入延迟 46 | fid: number; 47 | } 48 | export type FIDCB = (metric: FIDMetric) => void; 49 | 50 | export interface LCPMetric { 51 | // Largest Contentful Paint https://web.dev/lcp/ 52 | // 在onload时间内最大块内容绘制完成时间 53 | lcp: number; 54 | } 55 | export type LCPCB = (metric: LCPMetric) => void; 56 | 57 | export enum ResType { 58 | JS = 'js', 59 | CSS = 'css', 60 | IMG = 'img', 61 | FONT = 'font', 62 | } 63 | 64 | export interface ResourceMetric { 65 | // 页面整体大小:包括主文档、所有JS、CSS、Img、Font,单位KB 66 | allSize: number; 67 | // 主文档大小 KB 68 | docSize: number; 69 | // 主文档的响应header的大小,包含cookie等 KB 70 | headerSize: number; 71 | 72 | // js外链的个数 73 | jsNum: number; 74 | cssNum: number; 75 | imgNum: number; 76 | fontNum: number; 77 | 78 | // 所有JS外链的大小 79 | jsSize: number; 80 | cssSize: number; 81 | imgSize: number; 82 | fontSize: number; 83 | 84 | // 页面整体网络传输大小,通常来说资源有了缓存,传输大小就为0,另外有Gzip的话,传输大小相比资源本身大小也要小很多 85 | allTransferSize: number; 86 | // 主文档网络传输大小 87 | docTransferSize: number; 88 | // 所有JS外链的传输大小 89 | jsTransferSize: number; 90 | cssTransferSize: number; 91 | imgTransferSize: number; 92 | fontTransferSize: number; 93 | 94 | jsDuration: number; 95 | cssDuration: number; 96 | imgDuration: number; 97 | fontDuration: number; 98 | 99 | // js cache率 100 | jsCacheRate: number; 101 | cssCacheRate: number; 102 | imgCacheRate: number; 103 | }; 104 | export interface ResourceHostMetric { 105 | [host: string]: { 106 | hostNum: number; 107 | hostSize: number; 108 | hostTransferSize: number; 109 | hostDuration: number; 110 | hostCacheRate: number; 111 | }; 112 | }; 113 | export type ResourceCB = (metric: ResourceMetric, hostMetric: ResourceHostMetric) => void; 114 | 115 | 116 | export interface ResourceErrorInfo { 117 | // 发生异常的资源链接 118 | msg: string; 119 | // 发生异常的资源元素的xpath信息,一直到body 120 | xpath: string; 121 | // 资源host 122 | host: string; 123 | // 资源类型 124 | type: string; 125 | // 资源耗时 126 | dur?: number; 127 | } 128 | export type ResourceErrorCB = (info: ResourceErrorInfo) => void; 129 | 130 | 131 | export interface TimingMetric { 132 | // dns解析 133 | dns: number; 134 | // tcp链接 135 | tcp: number; 136 | // 主文档请求 137 | request: number; 138 | // 主文档响应时间 139 | response: number; 140 | // DOM解析时间:Dom解析开始到结束时间 141 | // 这是从页面部分数据返回,浏览器开始解析doc元素到最底部的script脚本解析执行完成 142 | // 脚本里触发的异步方法或绑定了更靠后的事件,不再纳入范围内 143 | parseHtml: number; 144 | // DOM解析完成总时间:页面开始加载到Dom解析结束 145 | // 很多事件绑定是在domContentLoaded事件里的,所以等其结束,一般页面元素的事件绑定好了,用户可以正确交互 146 | // 当然存在在该加载事件之后绑定元素事件情况,但不再此考虑范围内 147 | domReady: number; 148 | // 处理所有注册的load事件函数的时间 149 | loadEventHandle: number; 150 | // onload完成时间 151 | // 基本该做的都做完,资源也都加载完成了 152 | // 当然在onload事件处理函数里启动了异步方法,不再纳入范围内 153 | load: number; 154 | // first-paint https://w3c.github.io/paint-timing/#sec-PerformancePaintTiming 155 | fp?: number; 156 | // first-contentful-paint https://w3c.github.io/paint-timing/#sec-PerformancePaintTiming 157 | fcp?: number; 158 | // T7内核计算的首次绘制, 单位ms 159 | t7FirstPaint?: number; 160 | // T7内核计算的首屏时间, 单位ms 161 | t7FirstScreen?: number; 162 | } 163 | export type TimingCB = (metric: TimingMetric) => void; 164 | 165 | 166 | export interface TTIMetric { 167 | // Time to Interactive https://web.dev/tti/ 168 | // 用户可完全交互时间 169 | tti: number; 170 | } 171 | export type TTICB = (metric: TTIMetric) => void; 172 | 173 | export interface TTIOption { 174 | // TTI定义里的quiet window,默认是5000 单位ms 175 | interval?: number; 176 | // TTI要求在quiet window内,没有任何网络请求,但有一些不会影响页面的请求,比如日志请求,最好过滤掉,避免一直hang在quiet window 177 | // 默认下,排除掉所有gif请求 178 | filterRequest?: (entry: PerformanceEntry) => boolean; 179 | } 180 | 181 | export interface LayoutShiftMetric { 182 | // Cumulative Layout Shift定义 https://web.dev/cls/ 183 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内的 Cumulative Layout Shift 184 | layoutShift: number; 185 | } 186 | export type LayoutShiftCB = (metric: LayoutShiftMetric) => void; 187 | 188 | 189 | export interface MemoryMetric { 190 | // 使用内存, 单位KB 191 | usedJSHeapSize: number; 192 | // 分配给页面的内存,单位KB 193 | totalJSHeapSize: number; 194 | // 内存限制,单位KB 195 | jsHeapSizeLimit: number; 196 | // 内存使用率百分比 = 100 * usedJSHeapSize / totalJSHeapSize 197 | usedJSHeapRate: number; 198 | } 199 | export type MemoryCB = (metric: MemoryMetric) => void; 200 | 201 | 202 | export interface NavigatorInfoMetric { 203 | // 网络下载速度 https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/connection 204 | downlink?: number; 205 | // 网络类型 206 | effectiveType?: '2g' | '3g' | '4g' | 'slow-2g'; 207 | // 估算的往返时间 208 | rtt?: number; 209 | // 数据保护模式 210 | saveData?: boolean; 211 | // 设备内存 https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory 212 | deviceMemory?: number; 213 | // 设备逻辑核数 https://developer.mozilla.org/en-US/docs/Web/API/NavigatorConcurrentHardware/hardwareConcurrency 214 | hardwareConcurrency?: number; 215 | } 216 | export type NavigatorInfoCB = (metric: NavigatorInfoMetric) => void; 217 | 218 | 219 | export interface FSPLongtaskMetric { 220 | // 在T7内核首屏时间内, 每个longtask的时间总和 221 | fspLongtaskTime: number; 222 | // 在T7内核首屏时间内,Total Blocking Time时间总和,即Sum(每个longtask的时间 - 50) 223 | // Total Blocking Time定义: 224 | fspTBT: number; 225 | // T7内核首屏时间 226 | fspTotalTime: number; 227 | // 在T7内核首屏时间内, Longtask率 = 100 * fspLongtaskTime / fspTotalTime 228 | fspLongtaskRate: number; 229 | // 在T7内核首屏时间内的Longtask数量 230 | fspLongtaskNum: number; 231 | } 232 | export type FSPLongtaskCB = (metric: FSPLongtaskMetric) => void; 233 | 234 | export interface LCPLongtaskMetric { 235 | // 在Largest Contentful Paint时间内, 每个longtask的时间总和 236 | lcpLongtaskTime: number; 237 | // 在Largest Contentful Paint时间内,Total Blocking Time时间总和,即Sum(每个longtask的时间 - 50) 238 | lcpTBT: number; 239 | // Largest Contentful Paint时间 240 | lcpTotalTime: number; 241 | // 在Largest Contentful Paint时间内, Longtask率 = 100 * lcpLongtaskTime / lcpTotalTime 242 | lcpLongtaskRate: number; 243 | // 在Largest Contentful Paint时间内的Longtask数量 244 | lcpLongtaskNum: number; 245 | } 246 | export type LCPLongtaskCB = (metric: LCPLongtaskMetric) => void; 247 | 248 | 249 | export interface LoadLongtaskMetric { 250 | // 在onload即页面加载完成时间内, 每个longtask的时间总和 251 | loadLongtaskTime: number; 252 | // 在onload即页面加载完成时间内,Total Blocking Time时间总和,即Sum(每个longtask的时间 - 50) 253 | loadTBT: number; 254 | // onload即页面加载完成时间 255 | loadTotalTime: number; 256 | // 在onload即页面加载完成时间内, Longtask率 = 100 * loadLongtaskTime / loadTotalTime 257 | loadLongtaskRate: number; 258 | // 在onload即页面加载完成时间内的Longtask数量 259 | loadLongtaskNum: number; 260 | } 261 | export type LoadLongtaskCB = (metric: LoadLongtaskMetric) => void; 262 | 263 | 264 | export interface PageLongtaskMetric { 265 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内, 每个longtask的时间总和 266 | pageLongtaskTime: number; 267 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内,Total Blocking Time时间总和,即Sum(每个longtask的时间 - 50) 268 | pageTBT: number; 269 | // 开始加载页面到第一次离开页面(隐藏或点出)时间 270 | pageTotalTime: number; 271 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内, Longtask率 = 100 * pageLongtaskTime / pageTotalTime 272 | pageLongtaskRate: number; 273 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内的Longtask数量 274 | pageLongtaskNum: number; 275 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内,每个来自iframe内的longtask的时间总和 276 | pageIframeLongtaskTime: number; 277 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内,每个来自iframe内的Longtask率 = 100 * pageIframeLongtaskTime / pageTotalTime 278 | pageIframeLongtaskRate: number; 279 | // 在开始加载页面到第一次离开页面(隐藏或点出)时间内,每个来自iframe内的Longtask数量 280 | pageIframeLongtaskNum: number; 281 | } 282 | export type PageLongtaskCB = (metric: PageLongtaskMetric) => void; 283 | 284 | 285 | export interface ResOption { 286 | ignorePaths?: string[]; 287 | trigger?: 'load' | 'leave'; 288 | } 289 | 290 | export interface BigImgOption { 291 | /** 292 | * 体积大于该阈值,就认为是大图,默认150,单位是kb 293 | */ 294 | maxSize?: number; 295 | /** 296 | * 忽略指定path的资源 297 | */ 298 | ignorePaths?: string[]; 299 | /** 300 | * 触发时机,在load事件触发后,还是在用户离开页面后,收集出现的加载慢的资源。,默认是load 301 | */ 302 | trigger?: 'load' | 'leave'; 303 | } 304 | 305 | export interface HttpResOption { 306 | /** 307 | * 忽略指定path的资源 308 | */ 309 | ignorePaths?: string[]; 310 | /** 311 | * 触发时机,在load事件触发后,还是在用户离开页面后,收集出现的加载慢的资源。,默认是load 312 | */ 313 | trigger?: 'load' | 'leave'; 314 | } 315 | 316 | export interface SlowOption { 317 | /** 318 | * 加载时长大于该阈值,就认为是慢资源,默认1000,单位是ms 319 | */ 320 | threshold?: number; 321 | /** 322 | * 忽略指定path的资源 323 | */ 324 | ignorePaths?: string[]; 325 | /** 326 | * 触发时机,在load事件触发后,还是在用户离开页面后,收集出现的加载慢的资源。,默认是load 327 | */ 328 | trigger?: 'load' | 'leave'; 329 | } 330 | 331 | 332 | -------------------------------------------------------------------------------- /src/lib/spyHeadInterface.ts: -------------------------------------------------------------------------------- 1 | export interface ErrorHandlerData { 2 | info: { 3 | [propName: string]: any; 4 | msg: string; 5 | }; 6 | group: string; 7 | dim?: { 8 | [propName: string]: any; 9 | }; 10 | } 11 | 12 | export interface ErrorConf { 13 | // 抽样, 取值在[0, 1],为0,则相当于禁用,默认是0 14 | sample: number; 15 | // 自定义spy平台的分组 16 | group: string; 17 | // 异常数据上报前的自定义处理,可以修改data对象,增加信息和维度,如果返回false,则表示不上报该数据 18 | handler?: (data: ErrorHandlerData) => boolean | void | undefined; 19 | } 20 | 21 | export interface WhiteScreenErrorConf extends ErrorConf { 22 | // 超时时间,在到达超时时间后,执行检测,如果条件不成立,则认为会抛出白屏错误 23 | timeout: number; 24 | // css选择器 25 | selector: string; 26 | // css选择器 27 | subSelector: string; 28 | } 29 | 30 | export interface SpyHeadConf { 31 | // spy平台申请的pid 32 | pid: string; 33 | // 可选,用户log id 34 | lid?: string; 35 | // 可选,自定义日志服务器 36 | logServer?: string; 37 | jsError: ErrorConf; 38 | resourceError: ErrorConf; 39 | whiteScreenError: WhiteScreenErrorConf; 40 | } -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file utils 3 | * @author kaivean 4 | */ 5 | 6 | export function assign(...args: any[]) { 7 | const __assign = Object.assign || function __assign(t: any) { 8 | for (let s, i = 1, n = arguments.length; i < n; i++) { 9 | // eslint-disable-next-line prefer-rest-params 10 | s = arguments[i]; 11 | for (const p in s) { 12 | if (Object.prototype.hasOwnProperty.call(s, p)) { 13 | t[p] = s[p]; 14 | } 15 | } 16 | } 17 | return t; 18 | }; 19 | return __assign.apply(this, args); 20 | }; 21 | 22 | export interface URLINFO { 23 | protocol: string; 24 | host: string; 25 | pathname: string; 26 | ext: string; 27 | }; 28 | 29 | function getUrlInfoFromURL(url: string): URLINFO | undefined { 30 | if (URL && url) { 31 | try { 32 | const obj = new URL(url); 33 | if (obj.host !== undefined) { 34 | return { 35 | protocol: obj.protocol, 36 | host: obj.host, 37 | pathname: obj.pathname, 38 | ext: '', 39 | }; 40 | } 41 | } 42 | catch (e) { 43 | console.error(e); 44 | } 45 | } 46 | return; 47 | } 48 | 49 | export function getUrlInfo(url: string): URLINFO { 50 | let info = getUrlInfoFromURL(url); 51 | 52 | if (!info) { 53 | const parser = document.createElement('a'); 54 | parser.href = url; 55 | info = { 56 | protocol: parser.protocol, 57 | host: parser.host || location.host, 58 | pathname: parser.pathname, 59 | ext: '', 60 | }; 61 | } 62 | 63 | const split = info.pathname.split('.'); 64 | info.ext = split[split.length - 1]; 65 | return info; 66 | } 67 | 68 | function f(n: number): number { 69 | return +n.toFixed(1); 70 | } 71 | 72 | export function getResTiming(t: PerformanceTiming | PerformanceResourceTiming) { 73 | return { 74 | wait: f(t.domainLookupStart - ((t as any).navigationStart || t.fetchStart || (t as any).startTime)), 75 | dns: f(t.domainLookupEnd - t.domainLookupStart), 76 | connect: f(t.connectEnd - t.connectStart), 77 | req: f(t.responseStart - t.requestStart), 78 | res: f(t.responseEnd - t.responseStart), 79 | }; 80 | } 81 | 82 | 83 | export function getxpath(el: Element | null) { 84 | if (!el) { 85 | return {xpath: ''}; 86 | } 87 | 88 | const xpath = []; 89 | while (el && el.nodeType === 1 && el !== el.parentNode) { 90 | let t = el.tagName.toLowerCase(); 91 | if (el.getAttribute('id')) { 92 | t += '[#' + el.getAttribute('id') + ']'; 93 | } 94 | else if (el.classList && el.classList.length) { 95 | t += '[.' + el.classList[el.classList.length - 1] + ']'; 96 | } 97 | xpath.push(t); 98 | if (el === document.body) { 99 | break; 100 | } 101 | el = el.parentNode as HTMLElement; // 修复缺陷检查 102 | } 103 | return { 104 | xpath: xpath.join('<'), 105 | }; 106 | } -------------------------------------------------------------------------------- /src/module/fid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file FID 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, FIDCB} from '../lib/interface'; 7 | 8 | const spyclient = (window as any).__spyHead; 9 | const entryType = 'first-input'; 10 | 11 | export default class FID implements Module { 12 | private observer: PerformanceObserver | null = null; 13 | private cb: FIDCB; 14 | private finalValue: number; 15 | constructor() { 16 | if (!window.PerformanceObserver) { 17 | return; 18 | } 19 | 20 | if (spyclient && spyclient.entryMap && spyclient.entryMap[entryType]) { 21 | this.handle(spyclient.entryMap[entryType]); 22 | } 23 | try { 24 | this.observer = new PerformanceObserver(list => { 25 | this.handle(list.getEntries()); 26 | }); 27 | this.observer.observe({entryTypes: [entryType]}); 28 | } 29 | catch (e) {} 30 | } 31 | 32 | listenFID(cb: FIDCB) { 33 | this.cb = cb; 34 | this.callCB(); 35 | } 36 | 37 | callCB() { 38 | if (this.finalValue !== undefined && this.cb) { 39 | this.cb({fid: this.finalValue}); 40 | } 41 | } 42 | 43 | destroy() { 44 | this.observer && this.observer.disconnect(); 45 | this.observer = null; 46 | } 47 | 48 | private handle(entries: PerformanceEntryList) { 49 | const lastEntry = entries.pop(); 50 | if (lastEntry) { 51 | this.finalValue = lastEntry.duration; 52 | this.callCB(); 53 | this.destroy(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/module/layoutShift.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file LayoutShift 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, LayoutShiftCB} from '../lib/interface'; 7 | 8 | 9 | const spyclient = (window as any).__spyHead; 10 | const entryType = 'layout-shift'; 11 | 12 | export default class LayoutShift implements Module { 13 | private observer: PerformanceObserver | null = null; 14 | private cb: LayoutShiftCB; 15 | private value: number; 16 | private finalValue: number; 17 | private onceLeave = false; 18 | constructor() { 19 | if (!window.PerformanceObserver) { 20 | return; 21 | } 22 | 23 | // 先消费之前的 24 | if (spyclient && spyclient.entryMap && spyclient.entryMap[entryType]) { 25 | this.handle(spyclient.entryMap[entryType]); 26 | } 27 | 28 | try { 29 | this.observer = new PerformanceObserver(list => { 30 | this.handle(list.getEntries()); 31 | }); 32 | 33 | this.observer.observe({entryTypes: [entryType]}); 34 | } 35 | catch (e) {} 36 | } 37 | 38 | listenLayoutShift(cb: LayoutShiftCB) { 39 | this.cb = cb; 40 | } 41 | 42 | leave() { 43 | if (!this.onceLeave) { 44 | this.onceLeave = true; 45 | this.observer && this.observer.takeRecords && this.observer.takeRecords(); 46 | this.finalValue = this.value; 47 | this.callCB(); 48 | this.destroy(); 49 | } 50 | } 51 | 52 | callCB() { 53 | if (this.finalValue !== undefined && this.cb) { 54 | this.cb({layoutShift: this.finalValue}); 55 | } 56 | } 57 | 58 | destroy() { 59 | this.observer && this.observer.disconnect(); 60 | this.observer = null; 61 | } 62 | 63 | private handle(entries: PerformanceEntryList) { 64 | entries.map(entry => { 65 | // hadRecentInput 如果过去500毫秒内有用户输入,则返回 true, 刚进入页面内500ms,不会认为是layoutShift 66 | if (!entry.hadRecentInput) { 67 | this.value = (this.value || 0) + (entry.value || 0); 68 | } 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/module/lcp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file LCP 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, LCPCB} from '../lib/interface'; 7 | import {setData} from '../lib/data'; 8 | 9 | const spyclient = (window as any).__spyHead; 10 | const entryType = 'largest-contentful-paint'; 11 | 12 | export default class LCP implements Module { 13 | private observer: PerformanceObserver | null = null; 14 | private value: number; 15 | private finalValue: number; 16 | private cb: LCPCB; 17 | constructor() { 18 | if (!window.PerformanceObserver) { 19 | return; 20 | } 21 | if (spyclient && spyclient.entryMap && spyclient.entryMap[entryType]) { 22 | this.handle(spyclient.entryMap[entryType]); 23 | } 24 | try { 25 | // 仅在 img,image,svg,video,css url, block element with text nodes 26 | this.observer = new PerformanceObserver(list => { 27 | this.handle(list.getEntries()); 28 | }); 29 | this.observer.observe({entryTypes: [entryType]}); 30 | } 31 | catch (e) {} 32 | } 33 | 34 | listenLCP(cb: LCPCB) { 35 | this.cb = cb; 36 | this.callCB(); 37 | } 38 | 39 | callCB() { 40 | if (this.finalValue && this.cb) { 41 | this.cb({lcp: this.finalValue}); 42 | } 43 | } 44 | 45 | load() { 46 | this.observer && this.observer.takeRecords && this.observer.takeRecords(); 47 | this.finalValue = this.value; 48 | setData('lcp', this.value); 49 | this.callCB(); 50 | this.destroy(); 51 | } 52 | 53 | destroy() { 54 | this.observer && this.observer.disconnect(); 55 | this.observer = null; 56 | } 57 | 58 | private handle(entries: PerformanceEntryList) { 59 | entries.map(entry => { 60 | this.value = entry.renderTime || entry.loadTime; 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/module/longtask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Longtask 3 | * @author kaivean 4 | */ 5 | 6 | 7 | import {Module, FSPLongtaskCB, LCPLongtaskCB, LoadLongtaskCB, PageLongtaskCB} from '../lib/interface'; 8 | import {getData} from '../lib/data'; 9 | 10 | export interface LongtaskData { 11 | longtaskTime?: number; 12 | longtaskRate?: number; 13 | longtaskNum?: number; 14 | totalTime?: number; 15 | longtaskIframeTime?: number; 16 | longtaskIframeNum?: number; 17 | longtaskIframeRate?: number; 18 | iframeLongtasks?: {[propName: string]: number[] | undefined}; 19 | } 20 | 21 | const spyclient = window.__spyHead; 22 | const entryType = 'longtask'; 23 | 24 | export default class Longtask implements Module { 25 | private lts: PerformanceEntryList = []; 26 | private observer: PerformanceObserver | null = null; 27 | private fspCB: FSPLongtaskCB; 28 | private lcpCB: LCPLongtaskCB; 29 | private loadCB: LoadLongtaskCB; 30 | private pageCB: PageLongtaskCB; 31 | private onceLeave = false; 32 | constructor() { 33 | if (spyclient && spyclient.entryMap && spyclient.entryMap[entryType]) { 34 | this.lts = this.lts.concat(spyclient.entryMap[entryType]); 35 | } 36 | try { 37 | this.observer = new PerformanceObserver(list => { 38 | this.lts = this.lts.concat(list.getEntries()); 39 | }); 40 | // buffered 兼容性太差 41 | this.observer.observe({entryTypes: [entryType]}); 42 | } 43 | catch (e) {} 44 | } 45 | 46 | check() { 47 | return window.PerformanceObserver && performance && performance.timing && performance.timing.navigationStart; 48 | } 49 | 50 | listenFSPLongTask(cb: FSPLongtaskCB) { 51 | if (!this.check()) { 52 | return; 53 | } 54 | this.fspCB = cb; 55 | } 56 | 57 | listenLCPLongTask(cb: LCPLongtaskCB) { 58 | if (!this.check()) { 59 | return; 60 | } 61 | this.lcpCB = cb; 62 | } 63 | 64 | listenLoadLongTask(cb: LoadLongtaskCB) { 65 | if (!this.check()) { 66 | return; 67 | } 68 | this.loadCB = cb; 69 | } 70 | 71 | listenPageLongTask(cb: PageLongtaskCB) { 72 | if (!this.check()) { 73 | return; 74 | } 75 | this.pageCB = cb; 76 | } 77 | load() { 78 | if (!this.check()) { 79 | return; 80 | } 81 | 82 | const data = this.getStatData(Date.now()); 83 | 84 | // lcp的值存入data模块也是在load期间,这里延迟一会再获取 85 | setTimeout(() => { 86 | this.loadCB && this.loadCB({ 87 | loadLongtaskTime: data.time, 88 | loadTBT: data.tbt, 89 | loadTotalTime: data.totalTime, 90 | loadLongtaskRate: data.rate, 91 | loadLongtaskNum: data.num, 92 | }); 93 | 94 | if (performance.timing && performance.timing.domFirstScreenPaint) { 95 | const data = this.getStatData(performance.timing.domFirstScreenPaint); 96 | this.fspCB && this.fspCB({ 97 | fspLongtaskTime: data.time, 98 | fspTBT: data.tbt, 99 | fspTotalTime: data.totalTime, 100 | fspLongtaskRate: data.rate, 101 | fspLongtaskNum: data.num, 102 | }); 103 | } 104 | 105 | if (getData('lcp')) { 106 | const data = this.getStatData(performance.timing.navigationStart + (getData('lcp') as number)); 107 | this.lcpCB && this.lcpCB({ 108 | lcpLongtaskTime: data.time, 109 | lcpTBT: data.tbt, 110 | lcpTotalTime: data.totalTime, 111 | lcpLongtaskRate: data.rate, 112 | lcpLongtaskNum: data.num, 113 | }); 114 | } 115 | }, 200); 116 | } 117 | 118 | leave() { 119 | if (!this.onceLeave) { 120 | this.onceLeave = true; 121 | 122 | const data = this.getStatData(Date.now()); 123 | this.pageCB && this.pageCB({ 124 | pageLongtaskTime: data.time, 125 | pageTBT: data.tbt, 126 | pageTotalTime: data.totalTime, 127 | pageLongtaskRate: data.rate, 128 | pageLongtaskNum: data.num, 129 | pageIframeLongtaskTime: data.iframeTime, 130 | pageIframeLongtaskRate: data.iframeRate, 131 | pageIframeLongtaskNum: data.iframeNum, 132 | }); 133 | } 134 | } 135 | 136 | 137 | destroy() { 138 | this.lts = []; 139 | this.observer && this.observer.disconnect(); 140 | this.observer = null; 141 | } 142 | 143 | getStatData(finalTime: number) { 144 | const navigationStart = performance.timing.navigationStart; 145 | let time = 0; 146 | // Total Blocking Time 147 | let tbt = 0; 148 | let num = 0; 149 | let iframeTime = 0; 150 | let iframeNum = 0; 151 | let iframeLongtasks = {} as any; 152 | 153 | for (let index = 0; index < this.lts.length; index++) { 154 | const item = this.lts[index]; 155 | const duration = item.duration; 156 | const end = navigationStart + item.startTime + duration; 157 | 158 | if (end < finalTime) { 159 | time += duration; 160 | // 多出来的时间 161 | tbt += (duration - 50 > 0 ? duration - 50 : 0); 162 | num++; 163 | 164 | if (item.attribution && item.attribution[0]) { 165 | const containerSrc = item.attribution[0].containerSrc; 166 | if (containerSrc && containerSrc !== location.href) { 167 | if (!iframeLongtasks[containerSrc]) { 168 | iframeLongtasks[containerSrc] = []; 169 | } 170 | iframeLongtasks[containerSrc].push(duration); 171 | 172 | iframeTime += duration; 173 | iframeNum++; 174 | } 175 | } 176 | } 177 | } 178 | 179 | const totalTime = finalTime - navigationStart; 180 | 181 | return { 182 | num, 183 | time, 184 | tbt, 185 | totalTime: totalTime, 186 | rate: 100 * time / totalTime, 187 | iframeTime, 188 | iframeNum, 189 | iframeRate: 100 * iframeTime / totalTime, 190 | iframeLongtasks, 191 | }; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/module/memory.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Memory 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, MemoryCB} from '../lib/interface'; 7 | 8 | let initMemory: MemoryInfo | null = null; 9 | if (window.performance && window.performance.memory) { 10 | initMemory = window.performance.memory; 11 | } 12 | 13 | export default class Memory implements Module { 14 | private cb: MemoryCB; 15 | private onceLeave = false; 16 | // constructor() {} 17 | 18 | listenMemory(cb: MemoryCB) { 19 | this.cb = cb; 20 | } 21 | 22 | leave() { 23 | if (!this.onceLeave && initMemory) { 24 | this.onceLeave = true; 25 | 26 | const curMemory = window.performance.memory as MemoryInfo; 27 | // fix 早期浏览器的memroy api 值不更新的问题,将此情况排除 28 | if (curMemory.usedJSHeapSize === initMemory.usedJSHeapSize 29 | && curMemory.totalJSHeapSize === initMemory.totalJSHeapSize 30 | ) { 31 | return; 32 | } 33 | 34 | const memory = window.performance.memory as MemoryInfo; 35 | this.cb && this.cb({ 36 | usedJSHeapSize: memory.usedJSHeapSize / 1024, 37 | totalJSHeapSize: memory.totalJSHeapSize / 1024, 38 | jsHeapSizeLimit: memory.jsHeapSizeLimit / 1024, 39 | usedJSHeapRate: 100 * memory.usedJSHeapSize / memory.totalJSHeapSize, 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/module/navigatorInfo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file NavigatorInfo 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, NavigatorInfoMetric} from '../lib/interface'; 7 | 8 | export default class NavigatorInfo implements Module { 9 | // private cb: NavigatorInfoCB; 10 | 11 | getNavigatorInfo() { 12 | // this.cb = cb; 13 | 14 | let ret: NavigatorInfoMetric = {}; 15 | const dataConnection = navigator.connection; 16 | if (typeof dataConnection === 'object') { 17 | ret = { 18 | downlink: dataConnection.downlink, // 网站下载速度 M/s 19 | effectiveType: dataConnection.effectiveType, // 网络类型 20 | rtt: dataConnection.rtt, // 网络往返时间 ms 21 | saveData: !!dataConnection.saveData, // 数据节约模式 22 | }; 23 | } 24 | 25 | // 内存 G 26 | if (navigator.deviceMemory) { 27 | ret.deviceMemory = navigator.deviceMemory; 28 | } 29 | // 核数 30 | if (navigator.hardwareConcurrency) { 31 | ret.hardwareConcurrency = navigator.deviceMemory; 32 | } 33 | return ret; 34 | } 35 | 36 | load() {} 37 | 38 | // load() { 39 | // let ret: NavigatorInfoMetric = {}; 40 | // const dataConnection = navigator.connection; 41 | // if (typeof dataConnection === 'object') { 42 | // ret = { 43 | // downlink: dataConnection.downlink, // 网站下载速度 M/s 44 | // effectiveType: dataConnection.effectiveType, // 网络类型 45 | // rtt: dataConnection.rtt, // 网络往返时间 ms 46 | // saveData: !!dataConnection.saveData, // 数据节约模式 47 | // }; 48 | // } 49 | 50 | // // 内存 G 51 | // if (navigator.deviceMemory) { 52 | // ret.deviceMemory = navigator.deviceMemory; 53 | // } 54 | // // 核数 55 | // if (navigator.hardwareConcurrency) { 56 | // ret.hardwareConcurrency = navigator.deviceMemory; 57 | // } 58 | 59 | // this.cb && this.cb(ret); 60 | // } 61 | } 62 | -------------------------------------------------------------------------------- /src/module/resource.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Module, ResourceMetric, ResourceCB, ResourceErrorCB, ResType, 4 | ResOption, 5 | BigImgOption, 6 | HttpResOption, 7 | SlowOption, 8 | ResourceHostMetric, 9 | } from '../lib/interface'; 10 | import {getUrlInfo, URLINFO, assign, getResTiming, getxpath} from '../lib/util'; 11 | 12 | // 字体 13 | // svg使用场景比较灵活,可能作为一种字体格式,也可能当做图片使用 14 | // 但因woff在所有现代浏览器中都支持,在css中使用iconfont时 15 | // 往往优先加载woff格式字体文件,所以这里将svg划分为图片一类 16 | enum FontsTypes { 17 | ttf, 18 | eot, 19 | woff, 20 | woff2 21 | }; 22 | 23 | 24 | // 忽略图片 25 | // 下述图片路径往往是用来进行日志统计,不是正常图片 26 | const ignoreDefaultPaths = [ 27 | '/mwb2.gif', 28 | ]; 29 | 30 | function ignorePath(url: string, paths: string[]) { 31 | for (const path of paths) { 32 | if (url.indexOf(path) > -1) { 33 | return true; 34 | } 35 | } 36 | return false; 37 | } 38 | 39 | function f(n: number): number { 40 | return +n.toFixed(1); 41 | } 42 | 43 | interface ListItem { 44 | timing: PerformanceResourceTiming; 45 | type: string; 46 | } 47 | 48 | interface ListWithHost { 49 | [host: string]: ListItem[]; 50 | } 51 | 52 | export default class Resource implements Module { 53 | private cb: ResourceCB; 54 | private bigImgCB: ResourceErrorCB; 55 | private httpResCB: ResourceErrorCB; 56 | private slowResCB: ResourceErrorCB; 57 | private resOption: ResOption; 58 | private bigImgOption: BigImgOption; 59 | private httpResOption: HttpResOption; 60 | private slowOption: SlowOption; 61 | private trigger: string = 'load'; 62 | 63 | private readonly jsList: PerformanceResourceTiming[]; 64 | private readonly cssList: PerformanceResourceTiming[]; 65 | private readonly imgList: PerformanceResourceTiming[]; 66 | private readonly fontList: PerformanceResourceTiming[]; 67 | 68 | private readonly hostList: ListWithHost; 69 | private readonly bigImgList: ListWithHost; 70 | private readonly httpResList: ListWithHost; 71 | private readonly slowList: ListWithHost; 72 | 73 | constructor() { 74 | this.jsList = []; 75 | this.cssList = []; 76 | this.imgList = []; 77 | this.fontList = []; 78 | 79 | this.hostList = {}; 80 | this.bigImgList = {}; 81 | this.httpResList = {}; 82 | this.slowList = {}; 83 | 84 | this.resOption = {ignorePaths: [], trigger: 'load'}; 85 | this.bigImgOption = {ignorePaths: [], maxSize: 150 * 1024, trigger: 'load'}; 86 | this.httpResOption = {ignorePaths: [], trigger: 'load'}; 87 | this.slowOption = {ignorePaths: [], trigger: 'load', threshold: 1000}; 88 | } 89 | 90 | check() { 91 | return performance && performance.getEntriesByType; 92 | } 93 | 94 | listenResource(cb: ResourceCB, option: ResOption = {}) { 95 | if (!this.check()) { 96 | return; 97 | } 98 | this.resOption = assign(this.resOption, option); 99 | this.trigger = this.resOption.trigger as string; 100 | this.cb = cb; 101 | } 102 | 103 | listenBigImg(cb: ResourceErrorCB, option: BigImgOption = {}) { 104 | if (!this.check()) { 105 | return; 106 | } 107 | this.bigImgOption = assign(this.bigImgOption, option); 108 | this.trigger = this.bigImgOption.trigger as string; 109 | this.bigImgCB = cb; 110 | } 111 | 112 | listenHttpResource(cb: ResourceErrorCB, option: HttpResOption = {}) { 113 | if (!this.check()) { 114 | return; 115 | } 116 | this.httpResOption = assign(this.httpResOption, option); 117 | this.trigger = this.httpResOption.trigger as string; 118 | this.httpResCB = cb; 119 | } 120 | 121 | listenSlowResource(cb: ResourceErrorCB, option: SlowOption = {}) { 122 | if (!this.check()) { 123 | return; 124 | } 125 | this.slowOption = assign(this.slowOption, option); 126 | this.trigger = this.slowOption.trigger as string; 127 | this.slowResCB = cb; 128 | } 129 | 130 | report() { 131 | const metricRes = this.getMetric(); 132 | if (metricRes) { 133 | const {metric, hostMetric} = metricRes; 134 | this.cb && this.cb(metric, hostMetric); 135 | } 136 | 137 | if (this.bigImgCB) { 138 | // 发送大图监控数据 139 | if (typeof window.requestIdleCallback === 'function' && Object.keys(this.bigImgList).length) { 140 | window.requestIdleCallback(() => { 141 | for (const host of Object.keys(this.bigImgList)) { 142 | for (const entry of this.bigImgList[host]) { 143 | const timing = entry.timing; 144 | const type = entry.type; 145 | try { 146 | const img = document.body.querySelector('img[src="' + timing.name + '"]'); 147 | this.bigImgCB({ 148 | msg: timing.name, 149 | dur: timing.duration, 150 | xpath: getxpath(img).xpath, 151 | host, 152 | type, 153 | }); 154 | } 155 | catch (e) { 156 | console.error(e); 157 | } 158 | } 159 | } 160 | }); 161 | } 162 | } 163 | 164 | if (this.httpResCB) { 165 | // 发送http资源监控数据 166 | if (typeof window.requestIdleCallback === 'function' && Object.keys(this.httpResList).length) { 167 | window.requestIdleCallback(() => { 168 | for (const host of Object.keys(this.httpResList)) { 169 | for (const entry of this.httpResList[host]) { 170 | const timing = entry.timing; 171 | const type = entry.type; 172 | try { 173 | const img = document.body.querySelector('[src="' + timing.name + '"]'); 174 | this.httpResCB({ 175 | msg: timing.name, 176 | dur: timing.duration, 177 | xpath: getxpath(img).xpath, 178 | host, 179 | type, 180 | }); 181 | } 182 | catch (e) { 183 | console.error(e); 184 | } 185 | } 186 | } 187 | }); 188 | } 189 | } 190 | 191 | if (this.slowResCB) { 192 | // 发送慢资源监控数据 193 | if (typeof window.requestIdleCallback === 'function' && Object.keys(this.slowList).length) { 194 | window.requestIdleCallback(() => { 195 | for (const host of Object.keys(this.slowList)) { 196 | for (const entry of this.slowList[host]) { 197 | const timing = entry.timing; 198 | const type = entry.type; 199 | try { 200 | const img = document.body.querySelector('[src="' + timing.name + '"]'); 201 | 202 | const info = getResTiming(timing); 203 | 204 | this.slowResCB({ 205 | ...info, 206 | dur: timing.duration, 207 | msg: timing.name, 208 | xpath: getxpath(img).xpath, 209 | host, 210 | type, 211 | }); 212 | } 213 | catch (e) { 214 | console.error(e); 215 | } 216 | } 217 | } 218 | }); 219 | } 220 | } 221 | } 222 | 223 | load() { 224 | if (this.trigger !== 'load') { 225 | return; 226 | } 227 | if (!this.check()) { 228 | return; 229 | } 230 | 231 | setTimeout(() => { 232 | this.report(); 233 | }, 500); 234 | } 235 | 236 | leave() { 237 | if (this.trigger !== 'leave') { 238 | return; 239 | } 240 | this.report(); 241 | } 242 | 243 | private push(type: string, list: any, timing: PerformanceResourceTiming, urlInfo: URLINFO) { 244 | if (!ignorePath(urlInfo.pathname, this.resOption.ignorePaths || [])) { 245 | list.push(timing); 246 | this.pushWithHost(type, this.hostList, timing, urlInfo); 247 | } 248 | 249 | if (!ignorePath(urlInfo.pathname, this.slowOption.ignorePaths || [])) { 250 | if (timing.duration > (this.slowOption.threshold as number)) { 251 | this.pushWithHost(type, this.slowList, timing, urlInfo); 252 | } 253 | } 254 | 255 | } 256 | 257 | private pushWithHost(type: string, list: any, timing: PerformanceResourceTiming, urlInfo: URLINFO) { 258 | const host = urlInfo.host; 259 | if (!list[host]) { 260 | list[host] = [] as ListItem[]; 261 | } 262 | list[host].push({ 263 | timing, 264 | type, 265 | }); 266 | } 267 | 268 | private collectHttpResInHttps(type: string, timing: PerformanceResourceTiming, urlInfo: URLINFO) { 269 | if (location.protocol === 'https:' 270 | && timing.name.indexOf('http://') === 0 271 | && !ignorePath(urlInfo.pathname, this.httpResOption.ignorePaths || []) 272 | ) { 273 | this.pushWithHost(type, this.httpResList, timing, urlInfo); 274 | } 275 | } 276 | 277 | // 有些jsonp也属于script,这里只统计js后缀的文件 278 | private addScript(timing: PerformanceResourceTiming, urlInfo: URLINFO) { 279 | if (urlInfo.ext === 'js') { 280 | if (timing.decodedBodySize !== 0) { 281 | this.push('js', this.jsList, timing, urlInfo); 282 | } 283 | } 284 | } 285 | 286 | // 暂时将css文件或代码块发起的请求归位三类(主要为这两类) 287 | // 1、加载字体 288 | // 2、加载背景图(图片不容易区分,有的没有明确后缀名) 289 | // 3、光标文件(后缀为.cur,这里也划分为图片) 290 | // (svg当做图片,前述已说明) 291 | private addResFromCss(timing: PerformanceResourceTiming, urlInfo: URLINFO) { 292 | if (urlInfo.ext && FontsTypes.hasOwnProperty(urlInfo.ext)) { 293 | this.push('font', this.fontList, timing, urlInfo); 294 | } 295 | else { 296 | this.addImg(timing, urlInfo); 297 | } 298 | } 299 | 300 | // link一般加载css资源 301 | // 也可以通过preload可以预下载一些资源 302 | // 这里只统计js类型的preload 303 | private addLink(timing: PerformanceResourceTiming, urlInfo: URLINFO) { 304 | if (urlInfo.ext === 'css') { 305 | this.push('css', this.cssList, timing, urlInfo); 306 | } 307 | // preload as script 308 | else if (urlInfo.ext === 'js') { 309 | this.push('js', this.jsList, timing, urlInfo); 310 | } 311 | } 312 | 313 | private addImg(timing: PerformanceResourceTiming, urlInfo: URLINFO) { 314 | this.push('img', this.imgList, timing, urlInfo); 315 | 316 | // 大于指定size的图片采集 317 | if ( 318 | timing.decodedBodySize > (this.bigImgOption.maxSize || 0) 319 | && !ignorePath(urlInfo.pathname, this.bigImgOption.ignorePaths || []) 320 | ) { 321 | this.pushWithHost('img', this.bigImgList, timing, urlInfo); 322 | } 323 | } 324 | 325 | private handleTimings(list: PerformanceEntry[]) { 326 | const len = list.length; 327 | for (let i = 0; i < len; i++) { 328 | const timing = list[i] as PerformanceResourceTiming; 329 | const urlInfo = getUrlInfo(timing.name); 330 | 331 | if (ignorePath(urlInfo.pathname, ignoreDefaultPaths)) { 332 | continue; 333 | } 334 | switch ((list[i] as any).initiatorType) { 335 | case 'script': 336 | this.addScript(timing, urlInfo); 337 | break; 338 | case 'css': 339 | this.addResFromCss(timing, urlInfo); 340 | break; 341 | case 'img': 342 | this.addImg(timing, urlInfo); 343 | break; 344 | case 'link': 345 | this.addLink(timing, urlInfo); 346 | break; 347 | case 'audio': 348 | this.collectHttpResInHttps('audio', timing, urlInfo); 349 | break; 350 | case 'video': 351 | this.collectHttpResInHttps('video', timing, urlInfo); 352 | break; 353 | default: 354 | break; 355 | } 356 | } 357 | } 358 | 359 | private getNumAndSize(type: string, list: PerformanceResourceTiming[]) { 360 | const obj: any = {}; 361 | const num = type + 'Num'; 362 | const size = type + 'Size'; 363 | const transferSize = type + 'TransferSize'; 364 | const cacheRate = type + 'CacheRate'; 365 | const duration = type + 'Duration'; 366 | obj[num] = 0; 367 | obj[size] = 0; 368 | obj[transferSize] = 0; 369 | let totalDurationTime = 0; 370 | list.forEach((timing: PerformanceResourceTiming) => { 371 | obj[num]++; 372 | obj[size] += (timing.decodedBodySize / 1024); 373 | obj[transferSize] += (timing.transferSize / 1024); 374 | totalDurationTime += timing.duration; 375 | }); 376 | obj[duration] = f(obj[num] > 0 ? totalDurationTime / obj[num] : 0); 377 | 378 | if (obj[size]) { 379 | const diff = obj[size] - obj[transferSize]; 380 | obj[cacheRate] = f(diff >= 0 ? 100 * diff / obj[size] : 0); 381 | } 382 | 383 | obj[size] = f(obj[size]); 384 | obj[transferSize] = f(obj[transferSize]); 385 | 386 | return obj; 387 | } 388 | 389 | private getMetric(): {metric: ResourceMetric, hostMetric: ResourceHostMetric} | undefined { 390 | // 原来代码 391 | const {0: mainPageTiming} = performance.getEntriesByType('navigation'); 392 | const resourceTimings = performance.getEntriesByType('resource'); 393 | 394 | if (mainPageTiming && resourceTimings && resourceTimings.length) { 395 | this.handleTimings(resourceTimings); 396 | 397 | let metric: ResourceMetric = { 398 | ...this.getNumAndSize(ResType.JS, this.jsList), 399 | ...this.getNumAndSize(ResType.CSS, this.cssList), 400 | ...this.getNumAndSize(ResType.IMG, this.imgList), 401 | ...this.getNumAndSize(ResType.FONT, this.fontList), 402 | }; 403 | 404 | // 主文档大小 405 | const pageTiming = mainPageTiming as PerformanceResourceTiming; 406 | metric.docSize = f(pageTiming.decodedBodySize / 1024); 407 | metric.docTransferSize = f(pageTiming.transferSize / 1024); 408 | 409 | metric.headerSize = f((pageTiming.transferSize - pageTiming.encodedBodySize || 0) / 1024); 410 | 411 | metric.allSize = f(metric.docSize 412 | + metric.jsSize 413 | + metric.cssSize 414 | + metric.imgSize 415 | + metric.fontSize); 416 | 417 | metric.allTransferSize = f(metric.docTransferSize 418 | + metric.jsTransferSize 419 | + metric.cssTransferSize 420 | + metric.imgTransferSize 421 | + metric.fontTransferSize); 422 | 423 | const hostMetric: ResourceHostMetric = {}; 424 | for (const host of Object.keys(this.hostList)) { 425 | const timings = this.hostList[host].map(item => item.timing); 426 | hostMetric[host] = this.getNumAndSize('host', timings); 427 | } 428 | 429 | return {metric, hostMetric}; 430 | } 431 | return; 432 | } 433 | } -------------------------------------------------------------------------------- /src/module/timing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file LCP 3 | * @author kaivean 4 | */ 5 | 6 | import {Module, TimingCB, TimingMetric} from '../lib/interface'; 7 | 8 | export default class Timing implements Module { 9 | private cb: TimingCB; 10 | // constructor() {} 11 | 12 | listenTiming(cb: TimingCB) { 13 | if (!window.performance || !window.performance.timing) { 14 | // debug('no performance.timing api'); 15 | return; 16 | } 17 | this.cb = cb; 18 | } 19 | 20 | load() { 21 | setTimeout(() => { 22 | this.cb && this.cb(this.getMetric()); 23 | }, 500); 24 | } 25 | 26 | private getMetric(): TimingMetric { 27 | const metric = {} as TimingMetric; 28 | const timing: PerformanceTiming = window.performance.timing; 29 | 30 | const startTime = timing.navigationStart; 31 | 32 | // 基础信息 33 | metric.dns = timing.domainLookupEnd - timing.domainLookupStart; 34 | metric.tcp = timing.connectEnd - timing.connectStart; 35 | metric.request = timing.responseStart - timing.requestStart; 36 | metric.response = timing.responseEnd - timing.responseStart; 37 | // 这是从页面部分数据返回,浏览器开始解析doc元素到最底部的script脚本解析执行完成 38 | // 脚本里触发的异步或绑定了更靠后的事件,不再纳入范围内 39 | metric.parseHtml = timing.domInteractive - timing.domLoading; 40 | // 很多事件绑定是在domContentLoaded事件里的,所以等其结束,一般页面元素的事件绑定好了,用户可以正确交互 41 | // 当然存在在该加载事件之后绑定元素事件情况,但不再此考虑范围内 42 | metric.domReady = timing.domContentLoadedEventEnd - startTime; 43 | metric.loadEventHandle = timing.loadEventEnd - timing.loadEventStart; 44 | // 基本该做的都做完,资源也都加载完成了 45 | // 当然在onload事件处理函数里启动了异步方法,不再纳入范围内 46 | metric.load = timing.loadEventEnd - startTime; 47 | 48 | // 浏览器首次绘制与首次内容绘制, ios低版本无getEntriesByType api 49 | if (performance.getEntriesByType) { 50 | const paintEntries: PerformanceEntry[] = performance.getEntriesByType('paint'); 51 | if (paintEntries && paintEntries.length) { 52 | paintEntries.forEach(({name, duration, startTime}) => { 53 | const time = Math.ceil(duration + startTime); 54 | if (name === 'first-paint') { 55 | metric.fp = time; 56 | } 57 | if (name === 'first-contentful-paint') { 58 | metric.fcp = time; 59 | } 60 | }); 61 | } 62 | } 63 | 64 | // 端首次绘制与首屏 65 | if (timing.domFirstPaint) { 66 | metric.t7FirstPaint = timing.domFirstPaint - startTime; 67 | } 68 | 69 | if (timing.domFirstScreenPaint) { 70 | metric.t7FirstScreen = timing.domFirstScreenPaint - startTime; 71 | } 72 | 73 | return metric; 74 | } 75 | } -------------------------------------------------------------------------------- /src/module/tti.ts: -------------------------------------------------------------------------------- 1 | import {Module, TTICB, TTIOption} from '../lib/interface'; 2 | 3 | function filterExcludeLogGifs(entry: PerformanceEntry) { 4 | return entry.name.indexOf('.gif?') < 0; 5 | } 6 | 7 | 8 | export default class TTI implements Module { 9 | private lastLongTask: number = 0; 10 | private observer: PerformanceObserver | null = null; 11 | private ttiTimer: any; 12 | private stopLongTaskTimeoutId: any; 13 | private cb: TTICB; 14 | private interval: number = 5000; 15 | private filterRequest = filterExcludeLogGifs; 16 | constructor() { 17 | this.observerCallback = this.observerCallback.bind(this); 18 | this.ttiCheck = this.ttiCheck.bind(this); 19 | } 20 | 21 | check() { 22 | return window.PerformanceObserver && performance && performance.timing && performance.timing.navigationStart; 23 | } 24 | 25 | listenTTI(cb: TTICB, option?: TTIOption) { 26 | if (!this.check()) { 27 | return; 28 | } 29 | // 监听获取longtask,持续20s 30 | this.observeLongtask(20000); 31 | 32 | this.cb = cb; 33 | 34 | option = option || {}; 35 | if (option.interval) { 36 | this.interval = option.interval; 37 | } 38 | if (option.filterRequest) { 39 | this.filterRequest = option.filterRequest; 40 | } 41 | } 42 | 43 | load() { 44 | if (!this.check()) { 45 | return; 46 | } 47 | // 每 5 秒检查一次是否符合TTI要求 48 | this.ttiTimer = setInterval(this.ttiCheck, this.interval); 49 | } 50 | 51 | ttiCheck() { 52 | if (!this.cb) { 53 | return; 54 | } 55 | 56 | const now = performance.now(); 57 | if (now - this.lastLongTask < this.interval) { 58 | // debug('tti 没有达成条件,上次 longTask 离现在差' + (now - this.lastLongTask) + 'ms'); 59 | return; 60 | } 61 | 62 | const networkSilence = this.getNetworkSilenceAt(); 63 | if (networkSilence === false) { 64 | // debug('tti 没有达成条件,网络没有静默'); 65 | return; 66 | } 67 | if (now - networkSilence < this.interval) { 68 | // debug('tti 没有达成条件,上次网络请求离现在差' + (now - networkSilence) + 'ms'); 69 | return; 70 | } 71 | 72 | // debug('tti 达成条件'); 73 | 74 | clearTimeout(this.ttiTimer); 75 | 76 | let tti = this.lastLongTask; 77 | // 没有longtask,就用dom ready时间作为tti,默认是认为dom ready已经绑定好时间,可以交换了 78 | if (!tti) { 79 | tti = performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart; 80 | } 81 | 82 | // 停止 long task 获取 83 | this.stopObserveLongTask(); 84 | this.cb({ 85 | tti, 86 | }); 87 | } 88 | 89 | observerCallback(list: PerformanceObserverEntryList) { 90 | const entries = list.getEntries(); 91 | const lastEntry = entries[entries.length - 1]; 92 | // debug('long task (no more than ' + lastEntry.duration + 'ms) detected'); 93 | // 最新的longTask完成时间 94 | this.lastLongTask = lastEntry.startTime + lastEntry.duration; 95 | } 96 | 97 | stopObserveLongTask() { 98 | if (this.observer) { 99 | this.observer.disconnect(); 100 | this.observer = null; 101 | } 102 | if (this.stopLongTaskTimeoutId) { 103 | clearTimeout(this.stopLongTaskTimeoutId); 104 | } 105 | } 106 | 107 | getNetworkSilenceAt() { 108 | // const now = performance.now(); 109 | const resources = performance.getEntriesByType('resource') 110 | .filter(this.filterRequest) as PerformanceResourceTiming[]; 111 | 112 | let lastResourceEnd = 0; 113 | for (const item of resources) { 114 | // 还没有responseEnd字段,说明网络请求还未结束 115 | if (!item.responseEnd) { 116 | return false; 117 | } 118 | // 取responseEnd最大值 119 | if (item.responseEnd > lastResourceEnd) { 120 | lastResourceEnd = item.responseEnd; 121 | } 122 | } 123 | 124 | return lastResourceEnd; 125 | } 126 | 127 | private observeLongtask(timeout: number) { 128 | this.stopObserveLongTask(); 129 | 130 | try { 131 | this.observer = new PerformanceObserver(this.observerCallback); 132 | this.observer.observe({ 133 | entryTypes: ['longtask'], 134 | }); 135 | this.stopLongTaskTimeoutId = setTimeout(this.stopObserveLongTask, timeout); 136 | } 137 | catch (e) {} 138 | } 139 | } -------------------------------------------------------------------------------- /src/spy-client-basic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file SpyClient 3 | * @author kaivean 4 | */ 5 | 6 | import {SpyClientOption} from './lib/interface'; 7 | import {assign} from './lib/util'; 8 | 9 | interface Option { 10 | /** 11 | * 指标组,它的每个key就是指标名称(英文表示),在平台对应分组添加该指标名称便能实现自动统计 12 | */ 13 | info: object; 14 | 15 | /** 16 | * 维度信息对象,它的每个字段就是一个维度名称(英文表示),在平台对应分组添加该维度名称便能实现自动统计 17 | */ 18 | dim?: object; 19 | 20 | /** 21 | * 分组,默认:common 22 | */ 23 | group?: string; 24 | 25 | /** 26 | * 抽样,会覆盖全局抽样配置,默认是 1,取值从[0, 1] 27 | */ 28 | sample?: number; 29 | 30 | /** 31 | * 日志服务器,默认是webb服务器,尾部需要加? 32 | */ 33 | logServer?: string; 34 | } 35 | 36 | interface ErrorInfo { 37 | /** 38 | * 错误唯一标识,平台会统计该错误唯一标识的数量 39 | */ 40 | [propName: string]: any; 41 | } 42 | 43 | interface ErrorOption { 44 | /** 45 | * 错误信息对象,它必须有msg字段,是错误唯一标识,其他字段可用户随意添加用来补充错误信息 46 | */ 47 | info?: ErrorInfo; 48 | 49 | /** 50 | * 维度信息对象,它的每个字段就是一个维度名称(英文表示),在平台对应分组添加该维度名称便能实现自动统计 51 | */ 52 | dim?: object; 53 | 54 | /** 55 | * 分组,默认:common 56 | */ 57 | group?: string; 58 | 59 | /** 60 | * 抽样,默认是 1,取值从[0, 1],该抽样会覆盖实例初始化时的抽样配置 61 | */ 62 | sample?: number; 63 | 64 | /** 65 | * 业务拓展信息 66 | */ 67 | ext?: any; 68 | } 69 | 70 | const defaultLogServer = 'https://sp1.baidu.com/5b1ZeDe5KgQFm2e88IuM_a/mwb2.gif?'; 71 | // 基础版本兼容非浏览器环境 72 | const ver = navigator && navigator.userAgent ? navigator.userAgent.toLowerCase().match(/cpu iphone os (.*?)_/) : ''; 73 | const isLtIos14 = ver && ver[2] && (+ver[2] < 14); 74 | 75 | 76 | function err(msg: string) { 77 | console.error(`[SpyClient_log]${msg}`); 78 | // throw new Error(msg); 79 | } 80 | 81 | function stringify(obj: any) { 82 | return Object.keys(obj).map((key: string) => { 83 | let value = obj[key]; 84 | 85 | if (typeof value === 'undefined') { 86 | value = ''; 87 | } 88 | else if (typeof value !== 'string') { 89 | value = JSON.stringify(value); 90 | } 91 | 92 | return encodeURIComponent(key) + '=' + encodeURIComponent(value); 93 | }).join('&'); 94 | } 95 | 96 | function isArray(arr: any) { 97 | return Object.prototype.toString.call(arr) === '[object Array]'; 98 | } 99 | 100 | interface SpyClientInnerOption extends SpyClientOption { 101 | logServer: string; 102 | } 103 | 104 | export default class SpyClient { 105 | 106 | sample: any = {}; 107 | 108 | markCache: any = {}; 109 | 110 | option: SpyClientInnerOption; 111 | 112 | constructor(option: SpyClientOption) { 113 | if (!option.pid) { 114 | throw new Error('pid is required'); 115 | } 116 | 117 | this.option = { 118 | pid: option.pid, 119 | lid: option.lid, 120 | check: option.check !== false, 121 | sample: option.sample, 122 | localCache: option.localCache, 123 | logServer: option.logServer || defaultLogServer, 124 | }; 125 | } 126 | 127 | handle(logItem: any) { 128 | if (!this.check(logItem)) { 129 | return; 130 | } 131 | 132 | logItem = assign( 133 | { 134 | pid: this.option.pid, 135 | lid: this.option.lid, 136 | ts: Date.now(), 137 | group: 'common', 138 | }, 139 | logItem 140 | ); 141 | 142 | if (this.option.localCache) { 143 | this.option.localCache.addLog(logItem); 144 | } 145 | 146 | // 当前api设置了抽样, 147 | if (typeof logItem.sample === 'number') { 148 | if (Math.random() > logItem.sample) { 149 | return; 150 | } 151 | } 152 | else if (typeof this.option.sample === 'number' && Math.random() > this.option.sample) { // 否则,用全局抽样 153 | return; 154 | } 155 | 156 | delete logItem.sample; 157 | return logItem; 158 | } 159 | 160 | send(data: any, post = false) { 161 | let logItems: any[] = isArray(data) ? data : [data]; 162 | 163 | let postData = []; 164 | for (let logItem of logItems) { 165 | logItem = this.handle(logItem); 166 | if (!logItem) { 167 | continue; 168 | } 169 | 170 | postData.push(logItem); 171 | 172 | // 期望通过post方式上传日志 173 | if (post) { 174 | continue; 175 | } 176 | 177 | const url = this.option.logServer + stringify(logItem); 178 | this.request(url); 179 | } 180 | if (post) { 181 | this.sendPost(postData); 182 | } 183 | } 184 | 185 | check(query: any): boolean { 186 | if (!this.option.check) { 187 | return true; 188 | } 189 | 190 | const types = ['perf', 'except', 'dist', 'count']; 191 | if (types.indexOf(query.type) === -1) { 192 | err('type only is one of ' + types.join(', ')); 193 | return false; 194 | } 195 | 196 | if (query.group && query.group.length > 30) { 197 | err('group length execeeds 30'); 198 | return false; 199 | } 200 | 201 | const simpleReg = /^[a-zA-Z0-9-_]{0,30}$/; 202 | 203 | if (query.type === 'except') { 204 | if ( 205 | !(typeof query.info.msg === 'string' && query.info.msg.length) 206 | ) { 207 | err('info.msg field must be not empty and is String'); 208 | return false; 209 | } 210 | } 211 | else { 212 | for (const infoKey of Object.keys(query.info)) { 213 | if (!simpleReg.test(infoKey)) { 214 | err(`info.${infoKey} is unexpected. ` 215 | + 'Length must be not more than 30. ' 216 | + 'Supported chars: a-zA-Z0-9-_'); 217 | return false; 218 | } 219 | 220 | const infoVal = query.info[infoKey]; 221 | if (query.type === 'dist') { 222 | if (infoVal.length > 30) { 223 | err(`info.${infoKey} value length execeeds 30 when type == 'dist'`); 224 | return false; 225 | } 226 | } 227 | else if (typeof infoVal !== 'number') { 228 | err(`info.${infoKey} value must be number`); 229 | return false; 230 | } 231 | } 232 | } 233 | 234 | if (query.dim) { 235 | for (const dimKey of Object.keys(query.dim)) { 236 | if (!simpleReg.test(dimKey)) { 237 | err(`dim key [${dimKey}] is unexpected. ` 238 | + 'Length must be not more than 30. ' 239 | + 'Supported chars: a-zA-Z0-9-_'); 240 | return false; 241 | } 242 | const dimVal = query.dim[dimKey]; 243 | if (!/^[a-zA-Z0-9\-_\*\.\s\/#\+@\&\u4e00-\u9fa5]{0,30}$/.test(dimVal)) { 244 | err(`dim.${dimKey} value [${dimVal}] is unexpected. ` 245 | + 'Length must be not more than 30. ' 246 | + 'Supported chars: a-zA-Z0-9-_*. /#+@& and Chinese'); 247 | return false; 248 | } 249 | } 250 | } 251 | 252 | return true; 253 | } 254 | 255 | /** 256 | * 257 | * @param option 配置 258 | */ 259 | sendPerf(option: Option) { 260 | this.send(assign({ 261 | type: 'perf', 262 | }, option)); 263 | } 264 | 265 | /** 266 | * 267 | * @param option 错误配置项 268 | */ 269 | sendExcept(option: ErrorOption) { 270 | this.send(assign({ 271 | type: 'except', 272 | }, option)); 273 | } 274 | 275 | /** 276 | * 277 | * @param option 配置 278 | */ 279 | sendDist(option: Option) { 280 | this.send(assign({ 281 | type: 'dist', 282 | }, option)); 283 | } 284 | 285 | /** 286 | * 287 | * @param option 配置 288 | */ 289 | sendCount(option: Option) { 290 | this.send(assign({ 291 | type: 'count', 292 | }, option)); 293 | } 294 | 295 | /** 296 | * 297 | * @param e 错误实例 298 | * @param option 错误配置项 299 | */ 300 | sendExceptForError(e: Error, option: ErrorOption) { 301 | const newOpt: ErrorOption = assign({}, option); 302 | newOpt.info = assign({}, option.info || {}, { 303 | msg: e.message, 304 | stack: e.stack, 305 | }); 306 | 307 | this.sendExcept(newOpt); 308 | } 309 | 310 | startMark(sign: string) { 311 | this.markCache[sign] = { 312 | start: Date.now(), 313 | }; 314 | } 315 | 316 | endMark(sign: string): number { 317 | if (this.markCache[sign]) { 318 | this.markCache[sign].total = Date.now() - this.markCache[sign].start; 319 | return this.markCache[sign].total; 320 | } 321 | return 0; 322 | } 323 | 324 | clearMark(sign: string) { 325 | if (this.markCache[sign]) { 326 | delete this.markCache[sign]; 327 | } 328 | } 329 | 330 | getAllMark() { 331 | const ret: {[propName: string]: number} = {}; 332 | for (const sign of Object.keys(this.markCache)) { 333 | ret[sign] = this.markCache[sign].total; 334 | } 335 | return ret; 336 | } 337 | 338 | clearAllMark() { 339 | this.markCache = {}; 340 | } 341 | 342 | // send(data, true) 也能以post发送,但是会有严格校验 343 | // sendPost能以post发送,但没有校验 344 | sendPost(data: any) { 345 | let logItems: any[] = isArray(data) ? data : [data]; 346 | const first = logItems[0]; 347 | // 需要在页面暴漏出来pid等基本信息,方式调试查看基本信息与兼容目前的监控 348 | const query = { 349 | pid: first.pid, 350 | type: first.type, 351 | group: first.group, 352 | }; 353 | const url = this.option.logServer + stringify(query); 354 | 355 | this.request(url, data); 356 | } 357 | 358 | // 有data时,意味着要用post发送请求 359 | protected request(url: string, data?: any) { 360 | if (!( 361 | !isLtIos14 362 | && navigator 363 | && navigator.sendBeacon 364 | && navigator.sendBeacon(url, data ? JSON.stringify(data) : undefined) 365 | )) { 366 | if (data) { 367 | this.fetch(url, data); 368 | } 369 | else { 370 | (new Image()).src = url; 371 | } 372 | } 373 | } 374 | 375 | protected fetch(url: string, data: any) { 376 | if (!fetch) { 377 | err('Global fetch method doesn\'t exist'); 378 | return; 379 | } 380 | fetch(url, { 381 | method: 'POST', 382 | credentials: 'include', 383 | headers: { 384 | 'Content-Type': 'application/json', 385 | }, 386 | body: JSON.stringify(data), 387 | }); 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /src/spy-client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file SpyClient 3 | * @author kaivean 4 | */ 5 | 6 | import SpyClientBasic from './spy-client-basic'; 7 | 8 | import { 9 | Module, 10 | SpyClientOption, 11 | 12 | FIDCB, 13 | LCPCB, 14 | LayoutShiftCB, 15 | PageLongtaskCB, 16 | LoadLongtaskCB, 17 | LCPLongtaskCB, 18 | FSPLongtaskCB, 19 | MemoryCB, 20 | NavigatorInfoMetric, 21 | ResourceCB, 22 | ResourceErrorCB, 23 | TimingCB, 24 | TTICB, 25 | TTIOption, 26 | 27 | ResOption, 28 | BigImgOption, 29 | HttpResOption, 30 | SlowOption, 31 | } from './lib/interface'; 32 | 33 | import FID from './module/fid'; 34 | import LayoutShift from './module/layoutShift'; 35 | import LCP from './module/lcp'; 36 | import TTI from './module/tti'; 37 | import Timing from './module/timing'; 38 | import Resource from './module/resource'; 39 | import Memory from './module/memory'; 40 | import NavigatorInfo from './module/navigatorInfo'; 41 | import Longtask from './module/longtask'; 42 | 43 | export default class SpyClient extends SpyClientBasic { 44 | private readonly modules: Module[] = []; 45 | 46 | constructor(option: SpyClientOption) { 47 | super(option); 48 | 49 | this.register(new FID()); 50 | this.register(new LCP()); 51 | this.register(new LayoutShift()); 52 | this.register(new TTI()); 53 | this.register(new Timing()); 54 | this.register(new Resource()); 55 | this.register(new Memory()); 56 | this.register(new NavigatorInfo()); 57 | this.register(new Longtask()); 58 | 59 | this.visibilitychangeCB = this.visibilitychangeCB.bind(this); 60 | this.load = this.load.bind(this); 61 | this.leave = this.leave.bind(this); 62 | 63 | if (document.readyState === 'complete') { 64 | this.load(); 65 | } 66 | else { 67 | window.addEventListener('load', this.load); 68 | } 69 | 70 | document.addEventListener('visibilitychange', this.visibilitychangeCB); 71 | window.addEventListener('beforeunload', this.leave, false); 72 | window.addEventListener('unload', this.leave, false); 73 | 74 | this.handleHead(); 75 | } 76 | 77 | handleHead() { 78 | if (this.option.localCache) { 79 | const spyHead = window.__spyHead; 80 | if (spyHead && spyHead.winerrors) { 81 | for (let index = 0; index < spyHead.winerrors.length; index++) { 82 | const obj = spyHead.winerrors[index]; 83 | this.option.localCache.addLog(obj); 84 | } 85 | // Head发送的异常,也保存一份到本地,主要是JS错误和资源加载异常 86 | spyHead.interceptor = (obj: any) => { 87 | this.option.localCache.addLog(obj); 88 | }; 89 | } 90 | } 91 | } 92 | 93 | listenFID(cb: FIDCB) { 94 | this.invoke('listenFID', cb as any); 95 | } 96 | 97 | listenLayoutShift(cb: LayoutShiftCB) { 98 | this.invoke('listenLayoutShift', cb as any); 99 | } 100 | 101 | listenLCP(cb: LCPCB) { 102 | this.invoke('listenLCP', cb as any); 103 | } 104 | 105 | listenFSPLongTask(cb: FSPLongtaskCB) { 106 | this.invoke('listenFSPLongTask', cb as any); 107 | } 108 | 109 | listenLCPLongTask(cb: LCPLongtaskCB) { 110 | this.invoke('listenLCPLongTask', cb as any); 111 | } 112 | 113 | listenLoadLongTask(cb: LoadLongtaskCB) { 114 | this.invoke('listenLoadLongTask', cb as any); 115 | } 116 | 117 | listenPageLongTask(cb: PageLongtaskCB) { 118 | this.invoke('listenPageLongTask', cb as any); 119 | } 120 | 121 | listenMemory(cb: MemoryCB) { 122 | this.invoke('listenMemory', cb as any); 123 | } 124 | 125 | getNavigatorInfo(): NavigatorInfoMetric { 126 | return this.invoke('getNavigatorInfo'); 127 | } 128 | 129 | listenResource(cb: ResourceCB, option?: ResOption) { 130 | this.invoke('listenResource', cb as any, option); 131 | } 132 | 133 | listenBigImg(cb: ResourceErrorCB, option?: BigImgOption) { 134 | this.invoke('listenBigImg', cb as any, option); 135 | } 136 | 137 | listenHttpResource(cb: ResourceErrorCB, option?: HttpResOption) { 138 | this.invoke('listenHttpResource', cb as any, option); 139 | } 140 | 141 | listenSlowResource(cb: ResourceErrorCB, option?: SlowOption) { 142 | this.invoke('listenSlowResource', cb as any, option); 143 | } 144 | 145 | listenTiming(cb: TimingCB) { 146 | this.invoke('listenTiming', cb as any); 147 | } 148 | 149 | listenTTI(cb: TTICB, option?: TTIOption) { 150 | this.invoke('listenTTI', cb as any, option as any); 151 | } 152 | 153 | invoke(name: string, cb?: any, option?: any) { 154 | for (let index = 0; index < this.modules.length; index++) { 155 | const mod = this.modules[index]; 156 | if (typeof (mod as any)[name] === 'function') { 157 | return (mod as any)[name].apply(mod, [cb, option]); 158 | } 159 | } 160 | console.error('no method', name); 161 | } 162 | 163 | register(mod: Module) { 164 | this.modules.push(mod); 165 | } 166 | 167 | load() { 168 | for (let index = 0; index < this.modules.length; index++) { 169 | const mod = this.modules[index]; 170 | mod.load && mod.load(); 171 | } 172 | } 173 | 174 | leave() { 175 | for (let index = 0; index < this.modules.length; index++) { 176 | const mod = this.modules[index]; 177 | mod.leave && mod.leave(); 178 | } 179 | } 180 | 181 | destroy() { 182 | for (let index = 0; index < this.modules.length; index++) { 183 | const mod = this.modules[index]; 184 | mod.destroy && mod.destroy(); 185 | } 186 | 187 | document.removeEventListener('visibilitychange', this.visibilitychangeCB); 188 | window.removeEventListener('load', this.load); 189 | window.removeEventListener('beforeunload', this.leave); 190 | window.removeEventListener('unload', this.destroy); 191 | } 192 | 193 | private visibilitychangeCB() { 194 | if (document.visibilityState === 'hidden') { 195 | this.leave(); 196 | } 197 | } 198 | } -------------------------------------------------------------------------------- /src/spy-head.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file SpyClient 3 | * @author kaivean 4 | */ 5 | 6 | import * as error from './head/error'; 7 | import * as observer from './head/observer'; 8 | import * as whitescreen from './head/whitescreen'; 9 | import spyHead from './head/base'; 10 | 11 | import { 12 | SpyHeadConf, 13 | } from './lib/spyHeadInterface'; 14 | 15 | spyHead.init = function (conf: SpyHeadConf) { 16 | if (!conf.logServer) { 17 | conf.logServer = 'https://sp1.baidu.com/5b1ZeDe5KgQFm2e88IuM_a/mwb2.gif'; 18 | } 19 | this.conf = conf; 20 | 21 | error.init(conf); 22 | observer.init(); 23 | whitescreen.init(conf); 24 | }; 25 | 26 | // 兼容全局变量定义的初始化方式 27 | if (window.__spyclientConf) { 28 | spyHead.init(window.__spyclientConf); 29 | } 30 | 31 | export default spyHead; -------------------------------------------------------------------------------- /src/spy-local-cache.ts: -------------------------------------------------------------------------------- 1 | import {huffmanEncode, huffmanDecode} from './lib/huffman'; 2 | 3 | interface Option { 4 | defaultTrigger: boolean; 5 | compress: 'huffman' | 'lzw' | 'no'; 6 | key: string; 7 | maxRecordLen: number; 8 | interval: number; 9 | onFlush?: (res: any) => void; 10 | onSave?: (res: any) => void; 11 | onAdd?: (res: any) => boolean; 12 | storage: 'indexedDB' | 'localstorage' | 'empty'; 13 | } 14 | 15 | class Storage { 16 | static isSupport: () => boolean; 17 | 18 | set(key: string, value: any) { 19 | 20 | } 21 | 22 | get(key: string, cb: any) { 23 | 24 | } 25 | 26 | rm(key: string) { 27 | 28 | } 29 | } 30 | 31 | class LS extends Storage { 32 | static isSupport() { 33 | return !!window.localStorage; 34 | } 35 | 36 | set(key: string, value: any) { 37 | try { 38 | localStorage.setItem(key, value); 39 | } 40 | catch (e) { 41 | console.error(e); 42 | } 43 | return; 44 | } 45 | 46 | get(key: string, cb: any) { 47 | let res = null; 48 | try { 49 | res = localStorage.getItem(key); 50 | } 51 | catch (e) { 52 | console.error(e); 53 | } 54 | if (cb) { 55 | cb(res); 56 | } 57 | return; 58 | } 59 | 60 | rm(key: string) { 61 | try { 62 | localStorage.removeItem(key); 63 | } 64 | catch (e) { 65 | console.error(e); 66 | } 67 | return; 68 | } 69 | 70 | } 71 | 72 | class IndexedDB extends Storage { 73 | private readonly databaseName = 'spyLC'; 74 | private db: IDBDatabase | null = null; 75 | private readonly setQueue: any[] = []; 76 | private readonly getQueue: any[] = []; 77 | constructor() { 78 | super(); 79 | 80 | let request = window.indexedDB.open(this.databaseName); 81 | 82 | request.onupgradeneeded = event => { 83 | // @ts-ignore ts默认的EventTarget类型有问题,先ignore掉 84 | this.db = event.target && event.target.result; 85 | if (this.db && !this.db.objectStoreNames.contains(this.databaseName)) { 86 | this.db.createObjectStore(this.databaseName, {keyPath: 'key'}); 87 | } 88 | this.runQueueTask(); 89 | }; 90 | 91 | request.onsuccess = () => { 92 | this.db = request.result; 93 | this.runQueueTask(); 94 | }; 95 | 96 | } 97 | static isSupport() { 98 | return !!window.indexedDB; 99 | } 100 | 101 | set(key: string, value: any) { 102 | // 因为indexDB是异步初始化的,如果set的时候未初始化,先缓存之后再执行set 103 | if (!this.db) { 104 | this.setQueue.push({ 105 | key, 106 | value, 107 | }); 108 | return; 109 | } 110 | this.db.transaction([this.databaseName], 'readwrite') 111 | .objectStore(this.databaseName) 112 | .add({ 113 | key, 114 | value, 115 | }); 116 | 117 | } 118 | 119 | get(key: string, cb: any) { 120 | // 因为indexDB是异步初始化的,如果get的时候未初始化,先缓存之后再执行get 121 | if (!this.db) { 122 | this.getQueue.push({ 123 | key, 124 | cb, 125 | }); 126 | return; 127 | } 128 | const transaction = this.db.transaction([this.databaseName]); 129 | const objectStore = transaction.objectStore(this.databaseName); 130 | const request = objectStore.get(key); 131 | 132 | request.onsuccess = () => { 133 | const result = request.result ? request.result : {value: ''}; 134 | cb(result.value); 135 | }; 136 | } 137 | 138 | private runQueueTask() { 139 | this.setQueue.forEach(ele => { 140 | this.set(ele.key, ele.value); 141 | }); 142 | this.getQueue.forEach(ele => { 143 | this.get(ele.key, ele.cb); 144 | }); 145 | } 146 | } 147 | 148 | function assign(...args: any[]) { 149 | const __assign = Object.assign || function __assign(t: any) { 150 | for (let s, i = 1, n = arguments.length; i < n; i++) { 151 | // eslint-disable-next-line prefer-rest-params 152 | s = arguments[i]; 153 | for (const p in s) { 154 | if (Object.prototype.hasOwnProperty.call(s, p)) { 155 | t[p] = s[p]; 156 | } 157 | } 158 | } 159 | return t; 160 | }; 161 | return __assign.apply(this, args); 162 | }; 163 | 164 | function utf8Encode(text: string) { 165 | let result = ''; 166 | for (let n = 0; n < text.length; n++) { 167 | const c = text.charCodeAt(n); 168 | if (c < 128) { 169 | result += String.fromCharCode(c); 170 | } 171 | else if (c > 127 && c < 2048) { 172 | result += String.fromCharCode((c >> 6) | 192); 173 | result += String.fromCharCode((c & 63) | 128); 174 | } 175 | else { 176 | result += String.fromCharCode((c >> 12) | 224); 177 | result += String.fromCharCode(((c >> 6) & 63) | 128); 178 | result += String.fromCharCode((c & 63) | 128); 179 | } 180 | } 181 | return result; 182 | // return window.btoa(result); 183 | } 184 | 185 | function utf8Decode(text: string) { 186 | // text = window.atob(text); 187 | let result = ''; 188 | let i = 0; 189 | let c1 = 0; 190 | let c2 = 0; 191 | let c3 = 0; 192 | while (i < text.length) { 193 | c1 = text.charCodeAt(i); 194 | if (c1 < 128) { 195 | result += String.fromCharCode(c1); 196 | i++; 197 | } 198 | else if (c1 > 191 && c1 < 224) { 199 | c2 = text.charCodeAt(i + 1); 200 | result += String.fromCharCode(((c1 & 31) << 6) | (c2 & 63)); 201 | i += 2; 202 | } 203 | else { 204 | c2 = text.charCodeAt(i + 1); 205 | c3 = text.charCodeAt(i + 2); 206 | result += String.fromCharCode(((c1 & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); 207 | i += 3; 208 | } 209 | } 210 | return result; 211 | } 212 | 213 | 214 | function lzw(text: string, deCompress = false) { 215 | if (!text) { 216 | return ''; 217 | }; 218 | if (!deCompress) { 219 | text = utf8Encode(text); 220 | } 221 | 222 | let dict: any = {}; 223 | let out: any[] = []; 224 | let prefix = text.charAt(0); 225 | let curChar = prefix; 226 | let oldPrefix = curChar; 227 | let idx = 256; 228 | let i; 229 | let c; 230 | let d; 231 | let g = function () { 232 | out.push(prefix.length > 1 ? String.fromCharCode(dict[prefix]) : prefix); 233 | }; 234 | if (deCompress) { 235 | out.push(prefix); 236 | } 237 | for (i = 1, c, d; i < text.length; i++) { 238 | c = text.charAt(i); 239 | if (deCompress) { 240 | d = text.charCodeAt(i); 241 | prefix = d < 256 ? c : dict[d] || (prefix + curChar); 242 | out.push(prefix); 243 | curChar = prefix.charAt(0); 244 | dict[idx++] = oldPrefix + curChar; 245 | oldPrefix = prefix; 246 | } 247 | else { 248 | if (dict.hasOwnProperty(prefix + c)) { 249 | prefix += c; 250 | } 251 | else { 252 | g(); 253 | dict[prefix + c] = idx++; 254 | prefix = c; 255 | } 256 | } 257 | } 258 | if (!deCompress) { 259 | g(); 260 | } 261 | 262 | let ret = out.join(''); 263 | if (deCompress) { 264 | ret = utf8Decode(ret); 265 | } 266 | 267 | return ret; 268 | } 269 | 270 | 271 | export default class SpyLocalCache { 272 | private readonly option: Option; 273 | private storage: Storage; 274 | private timer: ReturnType; 275 | private tmpList: string[] = []; 276 | constructor(option: any = {}) { 277 | this.option = assign({ 278 | defaultTrigger: true, 279 | compress: 'lzw', // huffman lzw no 280 | key: 'SpyLocalCache', 281 | interval: 500, 282 | maxRecordLen: 30, 283 | onFlush: () => {}, 284 | onSave: () => {}, 285 | onAdd: () => { 286 | return true; 287 | }, 288 | storage: IndexedDB.isSupport() 289 | ? 'indexedDB' 290 | : LS.isSupport() 291 | ? 'localstorage' 292 | : 'empty', 293 | }, option); 294 | 295 | this.load = this.load.bind(this); 296 | 297 | this.init(); 298 | } 299 | 300 | init() { 301 | if (this.option.storage === 'indexedDB') { 302 | this.storage = new IndexedDB(); 303 | } 304 | else if (this.option.storage === 'localstorage') { 305 | this.storage = new LS(); 306 | } 307 | else { 308 | this.storage = new Storage(); 309 | } 310 | 311 | if (document.readyState === 'complete') { 312 | this.load(); 313 | } 314 | else { 315 | window.addEventListener('load', this.load); 316 | } 317 | } 318 | 319 | load() { 320 | if (location.search.indexOf('_FlushLogLocalCache=1') > -1 && this.option.defaultTrigger) { 321 | this.flushLog(); 322 | } 323 | } 324 | 325 | addLog(info: any) { 326 | if (this.option.onAdd) { 327 | if (!this.option.onAdd(info)) { 328 | return; 329 | } 330 | } 331 | 332 | info = JSON.stringify(info); 333 | 334 | this.tmpList.push(info as string); 335 | 336 | // 控制写日志频率 337 | if (this.timer) { 338 | clearTimeout(this.timer); 339 | } 340 | this.timer = setTimeout(() => { 341 | this.save(); 342 | }, this.option.interval); 343 | } 344 | 345 | getData(cb: any) { 346 | try { 347 | this.storage.get(this.option.key, (encodeStr: string) => { 348 | if (encodeStr) { 349 | this.storage.get(this.option.key + 'Codes', (codes: any) => { 350 | let res: any[] = []; 351 | try { 352 | if (codes) { 353 | codes = JSON.parse(codes); 354 | } 355 | const str = this.unzip(encodeStr, codes); 356 | res = str.split('\n'); 357 | } 358 | catch (e) { 359 | console.error(e); 360 | } 361 | cb(res); 362 | }); 363 | } 364 | else { 365 | cb([]); 366 | } 367 | }); 368 | 369 | } 370 | catch (e) { 371 | console.error(e); 372 | cb([]); 373 | } 374 | } 375 | 376 | save() { 377 | const st = Date.now(); 378 | this.getData((list: any[]) => { 379 | // 只保留最近的maxRecordLen条日志 380 | const reserveLen = this.option.maxRecordLen - 1; 381 | const originList = list.length > reserveLen ? list.slice(list.length - reserveLen, list.length) : list; 382 | let newList = originList.concat(this.tmpList); 383 | let content = newList.join('\n'); 384 | let error: Error | null = null; 385 | let len = 0; 386 | try { 387 | let data = this.zip(content); 388 | let codesStr = ''; 389 | if (data.codes) { 390 | codesStr = JSON.stringify(data.codes); 391 | this.storage.set(this.option.key + 'Codes', codesStr); 392 | } 393 | else { 394 | this.storage.rm(this.option.key + 'Codes'); 395 | } 396 | 397 | this.storage.set(this.option.key, data.result); 398 | len = data.result.length; 399 | } 400 | catch (e) { 401 | error = e; 402 | console.error(e); 403 | } 404 | 405 | this.tmpList = []; 406 | 407 | this.option.onSave && this.option.onSave({ 408 | cost: Date.now() - st, 409 | length: len / 1024, 410 | list: newList, 411 | error: error, 412 | }); 413 | }); 414 | } 415 | 416 | flushLog() { 417 | this.getData((list: any[]) => { 418 | 419 | // 先解析来自存储的数据,若失败,说明格式有问题,清空存储 420 | try { 421 | for (let index = 0; index < list.length; index++) { 422 | list[index] = JSON.parse(list[index]); 423 | } 424 | } 425 | catch (e) { 426 | list = []; 427 | this.storage.rm(this.option.key); 428 | console.error(e); 429 | } 430 | // 未落盘的数据也加上 431 | for (let index = 0; index < this.tmpList.length; index++) { 432 | list.push(JSON.parse(this.tmpList[index])); 433 | } 434 | this.option.onFlush && this.option.onFlush(list); 435 | }); 436 | } 437 | 438 | zip(str: string) { 439 | if (this.option.compress === 'lzw') { 440 | return { 441 | codes: null, 442 | result: lzw(str), 443 | }; 444 | } 445 | else if (this.option.compress === 'huffman') { 446 | return huffmanEncode(str); 447 | } 448 | return { 449 | codes: null, 450 | result: str, 451 | }; 452 | } 453 | 454 | unzip(str: string, codes?: any) { 455 | if (this.option.compress === 'lzw') { 456 | return lzw(str, true); 457 | } 458 | else if (this.option.compress === 'huffman') { 459 | return huffmanDecode(codes, str); 460 | } 461 | return str; 462 | } 463 | } 464 | 465 | // For test 466 | // let content = JSON.stringify({ 467 | // type: 3, 468 | // fm: 'disp', 469 | // data: [{"base":{"size":{"doc":{"w":360,"h":4875},"wind":{"w":360,"h":640},"scr":{"w":360,"h":640}},"vsb":"visible","num":16},"t":1629773746698,"path":"/s"}], 470 | // qid: 10991431029479106376, 471 | // did: '8dd09c47c7bc90c9fd7274f0ad2c581e', 472 | // q: '刘德华', 473 | // t: 1629773746698, 474 | // }); 475 | // const compressText = lzw(content); 476 | // const deCompressText = lzw(compressText, true); 477 | // console.log('compressText', compressText, compressText.length); 478 | // console.log('deCompressText', deCompressText, deCompressText.length); 479 | -------------------------------------------------------------------------------- /src/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 全局声明 3 | * @author kaivean 4 | */ 5 | 6 | interface Window { 7 | __spyHead: any; 8 | __spyclientConf: any; 9 | PerformanceObserver: any; 10 | requestIdleCallback?: (callback: () => void, options?: any) => void; 11 | } 12 | 13 | interface Event { 14 | // MSMediaKeyMessageEvent 继承 Event,并且有message属性 且是 Uint8Array,会有冲突。这里设置为any 15 | message?: any; 16 | lineno?: number; 17 | line?: number; 18 | colno?: number; 19 | column?: number; 20 | error?: any; 21 | filename: any; 22 | sourceURL: any; 23 | errorCharacter?: number; 24 | } 25 | 26 | interface Connection { 27 | downlink?: number; 28 | effectiveType?: '2g' | '3g' | '4g' | 'slow-2g'; 29 | onchange?: () => void; 30 | rtt?: number; 31 | saveData?: boolean; 32 | } 33 | 34 | interface Navigator { 35 | deviceMemory: number; 36 | hardwareConcurrency: number; 37 | connection: Connection; 38 | } 39 | 40 | interface MemoryInfo { 41 | totalJSHeapSize: number; 42 | usedJSHeapSize: number; 43 | jsHeapSizeLimit: number; 44 | } 45 | 46 | interface Performance { 47 | memory?: MemoryInfo; 48 | } 49 | 50 | interface PerformanceTiming { 51 | domFirstPaint?: number; 52 | domFirstScreenPaint?: number; 53 | } 54 | 55 | type PerformanceObserverType = 56 | | 'first-input' 57 | | 'largest-contentful-paint' 58 | | 'layout-shift' 59 | | 'longtask' 60 | | 'measure' 61 | | 'navigation' 62 | | 'paint' 63 | | 'resource'; 64 | 65 | 66 | type PerformanceEntryInitiatorType = 67 | | 'beacon' 68 | | 'css' 69 | | 'fetch' 70 | | 'img' 71 | | 'other' 72 | | 'script' 73 | | 'xmlhttprequest'; 74 | 75 | 76 | interface PerformanceEntry { 77 | decodedBodySize?: number; 78 | // duration: number; 79 | // entryType: PerformanceObserverType; 80 | initiatorType?: PerformanceEntryInitiatorType; 81 | loadTime: number; 82 | // name: string; 83 | renderTime: number; 84 | // startTime: number; 85 | // hadRecentInput?: boolean; 86 | value?: number; 87 | hadRecentInput: boolean; 88 | attribution: Array<{containerSrc: string}>; 89 | } 90 | 91 | interface PerformanceObserver { 92 | takeRecords: () => PerformanceEntryList; 93 | } 94 | -------------------------------------------------------------------------------- /test/spec/basicSpec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file main-spec 3 | * @author kaivean 4 | */ 5 | 6 | import SpyClient from 'spy-client'; 7 | 8 | // import SpyClient from 'spy-client/dist/spy-client'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires 11 | // const SpyClientClass: typeof SpyClient = require('spy-client'); 12 | // const SpyClient = window.SpyClient; 13 | 14 | async function checkSend(option: any, triggerCb: (spy: any) => void, finishCb?: (spy: any) => void) { 15 | const spy = new SpyClient({ 16 | pid: '1_1000', 17 | lid: 'xx', 18 | }); 19 | 20 | await new Promise(resolve => { 21 | 22 | function recover() { 23 | // 恢复 24 | (navigator.sendBeacon as any).and.callThrough(); 25 | // 监听src属性的变化 26 | const constructor = (new Image()).constructor.prototype; 27 | Object.defineProperty(constructor, 'src', { 28 | set(value) { 29 | this.setAttribute('src', value); 30 | }, 31 | }); 32 | } 33 | 34 | // 超过2s没有发出就是有问题了 35 | const timer = setTimeout(() => { 36 | expect('timeout > 2000').toBe('failure'); 37 | recover(); 38 | resolve(''); 39 | }, 2000); 40 | 41 | function checkUrl(url: string) { 42 | const urlObj = new URL(url); 43 | expect(urlObj.pathname).toContain('/mwb2.gif'); 44 | expect(urlObj.searchParams.get('pid')).toEqual('1_1000'); 45 | expect(urlObj.searchParams.get('type')).toEqual(option.type); 46 | expect(urlObj.searchParams.get('lid')).toEqual('xx'); 47 | expect(urlObj.searchParams.get('ts')).toMatch(/\d{11}/); 48 | expect(urlObj.searchParams.get('group')).toEqual(option.group || 'common'); 49 | expect(urlObj.searchParams.get('info')).toEqual(JSON.stringify(option.info)); 50 | expect(urlObj.searchParams.get('dim')).toEqual(JSON.stringify(option.dim)); 51 | clearTimeout(timer); 52 | if (finishCb) { 53 | finishCb(spy); 54 | } 55 | recover(); 56 | resolve(''); 57 | } 58 | 59 | spyOn(navigator, 'sendBeacon').and.callFake(url => { // specify callFake 60 | checkUrl(url); 61 | return false; 62 | }); 63 | 64 | // let constructor = document.createElement('img').constructor.prototype; 65 | // if (!constructor) { 66 | // return; 67 | // } 68 | const constructor = (new Image()).constructor.prototype; 69 | // 重写setAttribute方法 70 | // let originsetAttribute = constructor.setAttribute; 71 | // constructor.setAttribute = function (...args) { 72 | // const [attr, value] = args; 73 | // originsetAttribute.apply(this, args); 74 | // if (attr === attrName && value) { 75 | // addErrorListener(this); 76 | // } 77 | // }; 78 | 79 | // 监听src属性的变化 80 | Object.defineProperty(constructor, 'src', { 81 | set(value) { 82 | if (value.indexOf('/mwb2.gif') > -1) { 83 | checkUrl(value); 84 | } 85 | else { 86 | this.setAttribute('src', value); 87 | } 88 | // Ignore the IMG elements for sending Monitor. 89 | // this.setAttribute('src', value); 90 | }, 91 | }); 92 | 93 | triggerCb(spy); 94 | }); 95 | } 96 | 97 | describe('基本发送功能', () => { 98 | beforeEach(() => { 99 | 100 | // window.dispatchEvent(event); 101 | }); 102 | 103 | // afterEach(() => { 104 | // }); 105 | 106 | it('性能发送', async () => { 107 | const option = { 108 | group: 'kpi', 109 | info: { 110 | firstScreen: 1, 111 | }, 112 | dim: { 113 | os: 'ios #8&中', 114 | }, 115 | }; 116 | 117 | const checkOption = Object.assign({}, option, { 118 | type: 'perf', 119 | }); 120 | 121 | await checkSend(checkOption, spy => { 122 | spy.sendPerf(option); 123 | }); 124 | }); 125 | 126 | it('异常发送', async () => { 127 | const option = { 128 | group: 'js', 129 | info: { 130 | msg: 'not defined', 131 | }, 132 | dim: { 133 | os: 'ios', 134 | }, 135 | }; 136 | 137 | const checkOption = Object.assign({}, option, { 138 | type: 'except', 139 | }); 140 | 141 | await checkSend(checkOption, spy => { 142 | spy.sendExcept(option); 143 | }); 144 | }); 145 | 146 | it('计数发送', async () => { 147 | const option = { 148 | group: 'click', 149 | info: { 150 | buttonclick: 1, 151 | }, 152 | dim: { 153 | os: 'ios', 154 | }, 155 | }; 156 | 157 | const checkOption = Object.assign({}, option, { 158 | type: 'count', 159 | }); 160 | 161 | await checkSend(checkOption, spy => { 162 | spy.sendCount(option); 163 | }); 164 | }); 165 | 166 | it('分布发送', async () => { 167 | const option = { 168 | group: 'cookie', 169 | info: { 170 | isHit: 1, 171 | }, 172 | dim: { 173 | os: 'ios', 174 | }, 175 | }; 176 | 177 | const checkOption = Object.assign({}, option, { 178 | type: 'dist', 179 | }); 180 | 181 | await checkSend(checkOption, spy => { 182 | spy.sendDist(option); 183 | }); 184 | }); 185 | 186 | 187 | it('trycatch异常发送', async () => { 188 | const option = { 189 | group: 'trycatch', 190 | info: { 191 | 192 | }, 193 | dim: { 194 | os: 'ios', 195 | }, 196 | }; 197 | 198 | const checkOption = Object.assign({}, option, { 199 | type: 'except', 200 | info: { 201 | msg: 'try catch error', 202 | }, 203 | }); 204 | 205 | await checkSend(checkOption, spy => { 206 | try { 207 | throw new Error('try catch error'); 208 | } 209 | catch (e) { 210 | delete e.stack; 211 | spy.sendExceptForError(e, { 212 | group: 'trycatch', 213 | dim: { 214 | os: 'ios', 215 | }, 216 | }); 217 | } 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /test/spec/checkSpec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file main-spec 3 | * @author kaivean 4 | */ 5 | 6 | import SpyClient from 'spy-client'; 7 | 8 | async function checkConsoleLog(option: any, triggerCb: (spy: any) => void, finishCb?: (spy: any, msg: string) => void) { 9 | const spy = new SpyClient({ 10 | pid: '1_1000', 11 | lid: 'xx', 12 | }); 13 | 14 | await new Promise(resolve => { 15 | function recover() { 16 | // 恢复 17 | (console.error as any).and.callThrough(); 18 | } 19 | 20 | // 超过2s没有发出就是有问题了 21 | const timer = setTimeout(() => { 22 | expect('timeout > 2000').toBe('failure'); 23 | recover(); 24 | resolve(''); 25 | }, 2000); 26 | 27 | spyOn(console, 'error').and.callFake((msg: string) => { // specify callFake 28 | clearTimeout(timer); 29 | if (finishCb) { 30 | finishCb(spy, msg); 31 | } 32 | recover(); 33 | resolve(''); 34 | return true; 35 | }); 36 | 37 | triggerCb(spy); 38 | }); 39 | } 40 | 41 | describe('问题检测能力', () => { 42 | beforeEach(() => { 43 | 44 | // window.dispatchEvent(event); 45 | }); 46 | 47 | // afterEach(() => { 48 | // }); 49 | 50 | it('group length check', async () => { 51 | await checkConsoleLog({}, spy => { 52 | const option = { 53 | group: 'kpifwejfwalfjFWEFWJEFWFWAFALJFEWLFALJWEFLAWJF', 54 | info: { 55 | firstScreen: 1, 56 | }, 57 | dim: { 58 | os: 'ios', 59 | }, 60 | }; 61 | spy.sendPerf(option); 62 | }, (spy, msg) => { 63 | expect(msg).toContain('group length execeeds 30'); 64 | }); 65 | }); 66 | 67 | it('info key length check', async () => { 68 | await checkConsoleLog({}, spy => { 69 | const option = { 70 | group: 'kpi', 71 | info: { 72 | firstScreenfwfewfafawfwagewgawgwegwegweagweagweg: 1, 73 | }, 74 | dim: { 75 | os: 'ios', 76 | }, 77 | }; 78 | spy.sendPerf(option); 79 | }, (spy, msg) => { 80 | expect(msg).toContain('info.firstScreenfwfewfafawfwagewgawgwegwegweagweagweg is unexpected'); 81 | }); 82 | }); 83 | 84 | it('dim key length check', async () => { 85 | await checkConsoleLog({}, spy => { 86 | const option = { 87 | group: 'kpi', 88 | info: { 89 | firstScreen: 1, 90 | }, 91 | dim: { 92 | osfwfawfwafwefawefwefawfwafwfwfafafewefwffwaefwf: 'ios', 93 | }, 94 | }; 95 | spy.sendPerf(option); 96 | }, (spy, msg) => { 97 | expect(msg).toContain('dim key [osfwfawfwafwefawefwefawfwafwfwfafafewefwffwaefwf] is unexpected'); 98 | }); 99 | }); 100 | 101 | it('dim value check', async () => { 102 | await checkConsoleLog({}, spy => { 103 | const option = { 104 | group: 'kpi', 105 | info: { 106 | firstScreen: 1, 107 | }, 108 | dim: { 109 | os: 'Android (8)', 110 | }, 111 | }; 112 | spy.sendPerf(option); 113 | }, (spy, msg) => { 114 | expect(msg).toContain('dim.os value [Android (8)] is unexpected.'); 115 | }); 116 | }); 117 | 118 | it('except info msg check', async () => { 119 | await checkConsoleLog({}, spy => { 120 | const option = { 121 | group: 'kpi', 122 | info: { 123 | lineno: 1, 124 | }, 125 | dim: { 126 | os: 'ios', 127 | }, 128 | }; 129 | spy.sendExcept(option); 130 | }, (spy, msg) => { 131 | expect(msg).toContain('info.msg field must be not empty and is String'); 132 | }); 133 | }); 134 | }); -------------------------------------------------------------------------------- /test/spec/headSpec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file head-spec 3 | * @author kaivean 4 | */ 5 | 6 | // import SpyClient from 'spy-client'; 7 | 8 | window.__spyHead.init({ 9 | pid: '1_1000', 10 | lid: 'xx', 11 | logServer: 'https://sp1.baidu.com/5b1ZeDe5KgQFm2e88IuM_a/mwb2.gif', 12 | 13 | // 数据类型:异常,触发时间:OnLoadResourceError 14 | resourceError: { 15 | group: 'resource', 16 | sample: 1, 17 | handler: function (data: any) { 18 | data.dim.os = 'ios'; 19 | } 20 | }, 21 | // 数据类型:异常,触发时间:OnJSError 22 | jsError: { 23 | group: 'js', 24 | sample: 1, 25 | handler: function (data: any) { 26 | data.dim = {os: 'ios'}; 27 | } 28 | }, 29 | // 数据类型:异常,触发时间:OnJudgeReturnFalseWhenTimeout 30 | whiteScreenError: { 31 | sample: 1, 32 | group: 'whiteScreen', 33 | selector: 'body', 34 | subSelector: '#keyelement', 35 | timeout: 3000, 36 | handler: function(data: any) { 37 | data.dim = {os: 'ios'}; 38 | } 39 | } 40 | }); 41 | 42 | async function checkSend(option: any, triggerCb: () => void, finishCb?: (urlObj: URL) => void) { 43 | option.timeout = option.timeout || 2000; 44 | await new Promise(resolve => { 45 | 46 | function recover() { 47 | // 恢复 48 | (navigator.sendBeacon as any).and.callThrough(); 49 | // 监听src属性的变化 50 | const constructor = (new Image()).constructor.prototype; 51 | Object.defineProperty(constructor, 'src', { 52 | set(value) { 53 | this.setAttribute('src', value); 54 | }, 55 | }); 56 | } 57 | 58 | // 超过2s没有发出就是有问题了 59 | const timer = setTimeout(() => { 60 | expect('timeout > ' + option.timeout).toBe('failure'); 61 | recover(); 62 | resolve(''); 63 | }, option.timeout); 64 | 65 | function checkUrl(url: string) { 66 | const urlObj = new URL(url); 67 | expect(urlObj.pathname).toContain('/mwb2.gif'); 68 | expect(urlObj.searchParams.get('pid')).toEqual('1_1000'); 69 | expect(urlObj.searchParams.get('type')).toEqual(option.type); 70 | expect(urlObj.searchParams.get('lid')).toEqual('xx'); 71 | expect(urlObj.searchParams.get('ts')).toMatch(/\d{11}/); 72 | expect(urlObj.searchParams.get('group')).toEqual(option.group || 'common'); 73 | clearTimeout(timer); 74 | if (finishCb) { 75 | finishCb(urlObj); 76 | } 77 | recover(); 78 | resolve(''); 79 | } 80 | 81 | spyOn(navigator, 'sendBeacon').and.callFake(url => { // specify callFake 82 | checkUrl(url); 83 | return false; 84 | }); 85 | 86 | // let constructor = document.createElement('img').constructor.prototype; 87 | // if (!constructor) { 88 | // return; 89 | // } 90 | const constructor = (new Image()).constructor.prototype; 91 | // 重写setAttribute方法 92 | // let originsetAttribute = constructor.setAttribute; 93 | // constructor.setAttribute = function (...args) { 94 | // const [attr, value] = args; 95 | // originsetAttribute.apply(this, args); 96 | // if (attr === attrName && value) { 97 | // addErrorListener(this); 98 | // } 99 | // }; 100 | 101 | // 监听src属性的变化 102 | Object.defineProperty(constructor, 'src', { 103 | set(value) { 104 | if (value.indexOf('/mwb2.gif') > -1) { 105 | checkUrl(value); 106 | } 107 | else { 108 | this.setAttribute('src', value); 109 | } 110 | }, 111 | }); 112 | 113 | triggerCb(); 114 | }); 115 | } 116 | 117 | 118 | describe('spy-head', async () => { 119 | beforeEach(async () => { 120 | 121 | }); 122 | 123 | afterEach(async () => { 124 | 125 | }); 126 | 127 | // 无法模拟全局JS报错,会被判断错误,导致测试失败 128 | // it('check jsError', async () => { 129 | // // 选项来自test/head-conf.js 130 | // const option = { 131 | // pid: '1_1000', 132 | // lid: 'xx', 133 | // type: 'except', 134 | // group: 'js', 135 | // }; 136 | 137 | // await checkSend(option, () => { 138 | // // mock js error 139 | // // setTimeout(() => { 140 | // // var obj = {}; 141 | // // (obj as any).runError(); 142 | // // }); 143 | // }); 144 | // }); 145 | 146 | // chrome可以过,但TRAVIS headless chrome过不了 147 | if (process.env.TRAVIS) { 148 | return; 149 | } 150 | 151 | it('check whiteScreen', async () => { 152 | // 选项来自test/head-conf.js 153 | const option = { 154 | pid: '1_1000', 155 | lid: 'xx', 156 | type: 'except', 157 | group: 'whiteScreen', 158 | timeout: 6000 159 | }; 160 | await checkSend(option, function () {}, urlObj => { 161 | if (!urlObj.searchParams.get('info')) { 162 | throw new Error('no info'); 163 | } 164 | if (!urlObj.searchParams.get('dim')) { 165 | throw new Error('no dim'); 166 | } 167 | const info = JSON.parse(urlObj.searchParams.get('info') || '{}'); 168 | const dim = JSON.parse(urlObj.searchParams.get('dim') || '{}'); 169 | expect(dim.os).toEqual('ios'); 170 | expect(info.msg).toEqual('WhiteScren Error'); 171 | }); 172 | }); 173 | it('check resourceError', async () => { 174 | // 选项来自test/head-conf.js 175 | const option = { 176 | pid: '1_1000', 177 | lid: 'xx', 178 | type: 'except', 179 | group: 'resource', 180 | }; 181 | 182 | const url = 'https://mss0.bdstatic.com/se/static/js/iphone/zbios/zbiosT_f69.js'; 183 | 184 | await checkSend(option, () => { 185 | // mock js error 186 | // mock 404 js 187 | const script = document.createElement('script'); 188 | script.src = url; 189 | document.body.appendChild(script); 190 | }, urlObj => { 191 | if (!urlObj.searchParams.get('info')) { 192 | throw new Error('no info'); 193 | } 194 | if (!urlObj.searchParams.get('dim')) { 195 | throw new Error('no dim'); 196 | } 197 | const info = JSON.parse(urlObj.searchParams.get('info') || '{}'); 198 | const dim = JSON.parse(urlObj.searchParams.get('dim') || '{}'); 199 | expect(dim.os).toEqual('ios'); 200 | expect(info.msg).toEqual(url); 201 | }); 202 | }); 203 | 204 | 205 | }); 206 | -------------------------------------------------------------------------------- /test/spec/markSpec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file main-spec 3 | * @author kaivean 4 | */ 5 | 6 | import SpyClient from 'spy-client'; 7 | 8 | 9 | describe('mark', async () => { 10 | let time: number; 11 | let time2: number; 12 | let spy: any; 13 | beforeEach(async () => { 14 | spy = new SpyClient({ 15 | pid: '1_1000', 16 | lid: 'xx', 17 | }); 18 | 19 | await new Promise(resolve => { 20 | spy.startMark('playtime'); 21 | spy.startMark('playtime2'); 22 | setTimeout(() => { 23 | time = spy.endMark('playtime'); 24 | time2 = spy.endMark('playtime2'); 25 | resolve(''); 26 | }, 50); 27 | }); 28 | }); 29 | 30 | // afterEach(() => { 31 | // }); 32 | 33 | it('endMark', () => { 34 | expect(typeof time === 'number').toBe(true); 35 | expect(time).toBeGreaterThanOrEqual(50); 36 | }); 37 | 38 | 39 | it('getAllMark', () => { 40 | const ret = spy.getAllMark(); 41 | expect(ret).toEqual( 42 | jasmine.objectContaining({ 43 | playtime: time, 44 | playtime2: time2, 45 | }) 46 | ); 47 | }); 48 | 49 | it('clearAllMark', () => { 50 | spy.clearAllMark(); 51 | expect(spy.getAllMark()).toEqual({}); 52 | }); 53 | 54 | it('clearMark', () => { 55 | spy.clearMark('playtime2'); 56 | const ret = spy.getAllMark(); 57 | expect(ret).not.toEqual( 58 | jasmine.objectContaining({ 59 | playtime2: time2, 60 | }) 61 | ); 62 | expect(ret).toEqual( 63 | jasmine.objectContaining({ 64 | playtime: time, 65 | }) 66 | ); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/spec/metricSpec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file metric-spec 3 | * @author kaivean 4 | */ 5 | 6 | import SpyClient from 'spy-client'; 7 | 8 | 9 | describe('metric', async () => { 10 | let spy: SpyClient; 11 | 12 | const img1 = document.createElement('img'); 13 | img1.src = 'https://dss3.bdstatic.com/iPoZeXSm1A5BphGlnYG/skin/62.jpg'; 14 | document.body.appendChild(img1); 15 | 16 | const button = document.createElement('button'); 17 | button.textContent = 'Please click me'; 18 | document.body.appendChild(button); 19 | 20 | const p = document.createElement('p'); 21 | p.innerHTML = 'TypeScriptJavaScriptJavaScript 的超集用于解决大型项目的代码复杂性一种脚本语言,用于创建动态网页。可以在编译期间发现并纠正错误作为一种解释型语言,只能在运行时发现错误强类型,支持静态和动态类型弱类型,没有静态类型选项最终被编译成 JavaScript 代码,使浏览器可以理解可以直接在浏览器中使用支持模块、泛型和接口不支持模块,泛型或接口支持 ES3,ES4,ES5 和 ES6 等不支持编译其他 ES3,ES4,ES5 或 ES6 功能社区的支持仍在增长,而且还不是很大大量的社区支持以及大量文档和解决问题的支持'; 22 | // document.body.appendChild(p); 23 | document.body.prepend(p); 24 | 25 | const img3 = document.createElement('img'); 26 | img3.src = 'https://app-center.cdn.bcebos.com/appcenter/sts/pcfile/5944066977/2507de0a4ceaa97eedbc0421776a69a7.png'; 27 | document.body.appendChild(img3); 28 | 29 | // 模拟T7内核特有的两个指标 30 | performance.timing.domFirstPaint = performance.timing.navigationStart + 238; 31 | performance.timing.domFirstScreenPaint = performance.timing.navigationStart + 400; 32 | 33 | // 模拟Longtaks 34 | let n = 0; 35 | for (let i = 0; i < 10000000; i++) { 36 | n = n + i + i / 3 * 2; 37 | } 38 | 39 | beforeEach(async () => { 40 | window.jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; 41 | }); 42 | 43 | // chrome可以过,但TRAVIS headless chrome过不了 44 | if (process.env.TRAVIS) { 45 | return; 46 | } 47 | 48 | // 暂时没有找到模拟用户真实点击 49 | // it('check fid metric', async () => { 50 | // await new Promise((resolve, reject) => { 51 | // let spy = new SpyClient({ 52 | // pid: '1_1000', 53 | // lid: 'xx', 54 | // }); 55 | 56 | // spy.listenFID(metric => { 57 | // expect(typeof metric.fid).toBe('number'); 58 | // expect(metric.fid).toBeGreaterThan(0); 59 | // }); 60 | 61 | // (document.querySelector('button') as HTMLButtonElement).click(); 62 | 63 | // setTimeout(() => { 64 | // (document.querySelector('button') as HTMLButtonElement).click(); 65 | // }, 1000); 66 | // }); 67 | // }); 68 | 69 | it('check timing metric', async () => { 70 | await new Promise((resolve, reject) => { 71 | let spy = new SpyClient({ 72 | pid: '1_1000', 73 | lid: 'xx', 74 | }); 75 | spy.listenTiming(metric => { 76 | console.log('listenTiming', metric) 77 | expect(typeof metric.load).toBe('number'); 78 | expect(metric.load).toBeGreaterThan(0); 79 | expect(typeof metric.domReady).toBe('number'); 80 | expect(metric.domReady).toBeGreaterThan(0); 81 | expect(typeof metric.parseHtml).toBe('number'); 82 | expect(metric.parseHtml).toBeGreaterThan(0); 83 | expect(typeof metric.response).toBe('number'); 84 | expect(metric.response).toBeGreaterThanOrEqual(0); 85 | expect(typeof metric.request).toBe('number'); 86 | expect(metric.request).toBeGreaterThanOrEqual(0); 87 | expect(typeof metric.tcp).toBe('number'); 88 | expect(typeof metric.dns).toBe('number'); 89 | 90 | resolve(''); 91 | }); 92 | }); 93 | 94 | }); 95 | 96 | it('check tti metric', async () => { 97 | await new Promise((resolve, reject) => { 98 | let spy = new SpyClient({ 99 | pid: '1_1000', 100 | lid: 'xx', 101 | }); 102 | spy.listenTTI(metric => { 103 | expect(typeof metric.tti).toBe('number'); 104 | expect(metric.tti).toBeGreaterThan(0); 105 | resolve(''); 106 | }); 107 | }); 108 | }); 109 | 110 | it('check resource metric', async () => { 111 | await new Promise((resolve, reject) => { 112 | let spy = new SpyClient({ 113 | pid: '1_1000', 114 | lid: 'xx', 115 | }); 116 | spy.listenResource(metric => { 117 | console.log('listenResource', metric) 118 | expect(typeof metric.allSize).toBe('number'); 119 | expect(metric.allSize).toBeGreaterThan(0); 120 | expect(typeof metric.allTransferSize).toBe('number'); 121 | expect(metric.allTransferSize).toBeGreaterThan(0); 122 | expect(typeof metric.docSize).toBe('number'); 123 | expect(metric.docSize).toBeGreaterThan(0); 124 | expect(typeof metric.jsSize).toBe('number'); 125 | expect(metric.jsSize).toBeGreaterThan(0); 126 | expect(typeof metric.cssSize).toBe('number'); 127 | expect(typeof metric.imgSize).toBe('number'); 128 | expect(typeof metric.headerSize).toBe('number'); 129 | 130 | resolve(''); 131 | }); 132 | }); 133 | }); 134 | 135 | it('check big img metric', async () => { 136 | await new Promise((resolve, reject) => { 137 | let spy = new SpyClient({ 138 | pid: '1_1000', 139 | lid: 'xx', 140 | }); 141 | spy.listenBigImg(metric => { 142 | console.log('listenBigImg', metric) 143 | expect(metric.msg === 'https://app-center.cdn.bcebos.com/appcenter/sts/pcfile/5944066977/2507de0a4ceaa97eedbc0421776a69a7.png').toBe(true); 144 | expect(metric.xpath.includes('<')).toBe(true); 145 | 146 | resolve(''); 147 | }); 148 | }); 149 | }); 150 | 151 | 152 | it('check FSPLongTask metric', async () => { 153 | await new Promise((resolve, reject) => { 154 | let spy = new SpyClient({ 155 | pid: '1_1000', 156 | lid: 'xx', 157 | }); 158 | spy.listenFSPLongTask(metric => { 159 | console.log('listenFSPLongTask', metric) 160 | expect(typeof metric.fspLongtaskTime).toBe('number'); 161 | expect(metric.fspLongtaskTime).toBeGreaterThanOrEqual(0); 162 | 163 | expect(typeof metric.fspTBT).toBe('number'); 164 | expect(metric.fspTBT).toBeGreaterThanOrEqual(0); 165 | 166 | expect(typeof metric.fspLongtaskRate).toBe('number'); 167 | expect(metric.fspLongtaskRate).toBeLessThanOrEqual(100); 168 | expect(metric.fspLongtaskRate).toBeGreaterThanOrEqual(0); 169 | 170 | resolve(''); 171 | }); 172 | }); 173 | }); 174 | 175 | it('check LCPLongTask metric', async () => { 176 | await new Promise((resolve, reject) => { 177 | let spy = new SpyClient({ 178 | pid: '1_1000', 179 | lid: 'xx', 180 | }); 181 | spy.listenLCPLongTask(metric => { 182 | expect(typeof metric.lcpLongtaskTime).toBe('number'); 183 | expect(metric.lcpLongtaskTime).toBeGreaterThan(0); 184 | 185 | expect(typeof metric.lcpTBT).toBe('number'); 186 | expect(metric.lcpTBT).toBeGreaterThan(0); 187 | 188 | expect(typeof metric.lcpLongtaskRate).toBe('number'); 189 | expect(metric.lcpLongtaskRate).toBeLessThanOrEqual(100); 190 | expect(metric.lcpLongtaskRate).toBeGreaterThanOrEqual(0); 191 | 192 | resolve(''); 193 | }); 194 | }); 195 | }); 196 | 197 | it('check LoadLongTask metric', async () => { 198 | await new Promise((resolve, reject) => { 199 | let spy = new SpyClient({ 200 | pid: '1_1000', 201 | lid: 'xx', 202 | }); 203 | spy.listenLoadLongTask(metric => { 204 | expect(typeof metric.loadLongtaskTime).toBe('number'); 205 | expect(metric.loadLongtaskTime).toBeGreaterThan(0); 206 | 207 | expect(typeof metric.loadTBT).toBe('number'); 208 | expect(metric.loadTBT).toBeGreaterThan(0); 209 | 210 | expect(typeof metric.loadLongtaskRate).toBe('number'); 211 | expect(metric.loadLongtaskRate).toBeLessThanOrEqual(100); 212 | expect(metric.loadLongtaskRate).toBeGreaterThanOrEqual(0); 213 | 214 | resolve(''); 215 | }); 216 | }); 217 | }); 218 | 219 | 220 | it('check lcp metric', async () => { 221 | await new Promise((resolve, reject) => { 222 | let spy = new SpyClient({ 223 | pid: '1_1000', 224 | lid: 'xx', 225 | }); 226 | spy.listenLCP(metric => { 227 | console.log('listenLCP', metric) 228 | expect(typeof metric.lcp).toBe('number'); 229 | expect(metric.lcp).toBeGreaterThan(0); 230 | 231 | resolve(''); 232 | }); 233 | }); 234 | }); 235 | 236 | it('check leave metric', async () => { 237 | let spy = new SpyClient({ 238 | pid: '1_1000', 239 | lid: 'xx', 240 | }); 241 | let pros: any[] = []; 242 | 243 | pros.push(new Promise((resolve, reject) => { 244 | spy.listenPageLongTask(metric => { 245 | console.log('listenPageLongTask', metric) 246 | expect(typeof metric.pageLongtaskTime).toBe('number'); 247 | expect(metric.pageLongtaskTime).toBeGreaterThan(0); 248 | 249 | expect(typeof metric.pageTBT).toBe('number'); 250 | expect(metric.pageTBT).toBeGreaterThan(0); 251 | 252 | expect(typeof metric.pageLongtaskRate).toBe('number'); 253 | expect(metric.pageLongtaskRate).toBeLessThanOrEqual(100); 254 | expect(metric.pageLongtaskRate).toBeGreaterThanOrEqual(0); 255 | 256 | resolve(''); 257 | }); 258 | })); 259 | 260 | pros.push(new Promise((resolve, reject) => { 261 | spy.listenMemory(metric => { 262 | console.log('listenMemory', metric) 263 | expect(typeof metric.usedJSHeapSize).toBe('number'); 264 | expect(metric.usedJSHeapSize).toBeGreaterThan(0); 265 | 266 | expect(typeof metric.usedJSHeapRate).toBe('number'); 267 | expect(metric.usedJSHeapRate).toBeLessThanOrEqual(100); 268 | expect(metric.usedJSHeapRate).toBeGreaterThanOrEqual(0); 269 | 270 | resolve(''); 271 | }); 272 | })); 273 | 274 | pros.push(new Promise((resolve, reject) => { 275 | spy.listenLayoutShift(metric => { 276 | console.log('listenLayoutShift', metric) 277 | expect(typeof metric.layoutShift).toBe('number'); 278 | expect(metric.layoutShift).toBeGreaterThanOrEqual(0); 279 | 280 | resolve(''); 281 | }); 282 | })); 283 | 284 | // 产生layout shift 285 | const img2 = document.createElement('img'); 286 | img2.src = 'https://t7.baidu.com/it/u=3748430357,3395801118&fm=193&app=53&size=w414&n=0&g=0n&f=jpeg?sec=1594890084&t=4004954b76645ad405242a68e6f77599'; 287 | document.body.prepend(img2); 288 | 289 | setTimeout(() => { 290 | // 模拟页面隐藏事件,测试 page longtask 和 memory 291 | Object.defineProperty(document, 'visibilityState', {value: 'hidden', writable: true}); 292 | Object.defineProperty(document, 'hidden', {value: true, writable: true}); 293 | document.dispatchEvent(new Event("visibilitychange")); 294 | }, 100); 295 | 296 | await Promise.all(pros); 297 | 298 | }); 299 | }); 300 | -------------------------------------------------------------------------------- /test/spec/types/globals.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 全局声明 3 | * @author kaivean 4 | */ 5 | 6 | interface Window { 7 | SpyClient: any; 8 | __spyHead: any; 9 | } 10 | 11 | interface Event { 12 | // MSMediaKeyMessageEvent 继承 Event,并且有message属性 且是 Uint8Array,会有冲突。这里设置为any 13 | message?: any; 14 | lineno?: number; 15 | line?: number; 16 | colno?: number; 17 | column?: number; 18 | error?: any; 19 | filename: any; 20 | sourceURL: any; 21 | errorCharacter?: number; 22 | } 23 | 24 | interface PerformanceTiming { 25 | domFirstPaint?: number; 26 | domFirstScreenPaint?: number; 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // 该配置用于 src 和 test目录的ts 2 | { 3 | "compilerOptions": { 4 | // ts-node does not support any module syntax other than commonjs 5 | // 生成代码的模块风格是commonjs 、ES6 or es2015、amd、umd、ESNext等风格 6 | "module": "es2015", 7 | 8 | // tsc编译输出的代码的es版本 9 | "target": "ES5", // 生成的代码是 ES3 ES5 ES6 or ES2015 ES2016 ES2017 ESNEXT 10 | 11 | "sourceMap": false, 12 | // "allowJs": true, // 不能和declaration一起使用 13 | "noImplicitReturns": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "noFallthroughCasesInSwitch": true, 17 | 18 | "allowUnreachableCode": false, 19 | "allowUnusedLabels": false, 20 | 21 | "allowSyntheticDefaultImports": true, 22 | "esModuleInterop": true, 23 | 24 | "lib": ["es6", "ES5", "dom", "scripthost"], 25 | 26 | "declaration": true, // 产出 *.d.ts,不能和allowJs一起使用 27 | "declarationDir": "./dist", 28 | "baseUrl": ".", 29 | "paths": { 30 | 31 | } 32 | }, 33 | "compileOnSave": false, 34 | "include": [ 35 | "src/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "**/*Spec.ts" 39 | ] 40 | } --------------------------------------------------------------------------------