httpSamplesPanel.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import {Fragment} 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 {SegmentedControl} from 'sentry/components/segmentedControl';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields';
  12. import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  13. import {decodeScalar} from 'sentry/utils/queryString';
  14. import {
  15. EMPTY_OPTION_VALUE,
  16. escapeFilterValue,
  17. MutableSearch,
  18. } from 'sentry/utils/tokenizeSearch';
  19. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  20. import {useLocation} from 'sentry/utils/useLocation';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import useProjects from 'sentry/utils/useProjects';
  23. import useRouter from 'sentry/utils/useRouter';
  24. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  25. import {AverageValueMarkLine} from 'sentry/views/performance/charts/averageValueMarkLine';
  26. import {DurationChart} from 'sentry/views/performance/http/charts/durationChart';
  27. import {ResponseCodeCountChart} from 'sentry/views/performance/http/charts/responseCodeCountChart';
  28. import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/performance/http/data/definitions';
  29. import {useSpanSamples} from 'sentry/views/performance/http/data/useSpanSamples';
  30. import decodePanel from 'sentry/views/performance/http/queryParameterDecoders/panel';
  31. import decodeResponseCodeClass from 'sentry/views/performance/http/queryParameterDecoders/responseCodeClass';
  32. import {Referrer} from 'sentry/views/performance/http/referrers';
  33. import {SpanSamplesTable} from 'sentry/views/performance/http/tables/spanSamplesTable';
  34. import {useDebouncedState} from 'sentry/views/performance/http/useDebouncedState';
  35. import {MetricReadout} from 'sentry/views/performance/metricReadout';
  36. import * as ModuleLayout from 'sentry/views/performance/moduleLayout';
  37. import {computeAxisMax} from 'sentry/views/starfish/components/chart';
  38. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  39. import {getTimeSpentExplanation} from 'sentry/views/starfish/components/tableCells/timeSpentCell';
  40. import {useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover';
  41. import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useDiscoverSeries';
  42. import {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans';
  43. import {useSpanMetricsTopNSeries} from 'sentry/views/starfish/queries/useSpanMetricsTopNSeries';
  44. import {
  45. ModuleName,
  46. SpanFunction,
  47. SpanIndexedField,
  48. SpanMetricsField,
  49. type SpanMetricsQueryFilters,
  50. } from 'sentry/views/starfish/types';
  51. import {findSampleFromDataPoint} from 'sentry/views/starfish/utils/chart/findDataPoint';
  52. import {DataTitles, getThroughputTitle} from 'sentry/views/starfish/views/spans/types';
  53. import {useSampleScatterPlotSeries} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries';
  54. export function HTTPSamplesPanel() {
  55. const router = useRouter();
  56. const location = useLocation();
  57. const query = useLocationQuery({
  58. fields: {
  59. project: decodeScalar,
  60. domain: decodeScalar,
  61. transaction: decodeScalar,
  62. transactionMethod: decodeScalar,
  63. panel: decodePanel,
  64. responseCodeClass: decodeResponseCodeClass,
  65. },
  66. });
  67. const organization = useOrganization();
  68. const {projects} = useProjects();
  69. const project = projects.find(p => query.project === p.id);
  70. const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
  71. undefined,
  72. [],
  73. SAMPLE_HOVER_DEBOUNCE
  74. );
  75. // `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
  76. const detailKey = query.transaction
  77. ? [query.domain, query.transactionMethod, query.transaction].filter(Boolean).join(':')
  78. : undefined;
  79. const handlePanelChange = newPanelName => {
  80. router.replace({
  81. pathname: location.pathname,
  82. query: {
  83. ...location.query,
  84. panel: newPanelName,
  85. },
  86. });
  87. };
  88. const handleResponseCodeClassChange = newResponseCodeClass => {
  89. router.replace({
  90. pathname: location.pathname,
  91. query: {
  92. ...location.query,
  93. responseCodeClass: newResponseCodeClass.value,
  94. },
  95. });
  96. };
  97. const isPanelOpen = Boolean(detailKey);
  98. // The ribbon is above the data selectors, and not affected by them. So, it has its own filters.
  99. const ribbonFilters: SpanMetricsQueryFilters = {
  100. 'span.module': ModuleName.HTTP,
  101. 'span.domain':
  102. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  103. transaction: query.transaction,
  104. };
  105. // These filters are for the charts and samples tables
  106. const filters: SpanMetricsQueryFilters = {
  107. 'span.module': ModuleName.HTTP,
  108. 'span.domain':
  109. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  110. transaction: query.transaction,
  111. };
  112. const responseCodeInRange = query.responseCodeClass
  113. ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code =>
  114. code.startsWith(query.responseCodeClass)
  115. )
  116. : [];
  117. if (responseCodeInRange.length > 0) {
  118. // TODO: Allow automatic array parameter concatenation
  119. filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`;
  120. }
  121. const search = MutableSearch.fromQueryObject(filters);
  122. const {
  123. data: domainTransactionMetrics,
  124. isFetching: areDomainTransactionMetricsFetching,
  125. } = useSpanMetrics(
  126. {
  127. search: MutableSearch.fromQueryObject(ribbonFilters),
  128. fields: [
  129. `${SpanFunction.SPM}()`,
  130. `avg(${SpanMetricsField.SPAN_SELF_TIME})`,
  131. `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
  132. 'http_response_rate(3)',
  133. 'http_response_rate(4)',
  134. 'http_response_rate(5)',
  135. `${SpanFunction.TIME_SPENT_PERCENTAGE}()`,
  136. ],
  137. enabled: isPanelOpen,
  138. },
  139. Referrer.SAMPLES_PANEL_METRICS_RIBBON
  140. );
  141. const {
  142. isFetching: isDurationDataFetching,
  143. data: durationData,
  144. error: durationError,
  145. } = useSpanMetricsSeries(
  146. {
  147. search,
  148. yAxis: [`avg(span.self_time)`],
  149. enabled: isPanelOpen && query.panel === 'duration',
  150. },
  151. Referrer.SAMPLES_PANEL_DURATION_CHART
  152. );
  153. const {
  154. isFetching: isResponseCodeDataLoading,
  155. data: responseCodeData,
  156. error: responseCodeError,
  157. } = useSpanMetricsTopNSeries({
  158. search,
  159. fields: ['span.status_code', 'count()'],
  160. yAxis: ['count()'],
  161. topEvents: 5,
  162. enabled: isPanelOpen && query.panel === 'status',
  163. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART,
  164. });
  165. // NOTE: Due to some data confusion, the `domain` column in the spans table can either be `null` or `""`. Searches like `"!has:span.domain"` are turned into the ClickHouse clause `isNull(domain)`, and do not match the empty string. We need a query that matches empty strings _and_ null_ which is `(!has:domain OR domain:[""])`. This hack can be removed in August 2024, once https://github.com/getsentry/snuba/pull/5780 has been deployed for 90 days and all `""` domains have fallen out of the data retention window. Also, `null` domains will become more rare as people upgrade the JS SDK to versions that populate the `server.address` span attribute
  166. const sampleSpansSearch = MutableSearch.fromQueryObject({
  167. ...filters,
  168. 'span.domain': undefined,
  169. });
  170. if (query.domain === '') {
  171. sampleSpansSearch.addOp('(');
  172. sampleSpansSearch.addFilterValue('!has', 'span.domain');
  173. sampleSpansSearch.addOp('OR');
  174. // HACK: Use `addOp` to add the condition `'span.domain:[""]'` and avoid escaping the double quotes. Ideally there'd be a way to specify this explicitly, but this whole thing is a hack anyway. Once a plain `!has:span.domain` condition works, this is not necessary
  175. sampleSpansSearch.addOp('span.domain:[""]');
  176. sampleSpansSearch.addOp(')');
  177. } else {
  178. sampleSpansSearch.addFilterValue('span.domain', query.domain);
  179. }
  180. const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]);
  181. const {
  182. data: durationSamplesData,
  183. isFetching: isDurationSamplesDataFetching,
  184. error: durationSamplesDataError,
  185. refetch: refetchDurationSpanSamples,
  186. } = useSpanSamples({
  187. search: sampleSpansSearch,
  188. fields: [
  189. SpanIndexedField.TRACE,
  190. SpanIndexedField.TRANSACTION_ID,
  191. SpanIndexedField.SPAN_DESCRIPTION,
  192. SpanIndexedField.RESPONSE_CODE,
  193. ],
  194. min: 0,
  195. max: durationAxisMax,
  196. enabled: isPanelOpen && query.panel === 'duration' && durationAxisMax > 0,
  197. referrer: Referrer.SAMPLES_PANEL_DURATION_SAMPLES,
  198. });
  199. const {
  200. data: responseCodeSamplesData,
  201. isFetching: isResponseCodeSamplesDataFetching,
  202. error: responseCodeSamplesDataError,
  203. refetch: refetchResponseCodeSpanSamples,
  204. } = useIndexedSpans({
  205. search: sampleSpansSearch,
  206. fields: [
  207. SpanIndexedField.PROJECT,
  208. SpanIndexedField.TRACE,
  209. SpanIndexedField.TRANSACTION_ID,
  210. SpanIndexedField.ID,
  211. SpanIndexedField.TIMESTAMP,
  212. SpanIndexedField.SPAN_DESCRIPTION,
  213. SpanIndexedField.RESPONSE_CODE,
  214. ],
  215. sorts: [SPAN_SAMPLES_SORT],
  216. limit: SPAN_SAMPLE_LIMIT,
  217. enabled: isPanelOpen && query.panel === 'status',
  218. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES,
  219. });
  220. const sampledSpanDataSeries = useSampleScatterPlotSeries(
  221. durationSamplesData,
  222. domainTransactionMetrics?.[0]?.['avg(span.self_time)'],
  223. highlightedSpanId
  224. );
  225. const handleClose = () => {
  226. router.replace({
  227. pathname: router.location.pathname,
  228. query: {
  229. ...router.location.query,
  230. transaction: undefined,
  231. transactionMethod: undefined,
  232. },
  233. });
  234. };
  235. return (
  236. <PageAlertProvider>
  237. <DetailPanel detailKey={detailKey} onClose={handleClose}>
  238. <ModuleLayout.Layout>
  239. <ModuleLayout.Full>
  240. <HeaderContainer>
  241. {project && (
  242. <SpanSummaryProjectAvatar
  243. project={project}
  244. direction="left"
  245. size={40}
  246. hasTooltip
  247. tooltip={project.slug}
  248. />
  249. )}
  250. <TitleContainer>
  251. <Title>
  252. <Link
  253. to={normalizeUrl(
  254. `/organizations/${organization.slug}/performance/summary?${qs.stringify(
  255. {
  256. project: query.project,
  257. transaction: query.transaction,
  258. }
  259. )}`
  260. )}
  261. >
  262. {query.transaction &&
  263. query.transactionMethod &&
  264. !query.transaction.startsWith(query.transactionMethod)
  265. ? `${query.transactionMethod} ${query.transaction}`
  266. : query.transaction}
  267. </Link>
  268. </Title>
  269. </TitleContainer>
  270. </HeaderContainer>
  271. </ModuleLayout.Full>
  272. <ModuleLayout.Full>
  273. <MetricsRibbon>
  274. <MetricReadout
  275. align="left"
  276. title={getThroughputTitle('http')}
  277. value={domainTransactionMetrics?.[0]?.[`${SpanFunction.SPM}()`]}
  278. unit={RateUnit.PER_MINUTE}
  279. isLoading={areDomainTransactionMetricsFetching}
  280. />
  281. <MetricReadout
  282. align="left"
  283. title={DataTitles.avg}
  284. value={
  285. domainTransactionMetrics?.[0]?.[
  286. `avg(${SpanMetricsField.SPAN_SELF_TIME})`
  287. ]
  288. }
  289. unit={DurationUnit.MILLISECOND}
  290. isLoading={areDomainTransactionMetricsFetching}
  291. />
  292. <MetricReadout
  293. align="left"
  294. title={t('3XXs')}
  295. value={domainTransactionMetrics?.[0]?.[`http_response_rate(3)`]}
  296. unit="percentage"
  297. isLoading={areDomainTransactionMetricsFetching}
  298. />
  299. <MetricReadout
  300. align="left"
  301. title={t('4XXs')}
  302. value={domainTransactionMetrics?.[0]?.[`http_response_rate(4)`]}
  303. unit="percentage"
  304. isLoading={areDomainTransactionMetricsFetching}
  305. />
  306. <MetricReadout
  307. align="left"
  308. title={t('5XXs')}
  309. value={domainTransactionMetrics?.[0]?.[`http_response_rate(5)`]}
  310. unit="percentage"
  311. isLoading={areDomainTransactionMetricsFetching}
  312. />
  313. <MetricReadout
  314. align="left"
  315. title={DataTitles.timeSpent}
  316. value={domainTransactionMetrics?.[0]?.['sum(span.self_time)']}
  317. unit={DurationUnit.MILLISECOND}
  318. tooltip={getTimeSpentExplanation(
  319. domainTransactionMetrics?.[0]?.['time_spent_percentage()'],
  320. 'http.client'
  321. )}
  322. isLoading={areDomainTransactionMetricsFetching}
  323. />
  324. </MetricsRibbon>
  325. </ModuleLayout.Full>
  326. <ModuleLayout.Full>
  327. <PanelControls>
  328. <SegmentedControl
  329. value={query.panel}
  330. onChange={handlePanelChange}
  331. aria-label={t('Choose breakdown type')}
  332. >
  333. <SegmentedControl.Item key="duration">
  334. {t('By Duration')}
  335. </SegmentedControl.Item>
  336. <SegmentedControl.Item key="status">
  337. {t('By Response Code')}
  338. </SegmentedControl.Item>
  339. </SegmentedControl>
  340. <CompactSelect
  341. value={query.responseCodeClass}
  342. options={HTTP_RESPONSE_CODE_CLASS_OPTIONS}
  343. onChange={handleResponseCodeClassChange}
  344. triggerProps={{
  345. prefix: t('Response Code'),
  346. }}
  347. />
  348. </PanelControls>
  349. </ModuleLayout.Full>
  350. {query.panel === 'duration' && (
  351. <Fragment>
  352. <ModuleLayout.Full>
  353. <DurationChart
  354. series={[
  355. {
  356. ...durationData[`avg(span.self_time)`],
  357. markLine: AverageValueMarkLine(),
  358. },
  359. ]}
  360. scatterPlot={sampledSpanDataSeries}
  361. onHighlight={highlights => {
  362. const firstHighlight = highlights[0];
  363. if (!firstHighlight) {
  364. setHighlightedSpanId(undefined);
  365. return;
  366. }
  367. const sample = findSampleFromDataPoint<
  368. (typeof durationSamplesData)[0]
  369. >(
  370. firstHighlight.dataPoint,
  371. durationSamplesData,
  372. SpanIndexedField.SPAN_SELF_TIME
  373. );
  374. setHighlightedSpanId(sample?.span_id);
  375. }}
  376. isLoading={isDurationDataFetching}
  377. error={durationError}
  378. />
  379. </ModuleLayout.Full>
  380. <ModuleLayout.Full>
  381. <SpanSamplesTable
  382. data={durationSamplesData}
  383. isLoading={isDurationDataFetching || isDurationSamplesDataFetching}
  384. highlightedSpanId={highlightedSpanId}
  385. onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
  386. onSampleMouseOut={() => setHighlightedSpanId(undefined)}
  387. error={durationSamplesDataError}
  388. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  389. meta={{
  390. fields: {
  391. 'span.response_code': 'number',
  392. },
  393. units: {},
  394. }}
  395. />
  396. </ModuleLayout.Full>
  397. <ModuleLayout.Full>
  398. <Button onClick={() => refetchDurationSpanSamples()}>
  399. {t('Try Different Samples')}
  400. </Button>
  401. </ModuleLayout.Full>
  402. </Fragment>
  403. )}
  404. {query.panel === 'status' && (
  405. <Fragment>
  406. <ModuleLayout.Full>
  407. <ResponseCodeCountChart
  408. series={Object.values(responseCodeData).filter(Boolean)}
  409. isLoading={isResponseCodeDataLoading}
  410. error={responseCodeError}
  411. />
  412. </ModuleLayout.Full>
  413. <ModuleLayout.Full>
  414. <SpanSamplesTable
  415. data={responseCodeSamplesData ?? []}
  416. isLoading={isResponseCodeSamplesDataFetching}
  417. error={responseCodeSamplesDataError}
  418. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  419. meta={{
  420. fields: {
  421. 'span.response_code': 'number',
  422. },
  423. units: {},
  424. }}
  425. />
  426. </ModuleLayout.Full>
  427. <ModuleLayout.Full>
  428. <Button onClick={() => refetchResponseCodeSpanSamples()}>
  429. {t('Try Different Samples')}
  430. </Button>
  431. </ModuleLayout.Full>
  432. </Fragment>
  433. )}
  434. </ModuleLayout.Layout>
  435. </DetailPanel>
  436. </PageAlertProvider>
  437. );
  438. }
  439. const SAMPLE_HOVER_DEBOUNCE = 10;
  440. const SPAN_SAMPLE_LIMIT = 10;
  441. // This is functionally a random sort, which is what we want
  442. const SPAN_SAMPLES_SORT = {
  443. field: 'span_id',
  444. kind: 'desc' as const,
  445. };
  446. const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
  447. padding-right: ${space(1)};
  448. `;
  449. const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [
  450. {
  451. value: '',
  452. label: t('All'),
  453. },
  454. {
  455. value: '2',
  456. label: t('2XXs'),
  457. },
  458. {
  459. value: '3',
  460. label: t('3XXs'),
  461. },
  462. {
  463. value: '4',
  464. label: t('4XXs'),
  465. },
  466. {
  467. value: '5',
  468. label: t('5XXs'),
  469. },
  470. ];
  471. const HeaderContainer = styled('div')`
  472. display: grid;
  473. grid-template-rows: auto auto auto;
  474. @media (min-width: ${p => p.theme.breakpoints.small}) {
  475. grid-template-rows: auto;
  476. grid-template-columns: auto 1fr auto;
  477. }
  478. `;
  479. const TitleContainer = styled('div')`
  480. width: 100%;
  481. position: relative;
  482. height: 40px;
  483. `;
  484. const Title = styled('h4')`
  485. position: absolute;
  486. bottom: 0;
  487. margin-bottom: 0;
  488. `;
  489. const MetricsRibbon = styled('div')`
  490. display: flex;
  491. flex-wrap: wrap;
  492. gap: ${space(4)};
  493. `;
  494. const PanelControls = styled('div')`
  495. display: flex;
  496. justify-content: space-between;
  497. gap: ${space(2)};
  498. `;