/** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import ClientMonitor from './monitor'; (window as any).ClientMonitor = ClientMonitor; export default ClientMonitor; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { CustomOptionsType, CustomReportOptions, TagOption } from './types'; import { JSErrors, PromiseErrors, AjaxErrors, ResourceErrors, VueErrors, FrameErrors } from './errors/index'; import tracePerf from './performance/index'; import traceSegment, { setConfig } from './trace/segment'; const ClientMonitor = { customOptions: { collector: location.origin, // report serve jsErrors: true, // vue, js and promise errors apiErrors: true, resourceErrors: true, autoTracePerf: true, // trace performance detail useWebVitals: false, enableSPA: false, traceSDKInternal: false, detailMode: true, noTraceOrigins: [], traceTimeInterval: 60000, // 1min } as CustomOptionsType, register(configs: CustomOptionsType) { this.customOptions = { ...this.customOptions, ...configs, }; this.validateOptions(); this.catchErrors(this.customOptions); if (!this.customOptions.enableSPA) { this.performance(this.customOptions); } traceSegment(this.customOptions); }, performance(configs: any) { tracePerf.getPerf(configs); if (configs.enableSPA) { // hash router window.addEventListener( 'hashchange', () => { tracePerf.getPerf(configs); }, false, ); } }, catchErrors(options: CustomOptionsType) { const { service, pagePath, serviceVersion, collector } = options; if (options.jsErrors) { JSErrors.handleErrors({ service, pagePath, serviceVersion, collector }); PromiseErrors.handleErrors({ service, pagePath, serviceVersion, collector }); if (options.vue) { VueErrors.handleErrors({ service, pagePath, serviceVersion, collector }, options.vue); } } if (options.apiErrors) { AjaxErrors.handleError({ service, pagePath, serviceVersion, collector }); } if (options.resourceErrors) { ResourceErrors.handleErrors({ service, pagePath, serviceVersion, collector }); } }, setPerformance(configs: CustomReportOptions) { // history router this.customOptions = { ...this.customOptions, ...configs, }; this.validateOptions(); this.performance(this.customOptions); const { service, pagePath, serviceVersion, collector } = this.customOptions; if (this.customOptions.jsErrors) { JSErrors.setOptions({ service, pagePath, serviceVersion, collector }); PromiseErrors.setOptions({ service, pagePath, serviceVersion, collector }); if (this.customOptions.vue) { VueErrors.setOptions({ service, pagePath, serviceVersion, collector }); } } if (this.customOptions.apiErrors) { AjaxErrors.setOptions({ service, pagePath, serviceVersion, collector }); } if (this.customOptions.resourceErrors) { ResourceErrors.setOptions({ service, pagePath, serviceVersion, collector }); } setConfig(this.customOptions); }, reportFrameErrors(configs: CustomReportOptions, error: Error) { FrameErrors.handleErrors(configs, error); }, validateTags(customTags?: TagOption[]) { if (!customTags) { return false; } if (!Array.isArray(customTags)) { this.customOptions.customTags = undefined; console.error('customTags error'); return false; } let isTags = true; for (const ele of customTags) { if (!(ele && ele.key && ele.value)) { isTags = false; } } if (!isTags) { this.customOptions.customTags = undefined; console.error('customTags error'); return false; } return true; }, validateOptions() { const { collector, service, pagePath, serviceVersion, jsErrors, apiErrors, resourceErrors, autoTracePerf, useWebVitals, enableSPA, traceSDKInternal, detailMode, noTraceOrigins, traceTimeInterval, customTags, vue, } = this.customOptions; this.validateTags(customTags); if (typeof collector !== 'string') { this.customOptions.collector = location.origin; } if (typeof service !== 'string') { this.customOptions.service = ''; } if (typeof pagePath !== 'string') { this.customOptions.pagePath = ''; } if (typeof serviceVersion !== 'string') { this.customOptions.serviceVersion = ''; } if (typeof jsErrors !== 'boolean') { this.customOptions.jsErrors = true; } if (typeof apiErrors !== 'boolean') { this.customOptions.apiErrors = true; } if (typeof resourceErrors !== 'boolean') { this.customOptions.resourceErrors = true; } if (typeof autoTracePerf !== 'boolean') { this.customOptions.autoTracePerf = true; } if (typeof useWebVitals !== 'boolean') { this.customOptions.useWebVitals = false; } if (typeof enableSPA !== 'boolean') { this.customOptions.enableSPA = false; } if (typeof traceSDKInternal !== 'boolean') { this.customOptions.traceSDKInternal = false; } if (typeof detailMode !== 'boolean') { this.customOptions.detailMode = true; } if (typeof detailMode !== 'boolean') { this.customOptions.detailMode = true; } if (!Array.isArray(noTraceOrigins)) { this.customOptions.noTraceOrigins = []; } if (typeof traceTimeInterval !== 'number') { this.customOptions.traceTimeInterval = 60000; } if (typeof vue !== 'function') { this.customOptions.vue = undefined; } }, setCustomTags(tags: TagOption[]) { const opt = { ...this.customOptions, customTags: tags }; if (this.validateTags(tags)) { setConfig(opt); } }, }; export default ClientMonitor; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export interface CustomOptionsType extends CustomReportOptions { jsErrors?: boolean; apiErrors?: boolean; resourceErrors?: boolean; autoTracePerf?: boolean; enableSPA?: boolean; vue?: any; traceSDKInternal?: boolean; detailMode?: boolean; noTraceOrigins?: (string | RegExp)[]; traceTimeInterval?: number; customTags?: TagOption[]; useWebVitals?: boolean; } export interface CustomReportOptions { collector?: string; service: string; pagePath: string; serviceVersion: string; } export type TagOption = { key: string; value: string; }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory, ReportTypes } from '../services/constant'; import { CustomReportOptions } from '../types'; class AjaxErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; // get http error info public handleError(options: CustomReportOptions) { // XMLHttpRequest Object if (!window.XMLHttpRequest) { return; } this.infoOpt = options; window.addEventListener( 'xhrReadyStateChange', (event: CustomEvent) => { const detail = event.detail; if (detail.readyState !== 4) { return; } if (detail.getRequestConfig[1] === options.collector + ReportTypes.ERRORS) { return; } if (detail.status !== 0 && detail.status < 400) { return; } this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.AJAX_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: detail.getRequestConfig[1], message: `status: ${detail.status}; statusText: ${detail.statusText};`, collector: options.collector, stack: detail.responseText, }; this.traceInfo(); }, ); } setOptions(opt: CustomReportOptions) { this.infoOpt = opt; } } export default new AjaxErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory } from '../services/constant'; import { CustomReportOptions } from '../types'; class FrameErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; public handleErrors(options: CustomReportOptions, error: Error) { this.infoOpt = options; this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.JS_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: error.name || location.href, message: error.message, collector: options.collector || location.origin, stack: error.stack, }; this.traceInfo(); } } export default new FrameErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import JSErrors from './js'; import PromiseErrors from './promise'; import AjaxErrors from './ajax'; import ResourceErrors from './resource'; import VueErrors from './vue'; import FrameErrors from './frames'; export { JSErrors, PromiseErrors, AjaxErrors, ResourceErrors, VueErrors, FrameErrors }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory } from '../services/constant'; import { CustomReportOptions } from '../types'; class JSErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; public handleErrors(options: CustomReportOptions) { this.infoOpt = options; window.onerror = (message, url, line, col, error) => { this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.JS_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: url, line, col, message, collector: options.collector, stack: error ? error.stack : '', }; this.traceInfo(); }; } setOptions(opt: CustomReportOptions) { this.infoOpt = opt; } } export default new JSErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory } from '../services/constant'; import { CustomReportOptions } from '../types'; class PromiseErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; public handleErrors(options: CustomReportOptions) { this.infoOpt = options; window.addEventListener('unhandledrejection', (event) => { try { let url = ''; if (!event || !event.reason) { return; } if (event.reason.config && event.reason.config.url) { url = event.reason.config.url; } this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.PROMISE_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: url || location.href, message: event.reason.message, stack: event.reason.stack, collector: options.collector, }; this.traceInfo(); } catch (error) { console.log(error); } }); } setOptions(opt: CustomReportOptions) { this.infoOpt = opt; } } export default new PromiseErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory } from '../services/constant'; import { CustomReportOptions } from '../types'; class ResourceErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; public handleErrors(options: CustomReportOptions) { this.infoOpt = options; window.addEventListener('error', (event) => { try { if (!event) { return; } const target: any = event.target; const isElementTarget = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement; if (!isElementTarget) { // return js error return; } this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.RESOURCE_ERROR, grade: target.tagName === 'IMG' ? GradeTypeEnum.WARNING : GradeTypeEnum.ERROR, errorUrl: (target as HTMLScriptElement).src || (target as HTMLLinkElement).href || location.href, message: `load ${target.tagName} resource error`, collector: options.collector, stack: `load ${target.tagName} resource error`, }; this.traceInfo(); } catch (error) { throw error; } },true); } setOptions(opt: CustomReportOptions) { this.infoOpt = opt; } } export default new ResourceErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import uuid from '../services/uuid'; import Base from '../services/base'; import { GradeTypeEnum, ErrorsCategory } from '../services/constant'; import { CustomReportOptions } from '../types'; class VueErrors extends Base { private infoOpt: CustomReportOptions = { service: '', pagePath: '', serviceVersion: '', }; public handleErrors(options: CustomReportOptions, Vue: any) { this.infoOpt = options; if (!(Vue && Vue.config)) { return; } Vue.config.errorHandler = (error: Error, vm: any, info: string) => { try { this.logInfo = { ...this.infoOpt, uniqueId: uuid(), category: ErrorsCategory.VUE_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: location.href, message: info, collector: options.collector, stack: error.stack, }; this.traceInfo(); } catch (error) { throw error; } }; } setOptions(opt: CustomReportOptions) { this.infoOpt = opt; } } export default new VueErrors(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ICalScore, ElementList } from './type'; const getStyle = (element: Element | any, attr: any) => { if (window.getComputedStyle) { return window.getComputedStyle(element, null)[attr]; } else { return element.currentStyle[attr]; } }; // element weight for calculate score enum ELE_WEIGHT { SVG = 2, IMG = 2, CANVAS = 4, OBJECT = 4, EMBED = 4, VIDEO = 4, } const START_TIME: number = performance.now(); const IGNORE_TAG_SET: string[] = ['SCRIPT', 'STYLE', 'META', 'HEAD', 'LINK']; const LIMIT: number = 3000; const WW: number = window.innerWidth; const WH: number = window.innerHeight; const DELAY: number = 2000; // fmp retry interval class FMPTiming { public fmpTime: number = 0; private statusCollector: Array<{ time: number }> = []; // nodes change time private flag: boolean = true; private observer: MutationObserver = null; private callbackCount: number = 0; private entries: any = {}; constructor() { if (!performance || !performance.getEntries) { console.log('your browser do not support performance.getEntries'); return; } this.initObserver(); } private getFirstSnapShot() { const time: number = performance.now(); const $body: HTMLElement = document.body; if ($body) { this.setTag($body, this.callbackCount); } this.statusCollector.push({ time, }); } private initObserver() { this.getFirstSnapShot(); this.observer = new MutationObserver(() => { this.callbackCount += 1; const time = performance.now(); const $body: HTMLElement = document.body; if ($body) { this.setTag($body, this.callbackCount); } this.statusCollector.push({ time, }); }); // observe all child nodes this.observer.observe(document, { childList: true, subtree: true, }); this.calculateFinalScore(); } private calculateFinalScore() { if (!this.flag) { return; } if (!MutationObserver) { return; } if (this.checkNeedCancel(START_TIME)) { // cancel observer for dom change this.observer.disconnect(); this.flag = false; const res = this.getTreeScore(document.body); let tp: ICalScore = null; for (const item of res.dpss) { if (tp && tp.st) { if (tp.st < item.st) { tp = item; } } else { tp = item; } } // Get all of soures load time performance.getEntries().forEach((item: PerformanceResourceTiming) => { this.entries[item.name] = item.responseEnd; }); if (!tp) { return false; } const resultEls: ElementList = this.filterResult(tp.els); this.fmpTime = this.getFmpTime(resultEls); } else { setTimeout(() => { this.calculateFinalScore(); }, DELAY); } } private getFmpTime(resultEls: ElementList): number { let rt = 0; for (const item of resultEls) { let time: number = 0; if (item.weight === 1) { const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10); time = this.statusCollector[index] && this.statusCollector[index].time; } else if (item.weight === 2) { if (item.ele.tagName === 'IMG') { time = this.entries[(item.ele as HTMLImageElement).src]; } else if (item.ele.tagName === 'SVG') { const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10); time = this.statusCollector[index] && this.statusCollector[index].time; } else { const match = getStyle(item.ele, 'background-image').match(/url\(\"(.*?)\"\)/); let url: string = ''; if (match && match[1]) { url = match[1]; if (!url.includes('http')) { url = location.protocol + match[1]; } } time = this.entries[url]; } } else if (item.weight === 4) { if (item.ele.tagName === 'CANVAS') { const index: number = parseInt(item.ele.getAttribute('fmp_c'), 10); time = this.statusCollector[index] && this.statusCollector[index].time; } else if (item.ele.tagName === 'VIDEO') { time = this.entries[(item.ele as HTMLVideoElement).src]; if (!time) { time = this.entries[(item.ele as HTMLVideoElement).poster]; } } } if (typeof time !== 'number') { time = 0; } if (rt < time) { rt = time; } } return rt; } /** * The nodes with the highest score in the visible area are collected and the average value is taken, * and the low score ones are eliminated */ private filterResult(els: ElementList): ElementList { if (els.length === 1) { return els; } let sum: number = 0; els.forEach((item: any) => { sum += item.st; }); const avg: number = sum / els.length; return els.filter((item: any) => { return item.st > avg; }); } private checkNeedCancel(start: number): boolean { const time: number = performance.now() - start; const lastCalTime: number = this.statusCollector.length > 0 ? this.statusCollector[this.statusCollector.length - 1].time : 0; return time > LIMIT || time - lastCalTime > 1000; } private getTreeScore(node: Element): ICalScore | any { if (!node) { return {}; } const dpss = []; const children: any = node.children; for (const child of children) { // Only calculate marked elements if (!child.getAttribute('fmp_c')) { continue; } const s = this.getTreeScore(child); if (s.st) { dpss.push(s); } } return this.calcaulteGrades(node, dpss); } private calcaulteGrades(ele: Element, dpss: ICalScore[]): ICalScore { const { width, height, left, top } = ele.getBoundingClientRect(); let isInViewPort: boolean = true; if (WH < top || WW < left) { isInViewPort = false; } let sdp: number = 0; dpss.forEach((item: any) => { sdp += item.st; }); let weight: number = Number(ELE_WEIGHT[ele.tagName as any]) || 1; // If there is a common element of the background image, it is calculated according to the picture if ( weight === 1 && getStyle(ele, 'background-image') && getStyle(ele, 'background-image') !== 'initial' && getStyle(ele, 'background-image') !== 'none' ) { weight = ELE_WEIGHT.IMG; } // score = the area of element let st: number = isInViewPort ? width * height * weight : 0; let els = [{ ele, st, weight }]; const root = ele; // The percentage of the current element in the viewport const areaPercent = this.calculateAreaParent(ele); // If the sum of the child's weights is greater than the parent's true weight if (sdp > st * areaPercent || areaPercent === 0) { st = sdp; els = []; for (const item of dpss) { els = els.concat(item.els); } } return { dpss, st, els, root, }; } private calculateAreaParent(ele: Element): number { const { left, right, top, bottom, width, height } = ele.getBoundingClientRect(); const winLeft: number = 0; const winTop: number = 0; const winRight: number = WW; const winBottom: number = WH; const overlapX = right - left + (winRight - winLeft) - (Math.max(right, winRight) - Math.min(left, winLeft)); const overlapY = bottom - top + (winBottom - winTop) - (Math.max(bottom, winBottom) - Math.min(top, winTop)); if (overlapX <= 0 || overlapY <= 0) { return 0; } return (overlapX * overlapY) / (width * height); } // Depth first traversal to mark nodes private setTag(target: Element, callbackCount: number): void { const tagName: string = target.tagName; if (IGNORE_TAG_SET.indexOf(tagName) === -1) { const $children: HTMLCollection = target.children; if ($children && $children.length > 0) { for (let i = $children.length - 1; i >= 0; i--) { const $child: Element = $children[i]; const hasSetTag = $child.getAttribute('fmp_c') !== null; // If it is not marked, whether the marking condition is met is detected if (!hasSetTag) { const { left, top, width, height } = $child.getBoundingClientRect(); if (WH < top || WW < left || width === 0 || height === 0) { continue; } $child.setAttribute('fmp_c', `${callbackCount}`); } this.setTag($child, callbackCount); } } } } } export default new FMPTiming(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {CustomOptionsType} from '../types'; import Report from '../services/report'; import {prerenderChangeListener, onHidden, runOnce, idlePeriod} from '../services/eventsListener'; import pagePerf from './perf'; import FMP from './fmp'; import {observe} from '../services/observe'; import {LCPMetric, INPMetric, CLSMetric} from './type'; import {LayoutShift} from '../services/types'; import {getVisibilityObserver} from '../services/getVisibilityObserver'; import {getActivationStart, getResourceEntry} from '../services/getEntries'; import {onBFCacheRestore} from '../services/bfcache'; import {handleInteractionEntry, clearInteractions, getLongestInteraction, DEFAULT_DURATION_THRESHOLD} from '../services/interactions'; import {isLayoutShiftSupported, isEventSupported, isLargestContentfulPaintSupported} from '../services/apiDetectSupported'; const handler = { set(target: {[key: string]: unknown}, prop: string, value: unknown) { if (target[prop] !== undefined) { return true } target[prop] = value; const source: {[key: string]: unknown} = { ...target, collector: undefined, useWebVitals: undefined, }; if (target.useWebVitals && !isNaN(Number(target.fmpTime)) && !isNaN(Number(target.lcpTime)) && !isNaN(Number(target.clsTime))) { new TracePerf().reportPerf(source, String(target.collector)); } return true; } }; const reportedMetricNames: Record = {}; const InitiatorTypes = ["beacon", "xmlhttprequest", "fetch"]; class TracePerf { private options: CustomOptionsType = { pagePath: '', serviceVersion: '', service: '', collector: '' }; private perfInfo = {}; private coreWebMetrics: Record = {}; private resources: {name: string, duration: number, size: number, protocol: string, resourceType: string}[] = []; private inpList: Record[] = []; public getPerf(options: CustomOptionsType) { this.options = options; this.perfInfo = { pagePath: options.pagePath, serviceVersion: options.serviceVersion, service: options.service, } this.coreWebMetrics = new Proxy({...this.perfInfo, collector: options.collector, useWebVitals: options.useWebVitals}, handler); this.observeResources(); // trace and report perf data and pv to serve when page loaded if (document.readyState === 'complete') { this.getBasicPerf(); } else { window.addEventListener('load', () => this.getBasicPerf()); } this.getCorePerf(); window.addEventListener('beforeunload', () => { this.reportINP(); this.reportResources(); }); } private observeResources() { observe('resource', (list) => { const newResources = list.filter((d: PerformanceResourceTiming) => !InitiatorTypes.includes(d.initiatorType)) .map((d: PerformanceResourceTiming) => ({ service: this.options.service, serviceVersion: this.options.serviceVersion, pagePath: this.options.pagePath, name: d.name, duration: Math.floor(d.duration), size: d.transferSize, protocol: d.nextHopProtocol, resourceType: d.initiatorType, })); this.resources.push(...newResources); }); } private reportResources() { const newResources = getResourceEntry().filter((d: PerformanceResourceTiming) => !InitiatorTypes.includes(d.initiatorType)) .map((d: PerformanceResourceTiming) => ({ service: this.options.service, serviceVersion: this.options.serviceVersion, pagePath: this.options.pagePath, name: d.name, duration: Math.floor(d.duration), size: d.transferSize, protocol: d.nextHopProtocol, resourceType: d.initiatorType, })); const list = [...newResources, ...this.resources]; if (!list.length) { return; } new Report('RESOURCES', this.options.collector).sendByBeacon(list); } private getCorePerf() { if (!this.options.useWebVitals) { return; } this.LCP(); this.INP(); this.CLS(); setTimeout(() => { this.coreWebMetrics.fmpTime = isNaN(FMP.fmpTime) ? -1 : Math.floor(FMP.fmpTime); }, 5000); } private CLS() { if (!isLayoutShiftSupported()) { return this.coreWebMetrics.clsTime = -1; } let partValue = 0; let entryList: LayoutShift[] = []; const handleEntries = (entries: LayoutShift[]) => { entries.forEach((entry) => { // Count layout shifts without recent user input only if (!entry.hadRecentInput) { const firstEntry = entryList[0]; const lastEntry = entryList[entryList.length - 1]; if ( partValue && entry.startTime - lastEntry.startTime < 1000 && entry.startTime - firstEntry.startTime < 5000 ) { partValue += entry.value; } else { partValue = entry.value; } entryList.push(entry); } }); if (partValue > 0) { setTimeout(() => { this.coreWebMetrics.clsTime = partValue; }, 3000); } }; const obs = observe('layout-shift', handleEntries); if (!obs) { return; } onHidden(() => { handleEntries(obs.takeRecords() as CLSMetric['entries']); obs!.disconnect(); }); } private LCP() { if (!isLargestContentfulPaintSupported()) { return this.coreWebMetrics.lcpTime = -1; } prerenderChangeListener(() => { const visibilityObserver = getVisibilityObserver(); const processEntries = (entries: LCPMetric['entries']) => { entries = entries.slice(-1); for (const entry of entries) { if (entry.startTime < visibilityObserver.firstHiddenTime) { this.coreWebMetrics.lcpTime = Math.floor(Math.max(entry.startTime - getActivationStart(), 0)); } } }; const obs = observe('largest-contentful-paint', processEntries); if (!obs) { return; } const disconnect = runOnce(() => { if (!reportedMetricNames['lcp']) { processEntries(obs!.takeRecords() as LCPMetric['entries']); obs!.disconnect(); reportedMetricNames['lcp'] = true; } }); ['keydown', 'click'].forEach((type) => { addEventListener(type, () => idlePeriod(disconnect), true); }); onHidden(disconnect); }) } private INP() { if (!isEventSupported()) { return; } prerenderChangeListener(() => { const processEntries = (entries: INPMetric['entries']) => { idlePeriod(() => { entries.forEach(handleInteractionEntry); const interaction = getLongestInteraction(); const len = this.inpList.length; if (interaction && (!len || this.inpList[len - 1].inpTime !== interaction.latency)) { const param = { inpTime: interaction.latency, ...this.perfInfo, }; this.inpList.push(param); } }) }; const obs = observe('event', processEntries, { durationThreshold: DEFAULT_DURATION_THRESHOLD, }); if (!obs) { return; } obs.observe({type: 'first-input', buffered: true}); onHidden( runOnce(() => { processEntries(obs.takeRecords() as INPMetric['entries']); obs.disconnect(); }), ); onBFCacheRestore(() => { clearInteractions(); this.inpList.length = 0; }) }) } private reportINP() { if (!this.inpList.length) { return; } new Report('WEBINTERACTIONS', this.options.collector).sendByBeacon(this.inpList); } private getBasicPerf() { // auto report pv and perf data const perfDetail = this.options.autoTracePerf ? new pagePerf().getPerfTiming() : {}; const perfInfo = { ...perfDetail, ...this.perfInfo, }; new Report('PERF', this.options.collector).sendByXhr(perfInfo); // clear perf data this.clearPerf(); } public reportPerf(data: {[key: string]: unknown}, collector: string) { const perf = { ...data, ...this.perfInfo }; new Report('WEBVITALS', collector).sendByXhr(perf); } private clearPerf() { if (!(window.performance && window.performance.clearResourceTimings)) { return; } window.performance.clearResourceTimings(); } } export default new TracePerf(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { IPerfDetail } from './type'; import {getNavigationEntry} from '../services/getEntries'; class PagePerf { public getPerfTiming(): IPerfDetail { try { let { timing } = window.performance as PerformanceNavigationTiming | any; // PerformanceTiming if (typeof window.PerformanceNavigationTiming === 'function') { const nt2Timing = getNavigationEntry(); if (nt2Timing) { timing = nt2Timing; } } let redirectTime = 0; if (timing.navigationStart !== undefined) { redirectTime = Math.floor(timing.fetchStart - timing.navigationStart); } else if (timing.redirectEnd !== undefined) { redirectTime = Math.floor(timing.redirectEnd - timing.redirectStart); } else { redirectTime = 0; } return { redirectTime, dnsTime: Math.floor(timing.domainLookupEnd - timing.domainLookupStart), ttfbTime: Math.floor(timing.responseStart - timing.requestStart), // Time to First Byte tcpTime: Math.floor(timing.connectEnd - timing.connectStart), transTime: Math.floor(timing.responseEnd - timing.responseStart), domAnalysisTime: Math.floor(timing.domInteractive - timing.responseEnd), fptTime: Math.floor(timing.responseEnd - timing.fetchStart), // First Paint Time or Blank Screen Time domReadyTime: Math.floor(timing.domContentLoadedEventEnd - timing.fetchStart), loadPageTime: Math.floor(timing.loadEventStart - timing.fetchStart), // Page full load time // Synchronous load resources in the page resTime: Math.floor(timing.loadEventStart - timing.domContentLoadedEventEnd), // Only valid for HTTPS sslTime: location.protocol === 'https:' && timing.secureConnectionStart > 0 ? Math.floor(timing.connectEnd - timing.secureConnectionStart) : undefined, ttlTime: Math.floor(timing.domInteractive - timing.fetchStart), // time to interact firstPackTime: Math.floor(timing.responseStart - timing.domainLookupStart), // first pack time }; } catch (e) { throw e; } } } export default PagePerf; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {LargestContentfulPaint, LayoutShift, PerformanceEventTiming} from "../services/types"; export interface ICalScore { dpss: ICalScore[]; st: number; els: ElementList; root?: Element; } export type ElementList = Array<{ ele: Element; st: number; weight: number; }>; export type IPerfDetail = { redirectTime: number | undefined; // Time of redirection dnsTime: number | undefined; // DNS query time ttfbTime: number | undefined; // Time to First Byte tcpTime: number | undefined; // Tcp connection time transTime: number | undefined; // Content transfer time domAnalysisTime: number | undefined; // Dom parsing time fptTime: number | undefined; // First Paint Time or Blank Screen Time domReadyTime: number | undefined; // Dom ready time loadPageTime: number | undefined; // Page full load time resTime: number | undefined; // Synchronous load resources in the page sslTime: number | undefined; // Only valid for HTTPS ttlTime: number | undefined; // Time to interact firstPackTime: number | undefined; // first pack time }; export interface LCPMetric { name: 'LCP'; entries: LargestContentfulPaint[]; } export interface INPMetric { name: 'INP'; entries: PerformanceEventTiming[]; } export interface CLSMetric { name: 'CLS'; entries: LayoutShift[]; } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export function isLayoutShiftSupported() { return PerformanceObserver.supportedEntryTypes.includes('layout-shift'); } export function isLargestContentfulPaintSupported() { return PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint'); } export function isEventSupported() { return PerformanceObserver.supportedEntryTypes.includes('event'); } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import Task from './task'; import { ErrorsCategory, GradeTypeEnum } from './constant'; import { ErrorInfoFields, ReportFields } from './types'; let pageHasjsError: { [key: string]: boolean } = {}; let interval: NodeJS.Timeout; export default class Base { public logInfo: ErrorInfoFields & ReportFields & { collector: string } = { uniqueId: '', service: '', serviceVersion: '', pagePath: '', category: ErrorsCategory.UNKNOWN_ERROR, grade: GradeTypeEnum.INFO, errorUrl: '', line: 0, col: 0, message: '', firstReportedError: false, collector: '', }; public traceInfo(logInfo?: ErrorInfoFields & ReportFields & { collector: string }) { this.logInfo = logInfo || this.logInfo; const ExcludeErrorTypes: string[] = [ ErrorsCategory.AJAX_ERROR, ErrorsCategory.RESOURCE_ERROR, ErrorsCategory.UNKNOWN_ERROR, ]; // mark js error pv if (!pageHasjsError[location.href] && !ExcludeErrorTypes.includes(this.logInfo.category)) { pageHasjsError = { [location.href]: true, }; this.logInfo.firstReportedError = true; } const collector = this.logInfo.collector; delete this.logInfo.collector; Task.addTask(this.logInfo, collector); Task.finallyFireTasks(); if (interval) { return; } // report errors within 1min interval = setInterval(() => { Task.fireTasks(); }, 60000); } } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ interface onBFCacheRestoreCallback { (event: PageTransitionEvent): void; } let bfcacheRestoreTime = -1; export const getBFCacheRestoreTime = () => bfcacheRestoreTime; export function onBFCacheRestore(cb: onBFCacheRestoreCallback) { addEventListener( 'pageshow', (event) => { if (event.persisted) { bfcacheRestoreTime = event.timeStamp; cb(event); } }, true, ); }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export enum ErrorsCategory { AJAX_ERROR = 'ajax', RESOURCE_ERROR = 'resource', VUE_ERROR = 'vue', PROMISE_ERROR = 'promise', JS_ERROR = 'js', UNKNOWN_ERROR = 'unknown', } export enum GradeTypeEnum { INFO = 'Info', WARNING = 'Warning', ERROR = 'Error', } export enum ReportTypes { ERROR = '/browser/errorLog', ERRORS = '/browser/errorLogs', PERF = '/browser/perfData', WEBVITALS = '/browser/perfData/webVitals', WEBINTERACTIONS = '/browser/perfData/webInteractions', RESOURCES = '/browser/perfData/resources', SEGMENT = '/v3/segment', SEGMENTS = '/v3/segments', } export const SpanLayer = 'Http'; export const SpanType = 'Exit'; export enum ReadyStatus { OPENED = 1, DONE = 4, } export const ComponentId = 10001; // ajax /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export function prerenderChangeListener(callback: () => void) { if ((document as any).prerendering) { addEventListener('prerenderingchange', callback, true); return; } callback(); } export function onHidden (cb: () => void) { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { cb(); } }); }; export function runOnce (callback: () => void) { let called = false; return () => { if (!called) { callback(); called = true; } }; }; export function idlePeriod(callback: () => void): number { const func = window.requestIdleCallback || window.setTimeout; let handle = -1; callback = runOnce(callback); if (document.visibilityState === 'hidden') { callback(); } else { handle = func(callback); onHidden(callback); } return handle; }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export function getNavigationEntry() { const navigationEntry: PerformanceEntry | any = self.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; if ( navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now() ) { return navigationEntry; } }; export function getActivationStart() { const entry = getNavigationEntry(); return (entry && entry.activationStart) || 0; }; export function getResourceEntry() { const resourceEntry: PerformanceEntry | any = self.performance && performance.getEntriesByType && performance.getEntriesByType('resource'); return resourceEntry; }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {onBFCacheRestore} from './bfcache'; let firstHiddenTime = -1; function initHiddenTime () { return document.visibilityState === 'hidden' && !(document as any).prerendering ? 0 : Infinity; }; function onVisibilityUpdate(event: Event) { if (document.visibilityState === 'hidden' && firstHiddenTime > -1) { firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; removeChangeListeners(); } }; function addChangeListeners() { addEventListener('visibilitychange', onVisibilityUpdate, true); addEventListener('prerenderingchange', onVisibilityUpdate, true); }; function removeChangeListeners() { removeEventListener('visibilitychange', onVisibilityUpdate, true); removeEventListener('prerenderingchange', onVisibilityUpdate, true); }; export function getVisibilityObserver() { if (firstHiddenTime < 0) { firstHiddenTime = initHiddenTime(); addChangeListeners(); onBFCacheRestore(() => { setTimeout(() => { firstHiddenTime = initHiddenTime(); addChangeListeners(); }, 0); }); } return { get firstHiddenTime() { return firstHiddenTime; }, }; }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Interaction, EntryPreProcessingHook, PerformanceEventTiming } from "./types"; export const DEFAULT_DURATION_THRESHOLD = 40; // Longest interactions list export const interactionList: Interaction[] = []; export const interactionsMap: Map = new Map(); export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; const MAX_INTERACTIONS_TO_CONSIDER = 10; let prevInteractionCount = 0; export function handleInteractionEntry (entry: PerformanceEventTiming) { entryPreProcessingCallbacks.forEach((cb) => cb(entry)); if (!(entry.interactionId || entry.entryType === 'first-input')) return; const minLongestInteraction = interactionList[interactionList.length - 1]; const existingInteraction = interactionsMap.get(entry.interactionId!); if ( existingInteraction || interactionList.length < MAX_INTERACTIONS_TO_CONSIDER || entry.duration > minLongestInteraction.latency ) { if (existingInteraction) { if (entry.duration > existingInteraction.latency) { existingInteraction.entries = [entry]; existingInteraction.latency = entry.duration; } else if ( entry.duration === existingInteraction.latency && entry.startTime === existingInteraction.entries[0].startTime ) { existingInteraction.entries.push(entry); } } else { const interaction = { id: entry.interactionId!, latency: entry.duration, entries: [entry], }; interactionsMap.set(interaction.id, interaction); interactionList.push(interaction); } // Sort the entries by latency interactionList.sort((a, b) => b.latency - a.latency); if (interactionList.length > MAX_INTERACTIONS_TO_CONSIDER) { interactionList .splice(MAX_INTERACTIONS_TO_CONSIDER) .forEach((i) => interactionsMap.delete(i.id)); } } }; function getInteractionCountForNavigation() { return getInteractionCount() - prevInteractionCount; }; export function getLongestInteraction() { const index = Math.min(interactionList.length - 1, Math.floor(getInteractionCountForNavigation() / 50)); return interactionList[index]; }; export function clearInteractions() { prevInteractionCount = getInteractionCount(); interactionList.length = 0; interactionsMap.clear(); }; export function getInteractionCount() { return (performance.eventCounts as any).size || 0; }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {LargestContentfulPaint, LayoutShift, PerformanceEventTiming, PerformanceObserverInit} from "./types"; interface PerformanceEntryObj { 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; 'resource': PerformanceResourceTiming[]; 'event': PerformanceEventTiming[]; } export function observe ( type: K, callback: (entries: PerformanceEntryObj[K]) => void, opts?: PerformanceObserverInit, ): PerformanceObserver { try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { const perfObs = new PerformanceObserver((list) => { Promise.resolve().then(() => { callback(list.getEntries() as PerformanceEntryObj[K]); }); }); perfObs.observe( Object.assign( { type, buffered: true, }, opts || {}, ), ); return perfObs; } } catch (e) { console.error(e); } }; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ReportTypes } from './constant'; class Report { private url: string = ''; constructor(type: string, collector: string) { const typesMap: Record = { ERROR: ReportTypes.ERROR, ERRORS: ReportTypes.ERRORS, SEGMENT: ReportTypes.SEGMENT, SEGMENTS: ReportTypes.SEGMENTS, PERF: ReportTypes.PERF, WEBVITALS: ReportTypes.WEBVITALS, WEBINTERACTIONS: ReportTypes.WEBINTERACTIONS, RESOURCES: ReportTypes.RESOURCES, }; this.url = `${collector}${typesMap[type]}`; } public sendByFetch(data: any) { delete data.collector; if (!this.url) { return; } const sendRequest = new Request(this.url, { method: 'POST', body: JSON.stringify(data) }); fetch(sendRequest) .then((response) => { if (response.status >= 400 || response.status === 0) { throw new Error('Something went wrong on api server!'); } }) .catch((error) => { console.error(error); }); } public sendByXhr(data: any) { if (!this.url) { return; } const xhr = new XMLHttpRequest(); xhr.open('post', this.url, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status < 400) { console.log('Report successfully'); } }; xhr.send(JSON.stringify(data)); } public sendByBeacon(data: any) { if (!this.url) { return; } if (typeof navigator.sendBeacon === 'function') { navigator.sendBeacon( this.url, new Blob([JSON.stringify(data)], { type: 'application/json' }) ); return; } this.sendByXhr(data); } } export default Report; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ErrorInfoFields, ReportFields } from './types'; import Report from './report'; class TaskQueue { private queues: ((ErrorInfoFields & ReportFields) | undefined)[] = []; private collector: string = ''; public addTask(data: ErrorInfoFields & ReportFields, collector: string) { this.queues.push(data); this.collector = collector; } public fireTasks() { if (!(this.queues && this.queues.length)) { return; } new Report('ERRORS', this.collector).sendByXhr(this.queues); this.queues = []; } public finallyFireTasks() { window.addEventListener('beforeunload', () => { if (!this.queues.length) { return; } new Report('ERRORS', this.collector).sendByBeacon(this.queues); }); } } export default new TaskQueue(); /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export interface ErrorInfoFields { uniqueId: string; category: string; grade: string; message: any; errorUrl: string; line?: number; col?: number; stack?: string; firstReportedError?: boolean; } export interface ReportFields { service: string; serviceVersion: string; pagePath: string; } export interface LargestContentfulPaint extends PerformanceEntry { readonly renderTime: DOMHighResTimeStamp; readonly loadTime: DOMHighResTimeStamp; readonly size: number; readonly id: string; readonly url: string; readonly element: Element | null; } interface LayoutShiftAttribution { node?: Node; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; } export interface LayoutShift extends PerformanceEntry { value: number; sources: LayoutShiftAttribution[]; hadRecentInput: boolean; } export interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; interactionId: number; } export interface Interaction { id: number; latency: number; entries: PerformanceEventTiming[]; } export interface EntryPreProcessingHook { (entry: PerformanceEventTiming): void; } export interface PerformanceObserverInit { durationThreshold?: number; } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export default function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { /* tslint:disable */ const r = (Math.random() * 16) | 0; /* tslint:disable */ const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import xhrInterceptor, { setOptions } from './interceptors/xhr'; import windowFetch, { setFetchOptions } from './interceptors/fetch'; import Report from '../services/report'; import { SegmentFields } from './type'; import { CustomOptionsType } from '../types'; export default function traceSegment(options: CustomOptionsType) { const segments = [] as SegmentFields[]; // inject interceptor xhrInterceptor(options, segments); windowFetch(options, segments); window.addEventListener('beforeunload', () => { if (!segments.length) { return; } new Report('SEGMENTS', options.collector).sendByBeacon(segments); }); //report per options.traceTimeInterval min setInterval(() => { if (!segments.length) { return; } new Report('SEGMENTS', options.collector).sendByXhr(segments); segments.splice(0, segments.length); }, options.traceTimeInterval); } export function setConfig(opt: CustomOptionsType) { setOptions(opt); setFetchOptions(opt); } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export interface SegmentFields { traceId: string; service: string; spans: SpanFields[]; serviceInstance: string; traceSegmentId: string; } export interface SpanFields { operationName: string; startTime: number; endTime: number; spanId: number; spanLayer: string; spanType: string; isError: boolean; parentSpanId: number; componentId: number; peer: string; tags?: any; } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { encode } from 'js-base64'; import uuid from '../../services/uuid'; import { SegmentFields, SpanFields } from '../type'; import { CustomOptionsType } from '../../types'; import Base from '../../services/base'; import { ComponentId, ReportTypes, SpanLayer, SpanType, ErrorsCategory, GradeTypeEnum } from '../../services/constant'; let customConfig: any = {}; export default function windowFetch(options: CustomOptionsType, segments: SegmentFields[]) { const originFetch: any = window.fetch; setFetchOptions(options); window.fetch = async (...args: any) => { const startTime = new Date().getTime(); const traceId = uuid(); const traceSegmentId = uuid(); let segment = { traceId: '', service: customConfig.service, spans: [], serviceInstance: customConfig.serviceVersion, traceSegmentId: '', } as SegmentFields; let url = {} as URL; // for args[0] is Request object see: https://developer.mozilla.org/zh-CN/docs/Web/API/fetch if (Object.prototype.toString.call(args[0]) === '[object URL]') { url = args[0]; } else if (Object.prototype.toString.call(args[0]) === '[object Request]') { url = new URL(args[0].url); } else { if (args[0].startsWith('http://') || args[0].startsWith('https://')) { url = new URL(args[0]); } else if (args[0].startsWith('//')) { url = new URL(`${window.location.protocol}${args[0]}`); } else { url = new URL(window.location.href); url.pathname = args[0]; } } const noTraceOrigins = customConfig.noTraceOrigins.some((rule: string | RegExp) => { if (typeof rule === 'string') { if (rule === url.origin) { return true; } } else if (rule instanceof RegExp) { if (rule.test(url.origin)) { return true; } } }); const cURL = new URL(customConfig.collector); const pathname = cURL.pathname === '/' ? url.pathname : url.pathname.replace(new RegExp(`^${cURL.pathname}`), ''); const internals = [ReportTypes.ERROR, ReportTypes.ERRORS, ReportTypes.PERF, ReportTypes.SEGMENTS] as string[]; const isSDKInternal = internals.includes(pathname); const hasTrace = !noTraceOrigins || (isSDKInternal && customConfig.traceSDKInternal); if (hasTrace) { const traceIdStr = String(encode(traceId)); const segmentId = String(encode(traceSegmentId)); const service = String(encode(segment.service)); const instance = String(encode(segment.serviceInstance)); const endpoint = String(encode(customConfig.pagePath)); const peer = String(encode(url.host)); const index = segment.spans.length; const values = `${1}-${traceIdStr}-${segmentId}-${index}-${service}-${instance}-${endpoint}-${peer}`; if (args[0] instanceof Request) { args[0].headers.append('sw8', values); } else { if (!args[1]) { args[1] = {}; } if (!args[1].headers) { args[1].headers = new Headers(); } if (args[1].headers instanceof Headers) { args[1].headers.append('sw8', values); } else { args[1].headers['sw8'] = values; } } } const response = await originFetch(...args); try { if (response && (response.status === 0 || response.status >= 400)) { const logInfo = { uniqueId: uuid(), service: customConfig.service, serviceVersion: customConfig.serviceVersion, pagePath: customConfig.pagePath, category: ErrorsCategory.AJAX_ERROR, grade: GradeTypeEnum.ERROR, errorUrl: (response && response.url) || `${url.protocol}//${url.host}${url.pathname}`, message: `status: ${response ? response.status : 0}; statusText: ${response && response.statusText};`, collector: customConfig.collector, stack: 'Fetch: ' + response && response.statusText, }; new Base().traceInfo(logInfo); } if (hasTrace) { const tags = [ { key: 'http.method', value: args[0] instanceof Request ? args[0].method : args[1]?.method || 'GET', }, { key: 'url', value: (response && response.url) || `${url.protocol}//${url.host}${url.pathname}`, }, ]; const endTime = new Date().getTime(); const exitSpan: SpanFields = { operationName: customConfig.pagePath, startTime: startTime, endTime, spanId: segment.spans.length, spanLayer: SpanLayer, spanType: SpanType, isError: response && (response.status === 0 || response.status >= 400), // when requests failed, the status is 0 parentSpanId: segment.spans.length - 1, componentId: ComponentId, peer: url.host, tags: customConfig.detailMode ? customConfig.customTags ? [...tags, ...customConfig.customTags] : tags : undefined, }; segment = { ...segment, traceId: traceId, traceSegmentId: traceSegmentId, }; segment.spans.push(exitSpan); segments.push(segment); } } catch (e) { throw e; } return response.clone(); }; } export function setFetchOptions(opt: CustomOptionsType) { customConfig = { ...customConfig, ...opt }; } /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { ComponentId, ReadyStatus, ReportTypes, SpanLayer, SpanType } from '../../services/constant'; import uuid from '../../services/uuid'; import { encode } from 'js-base64'; import { CustomOptionsType } from '../../types'; import { SegmentFields, SpanFields } from '../type'; let customConfig: CustomOptionsType | any = {}; export default function xhrInterceptor(options: CustomOptionsType, segments: SegmentFields[]) { setOptions(options); const originalXHR = window.XMLHttpRequest as any; const xhrSend = XMLHttpRequest.prototype.send; const xhrOpen = XMLHttpRequest.prototype.open; if (!(xhrSend && xhrOpen)) { console.error('Tracing is not supported'); return; } originalXHR.getRequestConfig = []; function ajaxEventTrigger(event: string) { const ajaxEvent = new CustomEvent(event, { detail: this }); window.dispatchEvent(ajaxEvent); } function customizedXHR() { const liveXHR = new originalXHR(); liveXHR.addEventListener( 'readystatechange', function () { ajaxEventTrigger.call(this, 'xhrReadyStateChange'); }, false, ); liveXHR.open = function ( method: string, url: string, async: boolean, username?: string | null, password?: string | null, ) { this.getRequestConfig = arguments; return xhrOpen.apply(this, arguments); }; liveXHR.send = function (body?: Document | BodyInit | null) { return xhrSend.apply(this, arguments); }; return liveXHR; } (window as any).XMLHttpRequest = customizedXHR; const segCollector: { event: XMLHttpRequest; startTime: number; traceId: string; traceSegmentId: string }[] = []; window.addEventListener('xhrReadyStateChange', (event: CustomEvent) => { let segment = { traceId: '', service: customConfig.service, spans: [], serviceInstance: customConfig.serviceVersion, traceSegmentId: '', } as SegmentFields; const xhrState = event.detail.readyState; const config = event.detail.getRequestConfig; let url = {} as URL; if (config[1].startsWith('http://') || config[1].startsWith('https://')) { url = new URL(config[1]); } else if (config[1].startsWith('//')) { url = new URL(`${window.location.protocol}${config[1]}`); } else { url = new URL(window.location.href); url.pathname = config[1]; } const noTraceOrigins = customConfig.noTraceOrigins.some((rule: string | RegExp) => { if (typeof rule === 'string') { if (rule === url.origin) { return true; } } else if (rule instanceof RegExp) { if (rule.test(url.origin)) { return true; } } }); if (noTraceOrigins) { return; } const cURL = new URL(customConfig.collector); const pathname = cURL.pathname === '/' ? url.pathname : url.pathname.replace(new RegExp(`^${cURL.pathname}`), ''); const internals = [ReportTypes.ERROR, ReportTypes.ERRORS, ReportTypes.PERF, ReportTypes.SEGMENTS] as string[]; const isSDKInternal = internals.includes(pathname); if (isSDKInternal && !customConfig.traceSDKInternal) { return; } // The values of xhrState are from https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/readyState if (xhrState === ReadyStatus.OPENED) { const traceId = uuid(); const traceSegmentId = uuid(); segCollector.push({ event: event.detail, startTime: new Date().getTime(), traceId, traceSegmentId, }); const traceIdStr = String(encode(traceId)); const segmentId = String(encode(traceSegmentId)); const service = String(encode(segment.service)); const instance = String(encode(segment.serviceInstance)); const endpoint = String(encode(customConfig.pagePath)); const peer = String(encode(url.host)); const index = segment.spans.length; const values = `${1}-${traceIdStr}-${segmentId}-${index}-${service}-${instance}-${endpoint}-${peer}`; event.detail.setRequestHeader('sw8', values); } if (xhrState === ReadyStatus.DONE) { const endTime = new Date().getTime(); for (let i = 0; i < segCollector.length; i++) { if (segCollector[i].event.readyState === ReadyStatus.DONE) { let responseURL = {} as URL; if (segCollector[i].event.status) { responseURL = new URL(segCollector[i].event.responseURL); } const tags = [ { key: 'http.method', value: config[0], }, { key: 'url', value: segCollector[i].event.responseURL || `${url.protocol}//${url.host}${url.pathname}`, }, ]; const exitSpan: SpanFields = { operationName: customConfig.pagePath, startTime: segCollector[i].startTime, endTime, spanId: segment.spans.length, spanLayer: SpanLayer, spanType: SpanType, isError: event.detail.status === 0 || event.detail.status >= 400, // when requests failed, the status is 0 parentSpanId: segment.spans.length - 1, componentId: ComponentId, peer: responseURL.host, tags: customConfig.detailMode ? customConfig.customTags ? [...tags, ...customConfig.customTags] : tags : undefined, }; segment = { ...segment, traceId: segCollector[i].traceId, traceSegmentId: segCollector[i].traceSegmentId, }; segment.spans.push(exitSpan); segCollector.splice(i, 1); } } segments.push(segment); } }); } export function setOptions(opt: CustomOptionsType) { customConfig = { ...customConfig, ...opt }; }