Browse Source

feat(profiling) aggregate flamegraph on landing page (#75190)

Add aggregate flamegraph to the continuous profiling landing page
Jonas 7 months ago
parent
commit
8382c67d6f

+ 13 - 0
static/app/views/profiling/content.tsx

@@ -36,6 +36,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjects from 'sentry/utils/useProjects';
+import {LandingAggregateFlamegraph} from 'sentry/views/profiling/landingAggregateFlamegraph';
 import {DEFAULT_PROFILING_DATETIME_SELECTION} from 'sentry/views/profiling/utils';
 
 import {LandingWidgetSelector} from './landing/landingWidgetSelector';
@@ -396,6 +397,9 @@ function ProfilingContent({location}: ProfilingContentProps) {
                   maxQueryLength={MAX_QUERY_LENGTH}
                 />
               </ActionBar>
+              <LandingAggregateFlamegraphContainer>
+                <LandingAggregateFlamegraph />
+              </LandingAggregateFlamegraphContainer>
               {shouldShowProfilingOnboardingPanel ? (
                 <Fragment>
                   <ProfilingOnboardingPanel
@@ -510,6 +514,15 @@ const ALL_FIELDS = [
 
 type FieldType = (typeof ALL_FIELDS)[number];
 
+const LandingAggregateFlamegraphContainer = styled('div')`
+  height: 40vh;
+  min-height: 300px;
+  position: relative;
+  border: 1px solid ${p => p.theme.border};
+  border-radius: ${p => p.theme.borderRadius};
+  margin-bottom: ${space(2)};
+`;
+
 const StyledHeaderContent = styled(Layout.HeaderContent)`
   display: flex;
   align-items: center;

+ 304 - 0
static/app/views/profiling/landingAggregateFlamegraph.tsx

@@ -0,0 +1,304 @@
+import {useCallback, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {CompactSelect} from 'sentry/components/compactSelect';
+import type {SelectOption} from 'sentry/components/compactSelect/types';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {AggregateFlamegraph} from 'sentry/components/profiling/flamegraph/aggregateFlamegraph';
+import {AggregateFlamegraphTreeTable} from 'sentry/components/profiling/flamegraph/aggregateFlamegraphTreeTable';
+import {FlamegraphSearch} from 'sentry/components/profiling/flamegraph/flamegraphToolbar/flamegraphSearch';
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+import {IconPanel} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {DeepPartial} from 'sentry/types/utils';
+import {isAggregateField} from 'sentry/utils/discover/fields';
+import type {CanvasScheduler} from 'sentry/utils/profiling/canvasScheduler';
+import {
+  CanvasPoolManager,
+  useCanvasScheduler,
+} from 'sentry/utils/profiling/canvasScheduler';
+import type {FlamegraphState} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContext';
+import {FlamegraphStateProvider} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider';
+import {FlamegraphThemeProvider} from 'sentry/utils/profiling/flamegraph/flamegraphThemeProvider';
+import type {Frame} from 'sentry/utils/profiling/frame';
+import {useAggregateFlamegraphQuery} from 'sentry/utils/profiling/hooks/useAggregateFlamegraphQuery';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
+import {useLocation} from 'sentry/utils/useLocation';
+import {
+  FlamegraphProvider,
+  useFlamegraph,
+} from 'sentry/views/profiling/flamegraphProvider';
+import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
+
+const DEFAULT_FLAMEGRAPH_PREFERENCES: DeepPartial<FlamegraphState> = {
+  preferences: {
+    sorting: 'alphabetical' satisfies FlamegraphState['preferences']['sorting'],
+  },
+};
+
+const noop = () => void 0;
+
+function decodeViewOrDefault(
+  value: string | string[] | null | undefined,
+  defaultValue: 'flamegraph' | 'profiles'
+): 'flamegraph' | 'profiles' {
+  if (!value || Array.isArray(value)) {
+    return defaultValue;
+  }
+  if (value === 'flamegraph' || value === 'profiles') {
+    return value;
+  }
+  return defaultValue;
+}
+
+interface AggregateFlamegraphToolbarProps {
+  canvasPoolManager: CanvasPoolManager;
+  frameFilter: 'system' | 'application' | 'all';
+  hideSystemFrames: boolean;
+  onFrameFilterChange: (value: 'system' | 'application' | 'all') => void;
+  onHideRegressionsClick: () => void;
+  onVisualizationChange: (value: 'flamegraph' | 'call tree') => void;
+  scheduler: CanvasScheduler;
+  setHideSystemFrames: (value: boolean) => void;
+  visualization: 'flamegraph' | 'call tree';
+}
+
+function AggregateFlamegraphToolbar(props: AggregateFlamegraphToolbarProps) {
+  const flamegraph = useFlamegraph();
+  const flamegraphs = useMemo(() => [flamegraph], [flamegraph]);
+  const spans = useMemo(() => [], []);
+
+  const frameSelectOptions: SelectOption<'system' | 'application' | 'all'>[] =
+    useMemo(() => {
+      return [
+        {value: 'system', label: t('System Frames')},
+        {value: 'application', label: t('Application Frames')},
+        {value: 'all', label: t('All Frames')},
+      ];
+    }, []);
+
+  const onResetZoom = useCallback(() => {
+    props.scheduler.dispatch('reset zoom');
+  }, [props.scheduler]);
+
+  const onFrameFilterChange = useCallback(
+    (value: {value: 'application' | 'system' | 'all'}) => {
+      props.onFrameFilterChange(value.value);
+    },
+    [props]
+  );
+
+  return (
+    <AggregateFlamegraphToolbarContainer>
+      <ViewSelectContainer>
+        <SegmentedControl
+          aria-label={t('View')}
+          size="xs"
+          value={props.visualization}
+          onChange={props.onVisualizationChange}
+        >
+          <SegmentedControl.Item key="flamegraph">
+            {t('Flamegraph')}
+          </SegmentedControl.Item>
+          <SegmentedControl.Item key="call tree">{t('Call Tree')}</SegmentedControl.Item>
+        </SegmentedControl>
+      </ViewSelectContainer>
+      <AggregateFlamegraphSearch
+        spans={spans}
+        canvasPoolManager={props.canvasPoolManager}
+        flamegraphs={flamegraphs}
+      />
+      <Button size="xs" onClick={onResetZoom}>
+        {t('Reset Zoom')}
+      </Button>
+      <CompactSelect
+        size="xs"
+        onChange={onFrameFilterChange}
+        value={props.frameFilter}
+        options={frameSelectOptions}
+      />
+      <Button
+        size="xs"
+        onClick={props.onHideRegressionsClick}
+        title={t('Expand or collapse the view')}
+      >
+        <IconPanel size="xs" direction="right" />
+      </Button>
+    </AggregateFlamegraphToolbarContainer>
+  );
+}
+
+export function LandingAggregateFlamegraph(): React.ReactNode {
+  const location = useLocation();
+  const rawQuery = decodeScalar(location?.query?.query, '');
+
+  const query = useMemo(() => {
+    const search = new MutableSearch(rawQuery);
+    // there are no aggregations happening on this page,
+    // so remove any aggregate filters
+    Object.keys(search.filters).forEach(field => {
+      if (isAggregateField(field)) {
+        search.removeFilter(field);
+      }
+    });
+    return search.formatString();
+  }, [rawQuery]);
+
+  const {data, isLoading, isError} = useAggregateFlamegraphQuery({
+    query,
+  });
+
+  const [visualization, setVisualization] = useLocalStorageState<
+    'flamegraph' | 'call tree'
+  >('flamegraph-visualization', 'flamegraph');
+
+  const onVisualizationChange = useCallback(
+    (value: 'flamegraph' | 'call tree') => {
+      setVisualization(value);
+    },
+    [setVisualization]
+  );
+
+  const [hideRegressions, setHideRegressions] = useLocalStorageState<boolean>(
+    'flamegraph-hide-regressions',
+    false
+  );
+  const [frameFilter, setFrameFilter] = useLocalStorageState<
+    'system' | 'application' | 'all'
+  >('flamegraph-frame-filter', 'application');
+
+  const onFrameFilterChange = useCallback(
+    (value: 'system' | 'application' | 'all') => {
+      setFrameFilter(value);
+    },
+    [setFrameFilter]
+  );
+
+  const flamegraphFrameFilter: ((frame: Frame) => boolean) | undefined = useMemo(() => {
+    if (frameFilter === 'all') {
+      return () => true;
+    }
+    if (frameFilter === 'application') {
+      return frame => frame.is_application;
+    }
+    return frame => !frame.is_application;
+  }, [frameFilter]);
+
+  const canvasPoolManager = useMemo(() => new CanvasPoolManager(), []);
+  const scheduler = useCanvasScheduler(canvasPoolManager);
+
+  const [view, setView] = useState<'flamegraph' | 'profiles'>(
+    decodeViewOrDefault(location.query.view, 'flamegraph')
+  );
+
+  useEffect(() => {
+    const newView = decodeViewOrDefault(location.query.view, 'flamegraph');
+    if (newView !== view) {
+      setView(decodeViewOrDefault(location.query.view, 'flamegraph'));
+    }
+  }, [location.query.view, view]);
+
+  const onHideRegressionsClick = useCallback(() => {
+    return setHideRegressions(!hideRegressions);
+  }, [hideRegressions, setHideRegressions]);
+
+  return (
+    <ProfileGroupProvider
+      traceID=""
+      type="flamegraph"
+      input={data ?? null}
+      frameFilter={flamegraphFrameFilter}
+    >
+      <FlamegraphStateProvider initialState={DEFAULT_FLAMEGRAPH_PREFERENCES}>
+        <FlamegraphThemeProvider>
+          <FlamegraphProvider>
+            <AggregateFlamegraphContainer>
+              <AggregateFlamegraphToolbar
+                scheduler={scheduler}
+                canvasPoolManager={canvasPoolManager}
+                visualization={visualization}
+                onVisualizationChange={onVisualizationChange}
+                frameFilter={frameFilter}
+                onFrameFilterChange={onFrameFilterChange}
+                hideSystemFrames={false}
+                setHideSystemFrames={noop}
+                onHideRegressionsClick={onHideRegressionsClick}
+              />
+              {isLoading ? (
+                <RequestStateMessageContainer>
+                  <LoadingIndicator />
+                </RequestStateMessageContainer>
+              ) : isError ? (
+                <RequestStateMessageContainer>
+                  {t('There was an error loading the flamegraph.')}
+                </RequestStateMessageContainer>
+              ) : null}
+              {visualization === 'flamegraph' ? (
+                <AggregateFlamegraph
+                  canvasPoolManager={canvasPoolManager}
+                  scheduler={scheduler}
+                />
+              ) : (
+                <AggregateFlamegraphTreeTable
+                  recursion={null}
+                  expanded={false}
+                  frameFilter={frameFilter}
+                  canvasPoolManager={canvasPoolManager}
+                />
+              )}
+            </AggregateFlamegraphContainer>
+          </FlamegraphProvider>
+        </FlamegraphThemeProvider>
+      </FlamegraphStateProvider>
+    </ProfileGroupProvider>
+  );
+}
+
+const AggregateFlamegraphSearch = styled(FlamegraphSearch)`
+  max-width: 300px;
+`;
+
+const AggregateFlamegraphToolbarContainer = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  gap: ${space(1)};
+  padding: ${space(1)} ${space(1)};
+  /*
+    force height to be the same as profile digest header,
+    but subtract 1px for the border that doesnt exist on the header
+   */
+  height: 41px;
+  border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const ViewSelectContainer = styled('div')`
+  min-width: 160px;
+`;
+
+const RequestStateMessageContainer = styled('div')`
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: ${p => p.theme.subText};
+`;
+
+const AggregateFlamegraphContainer = styled('div')`
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 100%;
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+  position: absolute;
+  left: 0px;
+  top: 0px;
+`;