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

840 lines
26 KiB
TypeScript

import {useCallback, useEffect, useMemo, useState} from 'react';
import {browserHistory} from 'react-router';
import styled from '@emotion/styled';
import type {Location} from 'history';
import {Button, LinkButton} from 'sentry/components/button';
import {CompactSelect} from 'sentry/components/compactSelect';
import type {SelectOption} from 'sentry/components/compactSelect/types';
import Count from 'sentry/components/count';
import DateTime from 'sentry/components/dateTime';
import ErrorBoundary from 'sentry/components/errorBoundary';
import SearchBar from 'sentry/components/events/searchBar';
import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
import IdBadge from 'sentry/components/idBadge';
import * as Layout from 'sentry/components/layouts/thirds';
import Link from 'sentry/components/links/link';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
import PerformanceDuration from 'sentry/components/performanceDuration';
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 type {ProfilingBreadcrumbsProps} from 'sentry/components/profiling/profilingBreadcrumbs';
import {ProfilingBreadcrumbs} from 'sentry/components/profiling/profilingBreadcrumbs';
import {SegmentedControl} from 'sentry/components/segmentedControl';
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
import type {SmartSearchBarProps} from 'sentry/components/smartSearchBar';
import SmartSearchBar from 'sentry/components/smartSearchBar';
import {TabList, Tabs} from 'sentry/components/tabs';
import {MAX_QUERY_LENGTH} from 'sentry/constants';
import {IconPanel} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, PageFilters, Project} from 'sentry/types';
import type {DeepPartial} from 'sentry/types/utils';
import {defined} from 'sentry/utils';
import type EventView from 'sentry/utils/discover/eventView';
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 {useCurrentProjectFromRouteParam} from 'sentry/utils/profiling/hooks/useCurrentProjectFromRouteParam';
import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents';
import {useProfileFilters} from 'sentry/utils/profiling/hooks/useProfileFilters';
import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
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 useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
import {
FlamegraphProvider,
useFlamegraph,
} from 'sentry/views/profiling/flamegraphProvider';
import {ProfilesSummaryChart} from 'sentry/views/profiling/landing/profilesSummaryChart';
import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
import type {ProfilingFieldType} from 'sentry/views/profiling/profileSummary/content';
import {ProfilesTable} from 'sentry/views/profiling/profileSummary/profilesTable';
import {DEFAULT_PROFILING_DATETIME_SELECTION} from 'sentry/views/profiling/utils';
import {MostRegressedProfileFunctions} from './regressedProfileFunctions';
import {SlowestProfileFunctions} from './slowestProfileFunctions';
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;
}
const DEFAULT_FLAMEGRAPH_PREFERENCES: DeepPartial<FlamegraphState> = {
preferences: {
sorting: 'alphabetical' satisfies FlamegraphState['preferences']['sorting'],
},
};
interface ProfileSummaryHeaderProps {
location: Location;
onViewChange: (newView: 'flamegraph' | 'profiles') => void;
organization: Organization;
project: Project | null;
query: string;
transaction: string;
view: 'flamegraph' | 'profiles';
}
function ProfileSummaryHeader(props: ProfileSummaryHeaderProps) {
const breadcrumbTrails: ProfilingBreadcrumbsProps['trails'] = useMemo(() => {
return [
{
type: 'landing',
payload: {
query: props.location.query,
},
},
{
type: 'profile summary',
payload: {
projectSlug: props.project?.slug ?? '',
query: props.location.query,
transaction: props.transaction,
},
},
];
}, [props.location.query, props.project?.slug, props.transaction]);
const transactionSummaryTarget =
props.project &&
props.transaction &&
transactionSummaryRouteWithQuery({
orgSlug: props.organization.slug,
transaction: props.transaction,
projectID: props.project.id,
query: {query: props.query},
});
return (
<ProfilingHeader>
<ProfilingHeaderContent>
<ProfilingBreadcrumbs
organization={props.organization}
trails={breadcrumbTrails}
/>
<Layout.Title>
<ProfilingTitleContainer>
{props.project ? (
<IdBadge
hideName
project={props.project}
avatarSize={22}
avatarProps={{hasTooltip: true, tooltip: props.project.slug}}
/>
) : null}
{props.transaction}
</ProfilingTitleContainer>
</Layout.Title>
</ProfilingHeaderContent>
{transactionSummaryTarget && (
<StyledHeaderActions>
<FeedbackWidgetButton />
<LinkButton to={transactionSummaryTarget} size="sm">
{t('View Transaction Summary')}
</LinkButton>
</StyledHeaderActions>
)}
<Tabs onChange={props.onViewChange} value={props.view}>
<TabList hideBorder>
<TabList.Item key="flamegraph">{t('Flamegraph')}</TabList.Item>
<TabList.Item key="profiles">{t('Sampled Profiles')}</TabList.Item>
</TabList>
</Tabs>
</ProfilingHeader>
);
}
const ProfilingHeader = styled(Layout.Header)`
padding: ${space(1)} ${space(2)} ${space(0)} ${space(2)} !important;
`;
const ProfilingHeaderContent = styled(Layout.HeaderContent)`
margin-bottom: ${space(1)};
h1 {
line-height: normal;
}
`;
const StyledHeaderActions = styled(Layout.HeaderActions)`
display: flex;
flex-direction: row;
gap: ${space(1)};
`;
const ProfilingTitleContainer = styled('div')`
display: flex;
align-items: center;
gap: ${space(1)};
font-size: ${p => p.theme.fontSizeLarge};
`;
interface ProfileFiltersProps {
location: Location;
organization: Organization;
projectIds: EventView['project'];
query: string;
selection: PageFilters;
transaction: string | undefined;
usingTransactions: boolean;
}
function ProfileFilters(props: ProfileFiltersProps) {
const filtersQuery = useMemo(() => {
// To avoid querying for the filters each time the query changes,
// do not pass the user query to get the filters.
const search = new MutableSearch('');
if (defined(props.transaction)) {
search.setFilterValues('transaction_name', [props.transaction]);
}
return search.formatString();
}, [props.transaction]);
const profileFilters = useProfileFilters({
query: filtersQuery,
selection: props.selection,
disabled: props.usingTransactions,
});
const handleSearch: SmartSearchBarProps['onSearch'] = useCallback(
(searchQuery: string) => {
browserHistory.push({
...props.location,
query: {
...props.location.query,
query: searchQuery || undefined,
cursor: undefined,
},
});
},
[props.location]
);
return (
<ActionBar>
<PageFilterBar condensed>
<EnvironmentPageFilter />
<DatePageFilter />
</PageFilterBar>
{props.usingTransactions ? (
<SearchBar
searchSource="profile_summary"
organization={props.organization}
projectIds={props.projectIds}
query={props.query}
onSearch={handleSearch}
maxQueryLength={MAX_QUERY_LENGTH}
/>
) : (
<SmartSearchBar
organization={props.organization}
hasRecentSearches
searchSource="profile_summary"
supportedTags={profileFilters}
query={props.query}
onSearch={handleSearch}
maxQueryLength={MAX_QUERY_LENGTH}
/>
)}
</ActionBar>
);
}
const ActionBar = styled('div')`
display: grid;
gap: ${space(1)};
grid-template-columns: min-content auto;
padding: ${space(1)} ${space(1)};
background-color: ${p => p.theme.background};
`;
interface ProfileSummaryPageProps {
location: Location;
params: {
projectId?: Project['slug'];
};
selection: PageFilters;
view: 'flamegraph' | 'profile list';
}
function ProfileSummaryPage(props: ProfileSummaryPageProps) {
const organization = useOrganization();
const project = useCurrentProjectFromRouteParam();
const {selection} = usePageFilters();
const profilingUsingTransactions = organization.features.includes(
'profiling-using-transactions'
);
const transaction = decodeScalar(props.location.query.transaction);
if (!transaction) {
throw new TypeError(
`Profile summary requires a transaction query params, got ${
transaction?.toString() ?? transaction
}`
);
}
const rawQuery = decodeScalar(props.location?.query?.query, '');
const projectIds: number[] = useMemo(() => {
if (!defined(project)) {
return [];
}
const projects = parseInt(project.id, 10);
if (isNaN(projects)) {
return [];
}
return [projects];
}, [project]);
const projectSlugs: string[] = useMemo(() => {
return defined(project) ? [project.slug] : [];
}, [project]);
const query = useMemo(() => {
const search = new MutableSearch(rawQuery);
if (defined(transaction)) {
search.setFilterValues('transaction', [transaction]);
}
// 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, transaction]);
const {data, isLoading, isError} = useAggregateFlamegraphQuery({
transaction,
environments: selection.environments,
projects: selection.projects,
datetime: selection.datetime,
});
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 location = useLocation();
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 onSetView = useCallback(
(newView: 'flamegraph' | 'profiles') => {
setView(newView);
browserHistory.push({
...location,
query: {
...location.query,
view: newView,
},
});
},
[location]
);
const onHideRegressionsClick = useCallback(() => {
return setHideRegressions(!hideRegressions);
}, [hideRegressions, setHideRegressions]);
return (
<SentryDocumentTitle
title={t('Profiling \u2014 Profile Summary')}
orgSlug={organization.slug}
>
<ProfileSummaryContainer>
<PageFiltersContainer
shouldForceProject={defined(project)}
forceProject={project}
specificProjectSlugs={projectSlugs}
defaultSelection={
profilingUsingTransactions
? {datetime: DEFAULT_PROFILING_DATETIME_SELECTION}
: undefined
}
>
<ProfileSummaryHeader
view={view}
onViewChange={onSetView}
organization={organization}
location={props.location}
project={project}
query={query}
transaction={transaction}
/>
<ProfileFilters
projectIds={projectIds}
organization={organization}
location={props.location}
query={rawQuery}
selection={props.selection}
transaction={transaction}
usingTransactions={profilingUsingTransactions}
/>
<ProfilesSummaryChart
referrer="api.profiling.profile-summary-chart"
query={query}
hideCount
/>
{view === 'profiles' ? (
<ProfilesTable />
) : (
<ProfileVisualizationContainer hideRegressions={hideRegressions}>
<ProfileVisualization>
<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>
</ProfileVisualization>
{hideRegressions ? null : (
<ProfileDigestContainer>
<ProfileDigestScrollContainer>
<ProfileDigest onViewChange={onSetView} />
<MostRegressedProfileFunctions transaction={transaction} />
<SlowestProfileFunctions transaction={transaction} />
</ProfileDigestScrollContainer>
</ProfileDigestContainer>
)}
</ProfileVisualizationContainer>
)}
</PageFiltersContainer>
</ProfileSummaryContainer>
</SentryDocumentTitle>
);
}
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;
`;
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
onChange={onFrameFilterChange}
value={props.frameFilter}
size="xs"
options={frameSelectOptions}
/>
<Button
size="xs"
onClick={props.onHideRegressionsClick}
title={t('Expand or collapse the view')}
>
<IconPanel size="xs" direction="right" />
</Button>
</AggregateFlamegraphToolbarContainer>
);
}
const ViewSelectContainer = styled('div')`
min-width: 160px;
`;
const AggregateFlamegraphToolbarContainer = styled('div')`
display: flex;
justify-content: space-between;
gap: ${space(1)};
padding: ${space(1)} ${space(0.5)};
background-color: ${p => p.theme.background};
/*
force height to be the same as profile digest header,
but subtract 1px for the border that doesnt exist on the header
*/
height: 41px;
`;
const AggregateFlamegraphSearch = styled(FlamegraphSearch)`
max-width: 300px;
`;
const ProfileVisualization = styled('div')`
grid-area: visualization;
position: relative;
height: 100%;
`;
const ProfileDigestContainer = styled('div')`
grid-area: digest;
border-left: 1px solid ${p => p.theme.border};
background-color: ${p => p.theme.background};
display: flex;
flex: 1 1 100%;
flex-direction: column;
position: relative;
overflow: hidden;
`;
const ProfileDigestScrollContainer = styled('div')`
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
`;
const ProfileVisualizationContainer = styled('div')<{hideRegressions}>`
display: grid;
/* false positive for grid layout */
/* stylelint-disable */
grid-template-areas: ${p =>
p.hideRegressions ? "'visualization'" : "'visualization digest'"};
grid-template-columns: ${p => (p.hideRegressions ? `100%` : `60% 40%`)};
flex: 1 1 100%;
`;
const ProfileSummaryContainer = styled('div')`
display: flex;
flex-direction: column;
flex: 1 1 100%;
/*
* The footer component is a sibling of this div.
* Remove it so the flamegraph can take up the
* entire screen.
*/
~ footer {
display: none;
}
`;
const PROFILE_DIGEST_FIELDS = [
'last_seen()',
'p75()',
'p95()',
'p99()',
'count()',
] satisfies ProfilingFieldType[];
const percentiles = ['p75()', 'p95()', 'p99()'] as const;
interface ProfileDigestProps {
onViewChange: (newView: 'flamegraph' | 'profiles') => void;
}
function ProfileDigest(props: ProfileDigestProps) {
const location = useLocation();
const organization = useOrganization();
const project = useCurrentProjectFromRouteParam();
const profilesCursor = useMemo(
() => decodeScalar(location.query.cursor),
[location.query.cursor]
);
const profiles = useProfileEvents<ProfilingFieldType>({
cursor: profilesCursor,
fields: PROFILE_DIGEST_FIELDS,
query: '',
sort: {key: 'last_seen()', order: 'desc'},
referrer: 'api.profiling.profile-summary-table',
});
const data = profiles.data?.data?.[0];
const latestProfile = useProfileEvents<ProfilingFieldType>({
cursor: profilesCursor,
fields: ['profile.id', 'timestamp'],
query: '',
sort: {key: 'timestamp', order: 'desc'},
limit: 1,
referrer: 'api.profiling.profile-summary-table',
});
const profile = latestProfile.data?.data?.[0];
const flamegraphTarget =
project && profile
? generateProfileFlamechartRoute({
orgSlug: organization.slug,
projectSlug: project.slug,
profileId: profile?.['profile.id'] as string,
})
: undefined;
return (
<ProfileDigestHeader>
<div>
<ProfileDigestLabel>{t('Last Seen')}</ProfileDigestLabel>
<div>
{profiles.isLoading ? (
''
) : profiles.isError ? (
''
) : flamegraphTarget ? (
<Link to={flamegraphTarget}>
<DateTime date={new Date(data?.['last_seen()'] as string)} />
</Link>
) : (
<DateTime date={new Date(data?.['last_seen()'] as string)} />
)}
</div>
</div>
{percentiles.map(p => {
return (
<ProfileDigestColumn key={p}>
<ProfileDigestLabel>{p}</ProfileDigestLabel>
<div>
{profiles.isLoading ? (
''
) : profiles.isError ? (
''
) : (
<PerformanceDuration nanoseconds={data?.[p] as number} abbreviation />
)}
</div>
</ProfileDigestColumn>
);
})}
<ProfileDigestColumn>
<ProfileDigestLabel>{t('profiles')}</ProfileDigestLabel>
<div>
{profiles.isLoading ? (
''
) : profiles.isError ? (
''
) : (
<Link onClick={() => props.onViewChange('profiles')} to="">
<Count value={data?.['count()'] as number} />
</Link>
)}
</div>
</ProfileDigestColumn>
</ProfileDigestHeader>
);
}
const ProfileDigestColumn = styled('div')`
text-align: right;
`;
const ProfileDigestHeader = styled('div')`
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 ${space(1)};
border-bottom: 1px solid ${p => p.theme.border};
/* force height to be same as toolbar */
height: 42px;
flex-shrink: 0;
`;
const ProfileDigestLabel = styled('span')`
color: ${p => p.theme.textColor};
font-size: ${p => p.theme.fontSizeSmall};
font-weight: 600;
text-transform: uppercase;
`;
export default function ProfileSummaryPageToggle(props: ProfileSummaryPageProps) {
return (
<ProfileSummaryContainer data-test-id="profile-summary-redesign">
<ErrorBoundary>
<ProfileSummaryPage {...props} />
</ErrorBoundary>
</ProfileSummaryContainer>
);
}