Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: tracing Core Web Vitals #139

Merged
merged 20 commits into from
Sep 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.12.0",
"description": "Client-side JavaScript exception and tracing library for Apache SkyWalking APM",
"main": "index.js",
"types": "lib/src/index.d.ts",
"types": "lib/src/index.ts",
"repository": "apache/skywalking-client-js",
"homepage": "skywalking.apache.org",
"license": "Apache 2.0",
Expand Down
9 changes: 4 additions & 5 deletions src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,11 @@ const ClientMonitor = {
traceSegment(this.customOptions);
},
performance(configs: any) {
// trace and report perf data and pv to serve when page loaded
if (document.readyState === 'complete') {
tracePerf.getPerf(configs);
} else {
tracePerf.getPerf(configs);
if (configs.enableSPA) {
// hash router
window.addEventListener(
'load',
'hashchange',
() => {
tracePerf.getPerf(configs);
},
Expand Down
172 changes: 136 additions & 36 deletions src/performance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,68 +15,168 @@
* limitations under the License.
*/

import { CustomOptionsType } from '../types';
import {CustomOptionsType} from '../types';
import Report from '../services/report';
import {prerenderChangeListener} from "../services/eventsListener";
import pagePerf from './perf';
import FMP from './fmp';
import { IPerfDetail } from './type';
import {observe} from "../services/observe";
import {LCPMetric, FIDMetric} from "./type";
import {LayoutShift} from "../services/types";
import {getVisibilityObserver} from '../services/getVisibilityObserver';
import {getActivationStart} from '../services/getNavigationEntry';

class TracePerf {
private perfConfig = {
perfDetail: {},
} as { perfDetail: IPerfDetail };

private options: CustomOptionsType = {
pagePath: '',
serviceVersion: '',
service: '',
collector: ''
};
private perfInfo = {};
private coreWebMetrics: {[key: string]: string | number | undefined} = {};
public getPerf(options: CustomOptionsType) {
this.recordPerf(options);
if (options.enableSPA) {
// hash router
this.options = options;
this.perfInfo = {
pagePath: options.pagePath,
serviceVersion: options.serviceVersion,
service: options.service,
}
this.coreWebMetrics = new Proxy({...this.perfInfo, collector: options.collector}, handler);
// trace and report perf data and pv to serve when page loaded
if (document.readyState === 'complete') {
this.getBasicPerf();
} else {
window.addEventListener(
'hashchange',
'load',
() => {
this.recordPerf(options);
this.getBasicPerf();
},
false,
);
}
this.getCorePerf();
}

public async recordPerf(options: CustomOptionsType) {
let fmp: { fmpTime: number | undefined } = { fmpTime: undefined };
if (options.autoTracePerf && options.useFmp) {
fmp = await new FMP();
private async getCorePerf() {
if (this.options.useWebVitals) {
this.LCP();
this.FID();
this.CLS();
}
// auto report pv and perf data
setTimeout(() => {
if (options.autoTracePerf) {
this.perfConfig.perfDetail = new pagePerf().getPerfTiming();
if (this.options.useFmp) {
const {fmpTime} = await new FMP();
this.coreWebMetrics.fmpTime = Math.floor(fmpTime);
}
}
private CLS() {
let clsTime = 0;
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;
entryList.push(entry);
} else {
partValue = entry.value;
entryList = [entry];
}
}
});
if (partValue > clsTime) {
this.coreWebMetrics.clsTime = Math.floor(partValue);
}
const perfDetail = options.autoTracePerf
? {
...this.perfConfig.perfDetail,
fmpTime: options.useFmp ? parseInt(String(fmp.fmpTime), 10) : undefined,
};

observe('layout-shift', handleEntries);
}
private LCP() {
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));
}
: undefined;
const perfInfo = {
...perfDetail,
pagePath: options.pagePath,
serviceVersion: options.serviceVersion,
service: options.service,
}
};

observe('largest-contentful-paint', processEntries);
})
}
private FID() {
prerenderChangeListener(() => {
const visibilityWatcher = getVisibilityObserver();
const processEntry = (entry: PerformanceEventTiming) => {
// Only report if the page wasn't hidden prior to the first input.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
const fidTime = Math.floor(entry.processingStart - entry.startTime);
const perfInfo = {
fidTime,
...this.perfInfo,
};
this.reportPerf(perfInfo);
}
};

const processEntries = (entries: FIDMetric['entries']) => {
entries.forEach(processEntry);
};
new Report('PERF', options.collector).sendByXhr(perfInfo);
// clear perf data
this.clearPerf();
}, 6000);

observe('first-input', processEntries);
})
}
private getBasicPerf() {
// auto report pv and perf data
const perfDetail = this.options.autoTracePerf ? new pagePerf().getPerfTiming() : {};
const perfInfo = {
...perfDetail,
...this.perfInfo,
};
this.reportPerf(perfInfo);
}

public reportPerf(data: {[key: string]: number | string}, collector?: string) {
const perf = {
...data,
...this.perfInfo
};
new Report('PERF', collector || this.options.collector).sendByXhr(perf);
// clear perf data
this.clearPerf();
}

