table.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import {Fragment, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import {getInterval} from 'sentry/components/charts/utils';
  5. import type {GridColumnHeader} from 'sentry/components/gridEditable';
  6. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  7. import SortLink from 'sentry/components/gridEditable/sortLink';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import Link from 'sentry/components/links/link';
  10. import type {CursorHandler} from 'sentry/components/pagination';
  11. import Pagination from 'sentry/components/pagination';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {t, tct} from 'sentry/locale';
  14. import type {NewQuery} from 'sentry/types/organization';
  15. import type {Project} from 'sentry/types/project';
  16. import {browserHistory} from 'sentry/utils/browserHistory';
  17. import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  18. import type {MetaType} from 'sentry/utils/discover/eventView';
  19. import EventView, {isFieldSortable} from 'sentry/utils/discover/eventView';
  20. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  21. import type {Sort} from 'sentry/utils/discover/fields';
  22. import {fieldAlignment} from 'sentry/utils/discover/fields';
  23. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  24. import {decodeScalar, decodeSorts} from 'sentry/utils/queryString';
  25. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  26. import {useLocation} from 'sentry/utils/useLocation';
  27. import useOrganization from 'sentry/utils/useOrganization';
  28. import usePageFilters from 'sentry/utils/usePageFilters';
  29. import {
  30. SpanOpSelector,
  31. TTID_CONTRIBUTING_SPAN_OPS,
  32. } from 'sentry/views/performance/mobile/screenload/screenLoadSpans/spanOpSelector';
  33. import {MobileCursors} from 'sentry/views/performance/mobile/screenload/screens/constants';
  34. import {
  35. DEFAULT_PLATFORM,
  36. PLATFORM_LOCAL_STORAGE_KEY,
  37. PLATFORM_QUERY_PARAM,
  38. } from 'sentry/views/performance/mobile/screenload/screens/platformSelector';
  39. import {useTableQuery} from 'sentry/views/performance/mobile/screenload/screens/screensTable';
  40. import {isCrossPlatform} from 'sentry/views/performance/mobile/screenload/screens/utils';
  41. import {useModuleURL} from 'sentry/views/performance/utils/useModuleURL';
  42. import {
  43. PRIMARY_RELEASE_ALIAS,
  44. SECONDARY_RELEASE_ALIAS,
  45. } from 'sentry/views/starfish/components/releaseSelector';
  46. import {OverflowEllipsisTextContainer} from 'sentry/views/starfish/components/textAlign';
  47. import {useTTFDConfigured} from 'sentry/views/starfish/queries/useHasTtfdConfigured';
  48. import {SpanMetricsField} from 'sentry/views/starfish/types';
  49. import {STARFISH_CHART_INTERVAL_FIDELITY} from 'sentry/views/starfish/utils/constants';
  50. import {appendReleaseFilters} from 'sentry/views/starfish/utils/releaseComparison';
  51. import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
  52. const {SPAN_SELF_TIME, SPAN_DESCRIPTION, SPAN_GROUP, SPAN_OP, PROJECT_ID} =
  53. SpanMetricsField;
  54. type Props = {
  55. primaryRelease?: string;
  56. project?: Project | null;
  57. secondaryRelease?: string;
  58. transaction?: string;
  59. };
  60. export function ScreenLoadSpansTable({
  61. transaction,
  62. primaryRelease,
  63. secondaryRelease,
  64. project,
  65. }: Props) {
  66. const moduleURL = useModuleURL('screen_load');
  67. const location = useLocation();
  68. const {selection} = usePageFilters();
  69. const organization = useOrganization();
  70. const cursor = decodeScalar(location.query?.[MobileCursors.SPANS_TABLE]);
  71. const spanOp = decodeScalar(location.query[SpanMetricsField.SPAN_OP]) ?? '';
  72. const {hasTTFD, isLoading: hasTTFDLoading} = useTTFDConfigured([
  73. `transaction:"${transaction}"`,
  74. ]);
  75. const hasPlatformSelectFeature = organization.features.includes('spans-first-ui');
  76. const platform =
  77. decodeScalar(location.query[PLATFORM_QUERY_PARAM]) ??
  78. localStorage.getItem(PLATFORM_LOCAL_STORAGE_KEY) ??
  79. DEFAULT_PLATFORM;
  80. const queryStringPrimary = useMemo(() => {
  81. const searchQuery = new MutableSearch([
  82. 'transaction.op:ui.load',
  83. `transaction:${transaction}`,
  84. 'has:span.description',
  85. ...(spanOp
  86. ? [`${SpanMetricsField.SPAN_OP}:${spanOp}`]
  87. : [`span.op:[${TTID_CONTRIBUTING_SPAN_OPS.join(',')}]`]),
  88. ]);
  89. if (project && isCrossPlatform(project) && hasPlatformSelectFeature) {
  90. searchQuery.addFilterValue('os.name', platform);
  91. }
  92. return appendReleaseFilters(searchQuery, primaryRelease, secondaryRelease);
  93. }, [
  94. hasPlatformSelectFeature,
  95. platform,
  96. primaryRelease,
  97. project,
  98. secondaryRelease,
  99. spanOp,
  100. transaction,
  101. ]);
  102. const sort = decodeSorts(location.query[QueryParameterNames.SPANS_SORT])[0] ?? {
  103. kind: 'desc',
  104. field: 'time_spent_percentage()',
  105. };
  106. const newQuery: NewQuery = {
  107. name: '',
  108. fields: [
  109. PROJECT_ID,
  110. SPAN_OP,
  111. SPAN_GROUP,
  112. SPAN_DESCRIPTION,
  113. `avg_if(${SPAN_SELF_TIME},release,${primaryRelease})`,
  114. `avg_if(${SPAN_SELF_TIME},release,${secondaryRelease})`,
  115. 'ttid_contribution_rate()',
  116. 'ttfd_contribution_rate()',
  117. 'count()',
  118. 'time_spent_percentage()',
  119. `sum(${SPAN_SELF_TIME})`,
  120. ],
  121. query: queryStringPrimary,
  122. dataset: DiscoverDatasets.SPANS_METRICS,
  123. version: 2,
  124. projects: selection.projects,
  125. interval: getInterval(selection.datetime, STARFISH_CHART_INTERVAL_FIDELITY),
  126. };
  127. const eventView = EventView.fromNewQueryWithLocation(newQuery, location);
  128. eventView.sorts = [sort];
  129. const {data, isLoading, pageLinks} = useTableQuery({
  130. eventView,
  131. enabled: true,
  132. referrer: 'api.starfish.mobile-span-table',
  133. cursor,
  134. });
  135. const columnNameMap = {
  136. [SPAN_OP]: t('Operation'),
  137. [SPAN_DESCRIPTION]: t('Span Description'),
  138. 'count()': t('Total Count'),
  139. affects: hasTTFD ? t('Affects') : t('Affects TTID'),
  140. 'time_spent_percentage()': t('Total Time Spent'),
  141. [`avg_if(${SPAN_SELF_TIME},release,${primaryRelease})`]: t(
  142. 'Duration (%s)',
  143. PRIMARY_RELEASE_ALIAS
  144. ),
  145. [`avg_if(${SPAN_SELF_TIME},release,${secondaryRelease})`]: t(
  146. 'Duration (%s)',
  147. SECONDARY_RELEASE_ALIAS
  148. ),
  149. };
  150. function renderBodyCell(column, row): React.ReactNode {
  151. if (!data?.meta || !data?.meta.fields) {
  152. return row[column.key];
  153. }
  154. if (column.key === SPAN_DESCRIPTION) {
  155. const label = row[SpanMetricsField.SPAN_DESCRIPTION];
  156. const pathname = `${moduleURL}/spans/`;
  157. const query = {
  158. ...location.query,
  159. transaction,
  160. spanGroup: row[SpanMetricsField.SPAN_GROUP],
  161. spanDescription: row[SpanMetricsField.SPAN_DESCRIPTION],
  162. };
  163. return (
  164. <Link to={`${pathname}?${qs.stringify(query)}`}>
  165. <OverflowEllipsisTextContainer>{label}</OverflowEllipsisTextContainer>
  166. </Link>
  167. );
  168. }
  169. if (column.key === 'affects' && hasTTFD) {
  170. const ttid_contribution_rate = row['ttid_contribution_rate()']
  171. ? parseFloat(row['ttid_contribution_rate()'])
  172. : 0;
  173. const ttfd_contribution_rate = row['ttfd_contribution_rate()']
  174. ? parseFloat(row['ttfd_contribution_rate()'])
  175. : 0;
  176. if (!isNaN(ttid_contribution_rate) && ttid_contribution_rate > 0.99) {
  177. const tooltipValue = tct(
  178. 'This span always ends before TTID and TTFD and may affect initial and final display. [link: Learn more.]',
  179. {
  180. link: (
  181. <ExternalLink href="https://docs.sentry.io/product/performance/mobile-vitals/screen-loads/#ttid-and-ttfd-affecting-spans" />
  182. ),
  183. }
  184. );
  185. return (
  186. <Tooltip isHoverable title={tooltipValue} showUnderline>
  187. <Container>{t('TTID, TTFD')}</Container>
  188. </Tooltip>
  189. );
  190. }
  191. if (!isNaN(ttfd_contribution_rate) && ttfd_contribution_rate > 0.99) {
  192. const tooltipValue = tct(
  193. 'This span always ends before TTFD and may affect final display. [link: Learn more.]',
  194. {
  195. link: (
  196. <ExternalLink href="https://docs.sentry.io/product/performance/mobile-vitals/screen-loads/#ttid-and-ttfd-affecting-spans" />
  197. ),
  198. }
  199. );
  200. return (
  201. <Tooltip isHoverable title={tooltipValue} showUnderline>
  202. <Container>{t('TTFD')}</Container>
  203. </Tooltip>
  204. );
  205. }
  206. const tooltipValue = tct(
  207. 'This span may not be contributing to TTID or TTFD. [link: Learn more.]',
  208. {
  209. link: (
  210. <ExternalLink href="https://docs.sentry.io/product/performance/mobile-vitals/screen-loads/#ttid-and-ttfd-affecting-spans" />
  211. ),
  212. }
  213. );
  214. return (
  215. <Tooltip isHoverable title={tooltipValue}>
  216. <Container>{'--'}</Container>
  217. </Tooltip>
  218. );
  219. }
  220. if (column.key === 'affects') {
  221. const ttid_contribution_rate = row['ttid_contribution_rate()']
  222. ? parseFloat(row['ttid_contribution_rate()'])
  223. : 0;
  224. if (!isNaN(ttid_contribution_rate) && ttid_contribution_rate > 0.99) {
  225. const tooltipValue = tct(
  226. 'This span always ends before TTID and may affect initial display. [link: Learn more.]',
  227. {
  228. link: (
  229. <ExternalLink href="https://docs.sentry.io/product/performance/mobile-vitals/screen-loads/#ttid-and-ttfd-affecting-spans" />
  230. ),
  231. }
  232. );
  233. return (
  234. <Tooltip isHoverable title={tooltipValue} showUnderline>
  235. <Container>{t('Yes')}</Container>
  236. </Tooltip>
  237. );
  238. }
  239. const tooltipValue = tct(
  240. 'This span may not affect initial display. [link: Learn more.]',
  241. {
  242. link: (
  243. <ExternalLink href="https://docs.sentry.io/product/performance/mobile-vitals/screen-loads/#ttid-and-ttfd-affecting-spans" />
  244. ),
  245. }
  246. );
  247. return (
  248. <Tooltip isHoverable title={tooltipValue} showUnderline>
  249. <Container>{t('No')}</Container>
  250. </Tooltip>
  251. );
  252. }
  253. const renderer = getFieldRenderer(column.key, data?.meta.fields, false);
  254. const rendered = renderer(row, {
  255. location,
  256. organization,
  257. unit: data?.meta.units?.[column.key],
  258. });
  259. return rendered;
  260. }
  261. function renderHeadCell(
  262. column: GridColumnHeader,
  263. tableMeta?: MetaType
  264. ): React.ReactNode {
  265. const fieldType = tableMeta?.fields?.[column.key];
  266. let alignment = fieldAlignment(column.key as string, fieldType);
  267. if (column.key === 'affects') {
  268. alignment = 'right';
  269. }
  270. const field = {
  271. field: column.key as string,
  272. width: column.width,
  273. };
  274. const affectsIsCurrentSort =
  275. column.key === 'affects' &&
  276. (sort?.field === 'ttid_contribution_rate()' ||
  277. sort?.field === 'ttfd_contribution_rate()');
  278. function generateSortLink() {
  279. if (!tableMeta) {
  280. return undefined;
  281. }
  282. let newSortDirection: Sort['kind'] = 'desc';
  283. if (sort?.field === column.key) {
  284. if (sort.kind === 'desc') {
  285. newSortDirection = 'asc';
  286. }
  287. }
  288. function getNewSort() {
  289. if (column.key === 'affects') {
  290. if (sort?.field === 'ttid_contribution_rate()') {
  291. return '-ttfd_contribution_rate()';
  292. }
  293. return '-ttid_contribution_rate()';
  294. }
  295. return `${newSortDirection === 'desc' ? '-' : ''}${column.key}`;
  296. }
  297. return {
  298. ...location,
  299. query: {...location.query, [QueryParameterNames.SPANS_SORT]: getNewSort()},
  300. };
  301. }
  302. const canSort =
  303. column.key === 'affects' || isFieldSortable(field, tableMeta?.fields, true);
  304. const sortLink = (
  305. <SortLink
  306. align={alignment}
  307. title={column.name}
  308. direction={
  309. affectsIsCurrentSort
  310. ? sort?.field === 'ttid_contribution_rate()'
  311. ? 'desc'
  312. : 'asc'
  313. : sort?.field === column.key
  314. ? sort.kind
  315. : undefined
  316. }
  317. canSort={canSort}
  318. generateSortLink={generateSortLink}
  319. />
  320. );
  321. return sortLink;
  322. }
  323. const columnSortBy = eventView.getSorts();
  324. const handleCursor: CursorHandler = (newCursor, pathname, query) => {
  325. browserHistory.push({
  326. pathname,
  327. query: {...query, [MobileCursors.SPANS_TABLE]: newCursor},
  328. });
  329. };
  330. return (
  331. <Fragment>
  332. <SpanOpSelector
  333. primaryRelease={primaryRelease}
  334. transaction={transaction}
  335. secondaryRelease={secondaryRelease}
  336. />
  337. <GridEditable
  338. isLoading={isLoading || hasTTFDLoading}
  339. data={data?.data as TableDataRow[]}
  340. columnOrder={[
  341. String(SPAN_OP),
  342. String(SPAN_DESCRIPTION),
  343. `avg_if(${SPAN_SELF_TIME},release,${primaryRelease})`,
  344. `avg_if(${SPAN_SELF_TIME},release,${secondaryRelease})`,
  345. ...(organization.features.includes('spans-first-ui') ? ['affects'] : []),
  346. ...['count()', 'time_spent_percentage()'],
  347. ].map(col => {
  348. return {key: col, name: columnNameMap[col] ?? col, width: COL_WIDTH_UNDEFINED};
  349. })}
  350. columnSortBy={columnSortBy}
  351. location={location}
  352. grid={{
  353. renderHeadCell: column => renderHeadCell(column, data?.meta),
  354. renderBodyCell,
  355. }}
  356. />
  357. <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
  358. </Fragment>
  359. );
  360. }
  361. const Container = styled('div')`
  362. ${p => p.theme.overflowEllipsis};
  363. text-align: right;
  364. `;