httpSamplesPanel.tsx 19 KB

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