profileDetailsTable.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import {CompactSelect} from 'sentry/components/compactSelect';
  5. import GridEditable, {
  6. COL_WIDTH_UNDEFINED,
  7. GridColumnOrder,
  8. } from 'sentry/components/gridEditable';
  9. import Link from 'sentry/components/links/link';
  10. import Pagination from 'sentry/components/pagination';
  11. import SearchBar from 'sentry/components/searchBar';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import {Container, NumberContainer} from 'sentry/utils/discover/styles';
  15. import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes';
  16. import {renderTableHead} from 'sentry/utils/profiling/tableRenderer';
  17. import {makeFormatter} from 'sentry/utils/profiling/units/units';
  18. import {useLocation} from 'sentry/utils/useLocation';
  19. import {useParams} from 'sentry/utils/useParams';
  20. import {useProfileGroup} from 'sentry/views/profiling/profileGroupProvider';
  21. import {useProfiles} from 'sentry/views/profiling/profilesProvider';
  22. import {useColumnFilters} from '../hooks/useColumnFilters';
  23. import {useFuseSearch} from '../hooks/useFuseSearch';
  24. import {usePageLinks} from '../hooks/usePageLinks';
  25. import {useQuerystringState} from '../hooks/useQuerystringState';
  26. import {useSortableColumns} from '../hooks/useSortableColumn';
  27. import {aggregate, AggregateColumnConfig, collectProfileFrames, Row} from '../utils';
  28. const RESULTS_PER_PAGE = 50;
  29. export function ProfileDetailsTable() {
  30. const location = useLocation();
  31. const profiles = useProfiles();
  32. const profileGroup = useProfileGroup();
  33. const [groupByViewKey, setGroupByView] = useQuerystringState({
  34. key: 'detailView',
  35. initialState: 'occurrence',
  36. });
  37. const [searchQuery, setSearchQuery] = useQuerystringState({
  38. key: 'query',
  39. initialState: '',
  40. });
  41. const [paginationCursor, setPaginationCursor] = useQuerystringState({
  42. key: 'cursor',
  43. initialState: '',
  44. });
  45. const groupByView = GROUP_BY_OPTIONS[groupByViewKey!] ?? GROUP_BY_OPTIONS.occurrence;
  46. const cursor = paginationCursor ? parseInt(paginationCursor, 10) : 0;
  47. const allData = useMemo(() => {
  48. const data = profileGroup.profiles.flatMap(collectProfileFrames);
  49. return groupByView.transform(data);
  50. }, [profileGroup, groupByView]);
  51. const {search} = useFuseSearch(allData, {
  52. keys: groupByView.search.key,
  53. threshold: 0.3,
  54. });
  55. const debouncedSearch = useMemo(
  56. () => debounce(searchString => setFilteredDataBySearch(search(searchString)), 500),
  57. [search]
  58. );
  59. const [filteredDataBySearch, setFilteredDataBySearch] =
  60. useState<TableDataRow[]>(allData);
  61. const [typeFilter, setTypeFilter] = useQuerystringState<string[]>({
  62. key: 'type',
  63. });
  64. const [imageFilter, setImageFilter] = useQuerystringState<string[]>({
  65. key: 'image',
  66. });
  67. const {filters, columnFilters, filterPredicate} = useColumnFilters(allData, {
  68. columns: ['type', 'image'],
  69. initialState: {
  70. type: typeFilter,
  71. image: imageFilter,
  72. },
  73. });
  74. useEffect(() => {
  75. setTypeFilter(filters.type);
  76. setImageFilter(filters.image);
  77. }, [filters, setTypeFilter, setImageFilter]);
  78. const {currentSort, generateSortLink, sortCompareFn} = useSortableColumns({
  79. ...groupByView.sort,
  80. querystringKey: 'functionsSort',
  81. });
  82. const handleSearch = useCallback(
  83. searchString => {
  84. setSearchQuery(searchString);
  85. setPaginationCursor(undefined);
  86. debouncedSearch(searchString);
  87. },
  88. [setPaginationCursor, setSearchQuery, debouncedSearch]
  89. );
  90. useEffect(() => {
  91. setFilteredDataBySearch(search(searchQuery ?? ''));
  92. // purposely omitted `searchQuery` as we only want this to run once.
  93. // future search filters are called by handleSearch
  94. // eslint-disable-next-line react-hooks/exhaustive-deps
  95. }, [allData, search]);
  96. const filteredData = useMemo(
  97. () => filteredDataBySearch.filter(filterPredicate),
  98. [filterPredicate, filteredDataBySearch]
  99. );
  100. const sortedData = useMemo(
  101. () => filteredData.sort(sortCompareFn),
  102. [filteredData, sortCompareFn]
  103. );
  104. const pageLinks = usePageLinks(sortedData, cursor);
  105. const data = sortedData.slice(cursor, cursor + RESULTS_PER_PAGE);
  106. return (
  107. <Fragment>
  108. <ActionBar>
  109. <CompactSelect
  110. options={Object.values(GROUP_BY_OPTIONS).map(view => view.option)}
  111. value={groupByView.option.value}
  112. triggerProps={{
  113. prefix: t('View'),
  114. }}
  115. position="bottom-end"
  116. onChange={option => {
  117. setSearchQuery('');
  118. setPaginationCursor(undefined);
  119. setGroupByView(option.value);
  120. }}
  121. />
  122. <SearchBar
  123. defaultQuery=""
  124. query={searchQuery}
  125. placeholder={groupByView.search.placeholder}
  126. onChange={handleSearch}
  127. />
  128. <CompactSelect
  129. options={columnFilters.type.values.map(value => ({value, label: value}))}
  130. value={filters.type}
  131. triggerLabel={
  132. !filters.type ||
  133. (Array.isArray(filters.type) &&
  134. filters.type.length === columnFilters.type.values.length)
  135. ? t('All')
  136. : undefined
  137. }
  138. triggerProps={{
  139. prefix: t('Type'),
  140. }}
  141. multiple
  142. onChange={columnFilters.type.onChange}
  143. position="bottom-end"
  144. />
  145. <CompactSelect
  146. options={columnFilters.image.values.map(value => ({value, label: value}))}
  147. value={filters.image}
  148. triggerLabel={
  149. !filters.image ||
  150. (Array.isArray(filters.image) &&
  151. filters.image.length === columnFilters.image.values.length)
  152. ? t('All')
  153. : undefined
  154. }
  155. triggerProps={{
  156. prefix: t('Package'),
  157. }}
  158. multiple
  159. onChange={columnFilters.image.onChange}
  160. position="bottom-end"
  161. searchable
  162. />
  163. </ActionBar>
  164. <GridEditable
  165. isLoading={profiles.type === 'loading'}
  166. error={profiles.type === 'errored'}
  167. data={data}
  168. columnOrder={groupByView.columns.map(key => COLUMNS[key])}
  169. columnSortBy={[currentSort]}
  170. scrollable
  171. stickyHeader
  172. height="75vh"
  173. grid={{
  174. renderHeadCell: renderTableHead({
  175. rightAlignedColumns: new Set(groupByView.rightAlignedColumns),
  176. sortableColumns: new Set(groupByView.rightAlignedColumns),
  177. currentSort,
  178. generateSortLink,
  179. }),
  180. renderBodyCell: renderFunctionCell,
  181. }}
  182. location={location}
  183. />
  184. <Pagination
  185. pageLinks={pageLinks}
  186. onCursor={cur => {
  187. setPaginationCursor(cur);
  188. }}
  189. />
  190. </Fragment>
  191. );
  192. }
  193. const ActionBar = styled('div')`
  194. display: grid;
  195. grid-template-columns: auto 1fr auto auto;
  196. gap: ${space(2)};
  197. margin-bottom: ${space(2)};
  198. `;
  199. function renderFunctionCell(
  200. column: TableColumn,
  201. dataRow: TableDataRow,
  202. rowIndex: number,
  203. columnIndex: number
  204. ) {
  205. return (
  206. <ProfilingFunctionsTableCell
  207. column={column}
  208. dataRow={dataRow}
  209. rowIndex={rowIndex}
  210. columnIndex={columnIndex}
  211. />
  212. );
  213. }
  214. interface ProfilingFunctionsTableCellProps {
  215. column: TableColumn;
  216. columnIndex: number;
  217. dataRow: TableDataRow;
  218. rowIndex: number;
  219. }
  220. const formatter = makeFormatter('nanoseconds');
  221. function ProfilingFunctionsTableCell({
  222. column,
  223. dataRow,
  224. }: ProfilingFunctionsTableCellProps) {
  225. const value = dataRow[column.key];
  226. const {orgId, projectId, eventId} = useParams();
  227. switch (column.key) {
  228. case 'p75':
  229. case 'p95':
  230. case 'self weight':
  231. case 'total weight':
  232. return <NumberContainer>{formatter(value as number)}</NumberContainer>;
  233. case 'count':
  234. return <NumberContainer>{value}</NumberContainer>;
  235. case 'image':
  236. return <Container>{value ?? t('Unknown')}</Container>;
  237. case 'thread': {
  238. return (
  239. <Container>
  240. <Link
  241. to={generateProfileFlamechartRouteWithQuery({
  242. orgSlug: orgId,
  243. projectSlug: projectId,
  244. profileId: eventId,
  245. query: {tid: dataRow.thread as string},
  246. })}
  247. >
  248. {value}
  249. </Link>
  250. </Container>
  251. );
  252. }
  253. case 'symbol': {
  254. return (
  255. <Container>
  256. <Link
  257. to={generateProfileFlamechartRouteWithQuery({
  258. orgSlug: orgId,
  259. projectSlug: projectId,
  260. profileId: eventId,
  261. query: {
  262. frameName: dataRow.symbol as string,
  263. framePackage: dataRow.image as string,
  264. tid: (dataRow.thread ?? dataRow.tids?.[0]) as string,
  265. },
  266. })}
  267. >
  268. {value}
  269. </Link>
  270. </Container>
  271. );
  272. }
  273. default:
  274. return <Container>{value}</Container>;
  275. }
  276. }
  277. const tableColumnKey = [
  278. 'symbol',
  279. 'image',
  280. 'file',
  281. 'thread',
  282. 'type',
  283. 'self weight',
  284. 'total weight',
  285. // computed columns
  286. 'p75',
  287. 'p95',
  288. 'count',
  289. 'tids',
  290. ] as const;
  291. type TableColumnKey = (typeof tableColumnKey)[number];
  292. type TableDataRow = Partial<Row<TableColumnKey>>;
  293. type TableColumn = GridColumnOrder<TableColumnKey>;
  294. // TODO: looks like these column names change depending on the platform?
  295. const COLUMNS: Record<Exclude<TableColumnKey, 'tids'>, TableColumn> = {
  296. symbol: {
  297. key: 'symbol',
  298. name: t('Symbol'),
  299. width: COL_WIDTH_UNDEFINED,
  300. },
  301. image: {
  302. key: 'image',
  303. name: t('Package'),
  304. width: COL_WIDTH_UNDEFINED,
  305. },
  306. file: {
  307. key: 'file',
  308. name: t('File'),
  309. width: COL_WIDTH_UNDEFINED,
  310. },
  311. thread: {
  312. key: 'thread',
  313. name: t('Thread'),
  314. width: COL_WIDTH_UNDEFINED,
  315. },
  316. type: {
  317. key: 'type',
  318. name: t('Type'),
  319. width: COL_WIDTH_UNDEFINED,
  320. },
  321. 'self weight': {
  322. key: 'self weight',
  323. name: t('Self Weight'),
  324. width: COL_WIDTH_UNDEFINED,
  325. },
  326. 'total weight': {
  327. key: 'total weight',
  328. name: t('Total Weight'),
  329. width: COL_WIDTH_UNDEFINED,
  330. },
  331. p75: {
  332. key: 'p75',
  333. name: t('P75(Self)'),
  334. width: COL_WIDTH_UNDEFINED,
  335. },
  336. p95: {
  337. key: 'p95',
  338. name: t('P95(Self)'),
  339. width: COL_WIDTH_UNDEFINED,
  340. },
  341. count: {
  342. key: 'count',
  343. name: t('Count'),
  344. width: COL_WIDTH_UNDEFINED,
  345. },
  346. };
  347. const quantile = (arr: readonly number[], q: number) => {
  348. const sorted = Array.from(arr).sort((a, b) => a - b);
  349. const position = q * (sorted.length - 1);
  350. const int = Math.floor(position);
  351. const frac = position % 1;
  352. if (position === int) {
  353. return sorted[position];
  354. }
  355. return sorted[int] * (1 - frac) + sorted[int + 1] * frac;
  356. };
  357. const p75AggregateColumn: AggregateColumnConfig<TableColumnKey> = {
  358. key: 'p75',
  359. compute: rows => quantile(rows.map(v => v['self weight']) as number[], 0.75),
  360. };
  361. const p95AggregateColumn: AggregateColumnConfig<TableColumnKey> = {
  362. key: 'p95',
  363. compute: rows => quantile(rows.map(v => v['self weight']) as number[], 0.95),
  364. };
  365. const countAggregateColumn: AggregateColumnConfig<TableColumnKey> = {
  366. key: 'count',
  367. compute: rows => rows.length,
  368. };
  369. const uniqueTidAggregateColumn: AggregateColumnConfig<TableColumnKey> = {
  370. key: 'tids',
  371. compute: rows =>
  372. rows.reduce((acc, val) => {
  373. const thread = val.thread as number;
  374. if (!acc.includes(thread)) {
  375. acc.push(thread);
  376. }
  377. return acc;
  378. }, [] as number[]),
  379. };
  380. interface GroupByOptions<T> {
  381. columns: T[];
  382. option: {
  383. label: string;
  384. value: string;
  385. };
  386. rightAlignedColumns: T[];
  387. search: {
  388. key: T[];
  389. placeholder: string;
  390. };
  391. sort: {
  392. defaultSort: {
  393. key: T;
  394. order: 'asc' | 'desc';
  395. };
  396. sortableColumns: T[];
  397. };
  398. transform: (
  399. data: Partial<Record<Extract<T, string>, string | number | undefined>>[]
  400. ) => Row<Extract<T, string>>[];
  401. }
  402. const GROUP_BY_OPTIONS: Record<string, GroupByOptions<TableColumnKey>> = {
  403. occurrence: {
  404. option: {
  405. label: t('Slowest Functions'),
  406. value: 'occurrence',
  407. },
  408. columns: ['symbol', 'image', 'file', 'thread', 'type', 'self weight', 'total weight'],
  409. transform: (data: any[]) => data.slice(0, 500),
  410. search: {
  411. key: ['symbol'],
  412. placeholder: t('Search for frames'),
  413. },
  414. sort: {
  415. sortableColumns: ['self weight', 'total weight'],
  416. defaultSort: {
  417. key: 'self weight',
  418. order: 'desc',
  419. },
  420. },
  421. rightAlignedColumns: ['self weight', 'total weight'],
  422. },
  423. symbol: {
  424. option: {
  425. label: t('Group by Symbol'),
  426. value: 'symbol',
  427. },
  428. columns: ['symbol', 'type', 'image', 'p75', 'p95', 'count'],
  429. search: {
  430. key: ['symbol'],
  431. placeholder: t('Search for frames'),
  432. },
  433. transform: data =>
  434. aggregate(
  435. data,
  436. ['symbol', 'type', 'image'],
  437. [
  438. p75AggregateColumn,
  439. p95AggregateColumn,
  440. countAggregateColumn,
  441. uniqueTidAggregateColumn,
  442. ]
  443. ),
  444. sort: {
  445. sortableColumns: ['p75', 'p95', 'count'],
  446. defaultSort: {
  447. key: 'p75',
  448. order: 'desc',
  449. },
  450. },
  451. rightAlignedColumns: ['p75', 'p95', 'count'],
  452. },
  453. package: {
  454. option: {
  455. label: t('Group by Package'),
  456. value: 'package',
  457. },
  458. columns: ['image', 'type', 'p75', 'p95', 'count'],
  459. search: {
  460. key: ['image'],
  461. placeholder: t('Search for packages'),
  462. },
  463. transform: data =>
  464. aggregate(
  465. data,
  466. ['type', 'image'],
  467. [p75AggregateColumn, p95AggregateColumn, countAggregateColumn]
  468. ),
  469. sort: {
  470. sortableColumns: ['p75', 'p95', 'count'],
  471. defaultSort: {
  472. key: 'p75',
  473. order: 'desc',
  474. },
  475. },
  476. rightAlignedColumns: ['p75', 'p95', 'count'],
  477. },
  478. file: {
  479. option: {
  480. label: t('Group by File'),
  481. value: 'file',
  482. },
  483. columns: ['file', 'type', 'image', 'p75', 'p95', 'count'],
  484. search: {
  485. key: ['file'],
  486. placeholder: t('Search for files'),
  487. },
  488. transform: data =>
  489. aggregate(
  490. data,
  491. ['type', 'image', 'file'],
  492. [p75AggregateColumn, p95AggregateColumn, countAggregateColumn]
  493. ),
  494. sort: {
  495. sortableColumns: ['p75', 'p95', 'count'],
  496. defaultSort: {
  497. key: 'p75',
  498. order: 'desc',
  499. },
  500. },
  501. rightAlignedColumns: ['p75', 'p95', 'count'],
  502. },
  503. };