mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
514 lines
14 KiB
TypeScript
514 lines
14 KiB
TypeScript
import type {ReactNode} from 'react';
|
|
import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
|
|
import {browserHistory} from 'react-router';
|
|
import type {Theme} from '@emotion/react';
|
|
import {useTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import partition from 'lodash/partition';
|
|
|
|
import {Button} from 'sentry/components/button';
|
|
import ChartZoom from 'sentry/components/charts/chartZoom';
|
|
import {LineChart} from 'sentry/components/charts/lineChart';
|
|
import Count from 'sentry/components/count';
|
|
import EmptyStateWarning from 'sentry/components/emptyStateWarning';
|
|
import IdBadge from 'sentry/components/idBadge';
|
|
import Link from 'sentry/components/links/link';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import type {CursorHandler} from 'sentry/components/pagination';
|
|
import Pagination from 'sentry/components/pagination';
|
|
import PerformanceDuration from 'sentry/components/performanceDuration';
|
|
import TextOverflow from 'sentry/components/textOverflow';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {IconArrow, IconChevron, IconWarning} from 'sentry/icons';
|
|
import {t, tct} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Series} from 'sentry/types/echarts';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
|
|
import type {FunctionTrend, TrendType} from 'sentry/utils/profiling/hooks/types';
|
|
import {useProfileFunctionTrends} from 'sentry/utils/profiling/hooks/useProfileFunctionTrends';
|
|
import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
|
|
import {decodeScalar} from 'sentry/utils/queryString';
|
|
import {useLocation} from 'sentry/utils/useLocation';
|
|
import useOrganization from 'sentry/utils/useOrganization';
|
|
import usePageFilters from 'sentry/utils/usePageFilters';
|
|
import useProjects from 'sentry/utils/useProjects';
|
|
import useRouter from 'sentry/utils/useRouter';
|
|
|
|
import {
|
|
Accordion,
|
|
AccordionItem,
|
|
ContentContainer,
|
|
HeaderContainer,
|
|
HeaderTitleLegend,
|
|
StatusContainer,
|
|
Subtitle,
|
|
WidgetContainer,
|
|
} from './styles';
|
|
|
|
const MAX_FUNCTIONS = 3;
|
|
const DEFAULT_CURSOR_NAME = 'fnTrendCursor';
|
|
|
|
interface FunctionTrendsWidgetProps {
|
|
trendFunction: 'p50()' | 'p75()' | 'p95()' | 'p99()';
|
|
trendType: TrendType;
|
|
cursorName?: string;
|
|
header?: ReactNode;
|
|
userQuery?: string;
|
|
widgetHeight?: string;
|
|
}
|
|
|
|
export function FunctionTrendsWidget({
|
|
cursorName = DEFAULT_CURSOR_NAME,
|
|
header,
|
|
trendFunction,
|
|
trendType,
|
|
widgetHeight,
|
|
userQuery,
|
|
}: FunctionTrendsWidgetProps) {
|
|
const location = useLocation();
|
|
|
|
const [expandedIndex, setExpandedIndex] = useState(0);
|
|
|
|
const fnTrendCursor = useMemo(
|
|
() => decodeScalar(location.query[cursorName]),
|
|
[cursorName, location.query]
|
|
);
|
|
|
|
const handleCursor = useCallback(
|
|
(cursor, pathname, query) => {
|
|
browserHistory.push({
|
|
pathname,
|
|
query: {...query, [cursorName]: cursor},
|
|
});
|
|
},
|
|
[cursorName]
|
|
);
|
|
|
|
const trendsQuery = useProfileFunctionTrends({
|
|
trendFunction,
|
|
trendType,
|
|
query: userQuery,
|
|
limit: MAX_FUNCTIONS,
|
|
cursor: fnTrendCursor,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setExpandedIndex(0);
|
|
}, [trendsQuery.data]);
|
|
|
|
const hasTrends = (trendsQuery.data?.length || 0) > 0;
|
|
const isLoading = trendsQuery.isLoading;
|
|
const isError = trendsQuery.isError;
|
|
|
|
return (
|
|
<WidgetContainer height={widgetHeight}>
|
|
<FunctionTrendsWidgetHeader
|
|
header={header}
|
|
handleCursor={handleCursor}
|
|
pageLinks={trendsQuery.getResponseHeader?.('Link') ?? null}
|
|
trendType={trendType}
|
|
/>
|
|
<ContentContainer>
|
|
{isLoading && (
|
|
<StatusContainer>
|
|
<LoadingIndicator />
|
|
</StatusContainer>
|
|
)}
|
|
{isError && (
|
|
<StatusContainer>
|
|
<IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
|
|
</StatusContainer>
|
|
)}
|
|
{!isError && !isLoading && !hasTrends && (
|
|
<EmptyStateWarning>
|
|
{trendType === 'regression' ? (
|
|
<p>{t('No regressed functions detected')}</p>
|
|
) : (
|
|
<p>{t('No improved functions detected')}</p>
|
|
)}
|
|
</EmptyStateWarning>
|
|
)}
|
|
{hasTrends && (
|
|
<Accordion>
|
|
{(trendsQuery.data ?? []).map((f, i, l) => {
|
|
return (
|
|
<FunctionTrendsEntry
|
|
key={`${f.project}-${f.function}-${f.package}`}
|
|
trendFunction={trendFunction}
|
|
trendType={trendType}
|
|
isExpanded={i === expandedIndex}
|
|
setExpanded={() => {
|
|
const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
|
|
setExpandedIndex(nextIndex);
|
|
}}
|
|
func={f}
|
|
/>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
)}
|
|
</ContentContainer>
|
|
</WidgetContainer>
|
|
);
|
|
}
|
|
|
|
interface FunctionTrendsWidgetHeaderProps {
|
|
handleCursor: CursorHandler;
|
|
header: ReactNode;
|
|
pageLinks: string | null;
|
|
trendType: TrendType;
|
|
}
|
|
|
|
function FunctionTrendsWidgetHeader({
|
|
handleCursor,
|
|
header,
|
|
pageLinks,
|
|
trendType,
|
|
}: FunctionTrendsWidgetHeaderProps) {
|
|
switch (trendType) {
|
|
case 'regression':
|
|
return (
|
|
<HeaderContainer>
|
|
{header ?? (
|
|
<HeaderTitleLegend>{t('Most Regressed Functions')}</HeaderTitleLegend>
|
|
)}
|
|
<Subtitle>{t('Functions by most regressed.')}</Subtitle>
|
|
<StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
|
|
</HeaderContainer>
|
|
);
|
|
case 'improvement':
|
|
return (
|
|
<HeaderContainer>
|
|
{header ?? (
|
|
<HeaderTitleLegend>{t('Most Improved Functions')}</HeaderTitleLegend>
|
|
)}
|
|
<Subtitle>{t('Functions by most improved.')}</Subtitle>
|
|
<StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
|
|
</HeaderContainer>
|
|
);
|
|
default:
|
|
throw new Error(t('Unknown trend type'));
|
|
}
|
|
}
|
|
|
|
interface FunctionTrendsEntryProps {
|
|
func: FunctionTrend;
|
|
isExpanded: boolean;
|
|
setExpanded: () => void;
|
|
trendFunction: string;
|
|
trendType: TrendType;
|
|
}
|
|
|
|
function FunctionTrendsEntry({
|
|
func,
|
|
isExpanded,
|
|
setExpanded,
|
|
trendFunction,
|
|
trendType,
|
|
}: FunctionTrendsEntryProps) {
|
|
const organization = useOrganization();
|
|
const {projects} = useProjects();
|
|
const project = projects.find(p => p.id === func.project);
|
|
|
|
const [beforeExamples, afterExamples] = useMemo(() => {
|
|
return partition(func.worst, ([ts, _example]) => ts <= func.breakpoint);
|
|
}, [func]);
|
|
|
|
let before = <PerformanceDuration nanoseconds={func.aggregate_range_1} abbreviation />;
|
|
let after = <PerformanceDuration nanoseconds={func.aggregate_range_2} abbreviation />;
|
|
|
|
function handleGoToProfile() {
|
|
switch (trendType) {
|
|
case 'improvement':
|
|
trackAnalytics('profiling_views.go_to_flamegraph', {
|
|
organization,
|
|
source: 'profiling.function_trends.improvement',
|
|
});
|
|
break;
|
|
case 'regression':
|
|
trackAnalytics('profiling_views.go_to_flamegraph', {
|
|
organization,
|
|
source: 'profiling.function_trends.regression',
|
|
});
|
|
break;
|
|
default:
|
|
throw new Error('Unknown trend type');
|
|
}
|
|
}
|
|
|
|
if (project && beforeExamples.length >= 2 && afterExamples.length >= 2) {
|
|
// By choosing the 2nd most recent example in each period, we guarantee the example
|
|
// occurred within the period and eliminate confusion with picking an example in
|
|
// the same bucket as the breakpoint.
|
|
|
|
const beforeTarget = generateProfileFlamechartRouteWithQuery({
|
|
orgSlug: organization.slug,
|
|
projectSlug: project.slug,
|
|
profileId: beforeExamples[beforeExamples.length - 2][1],
|
|
query: {
|
|
frameName: func.function as string,
|
|
framePackage: func.package as string,
|
|
},
|
|
});
|
|
|
|
before = (
|
|
<Link to={beforeTarget} onClick={handleGoToProfile}>
|
|
{before}
|
|
</Link>
|
|
);
|
|
|
|
const afterTarget = generateProfileFlamechartRouteWithQuery({
|
|
orgSlug: organization.slug,
|
|
projectSlug: project.slug,
|
|
profileId: afterExamples[afterExamples.length - 2][1],
|
|
query: {
|
|
frameName: func.function as string,
|
|
framePackage: func.package as string,
|
|
},
|
|
});
|
|
|
|
after = (
|
|
<Link to={afterTarget} onClick={handleGoToProfile}>
|
|
{after}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Fragment>
|
|
<StyledAccordionItem>
|
|
{project && (
|
|
<Tooltip title={project.name}>
|
|
<IdBadge project={project} avatarSize={16} hideName />
|
|
</Tooltip>
|
|
)}
|
|
<FunctionName>
|
|
<Tooltip title={func.package}>{func.function}</Tooltip>
|
|
</FunctionName>
|
|
<Tooltip
|
|
title={tct('Appeared [count] times.', {
|
|
count: <Count value={func['count()']} />,
|
|
})}
|
|
>
|
|
<DurationChange>
|
|
{before}
|
|
<IconArrow direction="right" size="xs" />
|
|
{after}
|
|
</DurationChange>
|
|
</Tooltip>
|
|
<Button
|
|
icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
|
|
aria-label={t('Expand')}
|
|
aria-expanded={isExpanded}
|
|
size="zero"
|
|
borderless
|
|
onClick={() => setExpanded()}
|
|
/>
|
|
</StyledAccordionItem>
|
|
{isExpanded && (
|
|
<FunctionTrendsChartContainer>
|
|
<FunctionTrendsChart func={func} trendFunction={trendFunction} />
|
|
</FunctionTrendsChartContainer>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
interface FunctionTrendsChartProps {
|
|
func: FunctionTrend;
|
|
trendFunction: string;
|
|
}
|
|
|
|
function FunctionTrendsChart({func, trendFunction}: FunctionTrendsChartProps) {
|
|
const {selection} = usePageFilters();
|
|
const router = useRouter();
|
|
const theme = useTheme();
|
|
|
|
const series: Series[] = useMemo(() => {
|
|
const trendSeries = {
|
|
data: func.stats.data.map(([timestamp, data]) => {
|
|
return {
|
|
name: timestamp * 1e3,
|
|
value: data[0].count / 1e6,
|
|
};
|
|
}),
|
|
seriesName: trendFunction,
|
|
color: getTrendLineColor(func.change, theme),
|
|
};
|
|
|
|
const seriesStart = func.stats.data[0][0] * 1e3;
|
|
const seriesMid = func.breakpoint * 1e3;
|
|
const seriesEnd = func.stats.data[func.stats.data.length - 1][0] * 1e3;
|
|
|
|
const dividingLine = {
|
|
data: [],
|
|
color: theme.textColor,
|
|
seriesName: 'dividing line',
|
|
markLine: {},
|
|
};
|
|
dividingLine.markLine = {
|
|
data: [{xAxis: seriesMid}],
|
|
label: {show: false},
|
|
lineStyle: {
|
|
color: theme.textColor,
|
|
type: 'solid',
|
|
width: 2,
|
|
},
|
|
symbol: ['none', 'none'],
|
|
tooltip: {
|
|
show: false,
|
|
},
|
|
silent: true,
|
|
};
|
|
|
|
const beforeLine = {
|
|
data: [],
|
|
color: theme.textColor,
|
|
seriesName: 'before line',
|
|
markLine: {},
|
|
};
|
|
beforeLine.markLine = {
|
|
data: [
|
|
[
|
|
{value: 'Past', coord: [seriesStart, func.aggregate_range_1 / 1e6]},
|
|
{coord: [seriesMid, func.aggregate_range_1 / 1e6]},
|
|
],
|
|
],
|
|
label: {
|
|
fontSize: 11,
|
|
show: true,
|
|
color: theme.textColor,
|
|
silent: true,
|
|
formatter: 'Past',
|
|
position: 'insideStartTop',
|
|
},
|
|
lineStyle: {
|
|
color: theme.textColor,
|
|
type: 'dashed',
|
|
width: 1,
|
|
},
|
|
symbol: ['none', 'none'],
|
|
tooltip: {
|
|
formatter: getTooltipFormatter(t('Past Baseline'), func.aggregate_range_1),
|
|
},
|
|
};
|
|
|
|
const afterLine = {
|
|
data: [],
|
|
color: theme.textColor,
|
|
seriesName: 'after line',
|
|
markLine: {},
|
|
};
|
|
afterLine.markLine = {
|
|
data: [
|
|
[
|
|
{
|
|
value: 'Present',
|
|
coord: [seriesMid, func.aggregate_range_2 / 1e6],
|
|
},
|
|
{coord: [seriesEnd, func.aggregate_range_2 / 1e6]},
|
|
],
|
|
],
|
|
label: {
|
|
fontSize: 11,
|
|
show: true,
|
|
color: theme.textColor,
|
|
silent: true,
|
|
formatter: 'Present',
|
|
position: 'insideEndBottom',
|
|
},
|
|
lineStyle: {
|
|
color: theme.textColor,
|
|
type: 'dashed',
|
|
width: 1,
|
|
},
|
|
symbol: ['none', 'none'],
|
|
tooltip: {
|
|
formatter: getTooltipFormatter(t('Present Baseline'), func.aggregate_range_2),
|
|
},
|
|
};
|
|
|
|
return [trendSeries, dividingLine, beforeLine, afterLine];
|
|
}, [func, trendFunction, theme]);
|
|
|
|
const chartOptions = useMemo(() => {
|
|
return {
|
|
height: 150,
|
|
grid: {
|
|
top: '10px',
|
|
bottom: '10px',
|
|
left: '10px',
|
|
right: '10px',
|
|
},
|
|
yAxis: {
|
|
axisLabel: {
|
|
color: theme.chartLabel,
|
|
formatter: (value: number) => axisLabelFormatter(value, 'duration'),
|
|
},
|
|
},
|
|
xAxis: {
|
|
type: 'time' as const,
|
|
},
|
|
tooltip: {
|
|
valueFormatter: (value: number) => tooltipFormatter(value, 'duration'),
|
|
},
|
|
};
|
|
}, [theme.chartLabel]);
|
|
|
|
return (
|
|
<ChartZoom router={router} {...selection.datetime}>
|
|
{zoomRenderProps => (
|
|
<LineChart {...zoomRenderProps} {...chartOptions} series={series} />
|
|
)}
|
|
</ChartZoom>
|
|
);
|
|
}
|
|
|
|
function getTrendLineColor(trend: TrendType, theme: Theme) {
|
|
switch (trend) {
|
|
case 'improvement':
|
|
return theme.green300;
|
|
case 'regression':
|
|
return theme.red300;
|
|
default:
|
|
throw new Error('Unknown trend type');
|
|
}
|
|
}
|
|
|
|
function getTooltipFormatter(label: string, baseline: number) {
|
|
return [
|
|
'<div class="tooltip-series tooltip-series-solo">',
|
|
'<div>',
|
|
`<span class="tooltip-label"><strong>${label}</strong></span>`,
|
|
tooltipFormatter(baseline / 1e6, 'duration'),
|
|
'</div>',
|
|
'</div>',
|
|
'<div class="tooltip-arrow"></div>',
|
|
].join('');
|
|
}
|
|
|
|
const StyledPagination = styled(Pagination)`
|
|
margin: 0;
|
|
`;
|
|
|
|
const StyledAccordionItem = styled(AccordionItem)`
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto auto;
|
|
`;
|
|
|
|
const FunctionName = styled(TextOverflow)`
|
|
flex: 1 1 auto;
|
|
`;
|
|
|
|
const FunctionTrendsChartContainer = styled('div')`
|
|
flex: 1 1 auto;
|
|
`;
|
|
|
|
const DurationChange = styled('span')`
|
|
color: ${p => p.theme.gray300};
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${space(1)};
|
|
`;
|