Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ const productionEntryPoints = [
'static/app/chartcuterie/**/*.{js,ts,tsx}',
// TODO: Remove when used
'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}',
// TODO: Remove when integration into Explore has started
'static/app/views/dashboards/widgets/heatMapWidget/**/*.{ts,tsx}',
// TODO: Remove when used
'static/app/views/settings/organizationRepositories/connectProviderDropdown.tsx',
'static/app/views/settings/organizationRepositories/noIntegrationsEmptyState.tsx',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/se

import type {HeatMapPlottable, PlottableTimeSeriesValueType} from './heatMapPlottable';

export type HeatMapPlottingOptions = {
type HeatMapPlottingOptions = {
theme: Theme;
/**
* The Z-axis scale type. `'log'` applies `Math.log1p` to Z values so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function determineDefaultChartType(yAxes: readonly string[]): ChartType {
[ChartType.BAR]: 0,
[ChartType.LINE]: 0,
[ChartType.AREA]: 0,
[ChartType.HEATMAP]: 0,
};

for (const yAxis of yAxes) {
Expand Down
5 changes: 4 additions & 1 deletion static/app/views/explore/hooks/useAddToDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ import {
import {useSpansDataset} from 'sentry/views/explore/spans/spansQueryParams';
import {ChartType} from 'sentry/views/insights/common/components/chart';

export const CHART_TYPE_TO_DISPLAY_TYPE = {
// Heat Map widgets are not yet a dashboard `DisplayType`, so they fall back to
// LINE when adding to a dashboard.
export const CHART_TYPE_TO_DISPLAY_TYPE: Record<ChartType, DisplayType> = {
[ChartType.LINE]: DisplayType.LINE,
[ChartType.BAR]: DisplayType.BAR,
[ChartType.AREA]: DisplayType.AREA,
[ChartType.HEATMAP]: DisplayType.LINE,
};

export function useAddToDashboard() {
Expand Down
204 changes: 204 additions & 0 deletions static/app/views/explore/metrics/hooks/useMetricHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import {skipToken, useQuery} from '@tanstack/react-query';

import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours';
import {useOrganization} from 'sentry/utils/useOrganization';
import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types';
import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery';
import {createTraceMetricEventsFilter} from 'sentry/views/explore/metrics/utils';
import {useQueryParamsQuery} from 'sentry/views/explore/queryParams/context';

// Hard-coded for the MVP. Backend ignores `xBuckets` when `xAxis === 'time'`,
// so the actual horizontal bucket count is determined by `interval`.
const Y_BUCKETS = 60;
const X_BUCKETS = 200;

interface AxisMeta {
end: number | null;
name: string;
start: number | null;
bucketCount?: number;
bucketSize?: number;
}

interface HeatmapApiResponse {
meta: {
dataset: string;
xAxis: AxisMeta;
yAxis: AxisMeta;
zAxis: AxisMeta;
};
values: Array<{xAxis: number; yAxis: number; zAxis: number}>;
}

interface UseMetricHeatmapOptions {
enabled: boolean;
traceMetric: TraceMetric;
}

export function useMetricHeatmap({traceMetric, enabled}: UseMetricHeatmapOptions) {
const organization = useOrganization();
const {selection} = usePageFilters();
const userQuery = useQueryParamsQuery();

const traceMetricFilter = createTraceMetricEventsFilter([traceMetric]);
const combinedQuery = userQuery
? `${traceMetricFilter} ${userQuery}`
: traceMetricFilter;

// Aim for ~50 horizontal buckets when xAxis is time. The backend uses
// `interval` to determine x_buckets, so derive interval from the date range.
const interval = computeIntervalForBuckets(selection.datetime, X_BUCKETS);

const queryResult = useQuery(
apiOptions.as<HeatmapApiResponse>()(
'/organizations/$organizationIdOrSlug/events-heatmap/',
{
path: enabled ? {organizationIdOrSlug: organization.slug} : skipToken,
query: {
dataset: DiscoverDatasets.TRACEMETRICS,
xAxis: 'time',
yAxis: 'value',
zAxis: 'count()',
yBuckets: Y_BUCKETS,
interval,
query: combinedQuery,
project: selection.projects,
environment: selection.environments,
...normalizeDateTimeParams(selection.datetime),
referrer: 'api.explore.tracemetrics-heatmap',
},
staleTime: 30_000,
}
)
);

const heatMapSeries = queryResult.data
? toHeatMapSeries(queryResult.data, traceMetric)
: undefined;

return {
heatMapSeries,
isPending: queryResult.isPending,
isFetching: queryResult.isFetching,
error: queryResult.error,
};
}

function toHeatMapSeries(
response: HeatmapApiResponse,
traceMetric: TraceMetric
): HeatMapSeries {
// The backend doesn't return `valueType` / `valueUnit` for the y-axis, so we
// approximate from the metric's unit. This is a known limitation; we hard-code
// `'duration'` only when the unit looks like a time unit, otherwise `'number'`.
const valueType = inferValueType(traceMetric.unit);
const valueUnit = (traceMetric.unit ??
null) as HeatMapSeries['meta']['yAxis']['valueUnit'];

return {
meta: {
xAxis: {
name: response.meta.xAxis.name,
start: response.meta.xAxis.start ?? 0,
end: response.meta.xAxis.end ?? 0,
bucketCount: response.meta.xAxis.bucketCount ?? 0,
bucketSize: response.meta.xAxis.bucketSize ?? 0,
},
yAxis: {
name: response.meta.yAxis.name,
start: response.meta.yAxis.start ?? 0,
end: response.meta.yAxis.end ?? 0,
bucketCount: response.meta.yAxis.bucketCount ?? 0,
bucketSize: response.meta.yAxis.bucketSize ?? 0,
valueType,
valueUnit,
},
zAxis: {
name: response.meta.zAxis.name,
start: response.meta.zAxis.start ?? 0,
end: response.meta.zAxis.end ?? 0,
},
},
values: response.values.map(value => ({
xAxis: value.xAxis,
yAxis: value.yAxis,
zAxis: value.zAxis,
})),
};
}

const TIME_UNITS = new Set([
'nanosecond',
'microsecond',
'millisecond',
'second',
'minute',
'hour',
'day',
'week',
]);

const SIZE_UNITS = new Set([
'bit',
'byte',
'kibibyte',
'mebibyte',
'gibibyte',
'tebibyte',
'pebibyte',
'exbibyte',
'kilobyte',
'megabyte',
'gigabyte',
'terabyte',
'petabyte',
'exabyte',
]);

function inferValueType(
unit: string | undefined
): HeatMapSeries['meta']['yAxis']['valueType'] {
if (unit && TIME_UNITS.has(unit)) {
return 'duration';
}
if (unit && SIZE_UNITS.has(unit)) {
return 'size';
}
return 'number';
}

// Allowed interval buckets the `/events-heatmap/` endpoint accepts (seconds).
// Must stay in sync with `ALLOWED_INTERVALS` in the backend's `get_rollup`.
const ALLOWED_INTERVAL_SECONDS = [
15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 14400, 21600, 43200, 86400,
];

function computeIntervalForBuckets(
datetime: ReturnType<typeof usePageFilters>['selection']['datetime'],
bucketCount: number
): string {
let totalSeconds = 0;
if (datetime.start && datetime.end) {
totalSeconds = Math.max(
0,
(new Date(datetime.end).getTime() - new Date(datetime.start).getTime()) / 1000
);
} else {
const period = datetime.period ?? DEFAULT_STATS_PERIOD;
totalSeconds = parsePeriodToHours(period) * 3600;
}

const target = totalSeconds > 0 ? totalSeconds / bucketCount : 60;

// Pick the smallest allowed interval >= target so we don't exceed `bucketCount`.
const snapped =
ALLOWED_INTERVAL_SECONDS.find(s => s >= target) ??
ALLOWED_INTERVAL_SECONDS[ALLOWED_INTERVAL_SECONDS.length - 1]!;

return `${snapped}s`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {Container} from '@sentry/scraps/layout';

import {t} from 'sentry/locale';
import {WidgetLoadingPanel} from 'sentry/views/dashboards/widgets/common/widgetLoadingPanel';
import {HeatMapWidgetVisualization} from 'sentry/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization';
import {HeatMap} from 'sentry/views/dashboards/widgets/heatMapWidget/plottables/heatMap';
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
import {useMetricHeatmap} from 'sentry/views/explore/metrics/hooks/useMetricHeatmap';
import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery';

interface MetricsHeatmapVisualizationProps {
enabled: boolean;
traceMetric: TraceMetric;
}

export function MetricsHeatmapVisualization({
traceMetric,
enabled,
}: MetricsHeatmapVisualizationProps) {
const {heatMapSeries, isPending, error} = useMetricHeatmap({traceMetric, enabled});

if (isPending || !heatMapSeries) {
return <WidgetLoadingPanel />;
}

if (error) {
return (
<Container position="absolute" inset={0}>
<Widget.WidgetError error={error} />
</Container>
);
}

if (heatMapSeries.values.length === 0) {
return (
<Container position="absolute" inset={0}>
<Widget.WidgetError error={t('No data')} />
</Container>
);
}

return (
<HeatMapWidgetVisualization plottables={[new HeatMap(heatMapSeries)]} scale="log" />
);
}
29 changes: 23 additions & 6 deletions static/app/views/explore/metrics/metricGraph/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {formatTimeSeriesLabel} from 'sentry/views/dashboards/widgets/timeSeriesW
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
import {ChartVisualization} from 'sentry/views/explore/components/chart/chartVisualization';
import {ConfidenceFooter} from 'sentry/views/explore/metrics/confidenceFooter';
import {MetricsHeatmapVisualization} from 'sentry/views/explore/metrics/metricGraph/heatmapVisualization';
import {
useMetricLabel,
useMetricName,
Expand All @@ -34,7 +35,6 @@ import {
useQueryParamsTopEventsLimit,
} from 'sentry/views/explore/queryParams/context';
import {isVisualizeEquation} from 'sentry/views/explore/queryParams/visualize';
import {EXPLORE_CHART_TYPE_OPTIONS} from 'sentry/views/explore/spans/charts';
import {useRawCounts} from 'sentry/views/explore/useRawCounts';
import {
combineConfidenceForSeries,
Expand All @@ -52,6 +52,13 @@ import {WidgetWrapper} from './styles';
const MINIMIZED_GRAPH_HEIGHT = 50;
const STACKED_GRAPH_HEIGHT = 362;

const METRICS_CHART_TYPE_OPTIONS = [
{value: ChartType.LINE, label: t('Line')},
{value: ChartType.AREA, label: t('Area')},
{value: ChartType.BAR, label: t('Bar')},
{value: ChartType.HEATMAP, label: t('Heat Map')},
];

interface MetricsGraphProps {
timeseriesResult: ReturnType<typeof useSortedTimeSeries>;
additionalActions?: React.ReactNode;
Expand Down Expand Up @@ -188,7 +195,9 @@ function Graph({
? 'line'
: visualize.chartType === ChartType.AREA
? 'area'
: 'bar';
: visualize.chartType === ChartType.HEATMAP
? 'scatter'
: 'bar';

const Actions = (
<Fragment>
Expand All @@ -207,7 +216,7 @@ function Graph({
)}
value={visualize.chartType}
menuTitle="Type"
options={EXPLORE_CHART_TYPE_OPTIONS}
options={METRICS_CHART_TYPE_OPTIONS}
onChange={option => onChartTypeChange(option.value)}
/>
<CompactSelect
Expand All @@ -234,6 +243,7 @@ function Graph({

const showEmptyState = isMetricOptionsEmpty && visualize.visible;
const showChart = visualize.visible && !isMetricOptionsEmpty;
const isHeatmap = visualize.chartType === ChartType.HEATMAP;

const height = visualize.visible ? STACKED_GRAPH_HEIGHT : MINIMIZED_GRAPH_HEIGHT;

Expand All @@ -257,18 +267,25 @@ function Graph({
)}
/>
) : showChart ? (
<ChartVisualization chartInfo={chartInfo} />
isHeatmap ? (
<MetricsHeatmapVisualization
traceMetric={traceMetric}
enabled={showChart}
/>
) : (
<ChartVisualization chartInfo={chartInfo} />
)
) : undefined
}
Footer={
showChart && (
showChart && !isHeatmap ? (
<ConfidenceFooter
chartInfo={chartInfo}
isLoading={timeseriesResult.isPending || timeseriesResult.isFetching}
hasUserQuery={!!userQuery}
rawMetricCounts={rawMetricCounts}
/>
)
) : undefined
}
height={height}
revealActions="always"
Expand Down
1 change: 1 addition & 0 deletions static/app/views/insights/common/components/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export enum ChartType {
BAR = 0,
LINE = 1,
AREA = 2,
HEATMAP = 3,
}

export function isChartType(value: any): value is ChartType {
Expand Down
Loading