Browse Source

feat(profiling): add aggregateFlamegraph to profileSummary (#45458)

## Summary

Adds aggregate flamegraphs to `profileSummary` view.

<img width="1846" alt="image"
src="https://user-images.githubusercontent.com/7349258/223302062-560c29a9-2a97-4177-a86c-ac3437396dbd.png">
Elias Hussary 2 years ago
parent
commit
9dfe35cf44

+ 29 - 0
static/app/components/profiling/aggregateFlamegraphPanel.tsx

@@ -0,0 +1,29 @@
+import {Panel} from 'sentry/components/panels';
+import {AggregateFlamegraph} from 'sentry/components/profiling/flamegraph/aggregateFlamegraph';
+import {FlamegraphStateProvider} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/flamegraphContextProvider';
+import {FlamegraphThemeProvider} from 'sentry/utils/profiling/flamegraph/flamegraphThemeProvider';
+import {useAggregateFlamegraphQuery} from 'sentry/utils/profiling/hooks/useAggregateFlamegraphQuery';
+import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
+
+export function AggregateFlamegraphPanel({transaction}: {transaction: string}) {
+  const query = useAggregateFlamegraphQuery({transaction});
+
+  return (
+    <ProfileGroupProvider type="flamegraph" input={query.data ?? null} traceID="">
+      <FlamegraphStateProvider
+        initialState={{
+          preferences: {
+            sorting: 'alphabetical',
+            view: 'bottom up',
+          },
+        }}
+      >
+        <FlamegraphThemeProvider>
+          <Panel>
+            <AggregateFlamegraph />
+          </Panel>
+        </FlamegraphThemeProvider>
+      </FlamegraphStateProvider>
+    </ProfileGroupProvider>
+  );
+}

+ 413 - 0
static/app/components/profiling/flamegraph/aggregateFlamegraph.tsx

@@ -0,0 +1,413 @@
+import {ReactElement, useEffect, useLayoutEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+import {mat3, vec2} from 'gl-matrix';
+
+import {Button} from 'sentry/components/button';
+import {FlamegraphZoomView} from 'sentry/components/profiling/flamegraph/flamegraphZoomView';
+import {Flex} from 'sentry/components/profiling/flex';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import {
+  CanvasPoolManager,
+  useCanvasScheduler,
+} from 'sentry/utils/profiling/canvasScheduler';
+import {CanvasView} from 'sentry/utils/profiling/canvasView';
+import {Flamegraph as FlamegraphModel} from 'sentry/utils/profiling/flamegraph';
+import {FlamegraphProfiles} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/reducers/flamegraphProfiles';
+import {useFlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphPreferences';
+import {useFlamegraphProfiles} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphProfiles';
+import {useDispatchFlamegraphState} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphState';
+import {useFlamegraphZoomPosition} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphZoomPosition';
+import {
+  useFlamegraphTheme,
+  useMutateFlamegraphTheme,
+} from 'sentry/utils/profiling/flamegraph/useFlamegraphTheme';
+import {FlamegraphCanvas} from 'sentry/utils/profiling/flamegraphCanvas';
+import {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
+import {
+  computeConfigViewWithStrategy,
+  computeMinZoomConfigViewForFrames,
+  Rect,
+  useResizeCanvasObserver,
+} from 'sentry/utils/profiling/gl/utils';
+import {FlamegraphRendererWebGL} from 'sentry/utils/profiling/renderers/flamegraphRendererWebGL';
+import {useDevicePixelRatio} from 'sentry/utils/useDevicePixelRatio';
+import {useMemoWithPrevious} from 'sentry/utils/useMemoWithPrevious';
+import {useProfileGroup} from 'sentry/views/profiling/profileGroupProvider';
+
+type FlamegraphCandidate = {
+  frame: FlamegraphFrame;
+  threadId: number;
+  isActiveThread?: boolean; // this is the thread referred to by the active profile index
+};
+
+function findLongestMatchingFrame(
+  flamegraph: FlamegraphModel,
+  focusFrame: FlamegraphProfiles['highlightFrames']
+): FlamegraphFrame | null {
+  if (focusFrame === null) {
+    return null;
+  }
+
+  let longestFrame: FlamegraphFrame | null = null;
+
+  const frames: FlamegraphFrame[] = [...flamegraph.root.children];
+  while (frames.length > 0) {
+    const frame = frames.pop()!;
+    if (
+      focusFrame.name === frame.frame.name &&
+      focusFrame.package === frame.frame.image &&
+      (longestFrame?.node?.totalWeight || 0) < frame.node.totalWeight
+    ) {
+      longestFrame = frame;
+    }
+
+    if (longestFrame && longestFrame.node.totalWeight < frame.node.totalWeight) {
+      for (let i = 0; i < frame.children.length; i++) {
+        frames.push(frame.children[i]);
+      }
+    }
+  }
+
+  return longestFrame;
+}
+
+const LOADING_OR_FALLBACK_FLAMEGRAPH = FlamegraphModel.Empty();
+
+export function AggregateFlamegraph(): ReactElement {
+  const devicePixelRatio = useDevicePixelRatio();
+  const dispatch = useDispatchFlamegraphState();
+
+  const profileGroup = useProfileGroup();
+
+  const flamegraphTheme = useFlamegraphTheme();
+  const setFlamegraphThemeMutation = useMutateFlamegraphTheme();
+  const position = useFlamegraphZoomPosition();
+  const profiles = useFlamegraphProfiles();
+  const {colorCoding, sorting, view} = useFlamegraphPreferences();
+  const {threadId, highlightFrames} = profiles;
+
+  const [flamegraphCanvasRef, setFlamegraphCanvasRef] =
+    useState<HTMLCanvasElement | null>(null);
+  const [flamegraphOverlayCanvasRef, setFlamegraphOverlayCanvasRef] =
+    useState<HTMLCanvasElement | null>(null);
+
+  const canvasPoolManager = useMemo(() => new CanvasPoolManager(), []);
+  const scheduler = useCanvasScheduler(canvasPoolManager);
+
+  const profile = useMemo(() => {
+    return profileGroup.profiles.find(p => p.threadId === threadId);
+  }, [profileGroup, threadId]);
+
+  const flamegraph = useMemo(() => {
+    if (typeof threadId !== 'number') {
+      return LOADING_OR_FALLBACK_FLAMEGRAPH;
+    }
+
+    // This could happen if threadId was initialized from query string, but for some
+    // reason the profile was removed from the list of profiles.
+    if (!profile) {
+      return LOADING_OR_FALLBACK_FLAMEGRAPH;
+    }
+
+    const transaction = Sentry.startTransaction({
+      op: 'import',
+      name: 'flamegraph.constructor',
+    });
+
+    transaction.setTag('sorting', sorting.split(' ').join('_'));
+    transaction.setTag('view', view.split(' ').join('_'));
+
+    const newFlamegraph = new FlamegraphModel(profile, threadId, {
+      inverted: view === 'bottom up',
+      sort: sorting,
+      configSpace: undefined,
+    });
+    transaction.finish();
+
+    return newFlamegraph;
+  }, [profile, sorting, threadId, view]);
+
+  const flamegraphCanvas = useMemo(() => {
+    if (!flamegraphCanvasRef) {
+      return null;
+    }
+    const yOrigin = flamegraphTheme.SIZES.TIMELINE_HEIGHT * devicePixelRatio;
+    return new FlamegraphCanvas(flamegraphCanvasRef, vec2.fromValues(0, yOrigin));
+  }, [devicePixelRatio, flamegraphCanvasRef, flamegraphTheme]);
+
+  const flamegraphView = useMemoWithPrevious<CanvasView<FlamegraphModel> | null>(
+    previousView => {
+      if (!flamegraphCanvas) {
+        return null;
+      }
+
+      const newView = new CanvasView({
+        canvas: flamegraphCanvas,
+        model: flamegraph,
+        options: {
+          inverted: flamegraph.inverted,
+          minWidth: flamegraph.profile.minFrameDuration,
+          barHeight: flamegraphTheme.SIZES.BAR_HEIGHT,
+          depthOffset: flamegraphTheme.SIZES.FLAMEGRAPH_DEPTH_OFFSET,
+          configSpaceTransform: undefined,
+        },
+      });
+
+      if (defined(highlightFrames)) {
+        const frames = flamegraph.findAllMatchingFrames(
+          highlightFrames.name,
+          highlightFrames.package
+        );
+
+        if (frames.length > 0) {
+          const rectFrames = frames.map(
+            f => new Rect(f.start, f.depth, f.end - f.start, 1)
+          );
+          const newConfigView = computeMinZoomConfigViewForFrames(
+            newView.configView,
+            rectFrames
+          );
+          newView.setConfigView(newConfigView);
+          return newView;
+        }
+      }
+
+      // Because we render empty flamechart while we fetch the data, we need to make sure
+      // to have some heuristic when the data is fetched to determine if we should
+      // initialize the config view to the full view or a predefined value
+      else if (
+        !defined(highlightFrames) &&
+        position.view &&
+        !position.view.isEmpty() &&
+        previousView?.model === LOADING_OR_FALLBACK_FLAMEGRAPH
+      ) {
+        // We allow min width to be initialize to lower than view.minWidth because
+        // there is a chance that user zoomed into a span duration which may have been updated
+        // after the model was loaded (see L320)
+        newView.setConfigView(position.view, {width: {min: 0}});
+      }
+
+      return newView;
+    },
+
+    // We skip position.view dependency because it will go into an infinite loop
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [flamegraph, flamegraphCanvas, flamegraphTheme]
+  );
+
+  useEffect(() => {
+    const canvasHeight = flamegraphCanvas?.logicalSpace.height;
+    if (!canvasHeight) {
+      return;
+    }
+
+    setFlamegraphThemeMutation(theme => {
+      const flamegraphFitTo = canvasHeight / flamegraph.depth;
+      const minReadableRatio = 0.8; // this is quite small
+      const fitToRatio = flamegraphFitTo / theme.SIZES.BAR_HEIGHT;
+
+      theme.SIZES.BAR_HEIGHT =
+        theme.SIZES.BAR_HEIGHT * Math.max(minReadableRatio, fitToRatio);
+      theme.SIZES.BAR_FONT_SIZE =
+        theme.SIZES.BAR_FONT_SIZE * Math.max(minReadableRatio, fitToRatio);
+      return theme;
+    });
+
+    // We skip `flamegraphCanvas` as it causes an infinite loop
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [flamegraph, setFlamegraphThemeMutation]);
+
+  // Uses a useLayoutEffect to ensure that these top level/global listeners are added before
+  // any of the children components effects actually run. This way we do not lose events
+  // when we register/unregister these top level listeners.
+  useLayoutEffect(() => {
+    if (!flamegraphCanvas || !flamegraphView) {
+      return undefined;
+    }
+
+    // This code below manages the synchronization of the config views between spans and flamegraph
+    // We do so by listening to the config view change event and then updating the other views accordingly which
+    // allows us to keep the X axis in sync between the two views but keep the Y axis independent
+    const onConfigViewChange = (rect: Rect, sourceConfigViewChange: CanvasView<any>) => {
+      if (sourceConfigViewChange === flamegraphView) {
+        flamegraphView.setConfigView(rect.withHeight(flamegraphView.configView.height));
+      }
+
+      canvasPoolManager.draw();
+    };
+
+    const onTransformConfigView = (
+      mat: mat3,
+      sourceTransformConfigView: CanvasView<any>
+    ) => {
+      if (sourceTransformConfigView === flamegraphView) {
+        flamegraphView.transformConfigView(mat);
+      }
+
+      canvasPoolManager.draw();
+    };
+
+    const onResetZoom = () => {
+      flamegraphView.resetConfigView(flamegraphCanvas);
+
+      canvasPoolManager.draw();
+    };
+
+    const onZoomIntoFrame = (frame: FlamegraphFrame, strategy: 'min' | 'exact') => {
+      const newConfigView = computeConfigViewWithStrategy(
+        strategy,
+        flamegraphView.configView,
+        new Rect(frame.start, frame.depth, frame.end - frame.start, 1)
+      ).transformRect(flamegraphView.configSpaceTransform);
+
+      flamegraphView.setConfigView(newConfigView);
+
+      canvasPoolManager.draw();
+    };
+
+    scheduler.on('set config view', onConfigViewChange);
+    scheduler.on('transform config view', onTransformConfigView);
+    scheduler.on('reset zoom', onResetZoom);
+    scheduler.on('zoom at frame', onZoomIntoFrame);
+
+    return () => {
+      scheduler.off('set config view', onConfigViewChange);
+      scheduler.off('transform config view', onTransformConfigView);
+      scheduler.off('reset zoom', onResetZoom);
+      scheduler.off('zoom at frame', onZoomIntoFrame);
+    };
+  }, [canvasPoolManager, flamegraphCanvas, flamegraphView, scheduler]);
+
+  const flamegraphCanvases = useMemo(() => {
+    return [flamegraphCanvasRef, flamegraphOverlayCanvasRef];
+  }, [flamegraphCanvasRef, flamegraphOverlayCanvasRef]);
+
+  const flamegraphCanvasBounds = useResizeCanvasObserver(
+    flamegraphCanvases,
+    canvasPoolManager,
+    flamegraphCanvas,
+    flamegraphView
+  );
+
+  const flamegraphRenderer = useMemo(() => {
+    if (!flamegraphCanvasRef) {
+      return null;
+    }
+
+    return new FlamegraphRendererWebGL(flamegraphCanvasRef, flamegraph, flamegraphTheme, {
+      colorCoding,
+      draw_border: true,
+    });
+  }, [colorCoding, flamegraph, flamegraphCanvasRef, flamegraphTheme]);
+
+  useEffect(() => {
+    if (defined(profiles.threadId)) {
+      return;
+    }
+    const threadID =
+      typeof profileGroup.activeProfileIndex === 'number'
+        ? profileGroup.profiles[profileGroup.activeProfileIndex]?.threadId
+        : null;
+
+    // if the state has a highlight frame specified, then we want to jump to the
+    // thread containing it, highlight the frames on the thread, and change the
+    // view so it's obvious where it is
+    if (highlightFrames) {
+      const candidate = profileGroup.profiles.reduce<FlamegraphCandidate | null>(
+        (prevCandidate, currentProfile) => {
+          // if the previous candidate is the active thread, it always takes priority
+          if (prevCandidate?.isActiveThread) {
+            return prevCandidate;
+          }
+
+          const graph = new FlamegraphModel(currentProfile, currentProfile.threadId, {
+            inverted: false,
+            sort: sorting,
+            configSpace: undefined,
+          });
+
+          const frame = findLongestMatchingFrame(graph, highlightFrames);
+
+          if (!defined(frame)) {
+            return prevCandidate;
+          }
+
+          const newScore = frame.node.totalWeight || 0;
+          const oldScore = prevCandidate?.frame?.node?.totalWeight || 0;
+
+          // if we find the frame on the active thread, it always takes priority
+          if (newScore > 0 && currentProfile.threadId === threadID) {
+            return {
+              frame,
+              threadId: currentProfile.threadId,
+              isActiveThread: true,
+            };
+          }
+
+          return newScore <= oldScore
+            ? prevCandidate
+            : {
+                frame,
+                threadId: currentProfile.threadId,
+              };
+        },
+        null
+      );
+
+      if (defined(candidate)) {
+        dispatch({
+          type: 'set thread id',
+          payload: candidate.threadId,
+        });
+        return;
+      }
+    }
+
+    // fall back case, when we finally load the active profile index from the profile,
+    // make sure we update the thread id so that it is show first
+    if (defined(threadID)) {
+      dispatch({
+        type: 'set thread id',
+        payload: threadID,
+      });
+    }
+  }, [profileGroup, highlightFrames, profiles.threadId, dispatch, sorting]);
+
+  return (
+    <Flex h={500} column>
+      <FlamegraphZoomView
+        canvasBounds={flamegraphCanvasBounds}
+        canvasPoolManager={canvasPoolManager}
+        flamegraph={flamegraph}
+        flamegraphRenderer={flamegraphRenderer}
+        flamegraphCanvas={flamegraphCanvas}
+        flamegraphCanvasRef={flamegraphCanvasRef}
+        flamegraphOverlayCanvasRef={flamegraphOverlayCanvasRef}
+        flamegraphView={flamegraphView}
+        setFlamegraphCanvasRef={setFlamegraphCanvasRef}
+        setFlamegraphOverlayCanvasRef={setFlamegraphOverlayCanvasRef}
+        disablePanX
+        disableZoom
+        disableGrid
+      />
+      <AggregateFlamegraphToolbar>
+        <Button size="xs" onClick={() => scheduler.dispatch('reset zoom')}>
+          {t('Reset Zoom')}
+        </Button>
+      </AggregateFlamegraphToolbar>
+    </Flex>
+  );
+}
+
+const AggregateFlamegraphToolbar = styled('div')`
+  position: absolute;
+  left: 0;
+  top: 0;
+  padding: ${space(1)};
+  padding-left: ${space(1)};
+  background-color: rgba(255, 255, 255, 0.6);
+  width: 100%;
+`;

+ 13 - 4
static/app/components/profiling/flamegraph/flamegraphZoomView.tsx

@@ -69,6 +69,9 @@ interface FlamegraphZoomViewProps {
   setFlamegraphOverlayCanvasRef: React.Dispatch<
   setFlamegraphOverlayCanvasRef: React.Dispatch<
     React.SetStateAction<HTMLCanvasElement | null>
     React.SetStateAction<HTMLCanvasElement | null>
   >;
   >;
+  disableGrid?: boolean;
+  disablePanX?: boolean;
+  disableZoom?: boolean;
 }
 }
 
 
 function FlamegraphZoomView({
 function FlamegraphZoomView({
@@ -82,6 +85,9 @@ function FlamegraphZoomView({
   flamegraphView,
   flamegraphView,
   setFlamegraphCanvasRef,
   setFlamegraphCanvasRef,
   setFlamegraphOverlayCanvasRef,
   setFlamegraphOverlayCanvasRef,
+  disablePanX = false,
+  disableZoom = false,
+  disableGrid = false,
 }: FlamegraphZoomViewProps): React.ReactElement {
 }: FlamegraphZoomViewProps): React.ReactElement {
   const flamegraphTheme = useFlamegraphTheme();
   const flamegraphTheme = useFlamegraphTheme();
   const profileGroup = useProfileGroup();
   const profileGroup = useProfileGroup();
@@ -112,7 +118,7 @@ function FlamegraphZoomView({
   }, [flamegraph, flamegraphOverlayCanvasRef, flamegraphTheme]);
   }, [flamegraph, flamegraphOverlayCanvasRef, flamegraphTheme]);
 
 
   const gridRenderer: GridRenderer | null = useMemo(() => {
   const gridRenderer: GridRenderer | null = useMemo(() => {
-    if (!flamegraphOverlayCanvasRef) {
+    if (!flamegraphOverlayCanvasRef || disableGrid) {
       return null;
       return null;
     }
     }
     return new GridRenderer(
     return new GridRenderer(
@@ -120,7 +126,7 @@ function FlamegraphZoomView({
       flamegraphTheme,
       flamegraphTheme,
       flamegraph.formatter
       flamegraph.formatter
     );
     );
-  }, [flamegraphOverlayCanvasRef, flamegraph, flamegraphTheme]);
+  }, [flamegraphOverlayCanvasRef, flamegraph, flamegraphTheme, disableGrid]);
 
 
   const sampleTickRenderer: SampleTickRenderer | null = useMemo(() => {
   const sampleTickRenderer: SampleTickRenderer | null = useMemo(() => {
     if (!isInternalFlamegraphDebugModeEnabled) {
     if (!isInternalFlamegraphDebugModeEnabled) {
@@ -506,12 +512,14 @@ function FlamegraphZoomView({
   const onWheelCenterZoom = useWheelCenterZoom(
   const onWheelCenterZoom = useWheelCenterZoom(
     flamegraphCanvas,
     flamegraphCanvas,
     flamegraphView,
     flamegraphView,
-    canvasPoolManager
+    canvasPoolManager,
+    disableZoom
   );
   );
   const onCanvasScroll = useCanvasScroll(
   const onCanvasScroll = useCanvasScroll(
     flamegraphCanvas,
     flamegraphCanvas,
     flamegraphView,
     flamegraphView,
-    canvasPoolManager
+    canvasPoolManager,
+    disablePanX
   );
   );
 
 
   useCanvasZoomOrScroll({
   useCanvasZoomOrScroll({
@@ -657,6 +665,7 @@ const CanvasContainer = styled('div')`
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   height: 100%;
   height: 100%;
+  width: 100%;
   position: relative;
   position: relative;
 `;
 `;
 
 

+ 9 - 3
static/app/components/profiling/flamegraph/interactions/useCanvasScroll.tsx

@@ -8,7 +8,8 @@ import {getTranslationMatrixFromPhysicalSpace} from 'sentry/utils/profiling/gl/u
 export function useCanvasScroll(
 export function useCanvasScroll(
   canvas: FlamegraphCanvas | null,
   canvas: FlamegraphCanvas | null,
   view: CanvasView<any> | null,
   view: CanvasView<any> | null,
-  canvasPoolManager: CanvasPoolManager
+  canvasPoolManager: CanvasPoolManager,
+  disablePanX: boolean = false
 ) {
 ) {
   const onCanvasScroll = useCallback(
   const onCanvasScroll = useCallback(
     (evt: WheelEvent) => {
     (evt: WheelEvent) => {
@@ -17,11 +18,16 @@ export function useCanvasScroll(
       }
       }
 
 
       canvasPoolManager.dispatch('transform config view', [
       canvasPoolManager.dispatch('transform config view', [
-        getTranslationMatrixFromPhysicalSpace(evt.deltaX, evt.deltaY, view, canvas),
+        getTranslationMatrixFromPhysicalSpace(
+          disablePanX ? 0 : evt.deltaX,
+          evt.deltaY,
+          view,
+          canvas
+        ),
         view,
         view,
       ]);
       ]);
     },
     },
-    [canvas, view, canvasPoolManager]
+    [canvas, view, canvasPoolManager, disablePanX]
   );
   );
 
 
   return onCanvasScroll;
   return onCanvasScroll;

+ 4 - 3
static/app/components/profiling/flamegraph/interactions/useWheelCenterZoom.tsx

@@ -9,11 +9,12 @@ import {getCenterScaleMatrixFromMousePosition} from 'sentry/utils/profiling/gl/u
 export function useWheelCenterZoom(
 export function useWheelCenterZoom(
   canvas: FlamegraphCanvas | null,
   canvas: FlamegraphCanvas | null,
   view: CanvasView<any> | null,
   view: CanvasView<any> | null,
-  canvasPoolManager: CanvasPoolManager
+  canvasPoolManager: CanvasPoolManager,
+  disable: boolean = false
 ) {
 ) {
   const zoom = useCallback(
   const zoom = useCallback(
     (evt: WheelEvent) => {
     (evt: WheelEvent) => {
-      if (!canvas || !view) {
+      if (!canvas || !view || disable) {
         return;
         return;
       }
       }
 
 
@@ -28,7 +29,7 @@ export function useWheelCenterZoom(
         view,
         view,
       ]);
       ]);
     },
     },
-    [canvas, view, canvasPoolManager]
+    [canvas, view, canvasPoolManager, disable]
   );
   );
 
 
   return zoom;
   return zoom;

+ 32 - 7
static/app/utils/profiling/flamegraph/flamegraphThemeProvider.tsx

@@ -1,4 +1,5 @@
-import {createContext} from 'react';
+import {createContext, useCallback, useMemo, useState} from 'react';
+import cloneDeep from 'lodash/cloneDeep';
 
 
 import ConfigStore from 'sentry/stores/configStore';
 import ConfigStore from 'sentry/stores/configStore';
 import {useLegacyStore} from 'sentry/stores/useLegacyStore';
 import {useLegacyStore} from 'sentry/stores/useLegacyStore';
@@ -10,6 +11,15 @@ import {
 
 
 export const FlamegraphThemeContext = createContext<FlamegraphTheme | null>(null);
 export const FlamegraphThemeContext = createContext<FlamegraphTheme | null>(null);
 
 
+type FlamegraphThemeMutationCallback = (
+  theme: FlamegraphTheme,
+  colorMode?: 'light' | 'dark'
+) => FlamegraphTheme;
+
+export const FlamegraphThemeMutationContext = createContext<
+  ((cb: FlamegraphThemeMutationCallback) => void) | null
+>(null);
+
 interface FlamegraphThemeProviderProps {
 interface FlamegraphThemeProviderProps {
   children: React.ReactNode;
   children: React.ReactNode;
 }
 }
@@ -17,15 +27,30 @@ interface FlamegraphThemeProviderProps {
 function FlamegraphThemeProvider(
 function FlamegraphThemeProvider(
   props: FlamegraphThemeProviderProps
   props: FlamegraphThemeProviderProps
 ): React.ReactElement {
 ): React.ReactElement {
-  const {theme} = useLegacyStore(ConfigStore);
+  const {theme: colorMode} = useLegacyStore(ConfigStore);
+
+  const [mutation, setMutation] = useState<FlamegraphThemeMutationCallback | null>(null);
+
+  const addModifier = useCallback((cb: FlamegraphThemeMutationCallback) => {
+    setMutation(() => cb);
+  }, []);
 
 
-  const activeFlamegraphTheme =
-    theme === 'light' ? LightFlamegraphTheme : DarkFlamegraphTheme;
+  const activeFlamegraphTheme = useMemo(() => {
+    const flamegraphTheme =
+      colorMode === 'light' ? LightFlamegraphTheme : DarkFlamegraphTheme;
+    if (!mutation) {
+      return flamegraphTheme;
+    }
+    const clonedTheme = cloneDeep(flamegraphTheme);
+    return mutation(clonedTheme, colorMode);
+  }, [mutation, colorMode]);
 
 
   return (
   return (
-    <FlamegraphThemeContext.Provider value={activeFlamegraphTheme}>
-      {props.children}
-    </FlamegraphThemeContext.Provider>
+    <FlamegraphThemeMutationContext.Provider value={addModifier}>
+      <FlamegraphThemeContext.Provider value={activeFlamegraphTheme}>
+        {props.children}
+      </FlamegraphThemeContext.Provider>
+    </FlamegraphThemeMutationContext.Provider>
   );
   );
 }
 }
 
 

+ 14 - 1
static/app/utils/profiling/flamegraph/useFlamegraphTheme.ts

@@ -1,7 +1,10 @@
 import {useContext} from 'react';
 import {useContext} from 'react';
 
 
 import {FlamegraphTheme} from './flamegraphTheme';
 import {FlamegraphTheme} from './flamegraphTheme';
-import {FlamegraphThemeContext} from './flamegraphThemeProvider';
+import {
+  FlamegraphThemeContext,
+  FlamegraphThemeMutationContext,
+} from './flamegraphThemeProvider';
 
 
 export function useFlamegraphTheme(): FlamegraphTheme {
 export function useFlamegraphTheme(): FlamegraphTheme {
   const ctx = useContext(FlamegraphThemeContext);
   const ctx = useContext(FlamegraphThemeContext);
@@ -12,3 +15,13 @@ export function useFlamegraphTheme(): FlamegraphTheme {
 
 
   return ctx;
   return ctx;
 }
 }
+
+export function useMutateFlamegraphTheme() {
+  const ctx = useContext(FlamegraphThemeMutationContext);
+  if (!ctx) {
+    throw new Error(
+      'useMutateFlamegraphTheme was called outside of FlamegraphThemeProvider'
+    );
+  }
+  return ctx;
+}

+ 32 - 0
static/app/utils/profiling/hooks/useAggregateFlamegraphQuery.ts

@@ -0,0 +1,32 @@
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {useCurrentProjectFromRouteParam} from 'sentry/utils/profiling/hooks/useCurrentProjectFromRouteParam';
+import {useQuery} from 'sentry/utils/queryClient';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+
+export function useAggregateFlamegraphQuery({transaction}: {transaction: string}) {
+  const {selection} = usePageFilters();
+  const organization = useOrganization();
+  const project = useCurrentProjectFromRouteParam();
+  const url = `/projects/${organization.slug}/${project?.slug}/profiling/flamegraph/`;
+  const conditions = new MutableSearch([]);
+  conditions.setFilterValues('transaction_name', [transaction]);
+
+  return useQuery<Profiling.ProfileInput>(
+    [
+      url,
+      {
+        query: {
+          query: conditions.formatString(),
+          ...normalizeDateTimeParams(selection.datetime),
+        },
+      },
+    ],
+    {
+      staleTime: 0,
+      retry: false,
+      enabled: Boolean(organization?.slug && project?.slug && selection.datetime),
+    }
+  );
+}

+ 12 - 5
static/app/views/profiling/landing/profileCharts.tsx

@@ -17,6 +17,7 @@ import useRouter from 'sentry/utils/useRouter';
 
 
 interface ProfileChartsProps {
 interface ProfileChartsProps {
   query: string;
   query: string;
+  compact?: boolean;
   hideCount?: boolean;
   hideCount?: boolean;
   selection?: PageFilters;
   selection?: PageFilters;
 }
 }
@@ -26,7 +27,12 @@ interface ProfileChartsProps {
 // cover it up.
 // cover it up.
 const SERIES_ORDER = ['count()', 'p99()', 'p95()', 'p75()'] as const;
 const SERIES_ORDER = ['count()', 'p99()', 'p95()', 'p75()'] as const;
 
 
-export function ProfileCharts({query, selection, hideCount}: ProfileChartsProps) {
+export function ProfileCharts({
+  query,
+  selection,
+  hideCount,
+  compact = false,
+}: ProfileChartsProps) {
   const router = useRouter();
   const router = useRouter();
   const theme = useTheme();
   const theme = useTheme();
 
 
@@ -102,12 +108,12 @@ export function ProfileCharts({query, selection, hideCount}: ProfileChartsProps)
         <StyledPanel>
         <StyledPanel>
           <TitleContainer>
           <TitleContainer>
             {!hideCount && (
             {!hideCount && (
-              <StyledHeaderTitle>{t('Profiles by Count')}</StyledHeaderTitle>
+              <StyledHeaderTitle compact>{t('Profiles by Count')}</StyledHeaderTitle>
             )}
             )}
-            <StyledHeaderTitle>{t('Profiles by Percentiles')}</StyledHeaderTitle>
+            <StyledHeaderTitle compact>{t('Profiles by Percentiles')}</StyledHeaderTitle>
           </TitleContainer>
           </TitleContainer>
           <AreaChart
           <AreaChart
-            height={300}
+            height={compact ? 150 : 300}
             series={series}
             series={series}
             grid={[
             grid={[
               {
               {
@@ -192,7 +198,8 @@ const TitleContainer = styled('div')`
   flex-direction: row;
   flex-direction: row;
 `;
 `;
 
 
-const StyledHeaderTitle = styled(HeaderTitle)`
+const StyledHeaderTitle = styled(HeaderTitle)<{compact?: boolean}>`
   flex-grow: 1;
   flex-grow: 1;
   margin-left: ${space(2)};
   margin-left: ${space(2)};
+  font-size: ${p => (p.compact ? p.theme.fontSizeSmall : undefined)};
 `;
 `;

+ 15 - 1
static/app/views/profiling/profileSummary/content.tsx

@@ -6,6 +6,7 @@ import {Location} from 'history';
 import {CompactSelect} from 'sentry/components/compactSelect';
 import {CompactSelect} from 'sentry/components/compactSelect';
 import * as Layout from 'sentry/components/layouts/thirds';
 import * as Layout from 'sentry/components/layouts/thirds';
 import Pagination from 'sentry/components/pagination';
 import Pagination from 'sentry/components/pagination';
+import {AggregateFlamegraphPanel} from 'sentry/components/profiling/aggregateFlamegraphPanel';
 import {ProfileEventsTable} from 'sentry/components/profiling/profileEventsTable';
 import {ProfileEventsTable} from 'sentry/components/profiling/profileEventsTable';
 import {SuspectFunctionsTable} from 'sentry/components/profiling/suspectFunctions/suspectFunctionsTable';
 import {SuspectFunctionsTable} from 'sentry/components/profiling/suspectFunctions/suspectFunctionsTable';
 import {mobile} from 'sentry/data/platformCategories';
 import {mobile} from 'sentry/data/platformCategories';
@@ -17,6 +18,7 @@ import {
   useProfileEvents,
   useProfileEvents,
 } from 'sentry/utils/profiling/hooks/useProfileEvents';
 } from 'sentry/utils/profiling/hooks/useProfileEvents';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {decodeScalar} from 'sentry/utils/queryString';
+import useOrganization from 'sentry/utils/useOrganization';
 import {ProfileCharts} from 'sentry/views/profiling/landing/profileCharts';
 import {ProfileCharts} from 'sentry/views/profiling/landing/profileCharts';
 
 
 interface ProfileSummaryContentProps {
 interface ProfileSummaryContentProps {
@@ -28,6 +30,7 @@ interface ProfileSummaryContentProps {
 }
 }
 
 
 function ProfileSummaryContent(props: ProfileSummaryContentProps) {
 function ProfileSummaryContent(props: ProfileSummaryContentProps) {
+  const organization = useOrganization();
   const fields = useMemo(
   const fields = useMemo(
     () => getProfilesTableFields(props.project.platform),
     () => getProfilesTableFields(props.project.platform),
     [props.project]
     [props.project]
@@ -66,10 +69,21 @@ function ProfileSummaryContent(props: ProfileSummaryContentProps) {
     [props.location]
     [props.location]
   );
   );
 
 
+  const isAggregateFlamegraphEnabled = organization.features.includes(
+    'profiling-aggregate-flamegraph'
+  );
+
   return (
   return (
     <Fragment>
     <Fragment>
       <Layout.Main fullWidth>
       <Layout.Main fullWidth>
-        <ProfileCharts query={props.query} hideCount />
+        <ProfileCharts
+          query={props.query}
+          hideCount
+          compact={isAggregateFlamegraphEnabled}
+        />
+        {isAggregateFlamegraphEnabled && (
+          <AggregateFlamegraphPanel transaction={props.transaction} />
+        )}
         <TableHeader>
         <TableHeader>
           <CompactSelect
           <CompactSelect
             triggerProps={{prefix: t('Filter'), size: 'xs'}}
             triggerProps={{prefix: t('Filter'), size: 'xs'}}