import {useCallback, useMemo, useState} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import type {SelectOption} from 'sentry/components/compactSelect'; import {CompactSelect} from 'sentry/components/compactSelect'; import Link from 'sentry/components/links/link'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import Pagination from 'sentry/components/pagination'; import PerformanceDuration from 'sentry/components/performanceDuration'; import {TextTruncateOverflow} from 'sentry/components/profiling/textTruncateOverflow'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {browserHistory} from 'sentry/utils/browserHistory'; import {formatPercentage} from 'sentry/utils/number/formatPercentage'; import type {FunctionTrend, TrendType} from 'sentry/utils/profiling/hooks/types'; import {useCurrentProjectFromRouteParam} from 'sentry/utils/profiling/hooks/useCurrentProjectFromRouteParam'; import {useProfileFunctionTrends} from 'sentry/utils/profiling/hooks/useProfileFunctionTrends'; import { generateProfileDifferentialFlamegraphRouteWithQuery, generateProfileFlamechartRouteWithQuery, } from 'sentry/utils/profiling/routes'; import {relativeChange} from 'sentry/utils/profiling/units/units'; import {decodeScalar} from 'sentry/utils/queryString'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {ProfilingSparklineChart} from './profilingSparklineChart'; const REGRESSED_FUNCTIONS_LIMIT = 5; const REGRESSED_FUNCTIONS_CURSOR = 'functionRegressionCursor'; function trendToPoints(trend: FunctionTrend): {timestamp: number; value: number}[] { if (!trend.stats.data.length) { return []; } return trend.stats.data.map(p => { return { timestamp: p[0], value: p[1][0].count, }; }); } function findBreakPointIndex(breakpoint: number, worst: FunctionTrend['worst']): number { let low = 0; let high = worst.length - 1; let mid = 0; let bestMatch: number = worst.length; // eslint-disable-next-line while (low <= high) { mid = Math.floor((low + high) / 2); const value = worst[mid][0]; if (breakpoint === value) { return mid; } if (breakpoint > value) { low = mid + 1; bestMatch = mid + 1; } else if (breakpoint < value) { high = mid - 1; bestMatch = mid; } } // We dont need an exact match as the breakpoint is not guaranteed to be // in the worst array, so we return the closest index return bestMatch; } function findWorstProfileIDBeforeAndAfter(trend: FunctionTrend): { after: string | null; before: string | null; } { const breakPointIndex = findBreakPointIndex(trend.breakpoint, trend.worst); let beforeProfileID: string | null = null; let afterProfileID: string | null = null; const STABILITY_WINDOW = 2 * 60 * 1000; for (let i = breakPointIndex; i >= 0; i--) { if (!defined(trend.worst[i])) { continue; } if (trend.worst[i][0] < trend.breakpoint - STABILITY_WINDOW) { break; } beforeProfileID = trend.worst[i][1]; } for (let i = breakPointIndex; i < trend.worst.length; i++) { if (!defined(trend.worst[i])) { continue; } if (trend.worst[i][0] > trend.breakpoint + STABILITY_WINDOW) { break; } afterProfileID = trend.worst[i][1]; } return { before: beforeProfileID, after: afterProfileID, }; } interface MostRegressedProfileFunctionsProps { transaction: string; } export function MostRegressedProfileFunctions(props: MostRegressedProfileFunctionsProps) { const organization = useOrganization(); const project = useCurrentProjectFromRouteParam(); const location = useLocation(); const theme = useTheme(); const fnTrendCursor = useMemo( () => decodeScalar(location.query[REGRESSED_FUNCTIONS_CURSOR]), [location.query] ); const handleRegressedFunctionsCursor = useCallback((cursor, pathname, query) => { browserHistory.push({ pathname, query: {...query, [REGRESSED_FUNCTIONS_CURSOR]: cursor}, }); }, []); const functionQuery = useMemo(() => { const conditions = new MutableSearch(''); conditions.setFilterValues('is_application', ['1']); conditions.setFilterValues('transaction', [props.transaction]); return conditions.formatString(); }, [props.transaction]); const [trendType, setTrendType] = useState('regression'); const trendsQuery = useProfileFunctionTrends({ trendFunction: 'p95()', trendType, query: functionQuery, limit: REGRESSED_FUNCTIONS_LIMIT, cursor: fnTrendCursor, }); const trends = trendsQuery?.data ?? []; const onChangeTrendType = useCallback(v => setTrendType(v.value), []); const hasDifferentialFlamegraphPageFeature = false && organization.features.includes('profiling-differential-flamegraph-page'); return ( {trendsQuery.isLoading ? ( ) : trendsQuery.isError ? ( {t('Failed to fetch regressed functions')} ) : !trends.length ? ( {trendType === 'regression' ? (

{t('No regressed functions detected')}

) : (

{t('No improved functions detected')}

)}
) : ( trends.map((fn, i) => { const {before, after} = findWorstProfileIDBeforeAndAfter(fn); return ( {hasDifferentialFlamegraphPageFeature ? ( ) : ( )}
{fn.package}
{/* We dont handle improvements as formatPercentage and relativeChange on lines below dont return absolute values, else we end up with a double sign */} {trendType === 'regression' ? fn.aggregate_range_1 < fn.aggregate_range_2 ? '+' : '-' : null} {formatPercentage( relativeChange(fn.aggregate_range_2, fn.aggregate_range_1) )}
); }) )}
); } interface RegressedFunctionDifferentialFlamegraphProps { fn: FunctionTrend; organization: Organization; project: Project | null; transaction: string; } function RegressedFunctionDifferentialFlamegraph( props: RegressedFunctionDifferentialFlamegraphProps ) { const onRegressedFunctionClick = useCallback(() => { trackAnalytics('profiling_views.go_to_differential_flamegraph', { organization: props.organization, source: `profiling_transaction.regressed_functions_table`, }); }, [props.organization]); const differentialFlamegraphLink = generateProfileDifferentialFlamegraphRouteWithQuery({ orgSlug: props.organization.slug, projectSlug: props.project?.slug ?? '', transaction: props.transaction, fingerprint: props.fn.fingerprint, breakpoint: props.fn.breakpoint, query: { // specify the frame to focus, the flamegraph will switch // to the appropriate thread when these are specified frameName: props.fn.function as string, framePackage: props.fn.package as string, }, }); return (
{props.fn.function}
{' \u2192 '}
); } interface RegressedFunctionBeforeAfterProps { after: string | null; before: string | null; fn: FunctionTrend; organization: Organization; project: Project | null; } function RegressedFunctionBeforeAfterFlamechart( props: RegressedFunctionBeforeAfterProps ) { const onRegressedFunctionClick = useCallback(() => { trackAnalytics('profiling_views.go_to_flamegraph', { organization: props.organization, source: `profiling_transaction.regressed_functions_table`, }); }, [props.organization]); let rendered = {props.fn.function}; if (defined(props.fn['examples()']?.[0])) { rendered = ( {rendered} ); } let before = ( ); if (props.before) { before = ( {before} ); } let after = ( ); if (props.after) { after = ( {after} ); } return (
{rendered}
{before} {' \u2192 '} {after}
); } const ChangeArrow = styled('span')` color: ${p => p.theme.subText}; `; const RegressedFunctionsTypeSelect = styled(CompactSelect)` button { margin: 0; padding: 0; } `; const RegressedFunctionSparklineContainer = styled('div')``; const RegressedFunctionRow = styled('div')` position: relative; margin-bottom: ${space(1)}; `; const RegressedFunctionMainRow = styled('div')` display: flex; align-items: center; justify-content: space-between; > div:first-child { min-width: 0; } > div:last-child { white-space: nowrap; } `; const RegressedFunctionMetricsRow = styled('div')` display: flex; align-items: center; justify-content: space-between; font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.subText}; margin-top: ${space(0.25)}; `; const RegressedFunctionsContainer = styled('div')` flex-basis: 80px; padding: 0 ${space(1)}; border-bottom: 1px solid ${p => p.theme.border}; `; const RegressedFunctionsPagination = styled(Pagination)` margin: 0; button { height: 16px; width: 16px; min-width: 16px; min-height: 16px; svg { width: 10px; height: 10px; } } `; const RegressedFunctionsTitleContainer = styled('div')` display: flex; align-items: center; justify-content: space-between; margin-bottom: ${space(0.5)}; margin-top: ${space(0.5)}; `; const RegressedFunctionsQueryState = styled('div')` text-align: center; padding: ${space(2)} ${space(0.5)}; color: ${p => p.theme.subText}; `; const TRIGGER_PROPS = {borderless: true, size: 'zero' as const}; const TREND_FUNCTION_OPTIONS: SelectOption[] = [ { label: t('Most Regressed Functions'), value: 'regression' as const, }, { label: t('Most Improved Functions'), value: 'improvement' as const, }, ];