samplePanel.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import {Fragment, useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import keyBy from 'lodash/keyBy';
  4. import * as qs from 'query-string';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {Button} from 'sentry/components/button';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import Link from 'sentry/components/links/link';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {trackAnalytics} from 'sentry/utils/analytics';
  12. import {DurationUnit, RateUnit, SizeUnit} from 'sentry/utils/discover/fields';
  13. import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  14. import {decodeScalar} from 'sentry/utils/queryString';
  15. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  16. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  17. import {useLocation} from 'sentry/utils/useLocation';
  18. import useOrganization from 'sentry/utils/useOrganization';
  19. import useProjects from 'sentry/utils/useProjects';
  20. import useRouter from 'sentry/utils/useRouter';
  21. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  22. import {CacheHitMissChart} from 'sentry/views/performance/cache/charts/hitMissChart';
  23. import {Referrer} from 'sentry/views/performance/cache/referrers';
  24. import {TransactionDurationChart} from 'sentry/views/performance/cache/samplePanel/charts/transactionDurationChart';
  25. import {BASE_FILTERS} from 'sentry/views/performance/cache/settings';
  26. import {SpanSamplesTable} from 'sentry/views/performance/cache/tables/spanSamplesTable';
  27. import {useDebouncedState} from 'sentry/views/performance/http/useDebouncedState';
  28. import {MetricReadout} from 'sentry/views/performance/metricReadout';
  29. import * as ModuleLayout from 'sentry/views/performance/moduleLayout';
  30. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  31. import {getTimeSpentExplanation} from 'sentry/views/starfish/components/tableCells/timeSpentCell';
  32. import {useMetrics, useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover';
  33. import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useDiscoverSeries';
  34. import {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans';
  35. import {useTransactions} from 'sentry/views/starfish/queries/useTransactions';
  36. import {
  37. MetricsFields,
  38. type MetricsQueryFilters,
  39. ModuleName,
  40. SpanFunction,
  41. SpanIndexedField,
  42. type SpanIndexedQueryFilters,
  43. SpanMetricsField,
  44. type SpanMetricsQueryFilters,
  45. } from 'sentry/views/starfish/types';
  46. import {findSampleFromDataPoint} from 'sentry/views/starfish/utils/chart/findDataPoint';
  47. import {DataTitles, getThroughputTitle} from 'sentry/views/starfish/views/spans/types';
  48. // This is similar to http sample table, its difficult to use the generic span samples sidebar as we require a bunch of custom things.
  49. export function CacheSamplePanel() {
  50. const router = useRouter();
  51. const location = useLocation();
  52. const organization = useOrganization();
  53. const query = useLocationQuery({
  54. fields: {
  55. project: decodeScalar,
  56. transaction: decodeScalar,
  57. statusClass: decodeScalar,
  58. },
  59. });
  60. const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
  61. undefined,
  62. [],
  63. 10
  64. );
  65. const handleStatusClassChange = newStatusClass => {
  66. trackAnalytics('performance_views.sample_spans.filter_updated', {
  67. filter: 'status',
  68. new_state: newStatusClass.value,
  69. organization,
  70. source: ModuleName.CACHE,
  71. });
  72. router.replace({
  73. pathname: location.pathname,
  74. query: {
  75. ...location.query,
  76. statusClass: newStatusClass.value,
  77. },
  78. });
  79. };
  80. // `detailKey` controls whether the panel is open. If all required properties are ailable, concat them to make a key, otherwise set to `undefined` and hide the panel
  81. const detailKey = query.transaction
  82. ? [query.transaction].filter(Boolean).join(':')
  83. : undefined;
  84. const isPanelOpen = Boolean(detailKey);
  85. const filters: SpanMetricsQueryFilters = {
  86. ...BASE_FILTERS,
  87. transaction: query.transaction,
  88. 'project.id': query.project,
  89. };
  90. const {data: cacheHitRateData, isLoading: isCacheHitRateLoading} = useSpanMetricsSeries(
  91. {
  92. search: MutableSearch.fromQueryObject(filters satisfies SpanMetricsQueryFilters),
  93. yAxis: [`${SpanFunction.CACHE_MISS_RATE}()`],
  94. },
  95. Referrer.SAMPLES_CACHE_HIT_MISS_CHART
  96. );
  97. const {data: cacheTransactionMetrics, isFetching: areCacheTransactionMetricsFetching} =
  98. useSpanMetrics(
  99. {
  100. search: MutableSearch.fromQueryObject(filters),
  101. fields: [
  102. `${SpanFunction.SPM}()`,
  103. `${SpanFunction.CACHE_MISS_RATE}()`,
  104. `${SpanFunction.TIME_SPENT_PERCENTAGE}()`,
  105. `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
  106. `avg(${SpanMetricsField.CACHE_ITEM_SIZE})`,
  107. ],
  108. enabled: isPanelOpen,
  109. },
  110. Referrer.SAMPLES_CACHE_METRICS_RIBBON
  111. );
  112. const {data: transactionDurationData, isLoading: isTransactionDurationLoading} =
  113. useMetrics(
  114. {
  115. search: MutableSearch.fromQueryObject({
  116. transaction: query.transaction,
  117. } satisfies MetricsQueryFilters),
  118. fields: [`avg(${MetricsFields.TRANSACTION_DURATION})`],
  119. enabled: isPanelOpen && Boolean(query.transaction),
  120. },
  121. Referrer.SAMPLES_CACHE_TRANSACTION_DURATION
  122. );
  123. const sampleFilters: SpanIndexedQueryFilters = {
  124. ...BASE_FILTERS,
  125. transaction: query.transaction,
  126. project_id: query.project,
  127. };
  128. const useIndexedCacheSpans = (isCacheHit, limit) =>
  129. useIndexedSpans({
  130. search: MutableSearch.fromQueryObject({...sampleFilters, 'cache.hit': isCacheHit}),
  131. fields: [
  132. SpanIndexedField.PROJECT,
  133. SpanIndexedField.TRACE,
  134. SpanIndexedField.TRANSACTION_ID,
  135. SpanIndexedField.ID,
  136. SpanIndexedField.TIMESTAMP,
  137. SpanIndexedField.SPAN_DESCRIPTION,
  138. SpanIndexedField.CACHE_HIT,
  139. SpanIndexedField.SPAN_OP,
  140. SpanIndexedField.CACHE_ITEM_SIZE,
  141. ],
  142. sorts: [SPAN_SAMPLES_SORT],
  143. limit: limit,
  144. enabled: isPanelOpen,
  145. referrer: Referrer.SAMPLES_CACHE_SPAN_SAMPLES,
  146. });
  147. // display half hits and half misses by default
  148. let cacheHitSamplesLimit = SPAN_SAMPLE_LIMIT / 2;
  149. let cacheMissSamplesLimit = SPAN_SAMPLE_LIMIT / 2;
  150. if (query.statusClass === 'hit') {
  151. cacheHitSamplesLimit = SPAN_SAMPLE_LIMIT;
  152. cacheMissSamplesLimit = -1;
  153. } else if (query.statusClass === 'miss') {
  154. cacheHitSamplesLimit = -1;
  155. cacheMissSamplesLimit = SPAN_SAMPLE_LIMIT;
  156. }
  157. const {
  158. data: cacheHitSamples,
  159. isFetching: isCacheHitsFetching,
  160. refetch: refetchCacheHits,
  161. } = useIndexedCacheSpans('true', cacheHitSamplesLimit);
  162. const {
  163. data: cacheMissSamples,
  164. isFetching: isCacheMissesFetching,
  165. refetch: refetchCacheMisses,
  166. } = useIndexedCacheSpans('false', cacheMissSamplesLimit);
  167. const cacheSamples = [...(cacheHitSamples || []), ...(cacheMissSamples || [])];
  168. const {
  169. data: transactionData,
  170. error: transactionError,
  171. isFetching: isFetchingTransactions,
  172. } = useTransactions(
  173. cacheSamples?.map(span => span['transaction.id']) || [],
  174. Referrer.SAMPLES_CACHE_SPAN_SAMPLES
  175. );
  176. const transactionDurationsMap = keyBy(transactionData, 'id');
  177. const spansWithDuration =
  178. cacheSamples?.map(span => ({
  179. ...span,
  180. 'transaction.duration':
  181. transactionDurationsMap[span['transaction.id']]?.['transaction.duration'],
  182. })) || [];
  183. const {projects} = useProjects();
  184. const project = projects.find(p => query.project === p.id);
  185. const handleClose = () => {
  186. router.replace({
  187. pathname: router.location.pathname,
  188. query: {
  189. ...router.location.query,
  190. transaction: undefined,
  191. transactionMethod: undefined,
  192. },
  193. });
  194. };
  195. const handleOpen = useCallback(() => {
  196. if (query.transaction) {
  197. trackAnalytics('performance_views.sample_spans.opened', {
  198. organization,
  199. source: ModuleName.CACHE,
  200. });
  201. }
  202. }, [organization, query.transaction]);
  203. const handleRefetch = () => {
  204. refetchCacheHits();
  205. refetchCacheMisses();
  206. };
  207. return (
  208. <PageAlertProvider>
  209. <DetailPanel detailKey={detailKey} onClose={handleClose} onOpen={handleOpen}>
  210. <ModuleLayout.Layout>
  211. <ModuleLayout.Full>
  212. <HeaderContainer>
  213. {project && (
  214. <SpanSummaryProjectAvatar
  215. project={project}
  216. direction="left"
  217. size={40}
  218. hasTooltip
  219. tooltip={project.slug}
  220. />
  221. )}
  222. <TitleContainer>
  223. <Title>
  224. <Link
  225. to={normalizeUrl(
  226. `/organizations/${organization.slug}/performance/summary?${qs.stringify(
  227. {
  228. project: query.project,
  229. transaction: query.transaction,
  230. }
  231. )}`
  232. )}
  233. >
  234. {query.transaction}
  235. </Link>
  236. </Title>
  237. </TitleContainer>
  238. </HeaderContainer>
  239. </ModuleLayout.Full>
  240. <ModuleLayout.Full>
  241. <MetricsRibbon>
  242. <MetricReadout
  243. align="left"
  244. title={DataTitles[`avg(${SpanMetricsField.CACHE_ITEM_SIZE})`]}
  245. value={
  246. cacheTransactionMetrics?.[0]?.[
  247. `avg(${SpanMetricsField.CACHE_ITEM_SIZE})`
  248. ]
  249. }
  250. unit={SizeUnit.BYTE}
  251. isLoading={areCacheTransactionMetricsFetching}
  252. />
  253. <MetricReadout
  254. align="left"
  255. title={getThroughputTitle('cache')}
  256. value={cacheTransactionMetrics?.[0]?.[`${SpanFunction.SPM}()`]}
  257. unit={RateUnit.PER_MINUTE}
  258. isLoading={areCacheTransactionMetricsFetching}
  259. />
  260. <MetricReadout
  261. align="left"
  262. title={DataTitles[`avg(${MetricsFields.TRANSACTION_DURATION})`]}
  263. value={
  264. transactionDurationData?.[0]?.[
  265. `avg(${MetricsFields.TRANSACTION_DURATION})`
  266. ]
  267. }
  268. unit={DurationUnit.MILLISECOND}
  269. isLoading={isTransactionDurationLoading}
  270. />
  271. <MetricReadout
  272. align="left"
  273. title={DataTitles.cacheMissRate}
  274. value={
  275. cacheTransactionMetrics?.[0]?.[`${SpanFunction.CACHE_MISS_RATE}()`]
  276. }
  277. unit="percentage"
  278. isLoading={areCacheTransactionMetricsFetching}
  279. />
  280. <MetricReadout
  281. align="left"
  282. title={DataTitles.timeSpent}
  283. value={cacheTransactionMetrics?.[0]?.['sum(span.self_time)']}
  284. unit={DurationUnit.MILLISECOND}
  285. tooltip={getTimeSpentExplanation(
  286. cacheTransactionMetrics?.[0]?.['time_spent_percentage()']
  287. )}
  288. isLoading={areCacheTransactionMetricsFetching}
  289. />
  290. </MetricsRibbon>
  291. </ModuleLayout.Full>
  292. <ModuleLayout.Full>
  293. <CompactSelect
  294. value={query.statusClass}
  295. options={CACHE_STATUS_OPTIONS}
  296. onChange={handleStatusClassChange}
  297. triggerProps={{
  298. prefix: t('Status'),
  299. }}
  300. />
  301. </ModuleLayout.Full>
  302. <ModuleLayout.Half>
  303. <CacheHitMissChart
  304. isLoading={isCacheHitRateLoading}
  305. series={cacheHitRateData[`cache_miss_rate()`]}
  306. />
  307. </ModuleLayout.Half>
  308. <ModuleLayout.Half>
  309. <TransactionDurationChart
  310. samples={spansWithDuration}
  311. averageTransactionDuration={
  312. transactionDurationData?.[0]?.[
  313. `avg(${MetricsFields.TRANSACTION_DURATION})`
  314. ]
  315. }
  316. highlightedSpanId={highlightedSpanId}
  317. onHighlight={highlights => {
  318. const firstHighlight = highlights[0];
  319. if (!firstHighlight) {
  320. setHighlightedSpanId(undefined);
  321. return;
  322. }
  323. const sample = findSampleFromDataPoint<(typeof spansWithDuration)[0]>(
  324. firstHighlight.dataPoint,
  325. spansWithDuration,
  326. 'transaction.duration'
  327. );
  328. setHighlightedSpanId(sample?.span_id);
  329. }}
  330. />
  331. </ModuleLayout.Half>
  332. <Fragment>
  333. <ModuleLayout.Full>
  334. <SpanSamplesTable
  335. data={spansWithDuration ?? []}
  336. meta={{
  337. fields: {
  338. 'transaction.duration': 'duration',
  339. [SpanIndexedField.CACHE_ITEM_SIZE]: 'size',
  340. },
  341. units: {[SpanIndexedField.CACHE_ITEM_SIZE]: 'byte'},
  342. }}
  343. isLoading={
  344. isCacheHitsFetching || isCacheMissesFetching || isFetchingTransactions
  345. }
  346. highlightedSpanId={highlightedSpanId}
  347. onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
  348. onSampleMouseOut={() => setHighlightedSpanId(undefined)}
  349. error={transactionError}
  350. />
  351. </ModuleLayout.Full>
  352. </Fragment>
  353. <Fragment>
  354. <ModuleLayout.Full>
  355. <Button
  356. onClick={() => {
  357. trackAnalytics(
  358. 'performance_views.sample_spans.try_different_samples_clicked',
  359. {organization, source: ModuleName.CACHE}
  360. );
  361. handleRefetch();
  362. }}
  363. >
  364. {t('Try Different Samples')}
  365. </Button>
  366. </ModuleLayout.Full>
  367. </Fragment>
  368. </ModuleLayout.Layout>
  369. </DetailPanel>
  370. </PageAlertProvider>
  371. );
  372. }
  373. const SPAN_SAMPLE_LIMIT = 10;
  374. const SPAN_SAMPLES_SORT = {
  375. field: 'span_id',
  376. kind: 'desc' as const,
  377. };
  378. const CACHE_STATUS_OPTIONS = [
  379. {
  380. value: '',
  381. label: t('All'),
  382. },
  383. {
  384. value: 'hit',
  385. label: t('Hit'),
  386. },
  387. {
  388. value: 'miss',
  389. label: t('Miss'),
  390. },
  391. ];
  392. const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
  393. padding-right: ${space(1)};
  394. `;
  395. const HeaderContainer = styled('div')`
  396. display: grid;
  397. grid-template-rows: auto auto auto;
  398. @media (min-width: ${p => p.theme.breakpoints.small}) {
  399. grid-template-rows: auto;
  400. grid-template-columns: auto 1fr auto;
  401. }
  402. `;
  403. const TitleContainer = styled('div')`
  404. width: 100%;
  405. position: relative;
  406. height: 40px;
  407. `;
  408. const Title = styled('h4')`
  409. position: absolute;
  410. bottom: 0;
  411. margin-bottom: 0;
  412. `;
  413. const MetricsRibbon = styled('div')`
  414. display: flex;
  415. flex-wrap: wrap;
  416. gap: ${space(4)};
  417. `;