123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- import type {CSSProperties, ReactNode} from 'react';
- import {Fragment, useCallback, useMemo, useState} from 'react';
- import styled from '@emotion/styled';
- import {Button} from 'sentry/components/button';
- 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 Pagination from 'sentry/components/pagination';
- import PerformanceDuration from 'sentry/components/performanceDuration';
- import ScoreBar from 'sentry/components/scoreBar';
- import TextOverflow from 'sentry/components/textOverflow';
- import {Tooltip} from 'sentry/components/tooltip';
- import {CHART_PALETTE} from 'sentry/constants/chartPalette';
- import {IconChevron, IconWarning} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {browserHistory} from 'sentry/utils/browserHistory';
- import {Frame} from 'sentry/utils/profiling/frame';
- import type {EventsResultsDataRow} from 'sentry/utils/profiling/hooks/types';
- import {useProfileFunctions} from 'sentry/utils/profiling/hooks/useProfileFunctions';
- import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
- 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 useProjects from 'sentry/utils/useProjects';
- import {
- Accordion,
- AccordionItem,
- ContentContainer,
- HeaderContainer,
- HeaderTitleLegend,
- StatusContainer,
- Subtitle,
- WidgetContainer,
- } from './styles';
- const MAX_FUNCTIONS = 3;
- const DEFAULT_CURSOR_NAME = 'slowFnCursor';
- type BreakdownFunction = 'avg()' | 'p50()' | 'p75()' | 'p95()' | 'p99()';
- interface SlowestFunctionsWidgetProps {
- breakdownFunction: BreakdownFunction;
- cursorName?: string;
- header?: ReactNode;
- userQuery?: string;
- widgetHeight?: string;
- }
- export function SlowestFunctionsWidget({
- breakdownFunction,
- cursorName = DEFAULT_CURSOR_NAME,
- header,
- userQuery,
- widgetHeight,
- }: SlowestFunctionsWidgetProps) {
- const location = useLocation();
- const [expandedIndex, setExpandedIndex] = useState(0);
- const slowFnCursor = useMemo(
- () => decodeScalar(location.query[cursorName]),
- [cursorName, location.query]
- );
- const handleCursor = useCallback(
- (cursor, pathname, query) => {
- browserHistory.push({
- pathname,
- query: {...query, [cursorName]: cursor},
- });
- },
- [cursorName]
- );
- const functionsQuery = useProfileFunctions<FunctionsField>({
- fields: functionsFields,
- referrer: 'api.profiling.suspect-functions.list',
- sort: {
- key: 'sum()',
- order: 'desc',
- },
- query: userQuery,
- limit: MAX_FUNCTIONS,
- cursor: slowFnCursor,
- });
- const hasFunctions = (functionsQuery.data?.data?.length || 0) > 0;
- const totalsQuery = useProfileFunctions<TotalsField>({
- fields: totalsFields,
- referrer: 'api.profiling.suspect-functions.totals',
- sort: {
- key: 'sum()',
- order: 'desc',
- },
- query: userQuery,
- limit: MAX_FUNCTIONS,
- // make sure to query for the projects from the top functions
- projects: functionsQuery.isFetched
- ? [
- ...new Set(
- (functionsQuery.data?.data ?? []).map(func => func['project.id'] as number)
- ),
- ]
- : [],
- enabled: functionsQuery.isFetched && hasFunctions,
- });
- const isLoading = functionsQuery.isLoading || (hasFunctions && totalsQuery.isLoading);
- const isError = functionsQuery.isError || totalsQuery.isError;
- return (
- <WidgetContainer height={widgetHeight}>
- <HeaderContainer>
- {header ?? <HeaderTitleLegend>{t('Slowest Functions')}</HeaderTitleLegend>}
- <Subtitle>{t('Slowest functions by total self time spent.')}</Subtitle>
- <StyledPagination
- pageLinks={functionsQuery.getResponseHeader?.('Link') ?? null}
- size="xs"
- onCursor={handleCursor}
- />
- </HeaderContainer>
- <ContentContainer>
- {isLoading && (
- <StatusContainer>
- <LoadingIndicator />
- </StatusContainer>
- )}
- {isError && (
- <StatusContainer>
- <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
- </StatusContainer>
- )}
- {!isError && !isLoading && !hasFunctions && (
- <EmptyStateWarning>
- <p>{t('No functions found')}</p>
- </EmptyStateWarning>
- )}
- {hasFunctions && totalsQuery.isFetched && (
- <StyledAccordion>
- {(functionsQuery.data?.data ?? []).map((f, i, l) => {
- const projectEntry = totalsQuery.data?.data?.find(
- row => row['project.id'] === f['project.id']
- );
- const projectTotalDuration = projectEntry?.['sum()'] ?? f['sum()'];
- return (
- <SlowestFunctionEntry
- key={`${f['project.id']}-${f.package}-${f.function}`}
- breakdownFunction={breakdownFunction}
- isExpanded={i === expandedIndex}
- setExpanded={() => {
- const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
- setExpandedIndex(nextIndex);
- }}
- func={f}
- totalDuration={projectTotalDuration as number}
- query={userQuery ?? ''}
- />
- );
- })}
- </StyledAccordion>
- )}
- </ContentContainer>
- </WidgetContainer>
- );
- }
- interface SlowestFunctionEntryProps {
- breakdownFunction: BreakdownFunction;
- func: EventsResultsDataRow<FunctionsField>;
- isExpanded: boolean;
- query: string;
- setExpanded: () => void;
- totalDuration: number;
- }
- const BARS = 10;
- function SlowestFunctionEntry({
- breakdownFunction,
- func,
- isExpanded,
- query,
- setExpanded,
- totalDuration,
- }: SlowestFunctionEntryProps) {
- const organization = useOrganization();
- const {projects} = useProjects();
- const project = projects.find(p => p.id === String(func['project.id']));
- const score = Math.ceil((((func['sum()'] as number) ?? 0) / totalDuration) * BARS);
- const palette = new Array(BARS).fill([CHART_PALETTE[0][0]]);
- const frame = useMemo(() => {
- return new Frame(
- {
- key: 0,
- name: func.function as string,
- package: func.package as string,
- },
- // Ensures that the frame runs through the normalization code path
- project?.platform && /node|javascript/.test(project.platform)
- ? project.platform
- : undefined,
- 'aggregate'
- );
- }, [func, project]);
- const userQuery = useMemo(() => {
- const conditions = new MutableSearch(query);
- conditions.setFilterValues('project.id', [String(func['project.id'])]);
- // it is more efficient to filter on the fingerprint
- // than it is to filter on the package + function
- conditions.setFilterValues('fingerprint', [String(func.fingerprint)]);
- return conditions.formatString();
- }, [func, query]);
- const functionTransactionsQuery = useProfileFunctions<FunctionTransactionField>({
- fields: [...functionTransactionsFields, breakdownFunction],
- referrer: 'api.profiling.suspect-functions.transactions',
- sort: {
- key: 'sum()',
- order: 'desc',
- },
- query: userQuery,
- limit: 5,
- enabled: isExpanded,
- });
- return (
- <Fragment>
- <StyledAccordionItem>
- {project && (
- <Tooltip title={project.name}>
- <IdBadge project={project} avatarSize={16} hideName />
- </Tooltip>
- )}
- <FunctionName>
- <Tooltip title={frame.package}>{frame.name}</Tooltip>
- </FunctionName>
- <Tooltip
- title={tct('Appeared [count] times for a total time spent of [totalSelfTime]', {
- count: <Count value={func['count()'] as number} />,
- totalSelfTime: (
- <PerformanceDuration nanoseconds={func['sum()'] as number} abbreviation />
- ),
- })}
- >
- <ScoreBar score={score} palette={palette} size={20} radius={0} />
- </Tooltip>
- <Button
- icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
- aria-label={t('Expand')}
- aria-expanded={isExpanded}
- size="zero"
- borderless
- onClick={setExpanded}
- />
- </StyledAccordionItem>
- {isExpanded && (
- <Fragment>
- {functionTransactionsQuery.isError && (
- <StatusContainer>
- <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
- </StatusContainer>
- )}
- {functionTransactionsQuery.isLoading && (
- <StatusContainer>
- <LoadingIndicator />
- </StatusContainer>
- )}
- {functionTransactionsQuery.isFetched && (
- <TransactionsList data-test-id="transactions-list">
- <TransactionsListHeader>
- <TextOverflow>{t('Transaction')}</TextOverflow>
- </TransactionsListHeader>
- <TransactionsListHeader align="right">
- <TextOverflow>{t('Count')}</TextOverflow>
- </TransactionsListHeader>
- <TransactionsListHeader align="right">
- <TextOverflow>{breakdownFunction.toUpperCase()}</TextOverflow>
- </TransactionsListHeader>
- <TransactionsListHeader align="right">
- <TextOverflow>{t('Time Spent')}</TextOverflow>
- </TransactionsListHeader>
- {(functionTransactionsQuery.data?.data ?? []).map(transaction => {
- const examples = transaction['examples()'] as string[];
- let transactionCol = <Fragment>{transaction.transaction}</Fragment>;
- if (project && examples.length) {
- const target = generateProfileFlamechartRouteWithQuery({
- orgSlug: organization.slug,
- projectSlug: project.slug,
- profileId: examples[0],
- query: {
- frameName: frame.name,
- framePackage: frame.package,
- },
- });
- transactionCol = (
- <Link
- to={target}
- onClick={() => {
- trackAnalytics('profiling_views.go_to_flamegraph', {
- organization,
- source: 'profiling.global_suspect_functions',
- });
- }}
- >
- {transactionCol}
- </Link>
- );
- }
- return (
- <Fragment key={transaction.transaction as string}>
- <TransactionsListCell>
- <TextOverflow>{transactionCol}</TextOverflow>
- </TransactionsListCell>
- <TransactionsListCell align="right">
- <Count value={transaction['count()'] as number} />
- </TransactionsListCell>
- <TransactionsListCell align="right">
- <PerformanceDuration
- nanoseconds={transaction[breakdownFunction] as number}
- abbreviation
- />
- </TransactionsListCell>
- <TransactionsListCell align="right">
- <PerformanceDuration
- nanoseconds={transaction['sum()'] as number}
- abbreviation
- />
- </TransactionsListCell>
- </Fragment>
- );
- })}
- </TransactionsList>
- )}
- </Fragment>
- )}
- </Fragment>
- );
- }
- const functionsFields = [
- 'project.id',
- 'fingerprint',
- 'package',
- 'function',
- 'count()',
- 'sum()',
- ] as const;
- type FunctionsField = (typeof functionsFields)[number];
- const totalsFields = ['project.id', 'sum()'] as const;
- type TotalsField = (typeof totalsFields)[number];
- type FunctionTransactionField =
- | BreakdownFunction
- | 'transaction'
- | 'count()'
- | 'sum()'
- | 'examples()';
- const functionTransactionsFields: FunctionTransactionField[] = [
- 'transaction',
- 'count()',
- 'sum()',
- 'examples()',
- ];
- const StyledPagination = styled(Pagination)`
- margin: 0;
- `;
- const StyledAccordion = styled(Accordion)`
- display: flex;
- flex-direction: column;
- `;
- const StyledAccordionItem = styled(AccordionItem)`
- display: grid;
- grid-template-columns: auto 1fr auto auto;
- `;
- const FunctionName = styled(TextOverflow)`
- flex: 1 1 auto;
- `;
- const TransactionsList = styled('div')`
- flex: 1 1 auto;
- display: grid;
- grid-template-columns: minmax(0, 1fr) repeat(3, auto);
- grid-template-rows: 18px repeat(5, min-content);
- column-gap: ${space(1)};
- padding: ${space(0)} ${space(2)};
- `;
- const TransactionsListHeader = styled('span')<{
- align?: CSSProperties['textAlign'];
- }>`
- text-transform: uppercase;
- font-size: ${p => p.theme.fontSizeExtraSmall};
- font-weight: ${p => p.theme.fontWeightBold};
- color: ${p => p.theme.subText};
- text-align: ${p => p.align};
- `;
- const TransactionsListCell = styled('div')<{align?: CSSProperties['textAlign']}>`
- font-size: ${p => p.theme.fontSizeSmall};
- text-align: ${p => p.align};
- padding: ${space(0.5)} 0px;
- `;
|