samplePanel.tsx 16 KB

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