Skip to content
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,5 +1,6 @@
import * as React from 'react';

import {FlamegraphSearch} from 'sentry/components/profiling/FlamegraphSearch';
import {FlamegraphViewSelectMenu} from 'sentry/components/profiling/FlamegraphViewSelectMenu';
import {FlamegraphZoomView} from 'sentry/components/profiling/FlamegraphZoomView';
import {FlamegraphZoomViewMinimap} from 'sentry/components/profiling/FlamegraphZoomViewMinimap';
Expand Down Expand Up @@ -67,6 +68,10 @@ export const EventedTrace = () => {
canvasPoolManager={canvasPoolManager}
flamegraphTheme={LightFlamegraphTheme}
/>
<FlamegraphSearch
flamegraphs={[flamegraph]}
canvasPoolManager={canvasPoolManager}
/>
</div>
</div>
);
Expand Down
258 changes: 258 additions & 0 deletions static/app/components/profiling/FlamegraphSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import * as React from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import Fuse from 'fuse.js';

import space from 'sentry/styles/space';
import {CanvasPoolManager} from 'sentry/utils/profiling/canvasScheduler';
import {Flamegraph} from 'sentry/utils/profiling/flamegraph';
import {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
import {isRegExpString, parseRegExp} from 'sentry/utils/profiling/validators/regExp';

function uniqueFrameKey(frame: FlamegraphFrame): string {
return `${frame.frame.key + String(frame.start)}`;
}

function frameSearch(
query: string,
frames: ReadonlyArray<FlamegraphFrame>,
index: Fuse<
FlamegraphFrame,
{includeMatches: true; keys: 'frame.name'[]; threshold: number}
>
): Record<string, FlamegraphFrame> {
const results = {};
if (isRegExpString(query)) {
const [_, lookup, flags] = parseRegExp(query) ?? [];

try {
if (!lookup) {
throw new Error('Invalid RegExp');
}

for (let i = 0; i < frames.length; i++) {
const frame = frames[i];

if (new RegExp(lookup, flags ?? 'g').test(frame.frame.name.trim())) {
results[
`${
frame.frame.name +
(frame.frame.file ? frame.frame.file : '') +
String(frame.start)
}`
] = frame;
}
}
} catch (e) {
Sentry.captureMessage(e.message);
}

return results;
}

const fuseResults = index
.search(query)
.sort((a, b) => numericSort(a.item.start, b.item.start, 'asc'));

for (let i = 0; i < fuseResults.length; i++) {
const frame = fuseResults[i];

results[
`${
frame.item.frame.name +
(frame.item.frame.file ? frame.item.frame.file : '') +
String(frame.item.start)
}`
] = frame.item;
}

return results;
}

const numericSort = (
a: null | undefined | number,
b: null | undefined | number,
direction: 'asc' | 'desc'
): number => {
if (a === b) {
return 0;
}
if (a === null || a === undefined) {
return 1;
}
if (b === null || b === undefined) {
return -1;
}

return direction === 'asc' ? a - b : b - a;
};

interface FlamegraphSearchProps {
canvasPoolManager: CanvasPoolManager;
flamegraphs: Flamegraph[];
placement: 'top' | 'bottom';
}

function FlamegraphSearch({
flamegraphs,
canvasPoolManager,
}: FlamegraphSearchProps): React.ReactElement | null {
const ref = React.useRef<HTMLInputElement>(null);

const [open, setOpen] = React.useState<boolean>(false);
const [selectedNode, setSelectedNode] = React.useState<FlamegraphFrame | null>();
const [searchResults, setSearchResults] = React.useState<
Record<string, FlamegraphFrame>
>({});

const allFrames = React.useMemo(() => {
return flamegraphs.reduce(
(acc: FlamegraphFrame[], graph) => acc.concat(graph.frames),
[]
);
}, [flamegraphs]);

const searchIndex = React.useMemo(() => {
return new Fuse(allFrames, {
keys: ['frame.name'],
threshold: 0.3,
includeMatches: true,
});
}, [allFrames]);

const onZoomIntoFrame = React.useCallback(
(frame: FlamegraphFrame) => {
canvasPoolManager.dispatch('zoomIntoFrame', [frame]);
setSelectedNode(frame);
},
[canvasPoolManager]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should setSelectedNode be in the dependencies here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not necessary, react guarantees that the setState fn is stable between rerenders (https://reactjs.org/docs/hooks-reference.html)

Note
React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to > omit from the useEffect or useCallback dependency list.

);

const handleSearchInput = React.useCallback(
(evt: React.ChangeEvent<HTMLInputElement>) => {
const query = evt.currentTarget.value;

if (!query) {
setSearchResults({});
canvasPoolManager.dispatch('searchResults', [{}]);
return;
}

const results = frameSearch(query, allFrames, searchIndex);

setSearchResults(results);
canvasPoolManager.dispatch('searchResults', [results]);
},
[searchIndex, frames, canvasPoolManager, allFrames]
);

const onNextSearchClick = React.useCallback(() => {
const frames = Object.values(searchResults).sort((a, b) =>
a.start === b.start
? numericSort(a.depth, b.depth, 'asc')
: numericSort(a.start, b.start, 'asc')
);
if (!frames.length) {
return undefined;
}

if (!selectedNode) {
return onZoomIntoFrame(frames[0] ?? null);
}

const index = frames.findIndex(
f => uniqueFrameKey(f) === uniqueFrameKey(selectedNode)
);

if (index + 1 > frames.length - 1) {
return onZoomIntoFrame(frames[0]);
}
return onZoomIntoFrame(frames[index + 1]);
}, [selectedNode, searchResults, onZoomIntoFrame]);

const onPreviousSearchClick = React.useCallback(() => {
const frames = Object.values(searchResults).sort((a, b) =>
a.start === b.start
? numericSort(a.depth, b.depth, 'asc')
: numericSort(a.start, b.start, 'asc')
);
if (!frames.length) {
return undefined;
}

if (!selectedNode) {
return onZoomIntoFrame(frames[0] ?? null);
}
const index = frames.findIndex(
f => uniqueFrameKey(f) === uniqueFrameKey(selectedNode)
);

if (index - 1 < 0) {
return onZoomIntoFrame(frames[frames.length - 1]);
}
return onZoomIntoFrame(frames[index - 1]);
}, [selectedNode, searchResults, onZoomIntoFrame]);

const onCmdF = React.useCallback(
(evt: KeyboardEvent) => {
if (evt.key === 'f' && evt.metaKey) {
evt.preventDefault();
if (open) {
ref.current?.focus();
} else {
setOpen(true);
}
}
if (evt.key === 'Escape') {
setSearchResults({});
setOpen(false);
}
},
[open, setSearchResults]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add setOpen?

);

const onKeyDown = React.useCallback(
(evt: React.KeyboardEvent<HTMLInputElement>) => {
if (evt.key === 'Escape') {
setSearchResults({});
setOpen(false);
}
if (evt.key === 'ArrowDown') {
evt.preventDefault();
onNextSearchClick();
}
if (evt.key === 'ArrowUp') {
evt.preventDefault();
onPreviousSearchClick();
}
},
[onNextSearchClick, onPreviousSearchClick, setSearchResults]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add setOpen?

);

React.useEffect(() => {
document.addEventListener('keydown', onCmdF);

return () => {
document.removeEventListener('keydown', onCmdF);
};
}, [onCmdF]);

return open ? (
<Input
ref={ref}
autoFocus
type="text"
onChange={handleSearchInput}
onKeyDown={onKeyDown}
/>
) : null;
}

const Input = styled('input')`
position: absolute;
left: 50%;
top: ${space(4)};
transform: translateX(-50%);
`;

export {FlamegraphSearch};
Loading