pageOverviewWebVitalsDetailPanel.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import {useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LineChartSeries} from 'sentry/components/charts/lineChart';
  4. import {DrawerHeader} from 'sentry/components/globalDrawer/components';
  5. import type {
  6. GridColumnHeader,
  7. GridColumnOrder,
  8. GridColumnSortBy,
  9. } from 'sentry/components/gridEditable';
  10. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  11. import Link from 'sentry/components/links/link';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {t} from 'sentry/locale';
  14. import {defined} from 'sentry/utils';
  15. import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls';
  16. import getDuration from 'sentry/utils/duration/getDuration';
  17. import {getShortEventId} from 'sentry/utils/events';
  18. import {PageAlert, PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  19. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  20. import useReplayExists from 'sentry/utils/replayCount/useReplayExists';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import useProjects from 'sentry/utils/useProjects';
  24. import {useRoutes} from 'sentry/utils/useRoutes';
  25. import {WebVitalStatusLineChart} from 'sentry/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart';
  26. import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge';
  27. import {WebVitalDetailHeader} from 'sentry/views/insights/browser/webVitals/components/webVitalDescription';
  28. import {useProjectRawWebVitalsQuery} from 'sentry/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery';
  29. import {useProjectRawWebVitalsValuesTimeseriesQuery} from 'sentry/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsValuesTimeseriesQuery';
  30. import {getWebVitalScoresFromTableDataRow} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/getWebVitalScoresFromTableDataRow';
  31. import {useProjectWebVitalsScoresQuery} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useProjectWebVitalsScoresQuery';
  32. import {useInteractionsCategorizedSamplesQuery} from 'sentry/views/insights/browser/webVitals/queries/useInteractionsCategorizedSamplesQuery';
  33. import {useTransactionsCategorizedSamplesQuery} from 'sentry/views/insights/browser/webVitals/queries/useTransactionsCategorizedSamplesQuery';
  34. import type {
  35. InteractionSpanSampleRowWithScore,
  36. TransactionSampleRowWithScore,
  37. WebVitals,
  38. } from 'sentry/views/insights/browser/webVitals/types';
  39. import decodeBrowserTypes from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType';
  40. import useProfileExists from 'sentry/views/insights/browser/webVitals/utils/useProfileExists';
  41. import {SampleDrawerBody} from 'sentry/views/insights/common/components/sampleDrawerBody';
  42. import {useDomainViewFilters} from 'sentry/views/insights/pages/useFilters';
  43. import {SpanIndexedField, type SubregionCode} from 'sentry/views/insights/types';
  44. import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceHeader/breadcrumbs';
  45. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  46. type Column = GridColumnHeader;
  47. const columnOrder: GridColumnOrder[] = [
  48. {key: 'id', width: COL_WIDTH_UNDEFINED, name: t('Transaction')},
  49. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
  50. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
  51. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Web Vital')},
  52. {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
  53. ];
  54. const inpColumnOrder: GridColumnOrder[] = [
  55. {
  56. key: SpanIndexedField.SPAN_DESCRIPTION,
  57. width: COL_WIDTH_UNDEFINED,
  58. name: t('Interaction Target'),
  59. },
  60. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: t('Profile')},
  61. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: t('Replay')},
  62. {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: t('Inp')},
  63. {key: 'score', width: COL_WIDTH_UNDEFINED, name: t('Score')},
  64. ];
  65. const sort: GridColumnSortBy<keyof TransactionSampleRowWithScore> = {
  66. key: 'totalScore',
  67. order: 'desc',
  68. };
  69. const inpSort: GridColumnSortBy<keyof InteractionSpanSampleRowWithScore> = {
  70. key: 'inpScore',
  71. order: 'desc',
  72. };
  73. export function PageOverviewWebVitalsDetailPanel({
  74. webVital,
  75. }: {
  76. webVital: WebVitals | null;
  77. }) {
  78. const location = useLocation();
  79. const {projects} = useProjects();
  80. const organization = useOrganization();
  81. const routes = useRoutes();
  82. const {replayExists} = useReplayExists();
  83. const domainViewFilters = useDomainViewFilters();
  84. const browserTypes = decodeBrowserTypes(location.query[SpanIndexedField.BROWSER_NAME]);
  85. const subregions = location.query[
  86. SpanIndexedField.USER_GEO_SUBREGION
  87. ] as SubregionCode[];
  88. const isInp = webVital === 'inp';
  89. const replayLinkGenerator = generateReplayLink(routes);
  90. const project = useMemo(
  91. () => projects.find(p => p.id === String(location.query.project)),
  92. [projects, location.query.project]
  93. );
  94. const transaction = location.query.transaction
  95. ? Array.isArray(location.query.transaction)
  96. ? location.query.transaction[0]
  97. : location.query.transaction
  98. : undefined;
  99. const {data: projectData} = useProjectRawWebVitalsQuery({
  100. transaction,
  101. browserTypes,
  102. subregions,
  103. });
  104. const {data: projectScoresData} = useProjectWebVitalsScoresQuery({
  105. weightWebVital: webVital ?? 'total',
  106. transaction,
  107. browserTypes,
  108. subregions,
  109. });
  110. const projectScore = getWebVitalScoresFromTableDataRow(projectScoresData?.data?.[0]);
  111. const {data: transactionsTableData, isLoading: isTransactionWebVitalsQueryLoading} =
  112. useTransactionsCategorizedSamplesQuery({
  113. transaction: transaction ?? '',
  114. webVital,
  115. enabled: Boolean(webVital) && !isInp,
  116. browserTypes,
  117. subregions,
  118. });
  119. const {data: inpTableData, isLoading: isInteractionsLoading} =
  120. useInteractionsCategorizedSamplesQuery({
  121. transaction: transaction ?? '',
  122. enabled: Boolean(webVital) && isInp,
  123. browserTypes,
  124. subregions,
  125. });
  126. const {profileExists} = useProfileExists(
  127. inpTableData.filter(row => row['profile.id']).map(row => row['profile.id'])
  128. );
  129. const {data: timeseriesData, isLoading: isTimeseriesLoading} =
  130. useProjectRawWebVitalsValuesTimeseriesQuery({transaction, browserTypes, subregions});
  131. const webVitalData: LineChartSeries = {
  132. data:
  133. !isTimeseriesLoading && webVital
  134. ? timeseriesData?.[webVital].map(({name, value}) => ({
  135. name,
  136. value,
  137. }))
  138. : [],
  139. seriesName: webVital ?? '',
  140. };
  141. const getProjectSlug = (row: TransactionSampleRowWithScore): string => {
  142. return project && !Array.isArray(location.query.project)
  143. ? project.slug
  144. : row.projectSlug;
  145. };
  146. const renderHeadCell = (col: Column) => {
  147. if (col.key === 'transaction') {
  148. return <NoOverflow>{col.name}</NoOverflow>;
  149. }
  150. if (col.key === 'webVital') {
  151. return <AlignRight>{`${webVital}`}</AlignRight>;
  152. }
  153. if (col.key === 'score' || col.key === 'measurements.score.inp') {
  154. return <AlignCenter>{`${webVital} ${col.name}`}</AlignCenter>;
  155. }
  156. if (col.key === 'replayId' || col.key === 'profile.id') {
  157. return <AlignCenter>{col.name}</AlignCenter>;
  158. }
  159. return <NoOverflow>{col.name}</NoOverflow>;
  160. };
  161. const getFormattedDuration = (value: number) => {
  162. if (value === undefined) {
  163. return null;
  164. }
  165. if (value < 1000) {
  166. return getDuration(value / 1000, 0, true);
  167. }
  168. return getDuration(value / 1000, 2, true);
  169. };
  170. const renderBodyCell = (col: Column, row: TransactionSampleRowWithScore) => {
  171. const {key} = col;
  172. const projectSlug = getProjectSlug(row);
  173. if (key === 'score') {
  174. if (row[`measurements.${webVital}` as keyof typeof row] !== undefined) {
  175. return (
  176. <AlignCenter>
  177. <PerformanceBadge
  178. score={row[`${webVital}Score` as keyof typeof row] as number}
  179. />
  180. </AlignCenter>
  181. );
  182. }
  183. return null;
  184. }
  185. if (col.key === 'webVital') {
  186. // @ts-expect-error TS(2551): Property 'measurements.null' does not exist on typ... Remove this comment to see the full error message
  187. const value = row[`measurements.${webVital}`];
  188. if (value === undefined) {
  189. return (
  190. <AlignRight>
  191. <NoValue>{t('(no value)')}</NoValue>
  192. </AlignRight>
  193. );
  194. }
  195. const formattedValue =
  196. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  197. return <AlignRight>{formattedValue}</AlignRight>;
  198. }
  199. if (key === 'id') {
  200. const eventTarget = generateLinkToEventInTraceView({
  201. eventId: row.id,
  202. traceSlug: row.trace,
  203. timestamp: row.timestamp,
  204. projectSlug,
  205. organization,
  206. location,
  207. view: domainViewFilters.view,
  208. source: TraceViewSources.WEB_VITALS_MODULE,
  209. });
  210. return (
  211. <NoOverflow>
  212. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  213. </NoOverflow>
  214. );
  215. }
  216. if (key === 'replayId') {
  217. const replayTarget =
  218. row['transaction.duration'] !== undefined &&
  219. replayLinkGenerator(
  220. organization,
  221. {
  222. replayId: row.replayId,
  223. id: row.id,
  224. 'transaction.duration': row['transaction.duration'],
  225. timestamp: row.timestamp,
  226. },
  227. undefined
  228. );
  229. return row.replayId && replayTarget && replayExists(row[key]) ? (
  230. <AlignCenter>
  231. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  232. </AlignCenter>
  233. ) : (
  234. <AlignCenter>
  235. <NoValue>{t('(no value)')}</NoValue>
  236. </AlignCenter>
  237. );
  238. }
  239. if (key === 'profile.id') {
  240. if (!defined(project) || !defined(row['profile.id'])) {
  241. return (
  242. <AlignCenter>
  243. <NoValue>{t('(no value)')}</NoValue>
  244. </AlignCenter>
  245. );
  246. }
  247. const target = generateProfileFlamechartRoute({
  248. orgSlug: organization.slug,
  249. projectSlug,
  250. profileId: String(row['profile.id']),
  251. });
  252. return (
  253. <AlignCenter>
  254. <Link to={target}>{getShortEventId(row['profile.id'])}</Link>
  255. </AlignCenter>
  256. );
  257. }
  258. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  259. return <AlignRight>{row[key]}</AlignRight>;
  260. };
  261. const renderInpBodyCell = (col: Column, row: InteractionSpanSampleRowWithScore) => {
  262. const {key} = col;
  263. if (key === 'score') {
  264. if (row[`measurements.${webVital}` as keyof typeof row] !== undefined) {
  265. return (
  266. <AlignCenter>
  267. <PerformanceBadge
  268. score={row[`${webVital}Score` as keyof typeof row] as number}
  269. />
  270. </AlignCenter>
  271. );
  272. }
  273. return null;
  274. }
  275. if (col.key === 'webVital') {
  276. // @ts-expect-error TS(2551): Property 'measurements.cls' does not exist on type... Remove this comment to see the full error message
  277. const value = row[`measurements.${webVital}`];
  278. if (value === undefined) {
  279. return (
  280. <AlignRight>
  281. <NoValue>{t('(no value)')}</NoValue>
  282. </AlignRight>
  283. );
  284. }
  285. const formattedValue =
  286. webVital === 'cls' ? value?.toFixed(2) : getFormattedDuration(value);
  287. return <AlignRight>{formattedValue}</AlignRight>;
  288. }
  289. if (key === 'replayId') {
  290. const replayTarget = replayLinkGenerator(
  291. organization,
  292. {
  293. replayId: row.replayId,
  294. id: '', // id doesn't actually matter here. Just to satisfy type.
  295. 'transaction.duration': isInp
  296. ? row[SpanIndexedField.SPAN_SELF_TIME]
  297. : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  298. row['transaction.duration'],
  299. timestamp: row.timestamp,
  300. },
  301. undefined
  302. );
  303. return row.replayId && replayTarget && replayExists(row[key]) ? (
  304. <AlignCenter>
  305. <Link to={replayTarget}>{getShortEventId(row.replayId)}</Link>
  306. </AlignCenter>
  307. ) : (
  308. <AlignCenter>
  309. <NoValue>{t('(no value)')}</NoValue>
  310. </AlignCenter>
  311. );
  312. }
  313. if (key === 'profile.id') {
  314. if (
  315. !defined(project) ||
  316. !defined(row['profile.id']) ||
  317. !profileExists(row['profile.id'])
  318. ) {
  319. return (
  320. <AlignCenter>
  321. <NoValue>{t('(no value)')}</NoValue>
  322. </AlignCenter>
  323. );
  324. }
  325. const target = generateProfileFlamechartRoute({
  326. orgSlug: organization.slug,
  327. projectSlug: project.slug,
  328. profileId: String(row['profile.id']),
  329. });
  330. return (
  331. <AlignCenter>
  332. <Link to={target}>{getShortEventId(row['profile.id'])}</Link>
  333. </AlignCenter>
  334. );
  335. }
  336. if (key === SpanIndexedField.SPAN_DESCRIPTION) {
  337. return (
  338. <NoOverflow>
  339. <Tooltip title={row[key]}>{row[key]}</Tooltip>
  340. </NoOverflow>
  341. );
  342. }
  343. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  344. return <AlignRight>{row[key]}</AlignRight>;
  345. };
  346. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  347. const webVitalScore = projectScore[`${webVital}Score`];
  348. const webVitalValue = projectData?.data[0]?.[`p75(measurements.${webVital})`] as
  349. | number
  350. | undefined;
  351. return (
  352. <PageAlertProvider>
  353. <DrawerHeader />
  354. <SampleDrawerBody>
  355. {webVital && (
  356. <WebVitalDetailHeader
  357. value={
  358. webVitalValue !== undefined
  359. ? webVital !== 'cls'
  360. ? getDuration(webVitalValue / 1000, 2, true)
  361. : webVitalValue?.toFixed(2)
  362. : undefined
  363. }
  364. webVital={webVital}
  365. score={webVitalScore}
  366. />
  367. )}
  368. <ChartContainer>
  369. {webVital && <WebVitalStatusLineChart webVitalSeries={webVitalData} />}
  370. </ChartContainer>
  371. <TableContainer>
  372. {isInp ? (
  373. <GridEditable
  374. data={inpTableData}
  375. isLoading={isInteractionsLoading}
  376. columnOrder={inpColumnOrder}
  377. columnSortBy={[inpSort]}
  378. grid={{
  379. renderHeadCell,
  380. renderBodyCell: renderInpBodyCell,
  381. }}
  382. />
  383. ) : (
  384. <GridEditable
  385. data={transactionsTableData}
  386. isLoading={isTransactionWebVitalsQueryLoading}
  387. columnOrder={columnOrder}
  388. columnSortBy={[sort]}
  389. grid={{
  390. renderHeadCell,
  391. renderBodyCell,
  392. }}
  393. />
  394. )}
  395. </TableContainer>
  396. <PageAlert />
  397. </SampleDrawerBody>
  398. </PageAlertProvider>
  399. );
  400. }
  401. const NoOverflow = styled('span')`
  402. overflow: hidden;
  403. text-overflow: ellipsis;
  404. white-space: nowrap;
  405. `;
  406. const AlignRight = styled('span')<{color?: string}>`
  407. text-align: right;
  408. width: 100%;
  409. ${p => (p.color ? `color: ${p.color};` : '')}
  410. `;
  411. const AlignCenter = styled('span')`
  412. text-align: center;
  413. width: 100%;
  414. `;
  415. const ChartContainer = styled('div')`
  416. position: relative;
  417. flex: 1;
  418. `;
  419. const NoValue = styled('span')`
  420. color: ${p => p.theme.gray300};
  421. `;
  422. const TableContainer = styled('div')`
  423. margin-bottom: 80px;
  424. `;