123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- 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 (
- <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.examples, ([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 = generateProfileRouteFromProfileReference({
- orgSlug: organization.slug,
- projectSlug: project.slug,
- reference: beforeExamples[beforeExamples.length - 2][1],
- frameName: func.function as string,
- framePackage: func.package as string,
- });
- before = (
- <Link to={beforeTarget} onClick={handleGoToProfile}>
- {before}
- </Link>
- );
- 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 = (
- <Link to={afterTarget} onClick={handleGoToProfile}>
- {after}
- </Link>
- );
- }
- return (
- <Fragment>
- <StyledAccordionItem>
- <Button
- icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
- aria-label={t('Expand')}
- aria-expanded={isExpanded}
- size="zero"
- borderless
- onClick={() => setExpanded()}
- />
- {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>
- </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 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 {...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 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)};
- `;
|