private clearPerf() {
if (!(window.performance && window.performance.clearResourceTimings)) {
return;
}
window.performance.clearResourceTimings();
this.perfConfig = {
perfDetail: {},
} as { perfDetail: IPerfDetail };
}
}

export default new TracePerf();

const handler = {
set(target: {[key: string]: number | string | undefined}, prop: string, value: number | string | undefined) {
target[prop] = value;
if (!isNaN(Number(target.fmpTime)) && !isNaN(Number(target.lcpTime)) && !isNaN(Number(target.clsTime))) {
const source: {[key: string]: number | string | undefined} = {
...target,
collector: undefined,
};
new TracePerf().reportPerf(source, String(target.collector));
}
return true;
}
};
32 changes: 16 additions & 16 deletions src/performance/perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
* limitations under the License.
*/
import { IPerfDetail } from './type';
import {getNavigationEntry} from '../services/getNavigationEntry';
class PagePerf {
public getPerfTiming(): IPerfDetail {
try {
let { timing } = window.performance as PerformanceNavigationTiming | any; // PerformanceTiming
if (typeof window.PerformanceNavigationTiming === 'function') {
const nt2Timing = performance.getEntriesByType('navigation')[0];
const nt2Timing = getNavigationEntry();

if (nt2Timing) {
timing = nt2Timing;
Expand All @@ -29,33 +30,32 @@ class PagePerf {
let redirectTime = 0;

if (timing.navigationStart !== undefined) {
redirectTime = parseInt(String(timing.fetchStart - timing.navigationStart), 10);
redirectTime = Math.floor(timing.fetchStart - timing.navigationStart);
} else if (timing.redirectEnd !== undefined) {
redirectTime = parseInt(String(timing.redirectEnd - timing.redirectStart), 10);
redirectTime = Math.floor(timing.redirectEnd - timing.redirectStart);
} else {
redirectTime = 0;
}

return {
redirectTime,
dnsTime: parseInt(String(timing.domainLookupEnd - timing.domainLookupStart), 10),
ttfbTime: parseInt(String(timing.responseStart - timing.requestStart), 10), // Time to First Byte
tcpTime: parseInt(String(timing.connectEnd - timing.connectStart), 10),
transTime: parseInt(String(timing.responseEnd - timing.responseStart), 10),
domAnalysisTime: parseInt(String(timing.domInteractive - timing.responseEnd), 10),
fptTime: parseInt(String(timing.responseEnd - timing.fetchStart), 10), // First Paint Time or Blank Screen Time
domReadyTime: parseInt(String(timing.domContentLoadedEventEnd - timing.fetchStart), 10),
loadPageTime: parseInt(String(timing.loadEventStart - timing.fetchStart), 10), // Page full load time
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: parseInt(String(timing.loadEventStart - timing.domContentLoadedEventEnd), 10),
resTime: Math.floor(timing.loadEventStart - timing.domContentLoadedEventEnd),
// Only valid for HTTPS
sslTime:
location.protocol === 'https:' && timing.secureConnectionStart > 0
? parseInt(String(timing.connectEnd - timing.secureConnectionStart), 10)
? Math.floor(timing.connectEnd - timing.secureConnectionStart)
: undefined,
ttlTime: parseInt(String(timing.domInteractive - timing.fetchStart), 10), // time to interact
firstPackTime: parseInt(String(timing.responseStart - timing.domainLookupStart), 10), // first pack time
fmpTime: 0, // First Meaningful Paint
ttlTime: Math.floor(timing.domInteractive - timing.fetchStart), // time to interact
firstPackTime: Math.floor(timing.responseStart - timing.domainLookupStart), // first pack time
};
} catch (e) {
throw e;
Expand Down
12 changes: 11 additions & 1 deletion src/performance/type.d.ts → src/performance/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {LargestContentfulPaint} from "../services/types";
export interface ICalScore {
dpss: ICalScore[];
st: number;
Expand All @@ -39,5 +40,14 @@ export type IPerfDetail = {
sslTime: number | undefined; // Only valid for HTTPS
ttlTime: number | undefined; // Time to interact
firstPackTime: number | undefined; // first pack time
fmpTime: number | undefined; // First Meaningful Paint
};

export interface LCPMetric {
name: 'LCP';
entries: LargestContentfulPaint[];
}

export interface FIDMetric {
name: 'FID';
entries: PerformanceEventTiming[];
}
37 changes: 37 additions & 0 deletions src/services/bfcache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* 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,
);
};
2 changes: 0 additions & 2 deletions src/services/constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import Report from './report';

/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
Expand Down
29 changes: 13 additions & 16 deletions src/services/types.d.ts → src/services/eventsListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@
* 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 function prerenderChangeListener(callback: () => void) {
if ((document as any).prerendering) {
addEventListener('prerenderingchange', callback, true);
return;
}
callback();
}

export interface ReportFields {
service: string;
serviceVersion: string;
pagePath: string;
}
export function onHidden (cb: () => void) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
cb();
}
});
};
Loading
Loading