httpSamplesPanel.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import {Fragment} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import * as qs from 'query-string';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {Button} from 'sentry/components/button';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  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 {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans';
  41. import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
  42. import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
  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 {DataTitles, getThroughputTitle} from 'sentry/views/starfish/views/spans/types';
  52. import {useSampleScatterPlotSeries} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries';
  53. export function HTTPSamplesPanel() {
  54. const router = useRouter();
  55. const location = useLocation();
  56. const query = useLocationQuery({
  57. fields: {
  58. project: decodeScalar,
  59. domain: decodeScalar,
  60. transaction: decodeScalar,
  61. transactionMethod: decodeScalar,
  62. panel: decodePanel,
  63. responseCodeClass: decodeResponseCodeClass,
  64. },
  65. });
  66. const organization = useOrganization();
  67. const {projects} = useProjects();
  68. const project = projects.find(p => query.project === p.id);
  69. const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
  70. undefined,
  71. [],
  72. SAMPLE_HOVER_DEBOUNCE
  73. );
  74. // `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
  75. const detailKey = query.transaction
  76. ? [query.domain, query.transactionMethod, query.transaction].filter(Boolean).join(':')
  77. : undefined;
  78. const handlePanelChange = newPanelName => {
  79. router.replace({
  80. pathname: location.pathname,
  81. query: {
  82. ...location.query,
  83. panel: newPanelName,
  84. },
  85. });
  86. };
  87. const handleResponseCodeClassChange = newResponseCodeClass => {
  88. router.replace({
  89. pathname: location.pathname,
  90. query: {
  91. ...location.query,
  92. responseCodeClass: newResponseCodeClass.value,
  93. },
  94. });
  95. };
  96. const isPanelOpen = Boolean(detailKey);
  97. // The ribbon is above the data selectors, and not affected by them. So, it has its own filters.
  98. const ribbonFilters: SpanMetricsQueryFilters = {
  99. 'span.module': ModuleName.HTTP,
  100. 'span.domain':
  101. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  102. transaction: query.transaction,
  103. };
  104. // These filters are for the charts and samples tables
  105. const filters: SpanMetricsQueryFilters = {
  106. 'span.module': ModuleName.HTTP,
  107. 'span.domain':
  108. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  109. transaction: query.transaction,
  110. };
  111. const responseCodeInRange = query.responseCodeClass
  112. ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code =>
  113. code.startsWith(query.responseCodeClass)
  114. )
  115. : [];
  116. if (responseCodeInRange.length > 0) {
  117. // TODO: Allow automatic array parameter concatenation
  118. filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`;
  119. }
  120. const search = MutableSearch.fromQueryObject(filters);
  121. const {
  122. data: domainTransactionMetrics,
  123. isFetching: areDomainTransactionMetricsFetching,
  124. } = useSpanMetrics({
  125. search: MutableSearch.fromQueryObject(ribbonFilters),
  126. fields: [
  127. `${SpanFunction.SPM}()`,
  128. `avg(${SpanMetricsField.SPAN_SELF_TIME})`,
  129. `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
  130. 'http_response_rate(3)',
  131. 'http_response_rate(4)',
  132. 'http_response_rate(5)',
  133. `${SpanFunction.TIME_SPENT_PERCENTAGE}()`,
  134. ],
  135. enabled: isPanelOpen,
  136. referrer: Referrer.SAMPLES_PANEL_METRICS_RIBBON,
  137. });
  138. const {
  139. isFetching: isDurationDataFetching,
  140. data: durationData,
  141. error: durationError,
  142. } = useSpanMetricsSeries({
  143. search,
  144. yAxis: [`avg(span.self_time)`],
  145. enabled: isPanelOpen && query.panel === 'duration',
  146. referrer: Referrer.SAMPLES_PANEL_DURATION_CHART,
  147. });
  148. const {
  149. isFetching: isResponseCodeDataLoading,
  150. data: responseCodeData,
  151. error: responseCodeError,
  152. } = useSpanMetricsTopNSeries({
  153. search,
  154. fields: ['span.status_code', 'count()'],
  155. yAxis: ['count()'],
  156. topEvents: 5,
  157. enabled: isPanelOpen && query.panel === 'status',
  158. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART,
  159. });
  160. // 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
  161. const sampleSpansSearch = MutableSearch.fromQueryObject({
  162. ...filters,
  163. 'span.domain': undefined,
  164. });
  165. if (query.domain === '') {
  166. sampleSpansSearch.addOp('(');
  167. sampleSpansSearch.addFilterValue('!has', 'span.domain');
  168. sampleSpansSearch.addOp('OR');
  169. // 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
  170. sampleSpansSearch.addOp('span.domain:[""]');
  171. sampleSpansSearch.addOp(')');
  172. } else {
  173. sampleSpansSearch.addFilterValue('span.domain', query.domain);
  174. }
  175. const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]);
  176. const {
  177. data: durationSamplesData,
  178. isFetching: isDurationSamplesDataFetching,
  179. error: durationSamplesDataError,
  180. refetch: refetchDurationSpanSamples,
  181. } = useSpanSamples({
  182. search: sampleSpansSearch,
  183. fields: [
  184. SpanIndexedField.TRACE,
  185. SpanIndexedField.TRANSACTION_ID,
  186. SpanIndexedField.SPAN_DESCRIPTION,
  187. SpanIndexedField.RESPONSE_CODE,
  188. ],
  189. min: 0,
  190. max: durationAxisMax,
  191. enabled: isPanelOpen && query.panel === 'duration' && durationAxisMax > 0,
  192. referrer: Referrer.SAMPLES_PANEL_DURATION_SAMPLES,
  193. });
  194. const {
  195. data: responseCodeSamplesData,
  196. isFetching: isResponseCodeSamplesDataFetching,
  197. error: responseCodeSamplesDataError,
  198. refetch: refetchResponseCodeSpanSamples,
  199. } = useIndexedSpans({
  200. search: sampleSpansSearch,
  201. fields: [
  202. SpanIndexedField.PROJECT,
  203. SpanIndexedField.TRACE,
  204. SpanIndexedField.TRANSACTION_ID,
  205. SpanIndexedField.ID,
  206. SpanIndexedField.TIMESTAMP,
  207. SpanIndexedField.SPAN_DESCRIPTION,
  208. SpanIndexedField.RESPONSE_CODE,
  209. ],
  210. sorts: [SPAN_SAMPLES_SORT],
  211. limit: SPAN_SAMPLE_LIMIT,
  212. enabled: isPanelOpen && query.panel === 'status',
  213. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES,
  214. });
  215. const sampledSpanDataSeries = useSampleScatterPlotSeries(
  216. durationSamplesData,
  217. domainTransactionMetrics?.[0]?.['avg(span.self_time)'],
  218. highlightedSpanId
  219. );
  220. const findSampleFromDataPoint = (dataPoint: {name: string | number; value: number}) => {
  221. return durationSamplesData.find(
  222. s => s.timestamp === dataPoint.name && s['span.self_time'] === dataPoint.value
  223. );
  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(firstHighlight.dataPoint);
  368. setHighlightedSpanId(sample?.span_id);
  369. }}
  370. isLoading={isDurationDataFetching}
  371. error={durationError}
  372. />
  373. </ModuleLayout.Full>
  374. <ModuleLayout.Full>
  375. <SpanSamplesTable
  376. data={durationSamplesData}
  377. isLoading={isDurationDataFetching || isDurationSamplesDataFetching}
  378. highlightedSpanId={highlightedSpanId}
  379. onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
  380. onSampleMouseOut={() => setHighlightedSpanId(undefined)}
  381. error={durationSamplesDataError}
  382. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  383. meta={{
  384. fields: {
  385. 'span.response_code': 'number',
  386. },
  387. units: {},
  388. }}
  389. />
  390. </ModuleLayout.Full>
  391. <ModuleLayout.Full>
  392. <Button onClick={() => refetchDurationSpanSamples()}>
  393. {t('Try Different Samples')}
  394. </Button>
  395. </ModuleLayout.Full>
  396. </Fragment>
  397. )}
  398. {query.panel === 'status' && (
  399. <Fragment>
  400. <ModuleLayout.Full>
  401. <ResponseCodeCountChart
  402. series={Object.values(responseCodeData).filter(Boolean)}
  403. isLoading={isResponseCodeDataLoading}
  404. error={responseCodeError}
  405. />
  406. </ModuleLayout.Full>
  407. <ModuleLayout.Full>
  408. <SpanSamplesTable
  409. data={responseCodeSamplesData ?? []}
  410. isLoading={isResponseCodeSamplesDataFetching}
  411. error={responseCodeSamplesDataError}
  412. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  413. meta={{
  414. fields: {
  415. 'span.response_code': 'number',
  416. },
  417. units: {},
  418. }}
  419. />
  420. </ModuleLayout.Full>
  421. <ModuleLayout.Full>
  422. <Button onClick={() => refetchResponseCodeSpanSamples()}>
  423. {t('Try Different Samples')}
  424. </Button>
  425. </ModuleLayout.Full>
  426. </Fragment>
  427. )}
  428. </ModuleLayout.Layout>
  429. </DetailPanel>
  430. </PageAlertProvider>
  431. );
  432. }
  433. const SAMPLE_HOVER_DEBOUNCE = 10;
  434. const SPAN_SAMPLE_LIMIT = 10;
  435. // This is functionally a random sort, which is what we want
  436. const SPAN_SAMPLES_SORT = {
  437. field: 'span_id',
  438. kind: 'desc' as const,
  439. };
  440. const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
  441. padding-right: ${space(1)};
  442. `;
  443. const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [
  444. {
  445. value: '',
  446. label: t('All'),
  447. },
  448. {
  449. value: '2',
  450. label: t('2XXs'),
  451. },
  452. {
  453. value: '3',
  454. label: t('3XXs'),
  455. },
  456. {
  457. value: '4',
  458. label: t('4XXs'),
  459. },
  460. {
  461. value: '5',
  462. label: t('5XXs'),
  463. },
  464. ];
  465. const HeaderContainer = styled('div')`
  466. display: grid;
  467. grid-template-rows: auto auto auto;
  468. @media (min-width: ${p => p.theme.breakpoints.small}) {
  469. grid-template-rows: auto;
  470. grid-template-columns: auto 1fr auto;
  471. }
  472. `;
  473. const TitleContainer = styled('div')`
  474. width: 100%;
  475. position: relative;
  476. height: 40px;
  477. `;
  478. const Title = styled('h4')`
  479. position: absolute;
  480. bottom: 0;
  481. margin-bottom: 0;
  482. `;
  483. const MetricsRibbon = styled('div')`
  484. display: flex;
  485. flex-wrap: wrap;
  486. gap: ${space(4)};
  487. `;
  488. const PanelControls = styled('div')`
  489. display: flex;
  490. justify-content: space-between;
  491. gap: ${space(2)};
  492. `;