httpSamplesPanel.tsx 20 KB

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