httpSamplesPanel.tsx 20 KB

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