diff --git a/lib/client/hijack/wrapDynamicImport.js b/lib/client/hijack/wrapDynamicImport.js new file mode 100644 index 00000000..60ce97e6 --- /dev/null +++ b/lib/client/hijack/wrapDynamicImport.js @@ -0,0 +1,18 @@ +import { Ntp } from '../../ntp'; +const meteorInstall = require('meteor/modules').meteorInstall; +const oldFetch = meteorInstall.fetch; + +export function wrapDynamicImport () { + meteorInstall.fetch = function (ids) { + const promise = oldFetch(ids); + Kadira.webVitals.numberOfImports += 1; + const now = Ntp._now(); + return promise.then((...args) => { + Kadira.webVitals.importTime.push(Ntp._now() - now); + return Promise.resolve(...args); + }); + }; +} +export function unwrapDynamicImport () { + meteorInstall.fetch = oldFetch; +} diff --git a/lib/client/hijack/wrapLogin.js b/lib/client/hijack/wrapLogin.js new file mode 100644 index 00000000..d9ad28d0 --- /dev/null +++ b/lib/client/hijack/wrapLogin.js @@ -0,0 +1,16 @@ +import { Ntp } from '../../ntp'; + +let tracking = true; +export function wrapLogin () { + if (Package['accounts-base']) { + Package['accounts-base'].Accounts.onLogin(() => { + if (!tracking) { + return; + } + Kadira.webVitals.loggedIn = Ntp._now() - Kadira.webVitals.startTime; + }); + } +} +export function unWrapLogin () { + tracking = false; +} diff --git a/lib/client/hijack/wrapMethodCall.js b/lib/client/hijack/wrapMethodCall.js new file mode 100644 index 00000000..7e72e8cf --- /dev/null +++ b/lib/client/hijack/wrapMethodCall.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import { Ntp } from '../../ntp'; +const oldCall = Meteor.call; + +export function wrapMethodCall () { + Meteor.call = function (name, /* .. [arguments] .. callback */) { + // if it's a function, the last argument is the result callback, + // not a parameter to the remote method. + const args = [...arguments].slice(1); + let callback; + if (args.length && typeof args[args.length - 1] === 'function') { + callback = args.pop(); + } + const now = Ntp._now(); + + const newCallback = (...params) => { + Kadira.webVitals.methods.push(Ntp._now() - now); + callback?.(...params); + }; + return this.apply(name, args, newCallback); + }.bind(Meteor.connection); +} +export function unwrapMethodCall () { + Meteor.call = oldCall; +} diff --git a/lib/client/hijack/wrapSubscription.js b/lib/client/hijack/wrapSubscription.js new file mode 100644 index 00000000..0fc51fb1 --- /dev/null +++ b/lib/client/hijack/wrapSubscription.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { Ntp } from '../../ntp'; +const oldSubscribe = Meteor.subscribe; + +export function wrapSubscription () { + Meteor.subscribe = function (/* name, .. [arguments] .. (callback|callbacks) */) { + const params = [...arguments].slice(1); + let callbacks = Object.create(null); + const now = Ntp._now(); + + if (params.length) { + const lastParam = params[params.length - 1]; + if (typeof lastParam === 'function') { + callbacks.onReady = params.pop(); + } else if (lastParam && [ + lastParam.onReady, + // XXX COMPAT WITH 1.0.3.1 onError used to exist, but now we use + // onStop with an error callback instead. + lastParam.onError, + lastParam.onStop + ].some(f => typeof f === 'function')) { + callbacks = params.pop(); + } + } + const oldReady = callbacks.onReady; + const onReady = () => { + const diff = Ntp._now() - now; + if (diff > 0) { + Kadira.webVitals.subs.push(diff); + } + oldReady?.(); + }; + callbacks.onReady = onReady; + return oldSubscribe(arguments[0],...params, callbacks); + }.bind(Meteor.connection); +} +export function unwrapSubscription () { + Meteor.subscribe = oldSubscribe; +} diff --git a/lib/client/kadira.js b/lib/client/kadira.js index d8daa6db..2513d1bd 100644 --- a/lib/client/kadira.js +++ b/lib/client/kadira.js @@ -7,6 +7,9 @@ import { Ntp } from '../ntp'; import { getBrowserInfo } from './utils'; import { httpRequest } from './httpRequest'; import { WebVitalsModel } from './models/webVitals'; +import { wrapDynamicImport } from './hijack/wrapDynamicImport'; +import { wrapSubscription } from './hijack/wrapSubscription'; +import { wrapMethodCall } from './hijack/wrapMethodCall'; Kadira.enableErrorTracking = function () { Kadira.options.enableErrorTracking = true; @@ -58,7 +61,9 @@ function initialize (options = {}) { return; } initialized = true; - + wrapDynamicImport(); + wrapSubscription(); + wrapMethodCall(); Kadira.options = { errorDumpInterval: 1000 * 60, maxErrorsPerInterval: 10, diff --git a/lib/client/models/webVitals.js b/lib/client/models/webVitals.js index eac10abf..b639d2c9 100644 --- a/lib/client/models/webVitals.js +++ b/lib/client/models/webVitals.js @@ -1,9 +1,23 @@ -import { getClientArchVersion } from '../../common/utils'; -import { getClientArch, getBrowserInfo } from '../utils'; +import { Tracker } from 'meteor/tracker'; import { onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals'; +import { getClientArchVersion } from '../../common/utils'; +import { Ntp } from '../../ntp'; +import { unwrapDynamicImport } from '../hijack/wrapDynamicImport'; +import { unwrapMethodCall } from '../hijack/wrapMethodCall'; +import { unwrapSubscription } from '../hijack/wrapSubscription'; +import { getBrowserInfo, getClientArch } from '../utils'; +import { unWrapLogin, wrapLogin } from '../hijack/wrapLogin'; +const average = arr => arr.reduce( ( p, c ) => p + c, 0 ) / arr.length || 0; export class WebVitalsModel { + startTime = Ntp._now(); + connectionTime = 0; + loggedIn = 0; + importTime = []; + methods = []; + subs = []; queue = new Set(); + addToQueue (metric) { this.queue.add({ metric: metric.value, matricName: metric.name }); } @@ -23,6 +37,36 @@ export class WebVitalsModel { onLCP(bindedAddToQueue); onTTFB(bindedAddToQueue); onCLS(bindedAddToQueue); + + Tracker.autorun((computation) => { + const {connected} = Meteor.status(); + if (connected) { + this.connectionTime = Ntp._now() - this.startTime; + computation.stop(); + } + }); + + + // startup hooks run after the page is loaded + Meteor.startup(() => { + wrapLogin(); + + // wait until all startup hooks called (they're called synchronously all at once on the client) + setTimeout(() => { + /* calling unwrap before the document readyState is complete + is not the best for react based apps as it's not guaranteed the components are mounted + and hooks are called making the subscription */ + document.onreadystatechange = function () { + if (document.readyState === 'complete') { + /* stop tracking new dynamic imports/methods/publications */ + unwrapDynamicImport(); + unwrapSubscription(); + unwrapMethodCall(); + unWrapLogin(); + } + }; + }); + }); // Report all available metrics whenever the page is backgrounded or unloaded. addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { @@ -33,8 +77,9 @@ export class WebVitalsModel { // NOTE: Safari does not reliably fire the `visibilitychange` event when the // page is being unloaded. If Safari support is needed, you should also flush // the queue in the `pagehide` event. - addEventListener('pagehide', () => this.flushQueue()); + addEventListener('pagehide', this.flushQueue.bind(this)); } + constructor (options) { options = options || {}; @@ -43,6 +88,26 @@ export class WebVitalsModel { _buildPayload = function (metrics) { const arch = getClientArch(); const browserInfo = getBrowserInfo(); + metrics.push({ + metric: this.connectionTime, + metricName: 'connectionTime' + }); + metrics.push({ + metric: this.loggedIn, + metricName: 'loginTime' + }); + metrics.push({ + metric: average(this.importTime), + metricName: 'dynamicImportTime' + }); + metrics.push({ + metric: average(this.methods), + metricName: 'methods' + }); + metrics.push({ + metric: average(this.subs), + metricName: 'subs' + }); return { host: Kadira.options.hostname, @@ -51,6 +116,7 @@ export class WebVitalsModel { ...browserInfo, arch, legacy: arch.endsWith('.legacy'), + commitHash: Meteor.gitCommitHash, cacheCleaned: !localStorage?.length && !sessionStorage?.length, archVersion: getClientArchVersion(arch), };