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

ref(perf): Add basic interaction for Performance@Sentry #33463

Merged
merged 1 commit into from
Apr 8, 2022
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';

import {clamp, rectOfContent} from 'sentry/components/performance/waterfall/utils';
import {PerformanceInteraction} from 'sentry/utils/performanceForSentry';
import {setBodyUserSelect, UserSelectValues} from 'sentry/utils/userselect';

// we establish the minimum window size so that the window size of 0% is not possible
Expand Down Expand Up @@ -237,6 +238,8 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
return;
}

PerformanceInteraction.startInteraction('SpanTreeWindowDrag');

// prevent the user from selecting things outside the minimap when dragging
// the mouse cursor outside the minimap

Expand Down Expand Up @@ -308,6 +311,8 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
return;
}

PerformanceInteraction.finishInteraction();

// remove listeners that were attached in onWindowSelectionDragStart

this.cleanUpListeners();
Expand Down
6 changes: 3 additions & 3 deletions static/app/components/events/interfaces/spans/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import moment from 'moment';

import {EntryType, EventTransaction} from 'sentry/types/event';
import {assert} from 'sentry/types/utils';
import getCurrentSentryReactTransaction from 'sentry/utils/getCurrentSentryReactTransaction';
import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
import {getPerformanceTransaction} from 'sentry/utils/performanceForSentry';

import {
EnhancedSpan,
Expand All @@ -28,14 +28,14 @@ export const isValidSpanID = (maybeSpanID: any) =>
isString(maybeSpanID) && maybeSpanID.length > 0;

export const setSpansOnTransaction = (spanCount: number) => {
const transaction = getCurrentSentryReactTransaction();
const transaction = getPerformanceTransaction();

if (!transaction || spanCount === 0) {
return;
}

const spanCountGroups = [10, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1001];
const spanGroup = spanCountGroups.find(g => g <= spanCount) || -1;
const spanGroup = spanCountGroups.find(g => spanCount <= g) || -1;

transaction.setTag('ui.spanCount', spanCount);
transaction.setTag('ui.spanCount.grouped', `<=${spanGroup}`);
Expand Down
167 changes: 138 additions & 29 deletions static/app/utils/performanceForSentry.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
import {captureException} from '@sentry/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 = 5; // Frame boundary @ 60fps
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
Expand All @@ -15,7 +28,7 @@ export function onRenderCallback(
actualDuration: number
) {
try {
const transaction = getCurrentSentryReactTransaction();
const transaction: Transaction | undefined = getPerformanceTransaction();
if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) {
const now = timestampWithMs();
transaction.startChild({
Expand All @@ -30,59 +43,155 @@ export function onRenderCallback(
}
}

export class PerformanceInteraction {
private static interactionTransaction: Transaction | null = null;
private static interactionTimeoutId: number | undefined = undefined;

static getTransaction() {
return PerformanceInteraction.interactionTransaction;
}

static async startInteraction(
name: string,
timeout = INTERACTION_TIMEOUT,
immediate = true
) {
try {
const currentIdleTransaction = getCurrentSentryReactTransaction();
if (currentIdleTransaction) {
// If interaction is started while idle still exists.
LongTaskObserver.setLongTaskTags(currentIdleTransaction);
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);

LongTaskObserver.setLongTaskTags(PerformanceInteraction.interactionTransaction);

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);
}
}
}

class LongTaskObserver {
private static observer: PerformanceObserver;
private static longTaskCount = 0;
private static lastTransaction: IdleTransaction | Transaction | undefined;
private static currentId: string;

static setLongTaskTags(t: IdleTransaction | Transaction) {
t.setTag('ui.longTaskCount', LongTaskObserver.longTaskCount);
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`);
}

static getPerformanceObserver(id: string): PerformanceObserver | null {
try {
LongTaskObserver.currentId = id;
if (LongTaskObserver.observer) {
LongTaskObserver.observer.disconnect();
LongTaskObserver.observer.observe({entryTypes: ['longtask']});
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 transaction: any = getCurrentSentryReactTransaction();

const timeOrigin = browserPerformanceTimeOrigin / 1000;

const observer = new PerformanceObserver(function (list) {
const perfEntries = list.getEntries();
try {
const transaction = getPerformanceTransaction();
const perfEntries = list.getEntries();

if (!transaction) {
return;
}
perfEntries.forEach(entry => {
const startSeconds = timeOrigin + entry.startTime / 1000;
LongTaskObserver.longTaskCount++;
transaction.startChild({
description: `Long Task - ${LongTaskObserver.currentId}`,
op: `ui.sentry.long-task`,
startTimestamp: startSeconds,
endTimestamp: startSeconds + entry.duration / 1000,
});
});
});
if (!transaction) {
return;
}

if (!transaction) {
return null;
}
transaction?.registerBeforeFinishCallback?.(t => {
if (!browserPerformanceTimeOrigin) {
return;
}
if (transaction !== LongTaskObserver.lastTransaction) {
// If long tasks observer is active and is called while the transaction has changed.
if (LongTaskObserver.lastTransaction) {
LongTaskObserver.setLongTaskTags(LongTaskObserver.lastTransaction);
}
LongTaskObserver.longTaskCount = 0;
LongTaskObserver.lastTransaction = transaction;
}

t.setTag('longTaskCount', LongTaskObserver.longTaskCount);
perfEntries.forEach(entry => {
const startSeconds = timeOrigin + entry.startTime / 1000;
LongTaskObserver.longTaskCount++;
transaction.startChild({
description: `Long Task - ${LongTaskObserver.currentId}`,
op: `ui.sentry.long-task`,
startTimestamp: startSeconds,
endTimestamp: startSeconds + entry.duration / 1000,
});
});
LongTaskObserver.setLongTaskTags(transaction);
} catch (_) {
// Defensive catch.
}
});

if (!observer || !observer.observe) {
return null;
}
LongTaskObserver.observer = observer;
LongTaskObserver.observer.observe({entryTypes: ['longtask']});
try {
LongTaskObserver.observer.observe({entryTypes: ['longtask']});
} catch (_) {
// Safari doesn't support longtask, ignore this error.
}

return LongTaskObserver.observer;
} catch (e) {
Expand Down