Browse Source

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

Adding the basics of interactions to monitor an area we observed to have some poor performance interaction-wise. Holds onto interactions in a static since we currently don't support multiple ongoing transactions, manually will close an idle transaction from pageload since interactions would indicate pageload has finished.
Kev 2 years ago
parent
commit
cb06256080

+ 5 - 0
static/app/components/events/interfaces/spans/dragManager.tsx

@@ -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
@@ -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
 
@@ -308,6 +311,8 @@ class DragManager extends React.Component<DragManagerProps, DragManagerState> {
       return;
     }
 
+    PerformanceInteraction.finishInteraction();
+
     // remove listeners that were attached in onWindowSelectionDragStart
 
     this.cleanUpListeners();

+ 3 - 3
static/app/components/events/interfaces/spans/utils.tsx

@@ -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,
@@ -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}`);

+ 138 - 29
static/app/utils/performanceForSentry.tsx

@@ -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
@@ -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({
@@ -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) {