import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
import {captureException, captureMessage} from '@sentry/react';
import * as Sentry from '@sentry/react';
import {IdleTransaction} from '@sentry/tracing';
import {Transaction} from '@sentry/types';
import {browserPerformanceTimeOrigin, timestampWithMs} from '@sentry/utils';
import getCurrentSentryReactTransaction from './getCurrentSentryReactTransaction';
const MIN_UPDATE_SPAN_TIME = 16; // Frame boundary @ 60fps
const WAIT_POST_INTERACTION = 50; // Leave a small amount of time for observers and onRenderCallback to log since they come in after they occur and not during.
const INTERACTION_TIMEOUT = 2 * 60_000; // 2min. Wrap interactions up after this time since we don't want transactions sticking around forever.
/**
* It depends on where it is called but the way we fetch transactions can be empty despite an ongoing transaction existing.
* This will return an interaction-type transaction held onto by a class static if one exists.
*/
export function getPerformanceTransaction(): IdleTransaction | Transaction | undefined {
return PerformanceInteraction.getTransaction() ?? getCurrentSentryReactTransaction();
}
/**
* Callback for React Profiler https://reactjs.org/docs/profiler.html
*/
export function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number
) {
try {
const transaction: Transaction | undefined = getPerformanceTransaction();
if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) {
const now = timestampWithMs();
transaction.startChild({
description: `<${id}>`,
op: `ui.react.${phase}`,
startTimestamp: now - actualDuration / 1000,
endTimestamp: now,
});
}
} catch (_) {
// Add defensive catch since this wraps all of App
}
}
export class PerformanceInteraction {
private static interactionTransaction: Transaction | null = null;
private static interactionTimeoutId: number | undefined = undefined;
static getTransaction() {
return PerformanceInteraction.interactionTransaction;
}
static startInteraction(name: string, timeout = INTERACTION_TIMEOUT, immediate = true) {
try {
const currentIdleTransaction = getCurrentSentryReactTransaction();
if (currentIdleTransaction) {
// If interaction is started while idle still exists.
currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout.
currentIdleTransaction.finish();
}
PerformanceInteraction.finishInteraction(immediate);
const txn = Sentry?.startTransaction({
name: `ui.${name}`,
op: 'interaction',
});
PerformanceInteraction.interactionTransaction = txn;
// Auto interaction timeout
PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => {
if (!PerformanceInteraction.interactionTransaction) {
return;
}
PerformanceInteraction.interactionTransaction.setTag(
'ui.interaction.finish',
'timeout'
);
PerformanceInteraction.finishInteraction(true);
}, timeout);
} catch (e) {
captureMessage(e);
}
}
static async finishInteraction(immediate = false) {
try {
if (!PerformanceInteraction.interactionTransaction) {
return;
}
clearTimeout(PerformanceInteraction.interactionTimeoutId);
if (immediate) {
PerformanceInteraction.interactionTransaction?.finish();
PerformanceInteraction.interactionTransaction = null;
return;
}
// Add a slight wait if this isn't called as the result of another transaction starting.
await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION));
PerformanceInteraction.interactionTransaction?.finish();
PerformanceInteraction.interactionTransaction = null;
return;
} catch (e) {
captureMessage(e);
}
}
}
export class LongTaskObserver {
private static observer: PerformanceObserver;
private static longTaskCount = 0;
private static longTaskDuration = 0;
private static lastTransaction: IdleTransaction | Transaction | undefined;
static setLongTaskData(t: IdleTransaction | Transaction) {
const group =
[
1, 2, 5, 10, 25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1001,
].find(n => LongTaskObserver.longTaskCount <= n) || -1;
t.setTag('ui.longTaskCount.grouped', group < 1001 ? `<=${group}` : `>1000`);
t.setMeasurement('longTaskCount', LongTaskObserver.longTaskCount, '');
t.setMeasurement('longTaskDuration', LongTaskObserver.longTaskDuration, '');
}
static startPerformanceObserver(): PerformanceObserver | null {
try {
if (LongTaskObserver.observer) {
LongTaskObserver.observer.disconnect();
try {
LongTaskObserver.observer.observe({entryTypes: ['longtask']});
} catch (_) {
// Safari doesn't support longtask, ignore this error.
}
return LongTaskObserver.observer;
}
if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
return null;
}
const observer = new PerformanceObserver(function () {
try {
const transaction = getPerformanceTransaction();
if (!transaction) {
return;
}
if (transaction !== LongTaskObserver.lastTransaction) {
// If long tasks observer is active and is called while the transaction has changed.
if (LongTaskObserver.lastTransaction) {
LongTaskObserver.setLongTaskData(LongTaskObserver.lastTransaction);
}
LongTaskObserver.longTaskCount = 0;
LongTaskObserver.longTaskDuration = 0;
LongTaskObserver.lastTransaction = transaction;
}
LongTaskObserver.setLongTaskData(transaction);
} catch (_) {
// Defensive catch.
}
});
if (!observer || !observer.observe) {
return null;
}
LongTaskObserver.observer = observer;
try {
LongTaskObserver.observer.observe({entryTypes: ['longtask']});
} catch (_) {
// Safari doesn't support longtask, ignore this error.
}
return LongTaskObserver.observer;
} catch (e) {
captureException(e);
// Defensive try catch.
}
return null;
}
}
export const CustomerProfiler = ({id, children}: {children: ReactNode; id: string}) => {
return (
{children}
);
};
/**
* This component wraps the main component on a page with a measurement checking for visual completedness.
* It uses the data check to make sure endpoints have resolved and the component is meaningfully rendering
* which sets it apart from simply checking LCP, which makes it a good back up check the LCP heuristic performance.
*
* Since this component is guaranteed to be part of the -real- critical path, it also wraps the component with the custom profiler.
*/
export const VisuallyCompleteWithData = ({
id,
hasData,
children,
}: {
children: ReactNode;
hasData: boolean;
id: string;
}) => {
const isVisuallyCompleteSet = useRef(false);
const isDataCompleteSet = useRef(false);
const longTaskCount = useRef(0);
useEffect(() => {
let observer;
try {
if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
return () => {};
}
observer = LongTaskObserver.startPerformanceObserver();
} catch (_) {
// Defensive since this is auxiliary code.
}
return () => {
if (observer && observer.disconnect) {
observer.disconnect();
}
};
}, []);
const num = useRef(1);
const isVCDSet = useRef(false);
if (isVCDSet && hasData && performance && performance.mark) {
performance.mark(`${id}-vcsd-start`);
isVCDSet.current = true;
}
useEffect(() => {
try {
const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
if (!transaction) {
return;
}
if (!isVisuallyCompleteSet.current) {
const time = performance.now();
transaction.registerBeforeFinishCallback((t: Transaction, _) => {
// Should be called after performance entries finish callback.
t.setMeasurement('visuallyComplete', time, 'ms');
});
isVisuallyCompleteSet.current = true;
}
if (!isDataCompleteSet.current && hasData) {
isDataCompleteSet.current = true;
performance.mark(`${id}-vcsd-end-pre-timeout`);
window.setTimeout(() => {
if (!browserPerformanceTimeOrigin) {
return;
}
performance.mark(`${id}-vcsd-end`);
const measureName = `VCD [${id}] #${num.current}`;
performance.measure(
`VCD [${id}] #${num.current}`,
`${id}-vcsd-start`,
`${id}-vcsd-end`
);
num.current = num.current++;
const [measureEntry] = performance.getEntriesByName(measureName);
if (!measureEntry) {
return;
}
transaction.registerBeforeFinishCallback((t: Transaction) => {
if (!browserPerformanceTimeOrigin) {
return;
}
// Should be called after performance entries finish callback.
const lcp = (t as any)._measurements.lcp?.value;
// Adjust to be relative to transaction.startTimestamp
const entryStartSeconds =
browserPerformanceTimeOrigin / 1000 + measureEntry.startTime / 1000;
const time = (entryStartSeconds - transaction.startTimestamp) * 1000;
if (lcp) {
t.setMeasurement('lcpDiffVCD', lcp - time, 'ms');
}
t.setTag('longTaskCount', longTaskCount.current);
t.setMeasurement('visuallyCompleteData', time, 'ms');
});
}, 0);
}
} catch (_) {
// Defensive catch since this code is auxiliary.
}
}, [hasData, id]);
return (
{children}
);
};
interface OpAssetMeasurementDefinition {
key: string;
}
const OP_ASSET_MEASUREMENT_MAP: Record = {
'resource.script': {
key: 'script',
},
'resource.css': {
key: 'css',
},
'resource.link': {
key: 'link',
},
'resource.img': {
key: 'img',
},
};
const ASSET_MEASUREMENT_ALL = 'allResources';
const measureAssetsOnTransaction = () => {
try {
const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
if (!transaction) {
return;
}
transaction.registerBeforeFinishCallback((t: Transaction) => {
const spans: any[] = (t as any).spanRecorder?.spans;
const measurements = (t as any)._measurements;
if (!spans) {
return;
}
if (measurements[ASSET_MEASUREMENT_ALL]) {
return;
}
let allTransfered = 0;
let allEncoded = 0;
let allCount = 0;
for (const [op, definition] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) {
const filtered = spans.filter(s => s.op === op);
const count = filtered.length;
const transfered = filtered.reduce(
(acc, curr) => acc + (curr.data['Transfer Size'] ?? 0),
0
);
const encoded = filtered.reduce(
(acc, curr) => acc + (curr.data['Encoded Body Size'] ?? 0),
0
);
if (op === 'resource.script') {
t.setMeasurement(`assets.${definition.key}.encoded`, encoded, '');
t.setMeasurement(`assets.${definition.key}.transfer`, transfered, '');
t.setMeasurement(`assets.${definition.key}.count`, count, '');
}
allCount += count;
allTransfered += transfered;
allEncoded += encoded;
}
t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.encoded`, allEncoded, '');
t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.transfer`, allTransfered, '');
t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.count`, allCount, '');
});
} catch (_) {
// Defensive catch since this code is auxiliary.
}
};
/**
* This will add asset-measurement code to the transaction after a timeout.
* Meant to be called from the sdk without pushing too many perf concerns into our initializeSdk code,
* it's fine if not every transaction gets recorded.
*/
export const initializeMeasureAssetsTimeout = () => {
setTimeout(measureAssetsOnTransaction, 1000);
};