This is an automated email from the ASF dual-hosted git repository.
wusheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking-client-js.git
The following commit(s) were added to refs/heads/master by this push:
new 4602cb4 feat: tracing Core Web Vitals (#139)
4602cb4 is described below
commit 4602cb4f1055f019b2ab535254c61e90fc54348f
Author: Fine0830 <[email protected]>
AuthorDate: Sat Sep 14 10:27:42 2024 +0800
feat: tracing Core Web Vitals (#139)
---
package.json | 2 +-
src/monitor.ts | 9 +-
src/performance/index.ts | 172 ++++++++++++++++-----
src/performance/perf.ts | 32 ++--
src/performance/{type.d.ts => type.ts} | 12 +-
src/{trace/type.d.ts => services/bfcache.ts} | 37 +++--
src/services/constant.ts | 2 -
src/services/{types.d.ts => eventsListener.ts} | 29 ++--
src/services/{types.d.ts => getNavigationEntry.ts} | 34 ++--
src/services/getVisibilityObserver.ts | 61 ++++++++
src/services/observe.ts | 51 ++++++
src/services/{types.d.ts => types.ts} | 19 +++
src/trace/{type.d.ts => type.ts} | 0
src/{types.d.ts => types.ts} | 1 +
webpack.config.js | 4 +-
15 files changed, 351 insertions(+), 114 deletions(-)
diff --git a/package.json b/package.json
index 44d439e..a0b067d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/monitor.ts b/src/monitor.ts
index 70871c7..89a7a58 100644
--- a/src/monitor.ts
+++ b/src/monitor.ts
@@ -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);
},
diff --git a/src/performance/index.ts b/src/performance/index.ts
index e93b997..88742f0 100644
--- a/src/performance/index.ts
+++ b/src/performance/index.ts
@@ -15,57 +15,146 @@
* 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() {
@@ -73,10 +162,21 @@ class TracePerf {
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;
+ }
+};
diff --git a/src/performance/perf.ts b/src/performance/perf.ts
index 4b10d3e..820cf2f 100644
--- a/src/performance/perf.ts
+++ b/src/performance/perf.ts
@@ -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;
@@ -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;
diff --git a/src/performance/type.d.ts b/src/performance/type.ts
similarity index 88%
rename from src/performance/type.d.ts
rename to src/performance/type.ts
index 6eb4785..81d06b6 100644
--- a/src/performance/type.d.ts
+++ b/src/performance/type.ts
@@ -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;
@@ -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[];
+}
\ No newline at end of file
diff --git a/src/trace/type.d.ts b/src/services/bfcache.ts
similarity index 66%
copy from src/trace/type.d.ts
copy to src/services/bfcache.ts
index 2e49bd6..da803ce 100644
--- a/src/trace/type.d.ts
+++ b/src/services/bfcache.ts
@@ -15,24 +15,23 @@
* limitations under the License.
*/
-export interface SegmentFields {
- traceId: string;
- service: string;
- spans: SpanFields[];
- serviceInstance: string;
- traceSegmentId: string;
+interface onBFCacheRestoreCallback {
+ (event: PageTransitionEvent): void;
}
-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;
-}
+let bfcacheRestoreTime = -1;
+
+export const getBFCacheRestoreTime = () => bfcacheRestoreTime;
+
+export function onBFCacheRestore(cb: onBFCacheRestoreCallback) {
+ addEventListener(
+ 'pageshow',
+ (event) => {
+ if (event.persisted) {
+ bfcacheRestoreTime = event.timeStamp;
+ cb(event);
+ }
+ },
+ true,
+ );
+};
diff --git a/src/services/constant.ts b/src/services/constant.ts
index 7fb5922..d30d326 100644
--- a/src/services/constant.ts
+++ b/src/services/constant.ts
@@ -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
diff --git a/src/services/types.d.ts b/src/services/eventsListener.ts
similarity index 68%
copy from src/services/types.d.ts
copy to src/services/eventsListener.ts
index 2785520..5c863d1 100644
--- a/src/services/types.d.ts
+++ b/src/services/eventsListener.ts
@@ -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();
+ }
+ });
+};
diff --git a/src/services/types.d.ts b/src/services/getNavigationEntry.ts
similarity index 61%
copy from src/services/types.d.ts
copy to src/services/getNavigationEntry.ts
index 2785520..a735b89 100644
--- a/src/services/types.d.ts
+++ b/src/services/getNavigationEntry.ts
@@ -15,20 +15,22 @@
* 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 getNavigationEntry() {
+ const navigationEntry: PerformanceEntry | any =
+ self.performance &&
+ performance.getEntriesByType &&
+ performance.getEntriesByType('navigation')[0];
-export interface ReportFields {
- service: string;
- serviceVersion: string;
- pagePath: string;
-}
+ if (
+ navigationEntry &&
+ navigationEntry.responseStart > 0 &&
+ navigationEntry.responseStart < performance.now()
+ ) {
+ return navigationEntry;
+ }
+};
+
+export function getActivationStart() {
+ const entry = getNavigationEntry();
+ return (entry && entry.activationStart) || 0;
+};
diff --git a/src/services/getVisibilityObserver.ts
b/src/services/getVisibilityObserver.ts
new file mode 100644
index 0000000..33750ab
--- /dev/null
+++ b/src/services/getVisibilityObserver.ts
@@ -0,0 +1,61 @@
+/**
+ * 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;
+ },
+ };
+};
diff --git a/src/services/observe.ts b/src/services/observe.ts
new file mode 100644
index 0000000..92f6242
--- /dev/null
+++ b/src/services/observe.ts
@@ -0,0 +1,51 @@
+/**
+ * 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} from "./types";
+interface PerformanceEntryObj {
+ 'layout-shift': LayoutShift[];
+ 'largest-contentful-paint': LargestContentfulPaint[];
+ 'first-input': PerformanceEventTiming[];
+}
+
+export function observe <K extends keyof PerformanceEntryObj>(
+ 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 || {},
+ ) as PerformanceObserverInit,
+ );
+ return perfObs;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+};
\ No newline at end of file
diff --git a/src/services/types.d.ts b/src/services/types.ts
similarity index 68%
rename from src/services/types.d.ts
rename to src/services/types.ts
index 2785520..2185508 100644
--- a/src/services/types.d.ts
+++ b/src/services/types.ts
@@ -32,3 +32,22 @@ export interface ReportFields {
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;
+}
diff --git a/src/trace/type.d.ts b/src/trace/type.ts
similarity index 100%
rename from src/trace/type.d.ts
rename to src/trace/type.ts
diff --git a/src/types.d.ts b/src/types.ts
similarity index 98%
rename from src/types.d.ts
rename to src/types.ts
index 764639c..9c6eb3e 100644
--- a/src/types.d.ts
+++ b/src/types.ts
@@ -28,6 +28,7 @@ export interface CustomOptionsType extends
CustomReportOptions {
noTraceOrigins?: (string | RegExp)[];
traceTimeInterval?: number;
customTags?: TagOption[];
+ useWebVitals?: boolean;
}
export interface CustomReportOptions {
diff --git a/webpack.config.js b/webpack.config.js
index a099599..6b82fd6 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -44,8 +44,8 @@ const config = {
new WebpackConcatPlugin({
bundles: [
{
- dest: './lib/src/types.d.ts',
- src: './src/**/*.d.ts',
+ dest: './lib/src/types.ts',
+ src: './src/**/*.ts',
},
],
}),