123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- 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 (
- <Profiler id={id} onRender={onRenderCallback}>
- {children}
- </Profiler>
- );
- };
- /**
- * 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 (
- <Profiler id={id} onRender={onRenderCallback}>
- <Fragment>{children}</Fragment>
- </Profiler>
- );
- };
- interface OpAssetMeasurementDefinition {
- key: string;
- }
- const OP_ASSET_MEASUREMENT_MAP: Record<string, OpAssetMeasurementDefinition> = {
- '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);
- };
|