import type {ReactNode} from 'react';
import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
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 {browserHistory} from 'sentry/utils/browserHistory';
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 {generateProfileRouteFromProfileReference} 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 {
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.isPending;
const isError = trendsQuery.isError;
return (
{isLoading && (
)}
{isError && (
)}
{!isError && !isLoading && !hasTrends && (
{trendType === 'regression' ? (
{t('No regressed functions detected')}
) : (
{t('No improved functions detected')}
)}
)}
{hasTrends && (
{(trendsQuery.data ?? []).map((f, i, l) => {
return (
{
const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
setExpandedIndex(nextIndex);
}}
func={f}
/>
);
})}
)}
);
}
interface FunctionTrendsWidgetHeaderProps {
handleCursor: CursorHandler;
header: ReactNode;
pageLinks: string | null;
trendType: TrendType;
}
function FunctionTrendsWidgetHeader({
handleCursor,
header,
pageLinks,
trendType,
}: FunctionTrendsWidgetHeaderProps) {
switch (trendType) {
case 'regression':
return (
{header ?? (
{t('Most Regressed Functions')}
)}
{t('Functions by most regressed.')}
);
case 'improvement':
return (
{header ?? (
{t('Most Improved Functions')}
)}
{t('Functions by most improved.')}
);
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.examples, ([ts, _example]) => ts <= func.breakpoint);
}, [func]);
let before = ;
let after = ;
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 = generateProfileRouteFromProfileReference({
orgSlug: organization.slug,
projectSlug: project.slug,
reference: beforeExamples[beforeExamples.length - 2][1],
frameName: func.function as string,
framePackage: func.package as string,
});
before = (
{before}
);
const afterTarget = generateProfileRouteFromProfileReference({
orgSlug: organization.slug,
projectSlug: project.slug,
reference: afterExamples[afterExamples.length - 2][1],
frameName: func.function as string,
framePackage: func.package as string,
});
after = (
{after}
);
}
return (
}
aria-label={t('Expand')}
aria-expanded={isExpanded}
size="zero"
borderless
onClick={() => setExpanded()}
/>
{project && (
)}
{func.function}
,
})}
>
{before}
{after}
{isExpanded && (
)}
);
}
interface FunctionTrendsChartProps {
func: FunctionTrend;
trendFunction: string;
}
function FunctionTrendsChart({func, trendFunction}: FunctionTrendsChartProps) {
const {selection} = usePageFilters();
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 (
{zoomRenderProps => (
)}
);
}
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 [
'
',
'',
].join('');
}
const StyledPagination = styled(Pagination)`
margin: 0;
`;
const StyledAccordionItem = styled(AccordionItem)`
display: grid;
grid-template-columns: auto auto 1fr 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)};
`;