widget.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. import {Fragment, memo, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import type {SeriesOption} from 'echarts';
  5. import moment from 'moment';
  6. import {updateDateTime} from 'sentry/actionCreators/pageFilters';
  7. import Alert from 'sentry/components/alert';
  8. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  9. import type {DateTimeObject} from 'sentry/components/charts/utils';
  10. import type {SelectOption} from 'sentry/components/compactSelect';
  11. import {CompactSelect} from 'sentry/components/compactSelect';
  12. import type {Field} from 'sentry/components/ddm/metricSamplesTable';
  13. import EmptyMessage from 'sentry/components/emptyMessage';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import Panel from 'sentry/components/panels/panel';
  16. import PanelBody from 'sentry/components/panels/panelBody';
  17. import {Tooltip} from 'sentry/components/tooltip';
  18. import {IconSearch} from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import {space} from 'sentry/styles/space';
  21. import type {MetricsQueryApiResponse, PageFilters} from 'sentry/types';
  22. import {defined} from 'sentry/utils';
  23. import {
  24. getDefaultMetricDisplayType,
  25. getFormattedMQL,
  26. getMetricsSeriesId,
  27. getMetricsSeriesName,
  28. isCumulativeOp,
  29. unescapeMetricsFormula,
  30. } from 'sentry/utils/metrics';
  31. import {metricDisplayTypeOptions} from 'sentry/utils/metrics/constants';
  32. import {formatMRIField, MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
  33. import {
  34. getMetricValueNormalizer,
  35. getNormalizedMetricUnit,
  36. } from 'sentry/utils/metrics/normalizeMetricValue';
  37. import type {
  38. FocusedMetricsSeries,
  39. MetricCorrelation,
  40. MetricWidgetQueryParams,
  41. SortState,
  42. } from 'sentry/utils/metrics/types';
  43. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  44. import {useMetricSamples} from 'sentry/utils/metrics/useMetricsCorrelations';
  45. import {
  46. isMetricFormula,
  47. type MetricsQueryApiQueryParams,
  48. type MetricsQueryApiRequestQuery,
  49. useMetricsQuery,
  50. } from 'sentry/utils/metrics/useMetricsQuery';
  51. import type {MetricsSamplesResults} from 'sentry/utils/metrics/useMetricsSamples';
  52. import useRouter from 'sentry/utils/useRouter';
  53. import {getIngestionSeriesId, MetricChart} from 'sentry/views/ddm/chart/chart';
  54. import type {Series} from 'sentry/views/ddm/chart/types';
  55. import {useFocusArea} from 'sentry/views/ddm/chart/useFocusArea';
  56. import {
  57. useMetricChartSamples,
  58. useMetricChartSamplesV2,
  59. } from 'sentry/views/ddm/chart/useMetricChartSamples';
  60. import type {FocusAreaProps} from 'sentry/views/ddm/context';
  61. import {FormularFormatter} from 'sentry/views/ddm/formulaParser/formatter';
  62. import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
  63. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  64. import {useSeriesHover} from 'sentry/views/ddm/useSeriesHover';
  65. import {getQueryWithFocusedSeries} from 'sentry/views/ddm/utils';
  66. import {createChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
  67. import {DDM_CHART_GROUP, MIN_WIDGET_WIDTH} from './constants';
  68. type MetricWidgetProps = {
  69. context: 'ddm' | 'dashboard';
  70. displayType: MetricDisplayType;
  71. filters: PageFilters;
  72. focusAreaProps: FocusAreaProps;
  73. onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  74. queries: MetricsQueryApiQueryParams[];
  75. chartHeight?: number;
  76. focusedSeries?: FocusedMetricsSeries[];
  77. getChartPalette?: (seriesNames: string[]) => Record<string, string>;
  78. hasSiblings?: boolean;
  79. highlightedSampleId?: string;
  80. index?: number;
  81. isSelected?: boolean;
  82. metricsSamples?: MetricsSamplesResults<Field>['data'];
  83. onSampleClick?: (sample: Sample) => void;
  84. onSampleClickV2?: (sample: MetricsSamplesResults<Field>['data'][number]) => void;
  85. onSelect?: (index: number) => void;
  86. queryId?: number;
  87. showQuerySymbols?: boolean;
  88. tableSort?: SortState;
  89. };
  90. export type Sample = {
  91. projectId: number;
  92. spanId: string;
  93. transactionId: string;
  94. transactionSpanId: string;
  95. };
  96. function isNotQueryOnly(query: MetricsQueryApiQueryParams) {
  97. return !('isQueryOnly' in query) || !query.isQueryOnly;
  98. }
  99. export function getWidgetTitle(queries: MetricsQueryApiQueryParams[]) {
  100. const filteredQueries = queries.filter(isNotQueryOnly);
  101. if (filteredQueries.length === 1) {
  102. const firstQuery = filteredQueries[0];
  103. if (isMetricFormula(firstQuery)) {
  104. return (
  105. <Fragment>
  106. <FormularFormatter formula={unescapeMetricsFormula(firstQuery.formula)} />
  107. </Fragment>
  108. );
  109. }
  110. return getFormattedMQL(firstQuery);
  111. }
  112. return filteredQueries
  113. .map(q =>
  114. isMetricFormula(q)
  115. ? unescapeMetricsFormula(q.formula)
  116. : formatMRIField(MRIToField(q.mri, q.op))
  117. )
  118. .join(', ');
  119. }
  120. export const MetricWidget = memo(
  121. ({
  122. queryId,
  123. queries,
  124. filters,
  125. displayType,
  126. tableSort,
  127. index = 0,
  128. isSelected = false,
  129. getChartPalette,
  130. onSelect,
  131. onChange,
  132. hasSiblings = false,
  133. showQuerySymbols,
  134. focusAreaProps,
  135. onSampleClick,
  136. onSampleClickV2,
  137. highlightedSampleId,
  138. chartHeight = 300,
  139. focusedSeries,
  140. metricsSamples,
  141. context = 'ddm',
  142. }: MetricWidgetProps) => {
  143. const firstQuery = queries
  144. .filter(isNotQueryOnly)
  145. .find((query): query is MetricsQueryApiRequestQuery => !isMetricFormula(query));
  146. const handleChange = useCallback(
  147. (data: Partial<MetricWidgetQueryParams>) => {
  148. onChange(index, data);
  149. },
  150. [index, onChange]
  151. );
  152. const handleDisplayTypeChange = ({value}: SelectOption<MetricDisplayType>) => {
  153. Sentry.metrics.increment('ddm.widget.display');
  154. onChange(index, {displayType: value});
  155. };
  156. const queryWithFocusedSeries = useMemo(
  157. () => getQueryWithFocusedSeries(firstQuery?.query ?? '', focusedSeries),
  158. [firstQuery, focusedSeries]
  159. );
  160. const samplesQuery = useMetricSamples(firstQuery?.mri, {
  161. ...focusAreaProps?.selection?.range,
  162. query: queryWithFocusedSeries,
  163. });
  164. const samples = useMemo(() => {
  165. return {
  166. data: samplesQuery.data,
  167. onClick: onSampleClick,
  168. unit: parseMRI(firstQuery?.mri)?.unit ?? '',
  169. operation: firstQuery?.op ?? '',
  170. higlightedId: highlightedSampleId,
  171. };
  172. }, [
  173. samplesQuery.data,
  174. onSampleClick,
  175. firstQuery?.mri,
  176. firstQuery?.op,
  177. highlightedSampleId,
  178. ]);
  179. const samplesV2 = useMemo(() => {
  180. if (!defined(metricsSamples)) {
  181. return undefined;
  182. }
  183. return {
  184. data: metricsSamples,
  185. onSampleClick: onSampleClickV2,
  186. unit: parseMRI(firstQuery?.mri)?.unit ?? '',
  187. operation: firstQuery?.op ?? '',
  188. };
  189. }, [metricsSamples, firstQuery?.mri, firstQuery?.op, onSampleClickV2]);
  190. const widgetTitle = getWidgetTitle(queries);
  191. const queriesAreComplete = queries.every(q =>
  192. isMetricFormula(q) ? !!q.formula : !!q.mri
  193. );
  194. return (
  195. <MetricWidgetPanel
  196. // show the selection border only if we have more widgets than one
  197. isHighlighted={isSelected && !!hasSiblings}
  198. isHighlightable={!!hasSiblings}
  199. onClick={() => onSelect?.(index)}
  200. >
  201. <PanelBody>
  202. <MetricWidgetHeader>
  203. {showQuerySymbols && queryId !== undefined && (
  204. <QuerySymbol queryId={queryId} isSelected={isSelected} />
  205. )}
  206. <WidgetTitle>
  207. <StyledTooltip
  208. title={widgetTitle}
  209. showOnlyOnOverflow
  210. delay={500}
  211. overlayStyle={{maxWidth: '90vw'}}
  212. >
  213. {widgetTitle}
  214. </StyledTooltip>
  215. </WidgetTitle>
  216. <CompactSelect
  217. size="xs"
  218. triggerProps={{prefix: t('Display')}}
  219. value={
  220. displayType ??
  221. getDefaultMetricDisplayType(firstQuery?.mri, firstQuery?.op)
  222. }
  223. options={metricDisplayTypeOptions}
  224. onChange={handleDisplayTypeChange}
  225. />
  226. </MetricWidgetHeader>
  227. <MetricWidgetBodyWrapper>
  228. {queriesAreComplete ? (
  229. <MetricWidgetBody
  230. widgetIndex={index}
  231. getChartPalette={getChartPalette}
  232. onChange={handleChange}
  233. focusAreaProps={focusAreaProps}
  234. samples={samples}
  235. samplesV2={samplesV2}
  236. chartHeight={chartHeight}
  237. chartGroup={DDM_CHART_GROUP}
  238. queries={queries}
  239. filters={filters}
  240. displayType={displayType}
  241. tableSort={tableSort}
  242. focusedSeries={focusedSeries}
  243. context={context}
  244. />
  245. ) : (
  246. <StyledMetricWidgetBody>
  247. <EmptyMessage
  248. icon={<IconSearch size="xxl" />}
  249. title={t('Nothing to show!')}
  250. description={t('Choose a metric and an operation to display data.')}
  251. />
  252. </StyledMetricWidgetBody>
  253. )}
  254. </MetricWidgetBodyWrapper>
  255. </PanelBody>
  256. </MetricWidgetPanel>
  257. );
  258. }
  259. );
  260. interface MetricWidgetBodyProps {
  261. context: 'ddm' | 'dashboard';
  262. displayType: MetricDisplayType;
  263. filters: PageFilters;
  264. focusAreaProps: FocusAreaProps;
  265. queries: MetricsQueryApiQueryParams[];
  266. widgetIndex: number;
  267. chartGroup?: string;
  268. chartHeight?: number;
  269. focusedSeries?: FocusedMetricsSeries[];
  270. getChartPalette?: (seriesNames: string[]) => Record<string, string>;
  271. onChange?: (data: Partial<MetricWidgetQueryParams>) => void;
  272. samples?: SamplesProps;
  273. samplesV2?: SamplesV2Props;
  274. tableSort?: SortState;
  275. }
  276. export interface SamplesProps {
  277. operation: string;
  278. unit: string;
  279. data?: MetricCorrelation[];
  280. higlightedId?: string;
  281. onClick?: (sample: Sample) => void;
  282. }
  283. export interface SamplesV2Props {
  284. operation: string;
  285. unit: string;
  286. data?: MetricsSamplesResults<Field>['data'];
  287. higlightedId?: string;
  288. onSampleClick?: (sample: MetricsSamplesResults<Field>['data'][number]) => void;
  289. }
  290. const MetricWidgetBody = memo(
  291. ({
  292. onChange,
  293. displayType,
  294. focusedSeries,
  295. tableSort,
  296. widgetIndex,
  297. getChartPalette = createChartPalette,
  298. focusAreaProps,
  299. chartHeight,
  300. chartGroup,
  301. samples,
  302. samplesV2,
  303. filters,
  304. queries,
  305. context,
  306. }: MetricWidgetBodyProps) => {
  307. const router = useRouter();
  308. const {
  309. data: timeseriesData,
  310. isLoading,
  311. isError,
  312. error,
  313. } = useMetricsQuery(queries, filters, {
  314. intervalLadder: displayType === MetricDisplayType.BAR ? 'bar' : context,
  315. });
  316. const {chartRef, setHoveredSeries} = useSeriesHover();
  317. const handleHoverSeries = useCallback(
  318. (seriesId: string) => {
  319. setHoveredSeries([seriesId, getIngestionSeriesId(seriesId)]);
  320. },
  321. [setHoveredSeries]
  322. );
  323. const chartSeries = useMemo(() => {
  324. return timeseriesData
  325. ? getChartTimeseries(timeseriesData, queries, {
  326. getChartPalette,
  327. focusedSeries: focusedSeries && new Set(focusedSeries?.map(s => s.id)),
  328. })
  329. : [];
  330. }, [timeseriesData, queries, getChartPalette, focusedSeries]);
  331. const samplesProp = useMetricChartSamples({
  332. chartRef,
  333. correlations: samples?.data,
  334. unit: samples?.unit,
  335. onClick: samples?.onClick,
  336. highlightedSampleId: samples?.higlightedId,
  337. operation: samples?.operation,
  338. timeseries: chartSeries,
  339. });
  340. const samplesV2Prop = useMetricChartSamplesV2({
  341. samples: samplesV2?.data,
  342. highlightedSampleId: samples?.higlightedId,
  343. operation: samplesV2?.operation,
  344. onSampleClick: samplesV2?.onSampleClick,
  345. timeseries: chartSeries,
  346. unit: samplesV2?.unit,
  347. });
  348. const handleZoom = useCallback(
  349. (range: DateTimeObject) => {
  350. Sentry.metrics.increment('ddm.enhance.zoom');
  351. updateDateTime(range, router, {save: true});
  352. },
  353. [router]
  354. );
  355. const hasCumulativeOp = chartSeries.some(s => isCumulativeOp(s.operation));
  356. const firstUnit =
  357. chartSeries.find(s => !s.hidden)?.unit || chartSeries[0]?.unit || 'none';
  358. const focusArea = useFocusArea({
  359. ...focusAreaProps,
  360. sampleUnit: samples?.unit,
  361. chartUnit: firstUnit,
  362. chartRef,
  363. opts: {
  364. widgetIndex,
  365. isDisabled: !focusAreaProps.onAdd,
  366. useFullYAxis: hasCumulativeOp,
  367. },
  368. onZoom: handleZoom,
  369. });
  370. const toggleSeriesVisibility = useCallback(
  371. (series: FocusedMetricsSeries) => {
  372. setHoveredSeries('');
  373. // The focused series array is not populated yet, so we can add all series except the one that was de-selected
  374. if (!focusedSeries || focusedSeries.length === 0) {
  375. onChange?.({
  376. focusedSeries: chartSeries
  377. .filter(s => s.id !== series.id)
  378. .map(s => ({
  379. id: s.id,
  380. groupBy: s.groupBy,
  381. })),
  382. });
  383. return;
  384. }
  385. const filteredSeries = focusedSeries.filter(s => s.id !== series.id);
  386. if (filteredSeries.length === focusedSeries.length) {
  387. // The series was not focused before so we can add it
  388. filteredSeries.push(series);
  389. }
  390. onChange?.({
  391. focusedSeries: filteredSeries,
  392. });
  393. },
  394. [chartSeries, focusedSeries, onChange, setHoveredSeries]
  395. );
  396. const setSeriesVisibility = useCallback(
  397. (series: FocusedMetricsSeries) => {
  398. setHoveredSeries('');
  399. if (focusedSeries?.length === 1 && focusedSeries[0].id === series.id) {
  400. onChange?.({
  401. focusedSeries: [],
  402. });
  403. return;
  404. }
  405. onChange?.({
  406. focusedSeries: [series],
  407. });
  408. },
  409. [focusedSeries, onChange, setHoveredSeries]
  410. );
  411. const handleSortChange = useCallback(
  412. newSort => {
  413. onChange?.({sort: newSort});
  414. },
  415. [onChange]
  416. );
  417. if (!chartSeries || !timeseriesData || isError) {
  418. return (
  419. <StyledMetricWidgetBody>
  420. {isLoading && <LoadingIndicator />}
  421. {isError && (
  422. <Alert type="error">
  423. {(error?.responseJSON?.detail as string) ||
  424. t('Error while fetching metrics data')}
  425. </Alert>
  426. )}
  427. </StyledMetricWidgetBody>
  428. );
  429. }
  430. if (timeseriesData.data.length === 0) {
  431. return (
  432. <StyledMetricWidgetBody>
  433. <EmptyMessage
  434. icon={<IconSearch size="xxl" />}
  435. title={t('No results')}
  436. description={t('No results found for the given query')}
  437. />
  438. </StyledMetricWidgetBody>
  439. );
  440. }
  441. return (
  442. <StyledMetricWidgetBody>
  443. <TransparentLoadingMask visible={isLoading} />
  444. <MetricChart
  445. ref={chartRef}
  446. series={chartSeries}
  447. displayType={displayType}
  448. height={chartHeight}
  449. samples={samplesV2Prop ?? samplesProp}
  450. focusArea={focusArea}
  451. group={chartGroup}
  452. />
  453. <SummaryTable
  454. series={chartSeries}
  455. onSortChange={handleSortChange}
  456. sort={tableSort}
  457. onRowClick={setSeriesVisibility}
  458. onColorDotClick={toggleSeriesVisibility}
  459. onRowHover={handleHoverSeries}
  460. />
  461. </StyledMetricWidgetBody>
  462. );
  463. }
  464. );
  465. export function getChartTimeseries(
  466. data: MetricsQueryApiResponse,
  467. queries: MetricsQueryApiQueryParams[],
  468. {
  469. getChartPalette,
  470. focusedSeries,
  471. }: {
  472. getChartPalette: (seriesNames: string[]) => Record<string, string>;
  473. focusedSeries?: Set<string>;
  474. showQuerySymbol?: boolean;
  475. }
  476. ) {
  477. const filteredQueries = queries.filter(isNotQueryOnly);
  478. const series = data.data.flatMap((group, index) => {
  479. const query = filteredQueries[index];
  480. const metaUnit = data.meta[index]?.[1]?.unit;
  481. const isMultiQuery = filteredQueries.length > 1;
  482. let unit = '';
  483. let operation = '';
  484. if (!isMetricFormula(query)) {
  485. const parsed = parseMRI(query.mri);
  486. unit = parsed?.unit ?? '';
  487. operation = query.op ?? '';
  488. } else {
  489. // Treat formulas as if they were a single query with none as the unit and count as the operation
  490. unit = 'none';
  491. }
  492. // TODO(arthur): fully switch to using the meta unit once it's available
  493. if (metaUnit) {
  494. unit = metaUnit;
  495. }
  496. // We normalize metric units to make related units
  497. // (e.g. seconds & milliseconds) render in the correct ratio
  498. const normalizedUnit = getNormalizedMetricUnit(unit, operation);
  499. const normalizeValue = getMetricValueNormalizer(unit, operation);
  500. return group.map(entry => ({
  501. unit: normalizedUnit,
  502. operation: operation,
  503. values: entry.series.map(normalizeValue),
  504. name: getMetricsSeriesName(query, entry.by, isMultiQuery),
  505. id: getMetricsSeriesId(query, entry.by),
  506. groupBy: entry.by,
  507. transaction: entry.by.transaction,
  508. release: entry.by.release,
  509. }));
  510. });
  511. const chartPalette = getChartPalette(series.map(s => s.id));
  512. return series.map(item => ({
  513. id: item.id,
  514. seriesName: item.name,
  515. groupBy: item.groupBy,
  516. unit: item.unit,
  517. operation: item.operation,
  518. color: chartPalette[item.id],
  519. hidden: focusedSeries && focusedSeries.size > 0 && !focusedSeries.has(item.id),
  520. data: item.values.map((value, index) => ({
  521. name: moment(data.intervals[index]).valueOf(),
  522. value,
  523. })),
  524. transaction: item.transaction as string | undefined,
  525. release: item.release as string | undefined,
  526. emphasis: {
  527. focus: 'series',
  528. } as SeriesOption['emphasis'],
  529. })) as Series[];
  530. }
  531. const MetricWidgetPanel = styled(Panel)<{
  532. isHighlightable: boolean;
  533. isHighlighted: boolean;
  534. }>`
  535. padding-bottom: 0;
  536. margin-bottom: 0;
  537. min-width: ${MIN_WIDGET_WIDTH}px;
  538. position: relative;
  539. transition: box-shadow 0.2s ease;
  540. ${p =>
  541. p.isHighlightable &&
  542. `
  543. &:focus,
  544. &:hover {
  545. box-shadow: 0px 0px 0px 3px
  546. ${p.isHighlighted ? p.theme.purple200 : 'rgba(209, 202, 216, 0.2)'};
  547. }
  548. `}
  549. ${p =>
  550. p.isHighlighted &&
  551. `
  552. box-shadow: 0px 0px 0px 3px ${p.theme.purple200};
  553. border-color: transparent;
  554. `}
  555. `;
  556. const StyledMetricWidgetBody = styled('div')`
  557. padding: ${space(1)};
  558. gap: ${space(3)};
  559. display: flex;
  560. flex-direction: column;
  561. justify-content: center;
  562. height: 100%;
  563. `;
  564. const MetricWidgetBodyWrapper = styled('div')`
  565. padding: ${space(1)};
  566. `;
  567. const MetricWidgetHeader = styled('div')`
  568. display: flex;
  569. justify-content: space-between;
  570. align-items: center;
  571. gap: ${space(1)};
  572. padding-left: ${space(2)};
  573. padding-top: ${space(1.5)};
  574. padding-right: ${space(2)};
  575. `;
  576. const WidgetTitle = styled('div')`
  577. flex-grow: 1;
  578. font-size: ${p => p.theme.fontSizeMedium};
  579. display: inline-grid;
  580. grid-auto-flow: column;
  581. `;
  582. const StyledTooltip = styled(Tooltip)`
  583. ${p => p.theme.overflowEllipsis};
  584. `;