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)}; `;