import {useCallback, useMemo} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {CompactSelect} from 'sentry/components/compactSelect'; import {Tooltip} from 'sentry/components/tooltip'; import {IconClock, IconGraph} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Confidence, NewQuery} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {dedupeArray} from 'sentry/utils/dedupeArray'; import EventView from 'sentry/utils/discover/eventView'; import {parseFunction, prettifyParsedFunction} from 'sentry/utils/discover/fields'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {isTimeSeriesOther} from 'sentry/utils/timeSeries/isTimeSeriesOther'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import usePageFilters from 'sentry/utils/usePageFilters'; import usePrevious from 'sentry/utils/usePrevious'; import {determineSeriesSampleCountAndIsSampled} from 'sentry/views/alerts/rules/metric/utils/determineSeriesSampleCount'; import {WidgetSyncContextProvider} from 'sentry/views/dashboards/contexts/widgetSyncContext'; import {Area} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plottables/area'; import {Bars} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plottables/bars'; import {Line} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plottables/line'; import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; import {ConfidenceFooter} from 'sentry/views/explore/charts/confidenceFooter'; import ChartContextMenu from 'sentry/views/explore/components/chartContextMenu'; import { useExploreDataset, useExploreVisualizes, useSetExploreVisualizes, } from 'sentry/views/explore/contexts/pageParamsContext'; import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; import {showConfidence} from 'sentry/views/explore/utils'; import { ChartType, useSynchronizeCharts, } from 'sentry/views/insights/common/components/chart'; import type {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery'; import {CHART_HEIGHT, INGESTION_DELAY} from '../settings'; interface ExploreChartsProps { canUsePreviousResults: boolean; confidences: Confidence[]; query: string; timeseriesResult: ReturnType<typeof useSortedTimeSeries>; } export const EXPLORE_CHART_TYPE_OPTIONS = [ { value: ChartType.LINE, label: t('Line'), }, { value: ChartType.AREA, label: t('Area'), }, { value: ChartType.BAR, label: t('Bar'), }, ]; export const EXPLORE_CHART_GROUP = 'explore-charts_group'; export function ExploreCharts({ canUsePreviousResults, confidences, query, timeseriesResult, }: ExploreChartsProps) { const theme = useTheme(); const dataset = useExploreDataset(); const visualizes = useExploreVisualizes(); const setVisualizes = useSetExploreVisualizes(); const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const isTopN = defined(topEvents) && topEvents > 0; const previousTimeseriesResult = usePrevious(timeseriesResult); const getSeries = useCallback( (dedupedYAxes: string[], formattedYAxes: Array<string | undefined>) => { const shouldUsePreviousResults = timeseriesResult.isPending && canUsePreviousResults && dedupedYAxes.every(yAxis => previousTimeseriesResult.data.hasOwnProperty(yAxis)); const data = dedupedYAxes.flatMap((yAxis, i) => { const series = shouldUsePreviousResults ? previousTimeseriesResult.data[yAxis] : timeseriesResult.data[yAxis]; return (series ?? []).map(s => { // We replace the series name with the formatted series name here // when possible as it's cleaner to read. // // We can't do this in top N mode as the series name uses the row // values instead of the aggregate function. if (s.field === yAxis) { return { ...s, seriesName: formattedYAxes[i] ?? yAxis, }; } return s; }); }); return { data, error: shouldUsePreviousResults ? previousTimeseriesResult.error : timeseriesResult.error, loading: shouldUsePreviousResults ? previousTimeseriesResult.isPending : timeseriesResult.isPending, }; }, [canUsePreviousResults, timeseriesResult, previousTimeseriesResult] ); const chartInfos = useMemo(() => { return visualizes.map((visualize, index) => { const dedupedYAxes = dedupeArray(visualize.yAxes); const formattedYAxes = dedupedYAxes.map(yaxis => { const func = parseFunction(yaxis); return func ? prettifyParsedFunction(func) : undefined; }); const chartIcon = visualize.chartType === ChartType.LINE ? 'line' : visualize.chartType === ChartType.AREA ? 'area' : 'bar'; const {data, error, loading} = getSeries(dedupedYAxes, formattedYAxes); const {sampleCount, isSampled} = determineSeriesSampleCountAndIsSampled( data, isTopN ); return { chartIcon: <IconGraph type={chartIcon} />, chartType: visualize.chartType, label: visualize.label, yAxes: visualize.yAxes, formattedYAxes, data, error, loading, confidence: confidences[index], sampleCount, isSampled, }; }); }, [confidences, getSeries, visualizes, isTopN]); const handleChartTypeChange = useCallback( (chartType: ChartType, index: number) => { const newVisualizes = visualizes.slice(); newVisualizes[index] = {...newVisualizes[index]!, chartType}; setVisualizes(newVisualizes); }, [visualizes, setVisualizes] ); useSynchronizeCharts( visualizes.length, !timeseriesResult.isPending, EXPLORE_CHART_GROUP ); const shouldRenderLabel = visualizes.length > 1; return ( <ChartList> <WidgetSyncContextProvider> {chartInfos.map((chartInfo, index) => { const Title = ( <ChartTitle> {shouldRenderLabel && <ChartLabel>{chartInfo.label}</ChartLabel>} <Widget.WidgetTitle title={chartInfo.formattedYAxes.filter(Boolean).join(', ')} /> </ChartTitle> ); if (chartInfo.loading) { return ( <Widget key={index} height={CHART_HEIGHT} Title={Title} Visualization={<TimeSeriesWidgetVisualization.LoadingPlaceholder />} revealActions="always" /> ); } if (chartInfo.error) { return ( <Widget key={index} height={CHART_HEIGHT} Title={Title} Visualization={<Widget.WidgetError error={chartInfo.error} />} revealActions="always" /> ); } if (chartInfo.data.length === 0) { // This happens when the `/events-stats/` endpoint returns a blank // response. This is a rare error condition that happens when // proxying to RPC. Adding explicit handling with a "better" message return ( <Widget key={index} height={CHART_HEIGHT} Title={Title} Visualization={<Widget.WidgetError error={t('No data')} />} revealActions="always" /> ); } const DataPlottableConstructor = chartInfo.chartType === ChartType.LINE ? Line : chartInfo.chartType === ChartType.AREA ? Area : Bars; return ( <Widget key={index} height={CHART_HEIGHT} Title={Title} Actions={[ <Tooltip key="visualization" title={t('Type of chart displayed in this visualization (ex. line)')} > <CompactSelect triggerProps={{ icon: chartInfo.chartIcon, borderless: true, showChevron: false, size: 'xs', }} value={chartInfo.chartType} menuTitle="Type" options={EXPLORE_CHART_TYPE_OPTIONS} onChange={option => handleChartTypeChange(option.value, index)} /> </Tooltip>, <Tooltip key="interval" title={t('Time interval displayed in this visualization (ex. 5m)')} > <CompactSelect value={interval} onChange={({value}) => setInterval(value)} triggerProps={{ icon: <IconClock />, borderless: true, showChevron: false, size: 'xs', }} menuTitle="Interval" options={intervalOptions} /> </Tooltip>, <ChartContextMenu key="context" visualizeYAxes={chartInfo.yAxes} query={query} interval={interval} visualizeIndex={index} />, ]} revealActions="always" Visualization={ <TimeSeriesWidgetVisualization plottables={chartInfo.data.map(timeSeries => { return new DataPlottableConstructor(timeSeries, { delay: INGESTION_DELAY, color: isTimeSeriesOther(timeSeries) ? theme.chartOther : undefined, stack: 'all', }); })} /> } Footer={ dataset === DiscoverDatasets.SPANS_EAP_RPC && showConfidence(chartInfo.isSampled) && ( <ConfidenceFooter sampleCount={chartInfo.sampleCount} confidence={chartInfo.confidence} topEvents={ topEvents ? Math.min(topEvents, chartInfo.data.length) : undefined } /> ) } /> ); })} </WidgetSyncContextProvider> </ChartList> ); } export function useExtrapolationMeta({ dataset, query, isAllowedSelection, }: { dataset: DiscoverDatasets; query: string; isAllowedSelection?: boolean; }) { const {selection} = usePageFilters(); const extrapolationMetaEventView = useMemo(() => { const search = new MutableSearch(query); // Filtering out all spans with op like 'ui.interaction*' which aren't // embedded under transactions. The trace view does not support rendering // such spans yet. search.addFilterValues('!transaction.span_id', ['00']); const discoverQuery: NewQuery = { id: undefined, name: 'Explore - Extrapolation Meta', fields: ['count_sample()', 'min(sampling_rate)'], query: search.formatString(), version: 2, dataset, }; return EventView.fromNewQueryWithPageFilters(discoverQuery, selection); }, [dataset, query, selection]); return useSpansQuery({ eventView: extrapolationMetaEventView, initialData: [], referrer: 'api.explore.spans-extrapolation-meta', enabled: (defined(isAllowedSelection) ? isAllowedSelection : true) && dataset === DiscoverDatasets.SPANS_EAP_RPC, trackResponseAnalytics: false, }); } const ChartList = styled('div')` display: grid; row-gap: ${space(2)}; margin-bottom: ${space(2)}; `; const ChartLabel = styled('div')` background-color: ${p => p.theme.purple100}; border-radius: ${p => p.theme.borderRadius}; text-align: center; min-width: 32px; color: ${p => p.theme.purple400}; white-space: nowrap; font-weight: ${p => p.theme.fontWeightBold}; align-content: center; margin-right: ${space(1)}; `; const ChartTitle = styled('div')` display: flex; margin-left: ${space(2)}; `;