table.tsx 13 KB

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