Files
2024-02-15 17:01:50 +00:00

339 lines
12 KiB
TypeScript

import type {ReactElement} from 'react';
import type React from 'react';
import {Fragment, useEffect, useLayoutEffect, useMemo, useState} from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import type {mat3} from 'gl-matrix';
import {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 SwitchButton from 'sentry/components/switchButton';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import type {CanvasScheduler} from 'sentry/utils/profiling/canvasScheduler';
import {
CanvasPoolManager,
useCanvasScheduler,
} from 'sentry/utils/profiling/canvasScheduler';
import {CanvasView} from 'sentry/utils/profiling/canvasView';
import type {Flamegraph} from 'sentry/utils/profiling/flamegraph';
import {Flamegraph as FlamegraphModel} from 'sentry/utils/profiling/flamegraph';
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 {
useFlamegraphTheme,
useMutateFlamegraphTheme,
} from 'sentry/utils/profiling/flamegraph/useFlamegraphTheme';
import {FlamegraphCanvas} from 'sentry/utils/profiling/flamegraphCanvas';
import type {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
import {
computeConfigViewWithStrategy,
initializeFlamegraphRenderer,
useResizeCanvasObserver,
} from 'sentry/utils/profiling/gl/utils';
import {FlamegraphRenderer2D} from 'sentry/utils/profiling/renderers/flamegraphRenderer2D';
import {FlamegraphRendererWebGL} from 'sentry/utils/profiling/renderers/flamegraphRendererWebGL';
import {Rect} from 'sentry/utils/profiling/speedscope';
import {useDevicePixelRatio} from 'sentry/utils/useDevicePixelRatio';
import {useProfileGroup} from 'sentry/views/profiling/profileGroupProvider';
const LOADING_OR_FALLBACK_FLAMEGRAPH = FlamegraphModel.Empty();
interface DeprecatedAggregateFlamegraphProps {
hideSystemFrames: boolean;
setHideSystemFrames: (hideSystemFrames: boolean) => void;
children?: (props: {
canvasPoolManager: CanvasPoolManager;
flamegraph: Flamegraph;
scheduler: CanvasScheduler;
}) => React.ReactNode;
hideToolbar?: boolean;
}
export function DeprecatedAggregateFlamegraph(
props: DeprecatedAggregateFlamegraphProps
): ReactElement {
const devicePixelRatio = useDevicePixelRatio();
const dispatch = useDispatchFlamegraphState();
const profileGroup = useProfileGroup();
const flamegraphTheme = useFlamegraphTheme();
const setFlamegraphThemeMutation = useMutateFlamegraphTheme();
const profiles = useFlamegraphProfiles();
const {colorCoding, sorting, view} = useFlamegraphPreferences();
const {threadId} = 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, {
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 = useMemo<CanvasView<FlamegraphModel> | null>(
() => {
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,
},
});
// Set to 3/4 of the view up, magic number... Would be best to comput some weighted visual score
// based on the number of frames and the depth of the frames, but lets see if we can make it work
// with this for now
newView.setConfigView(newView.configView.withY(newView.configView.height * 0.75));
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;
const barHeightRatio = Math.min(Math.max(minReadableRatio, fitToRatio), 1.2);
// reduce the offset to leave just enough space for the toolbar
theme.SIZES.FLAMEGRAPH_DEPTH_OFFSET = 2.5;
theme.SIZES.BAR_HEIGHT = theme.SIZES.BAR_HEIGHT * barHeightRatio;
theme.SIZES.BAR_FONT_SIZE = theme.SIZES.BAR_FONT_SIZE * barHeightRatio;
return theme;
});
// We skip `flamegraphCanvas` as it causes an infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flamegraph, setFlamegraphThemeMutation, flamegraphCanvas?.logicalSpace.height]);
// 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;
}
const renderer = initializeFlamegraphRenderer(
[FlamegraphRendererWebGL, FlamegraphRenderer2D],
[
flamegraphCanvasRef,
flamegraph,
flamegraphTheme,
{
colorCoding,
draw_border: true,
},
]
);
if (renderer === null) {
Sentry.captureException('Failed to initialize a flamegraph renderer');
return null;
}
return renderer;
}, [colorCoding, flamegraph, flamegraphCanvasRef, flamegraphTheme]);
useEffect(() => {
if (defined(profiles.threadId)) {
return;
}
const threadID =
typeof profileGroup.activeProfileIndex === 'number'
? profileGroup.profiles[profileGroup.activeProfileIndex]?.threadId
: null;
// 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, profiles.threadId, dispatch]);
return (
<Fragment>
{props.children ? props.children({canvasPoolManager, scheduler, flamegraph}) : null}
<FlamegraphZoomView
profileGroup={profileGroup}
canvasBounds={flamegraphCanvasBounds}
canvasPoolManager={canvasPoolManager}
flamegraph={flamegraph}
flamegraphRenderer={flamegraphRenderer}
flamegraphCanvas={flamegraphCanvas}
flamegraphCanvasRef={flamegraphCanvasRef}
flamegraphOverlayCanvasRef={flamegraphOverlayCanvasRef}
flamegraphView={flamegraphView}
setFlamegraphCanvasRef={setFlamegraphCanvasRef}
setFlamegraphOverlayCanvasRef={setFlamegraphOverlayCanvasRef}
disablePanX
disableZoom
disableGrid
disableCallOrderSort
/>
{props.hideToolbar ? null : (
<AggregateFlamegraphToolbar>
<Flex justify="space-between" align="center">
<Button size="xs" onClick={() => scheduler.dispatch('reset zoom')}>
{t('Reset Zoom')}
</Button>
<Flex align="center" gap={space(1)}>
<span>{t('Hide System Frames')}</span>
<SwitchButton
toggle={() => props.setHideSystemFrames(!props.hideSystemFrames)}
isActive={props.hideSystemFrames}
/>
</Flex>
</Flex>
</AggregateFlamegraphToolbar>
)}
</Fragment>
);
}
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%;
`;