httpSamplesPanel.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import {Fragment, useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import Feature from 'sentry/components/acl/feature';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {Button} from 'sentry/components/button';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import Link from 'sentry/components/links/link';
  10. import {SegmentedControl} from 'sentry/components/segmentedControl';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {trackAnalytics} from 'sentry/utils/analytics';
  14. import {DurationUnit, RateUnit} 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 {
  19. EMPTY_OPTION_VALUE,
  20. escapeFilterValue,
  21. MutableSearch,
  22. } from 'sentry/utils/tokenizeSearch';
  23. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  24. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import usePageFilters from 'sentry/utils/usePageFilters';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import useRouter from 'sentry/utils/useRouter';
  30. import {computeAxisMax} from 'sentry/views/insights/common/components/chart';
  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. useSpanMetrics,
  38. useSpansIndexed,
  39. } from 'sentry/views/insights/common/queries/useDiscover';
  40. import {useSpanMetricsSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
  41. import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries';
  42. import {AverageValueMarkLine} from 'sentry/views/insights/common/utils/averageValueMarkLine';
  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 {useSampleScatterPlotSeries} from 'sentry/views/insights/common/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries';
  49. import {DurationChart} from 'sentry/views/insights/http/components/charts/durationChart';
  50. import {ResponseCodeCountChart} from 'sentry/views/insights/http/components/charts/responseCodeCountChart';
  51. import {SpanSamplesTable} from 'sentry/views/insights/http/components/tables/spanSamplesTable';
  52. import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/insights/http/data/definitions';
  53. import {useSpanSamples} from 'sentry/views/insights/http/queries/useSpanSamples';
  54. import {Referrer} from 'sentry/views/insights/http/referrers';
  55. import {BASE_FILTERS} from 'sentry/views/insights/http/settings';
  56. import decodePanel from 'sentry/views/insights/http/utils/queryParameterDecoders/panel';
  57. import decodeResponseCodeClass from 'sentry/views/insights/http/utils/queryParameterDecoders/responseCodeClass';
  58. import {useDebouncedState} from 'sentry/views/insights/http/utils/useDebouncedState';
  59. import {
  60. ModuleName,
  61. SpanFunction,
  62. SpanIndexedField,
  63. SpanMetricsField,
  64. type SpanMetricsQueryFilters,
  65. } from 'sentry/views/insights/types';
  66. import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceMetadataHeader';
  67. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  68. export function HTTPSamplesPanel() {
  69. const router = useRouter();
  70. const location = useLocation();
  71. const query = useLocationQuery({
  72. fields: {
  73. project: decodeScalar,
  74. domain: decodeScalar,
  75. transaction: decodeScalar,
  76. transactionMethod: decodeScalar,
  77. panel: decodePanel,
  78. responseCodeClass: decodeResponseCodeClass,
  79. spanSearchQuery: decodeScalar,
  80. },
  81. });
  82. const organization = useOrganization();
  83. const {projects} = useProjects();
  84. const {selection} = usePageFilters();
  85. const supportedTags = useSpanFieldSupportedTags();
  86. const project = projects.find(p => query.project === p.id);
  87. const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
  88. undefined,
  89. [],
  90. SAMPLE_HOVER_DEBOUNCE
  91. );
  92. // `detailKey` controls whether the panel is open. If all required properties are available, concat them to make a key, otherwise set to `undefined` and hide the panel
  93. const detailKey = query.transaction
  94. ? [query.domain, query.transactionMethod, query.transaction].filter(Boolean).join(':')
  95. : undefined;
  96. const handlePanelChange = newPanelName => {
  97. trackAnalytics('performance_views.sample_spans.filter_updated', {
  98. filter: 'panel',
  99. new_state: newPanelName,
  100. organization,
  101. source: ModuleName.HTTP,
  102. });
  103. router.replace({
  104. pathname: location.pathname,
  105. query: {
  106. ...location.query,
  107. panel: newPanelName,
  108. },
  109. });
  110. };
  111. const handleResponseCodeClassChange = newResponseCodeClass => {
  112. trackAnalytics('performance_views.sample_spans.filter_updated', {
  113. filter: 'status_code',
  114. new_state: newResponseCodeClass.value,
  115. organization,
  116. source: ModuleName.HTTP,
  117. });
  118. router.replace({
  119. pathname: location.pathname,
  120. query: {
  121. ...location.query,
  122. responseCodeClass: newResponseCodeClass.value,
  123. },
  124. });
  125. };
  126. const isPanelOpen = Boolean(detailKey);
  127. // The ribbon is above the data selectors, and not affected by them. So, it has its own filters.
  128. const ribbonFilters: SpanMetricsQueryFilters = {
  129. ...BASE_FILTERS,
  130. 'span.domain':
  131. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  132. transaction: query.transaction,
  133. };
  134. // These filters are for the charts and samples tables
  135. const filters: SpanMetricsQueryFilters = {
  136. ...BASE_FILTERS,
  137. 'span.domain':
  138. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  139. transaction: query.transaction,
  140. };
  141. const responseCodeInRange = query.responseCodeClass
  142. ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code =>
  143. code.startsWith(query.responseCodeClass)
  144. )
  145. : [];
  146. if (responseCodeInRange.length > 0) {
  147. // TODO: Allow automatic array parameter concatenation
  148. filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`;
  149. }
  150. const search = MutableSearch.fromQueryObject(filters);
  151. const {
  152. data: domainTransactionMetrics,
  153. isFetching: areDomainTransactionMetricsFetching,
  154. } = useSpanMetrics(
  155. {
  156. search: MutableSearch.fromQueryObject(ribbonFilters),
  157. fields: [
  158. `${SpanFunction.SPM}()`,
  159. `avg(${SpanMetricsField.SPAN_SELF_TIME})`,
  160. `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
  161. 'http_response_rate(3)',
  162. 'http_response_rate(4)',
  163. 'http_response_rate(5)',
  164. `${SpanFunction.TIME_SPENT_PERCENTAGE}()`,
  165. ],
  166. enabled: isPanelOpen,
  167. },
  168. Referrer.SAMPLES_PANEL_METRICS_RIBBON
  169. );
  170. const {
  171. isFetching: isDurationDataFetching,
  172. data: durationData,
  173. error: durationError,
  174. } = useSpanMetricsSeries(
  175. {
  176. search,
  177. yAxis: [`avg(span.self_time)`],
  178. enabled: isPanelOpen && query.panel === 'duration',
  179. },
  180. Referrer.SAMPLES_PANEL_DURATION_CHART
  181. );
  182. const {
  183. isFetching: isResponseCodeDataLoading,
  184. data: responseCodeData,
  185. error: responseCodeError,
  186. } = useSpanMetricsTopNSeries({
  187. search,
  188. fields: ['span.status_code', 'count()'],
  189. yAxis: ['count()'],
  190. topEvents: 5,
  191. sorts: [
  192. {
  193. kind: 'desc',
  194. field: 'count()',
  195. },
  196. ],
  197. enabled: isPanelOpen && query.panel === 'status',
  198. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART,
  199. });
  200. const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]);
  201. const {
  202. data: durationSamplesData,
  203. isFetching: isDurationSamplesDataFetching,
  204. error: durationSamplesDataError,
  205. refetch: refetchDurationSpanSamples,
  206. } = useSpanSamples({
  207. search,
  208. fields: [
  209. SpanIndexedField.TRACE,
  210. SpanIndexedField.TRANSACTION_ID,
  211. SpanIndexedField.SPAN_DESCRIPTION,
  212. SpanIndexedField.RESPONSE_CODE,
  213. ],
  214. min: 0,
  215. max: durationAxisMax,
  216. enabled: isPanelOpen && query.panel === 'duration' && durationAxisMax > 0,
  217. referrer: Referrer.SAMPLES_PANEL_DURATION_SAMPLES,
  218. });
  219. const {
  220. data: responseCodeSamplesData,
  221. isFetching: isResponseCodeSamplesDataFetching,
  222. error: responseCodeSamplesDataError,
  223. refetch: refetchResponseCodeSpanSamples,
  224. } = useSpansIndexed(
  225. {
  226. search,
  227. fields: [
  228. SpanIndexedField.PROJECT,
  229. SpanIndexedField.TRACE,
  230. SpanIndexedField.TRANSACTION_ID,
  231. SpanIndexedField.ID,
  232. SpanIndexedField.TIMESTAMP,
  233. SpanIndexedField.SPAN_DESCRIPTION,
  234. SpanIndexedField.RESPONSE_CODE,
  235. ],
  236. sorts: [SPAN_SAMPLES_SORT],
  237. limit: SPAN_SAMPLE_LIMIT,
  238. enabled: isPanelOpen && query.panel === 'status',
  239. },
  240. Referrer.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES
  241. );
  242. const sampledSpanDataSeries = useSampleScatterPlotSeries(
  243. durationSamplesData,
  244. domainTransactionMetrics?.[0]?.['avg(span.self_time)'],
  245. highlightedSpanId
  246. );
  247. const handleSearch = (newSpanSearchQuery: string) => {
  248. router.replace({
  249. pathname: location.pathname,
  250. query: {
  251. ...location.query,
  252. spanSearchQuery: newSpanSearchQuery,
  253. },
  254. });
  255. };
  256. const handleClose = () => {
  257. router.replace({
  258. pathname: router.location.pathname,
  259. query: {
  260. ...router.location.query,
  261. transaction: undefined,
  262. transactionMethod: undefined,
  263. },
  264. });
  265. };
  266. const handleOpen = useCallback(() => {
  267. if (query.transaction) {
  268. trackAnalytics('performance_views.sample_spans.opened', {
  269. organization,
  270. source: ModuleName.HTTP,
  271. });
  272. }
  273. }, [organization, query.transaction]);
  274. return (
  275. <PageAlertProvider>
  276. <DetailPanel detailKey={detailKey} onClose={handleClose} onOpen={handleOpen}>
  277. <ModuleLayout.Layout>
  278. <ModuleLayout.Full>
  279. <HeaderContainer>
  280. {project && (
  281. <SpanSummaryProjectAvatar
  282. project={project}
  283. direction="left"
  284. size={40}
  285. hasTooltip
  286. tooltip={project.slug}
  287. />
  288. )}
  289. <Title>
  290. <Link
  291. to={normalizeUrl(
  292. `/organizations/${organization.slug}/performance/summary?${qs.stringify(
  293. {
  294. project: query.project,
  295. transaction: query.transaction,
  296. }
  297. )}`
  298. )}
  299. >
  300. {query.transaction &&
  301. query.transactionMethod &&
  302. !query.transaction.startsWith(query.transactionMethod)
  303. ? `${query.transactionMethod} ${query.transaction}`
  304. : query.transaction}
  305. </Link>
  306. </Title>
  307. </HeaderContainer>
  308. </ModuleLayout.Full>
  309. <ModuleLayout.Full>
  310. <ReadoutRibbon>
  311. <MetricReadout
  312. title={getThroughputTitle('http')}
  313. value={domainTransactionMetrics?.[0]?.[`${SpanFunction.SPM}()`]}
  314. unit={RateUnit.PER_MINUTE}
  315. isLoading={areDomainTransactionMetricsFetching}
  316. />
  317. <MetricReadout
  318. title={DataTitles.avg}
  319. value={
  320. domainTransactionMetrics?.[0]?.[
  321. `avg(${SpanMetricsField.SPAN_SELF_TIME})`
  322. ]
  323. }
  324. unit={DurationUnit.MILLISECOND}
  325. isLoading={areDomainTransactionMetricsFetching}
  326. />
  327. <MetricReadout
  328. title={t('3XXs')}
  329. value={domainTransactionMetrics?.[0]?.[`http_response_rate(3)`]}
  330. unit="percentage"
  331. isLoading={areDomainTransactionMetricsFetching}
  332. />
  333. <MetricReadout
  334. title={t('4XXs')}
  335. value={domainTransactionMetrics?.[0]?.[`http_response_rate(4)`]}
  336. unit="percentage"
  337. isLoading={areDomainTransactionMetricsFetching}
  338. />
  339. <MetricReadout
  340. title={t('5XXs')}
  341. value={domainTransactionMetrics?.[0]?.[`http_response_rate(5)`]}
  342. unit="percentage"
  343. isLoading={areDomainTransactionMetricsFetching}
  344. />
  345. <MetricReadout
  346. title={DataTitles.timeSpent}
  347. value={domainTransactionMetrics?.[0]?.['sum(span.self_time)']}
  348. unit={DurationUnit.MILLISECOND}
  349. tooltip={getTimeSpentExplanation(
  350. domainTransactionMetrics?.[0]?.['time_spent_percentage()'],
  351. 'http.client'
  352. )}
  353. isLoading={areDomainTransactionMetricsFetching}
  354. />
  355. </ReadoutRibbon>
  356. </ModuleLayout.Full>
  357. <ModuleLayout.Full>
  358. <PanelControls>
  359. <SegmentedControl
  360. value={query.panel}
  361. onChange={handlePanelChange}
  362. aria-label={t('Choose breakdown type')}
  363. >
  364. <SegmentedControl.Item key="duration">
  365. {t('By Duration')}
  366. </SegmentedControl.Item>
  367. <SegmentedControl.Item key="status">
  368. {t('By Response Code')}
  369. </SegmentedControl.Item>
  370. </SegmentedControl>
  371. <CompactSelect
  372. value={query.responseCodeClass}
  373. options={HTTP_RESPONSE_CODE_CLASS_OPTIONS}
  374. onChange={handleResponseCodeClassChange}
  375. triggerProps={{
  376. prefix: t('Response Code'),
  377. }}
  378. />
  379. </PanelControls>
  380. </ModuleLayout.Full>
  381. {query.panel === 'duration' && (
  382. <Fragment>
  383. <ModuleLayout.Full>
  384. <DurationChart
  385. series={[
  386. {
  387. ...durationData[`avg(span.self_time)`],
  388. markLine: AverageValueMarkLine(),
  389. },
  390. ]}
  391. scatterPlot={sampledSpanDataSeries}
  392. onHighlight={highlights => {
  393. const firstHighlight = highlights[0];
  394. if (!firstHighlight) {
  395. setHighlightedSpanId(undefined);
  396. return;
  397. }
  398. const sample = findSampleFromDataPoint<
  399. (typeof durationSamplesData)[0]
  400. >(
  401. firstHighlight.dataPoint,
  402. durationSamplesData,
  403. SpanIndexedField.SPAN_SELF_TIME
  404. );
  405. setHighlightedSpanId(sample?.span_id);
  406. }}
  407. isLoading={isDurationDataFetching}
  408. error={durationError}
  409. />
  410. </ModuleLayout.Full>
  411. </Fragment>
  412. )}
  413. {query.panel === 'status' && (
  414. <Fragment>
  415. <ModuleLayout.Full>
  416. <ResponseCodeCountChart
  417. series={Object.values(responseCodeData).filter(Boolean)}
  418. isLoading={isResponseCodeDataLoading}
  419. error={responseCodeError}
  420. />
  421. </ModuleLayout.Full>
  422. </Fragment>
  423. )}
  424. <Feature features="performance-sample-panel-search">
  425. <ModuleLayout.Full>
  426. <SearchBar
  427. searchSource={`${ModuleName.HTTP}-sample-panel`}
  428. query={query.spanSearchQuery}
  429. onSearch={handleSearch}
  430. placeholder={t('Search for span attributes')}
  431. organization={organization}
  432. supportedTags={supportedTags}
  433. dataset={DiscoverDatasets.SPANS_INDEXED}
  434. projectIds={selection.projects}
  435. />
  436. </ModuleLayout.Full>
  437. </Feature>
  438. {query.panel === 'duration' && (
  439. <Fragment>
  440. <ModuleLayout.Full>
  441. <SpanSamplesTable
  442. data={durationSamplesData}
  443. isLoading={isDurationDataFetching || isDurationSamplesDataFetching}
  444. highlightedSpanId={highlightedSpanId}
  445. onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
  446. onSampleMouseOut={() => setHighlightedSpanId(undefined)}
  447. error={durationSamplesDataError}
  448. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  449. meta={{
  450. fields: {
  451. 'span.response_code': 'number',
  452. },
  453. units: {},
  454. }}
  455. referrer={TraceViewSources.REQUESTS_MODULE}
  456. />
  457. </ModuleLayout.Full>
  458. <ModuleLayout.Full>
  459. <Button
  460. onClick={() => {
  461. trackAnalytics(
  462. 'performance_views.sample_spans.try_different_samples_clicked',
  463. {organization, source: ModuleName.HTTP}
  464. );
  465. refetchDurationSpanSamples();
  466. }}
  467. >
  468. {t('Try Different Samples')}
  469. </Button>
  470. </ModuleLayout.Full>
  471. </Fragment>
  472. )}
  473. {query.panel === 'status' && (
  474. <Fragment>
  475. <ModuleLayout.Full>
  476. <SpanSamplesTable
  477. data={responseCodeSamplesData ?? []}
  478. isLoading={isResponseCodeSamplesDataFetching}
  479. error={responseCodeSamplesDataError}
  480. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  481. meta={{
  482. fields: {
  483. 'span.response_code': 'number',
  484. },
  485. units: {},
  486. }}
  487. />
  488. </ModuleLayout.Full>
  489. <ModuleLayout.Full>
  490. <Button
  491. onClick={() => {
  492. trackAnalytics(
  493. 'performance_views.sample_spans.try_different_samples_clicked',
  494. {organization, source: ModuleName.HTTP}
  495. );
  496. refetchResponseCodeSpanSamples();
  497. }}
  498. >
  499. {t('Try Different Samples')}
  500. </Button>
  501. </ModuleLayout.Full>
  502. </Fragment>
  503. )}
  504. </ModuleLayout.Layout>
  505. </DetailPanel>
  506. </PageAlertProvider>
  507. );
  508. }
  509. const SAMPLE_HOVER_DEBOUNCE = 10;
  510. const SPAN_SAMPLE_LIMIT = 10;
  511. // This is functionally a random sort, which is what we want
  512. const SPAN_SAMPLES_SORT = {
  513. field: 'span_id',
  514. kind: 'desc' as const,
  515. };
  516. const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
  517. padding-right: ${space(1)};
  518. `;
  519. const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [
  520. {
  521. value: '',
  522. label: t('All'),
  523. },
  524. {
  525. value: '2',
  526. label: t('2XXs'),
  527. },
  528. {
  529. value: '3',
  530. label: t('3XXs'),
  531. },
  532. {
  533. value: '4',
  534. label: t('4XXs'),
  535. },
  536. {
  537. value: '5',
  538. label: t('5XXs'),
  539. },
  540. ];
  541. // TODO - copy of static/app/views/starfish/views/spanSummaryPage/sampleList/index.tsx
  542. const HeaderContainer = styled('div')`
  543. display: grid;
  544. grid-template-rows: auto auto auto;
  545. align-items: center;
  546. @media (min-width: ${p => p.theme.breakpoints.small}) {
  547. grid-template-rows: auto;
  548. grid-template-columns: auto 1fr;
  549. }
  550. `;
  551. const Title = styled('h4')`
  552. overflow: hidden;
  553. text-overflow: ellipsis;
  554. white-space: nowrap;
  555. margin: 0;
  556. `;
  557. const PanelControls = styled('div')`
  558. display: flex;
  559. justify-content: space-between;
  560. gap: ${space(2)};
  561. `;