samplePanel.tsx 16 KB

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