samplePanel.tsx 16 KB

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