feat: always-on attribute distribution mode (#1899)

## Summary
- Show attribute value distribution charts in Event Deltas immediately on load, without requiring a heatmap selection first
- Users see blue "All spans" distribution bars right away; selecting an area on the heatmap switches to red/green comparison mode (selection vs background)
- Add legend UI that shows current mode and hints at interaction

## Changes
- **deltaChartUtils.ts**: Add `ALL_SPANS_COLOR` constant (Mantine blue-6 CSS variable)
- **DBDeltaChart.tsx**: Accept nullable `xMin/xMax/yMin/yMax` props; derive `hasSelection` boolean; gate outlier/inlier queries with `enabled: hasSelection`; add `allSpansData` query for distribution mode; add legend UI showing current mode; pass `hasSelection` to `PropertyComparisonChart`
- **PropertyComparisonChart.tsx**: Accept optional `hasSelection` prop (default `true`); show single blue bar labeled "All spans" in distribution mode; hide inlier (background) bar when no selection
- **DBSearchHeatmapChart.tsx**: Always render `DBDeltaChart` (remove null-check conditional and placeholder text); pass `onAddFilter` for click-to-filter support

## Not included (follow-up)
- "X" button to clear heatmap selection and return to distribution mode — see #1906

## Test plan
- [x] Existing deltaChart unit tests pass (51 tests across 3 suites)
- [x] ESLint passes on all changed files
- [x] Manual: Open Event Deltas tab -- attribute distribution charts should appear immediately with blue bars
- [x] Manual: Select an area on the heatmap -- charts should switch to red/green comparison mode with legend update
- [ ] Manual: Clear selection -- should revert to blue distribution mode (currently only via filter action; explicit "X" button in #1906)

Closes #1824
Supersedes #1855 (rebased after #1854 merge, resolved conflicts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alex Fedotyev 2026-03-13 07:59:36 -07:00 committed by GitHub
parent a8216d7e36
commit 60d1bbaf58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 214 additions and 85 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: always-on attribute distribution mode for Event Deltas

View file

@ -18,10 +18,12 @@ import { useElementSize } from '@mantine/hooks';
import { isAggregateFunction } from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { getFirstTimestampValueExpression } from '@/source';
import { getChartColorError, getChartColorSuccess } from '@/utils';
import { SQLPreview } from './ChartSQLPreview';
import type { AddFilterFn } from './deltaChartUtils';
import {
ALL_SPANS_COLOR,
computeComparisonScore,
flattenedKeyToFilterKey,
getPropertyStatistics,
@ -45,22 +47,32 @@ export type { AddFilterFn } from './deltaChartUtils';
export default function DBDeltaChart({
config,
valueExpr,
xMin,
xMax,
yMin,
yMax,
xMin: rawXMin,
xMax: rawXMax,
yMin: rawYMin,
yMax: rawYMax,
onAddFilter,
spanIdExpression,
}: {
config: BuilderChartConfigWithDateRange;
valueExpr: string;
xMin: number;
xMax: number;
yMin: number;
yMax: number;
xMin?: number | null;
xMax?: number | null;
yMin?: number | null;
yMax?: number | null;
onAddFilter?: AddFilterFn;
spanIdExpression?: string;
}) {
// Derive whether a heatmap selection exists from nullable props
const hasSelection =
rawXMin != null && rawXMax != null && rawYMin != null && rawYMax != null;
// Safe numeric defaults so query builders always get valid values
// (outlier/inlier queries are gated by enabled:hasSelection)
const xMin = rawXMin ?? 0;
const xMax = rawXMax ?? 0;
const yMin = rawYMin ?? 0;
const yMax = rawYMax ?? 0;
// Determine if the value expression uses aggregate functions
const isAggregate = isAggregateFunction(valueExpr);
@ -200,28 +212,60 @@ export default function DBDeltaChart({
];
};
const { data: outlierData, error } = useQueriedChartConfig({
...config,
with: buildWithClauses(true),
select: '*',
filters: buildFilters(true),
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
limit: { limit: SAMPLE_SIZE },
});
const {
data: outlierData,
error: outlierError,
isLoading: isOutlierLoading,
} = useQueriedChartConfig(
{
...config,
with: buildWithClauses(true),
select: '*',
filters: buildFilters(true),
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
limit: { limit: SAMPLE_SIZE },
},
{ enabled: hasSelection },
);
const { data: inlierData } = useQueriedChartConfig({
...config,
with: buildWithClauses(false),
select: '*',
filters: buildFilters(false),
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
limit: { limit: SAMPLE_SIZE },
});
const { data: inlierData, isLoading: isInlierLoading } =
useQueriedChartConfig(
{
...config,
with: buildWithClauses(false),
select: '*',
filters: buildFilters(false),
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
limit: { limit: SAMPLE_SIZE },
},
{ enabled: hasSelection },
);
// When no selection exists, fetch all spans without any range filter
const {
data: allSpansData,
error: allSpansError,
isLoading: isAllSpansLoading,
} = useQueriedChartConfig(
{
...config,
select: '*',
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
limit: { limit: SAMPLE_SIZE },
},
{ enabled: !hasSelection },
);
const isLoading = hasSelection
? isOutlierLoading || isInlierLoading
: isAllSpansLoading;
const error = outlierError ?? allSpansError;
// Column metadata for field classification (from ClickHouse response)
const columnMeta = useMemo<{ name: string; type: string }[]>(
() => outlierData?.meta ?? inlierData?.meta ?? [],
[outlierData?.meta, inlierData?.meta],
() => outlierData?.meta ?? inlierData?.meta ?? allSpansData?.meta ?? [],
[outlierData?.meta, inlierData?.meta, allSpansData?.meta],
);
// Wrap onAddFilter to convert flattened dot-notation keys into ClickHouse bracket notation
@ -233,24 +277,30 @@ export default function DBDeltaChart({
[onAddFilter, columnMeta],
);
// TODO: Is loading state
const {
visibleProperties,
hiddenProperties,
outlierValueOccurences,
inlierValueOccurences,
} = useMemo(() => {
// When no selection: use allSpans as "outlier" data and empty for inliers.
// The sort will rank by frequency (delta = count - 0 = count).
const actualOutlierData = hasSelection
? (outlierData?.data ?? [])
: (allSpansData?.data ?? []);
const actualInlierData = hasSelection ? (inlierData?.data ?? []) : [];
const {
percentageOccurences: outlierValueOccurences,
propertyOccurences: outlierPropertyOccurences,
valueOccurences: outlierRawValueOccurences,
} = getPropertyStatistics(outlierData?.data ?? []);
} = getPropertyStatistics(actualOutlierData);
const {
percentageOccurences: inlierValueOccurences,
propertyOccurences: inlierPropertyOccurences,
valueOccurences: inlierRawValueOccurences,
} = getPropertyStatistics(inlierData?.data ?? []);
} = getPropertyStatistics(actualInlierData);
// Get all the unique keys from the outliers
let uniqueKeys = new Set([...outlierValueOccurences.keys()]);
@ -308,7 +358,13 @@ export default function DBDeltaChart({
outlierValueOccurences,
inlierValueOccurences,
};
}, [outlierData?.data, inlierData?.data, columnMeta]);
}, [
outlierData?.data,
inlierData?.data,
allSpansData?.data,
hasSelection,
columnMeta,
]);
const [activePage, setPage] = useState(1);
@ -411,6 +467,71 @@ export default function DBDeltaChart({
flexDirection: 'column',
}}
>
{/* Legend */}
<Flex gap="md" align="center" mb="xs" wrap="wrap">
{hasSelection ? (
<>
<Flex align="center" gap={4}>
<Box
w={10}
h={10}
style={{
background: getChartColorError(),
borderRadius: 2,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed">
Selection
</Text>
</Flex>
<Flex align="center" gap={4}>
<Box
w={10}
h={10}
style={{
background: getChartColorSuccess(),
borderRadius: 2,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed">
Background
</Text>
</Flex>
</>
) : (
<>
<Flex align="center" gap={4}>
<Box
w={10}
h={10}
style={{
background: ALL_SPANS_COLOR,
borderRadius: 2,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed">
All spans
</Text>
</Flex>
<Text size="xs" c="dimmed" fs="italic">
{isLoading
? 'Loading\u2026'
: 'Select an area on the chart above to enable comparisons'}
</Text>
</>
)}
</Flex>
{/* Loading state */}
{isLoading && visibleOnPage.length === 0 && hiddenOnPage.length === 0 && (
<Flex align="center" justify="center" style={{ flex: 1 }}>
<Text size="sm" c="dimmed">
Loading attribute distributions\u2026
</Text>
</Flex>
)}
{/* Primary fields */}
{visibleOnPage.length > 0 && (
<div
@ -431,6 +552,7 @@ export default function DBDeltaChart({
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={property}
hasSelection={hasSelection}
/>
))}
</div>
@ -448,7 +570,7 @@ export default function DBDeltaChart({
labelPosition="left"
/>
)}
{/* Lower-priority fields separate grid so rows align independently */}
{/* Lower-priority fields - separate grid so rows align independently */}
{hiddenOnPage.length > 0 && (
<div
style={{
@ -468,6 +590,7 @@ export default function DBDeltaChart({
}
onAddFilter={onAddFilter ? handleAddFilter : undefined}
key={key}
hasSelection={hasSelection}
/>
))}
</div>

View file

@ -23,6 +23,7 @@ import {
import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton';
import type { AddFilterFn } from './deltaChartUtils';
import {
ALL_SPANS_COLOR,
applyTopNAggregation,
mergeValueStatisticsMaps,
OTHER_BUCKET_COLOR,
@ -127,11 +128,13 @@ export function PropertyComparisonChart({
outlierValueOccurences,
inlierValueOccurences,
onAddFilter,
hasSelection = true,
}: {
name: string;
outlierValueOccurences: Map<string, number>;
inlierValueOccurences: Map<string, number>;
onAddFilter?: AddFilterFn;
hasSelection?: boolean;
}) {
const mergedValueStatistics = mergeValueStatisticsMaps(
outlierValueOccurences,
@ -206,41 +209,47 @@ export function PropertyComparisonChart({
/>
<Tooltip
content={<HDXBarChartTooltip title={name} />}
allowEscapeViewBox={{ x: false, y: true }}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
/>
<Bar
dataKey="outlierCount"
name="Selection"
fill={getChartColorError()}
name={hasSelection ? 'Selection' : 'All spans'}
fill={hasSelection ? getChartColorError() : ALL_SPANS_COLOR}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`out-${index}`}
fill={
entry.isOther ? OTHER_BUCKET_COLOR : getChartColorError()
}
/>
))}
</Bar>
<Bar
dataKey="inlierCount"
name="Background"
fill={getChartColorSuccess()}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`in-${index}`}
fill={
entry.isOther
? OTHER_BUCKET_COLOR
: getChartColorSuccess()
: hasSelection
? getChartColorError()
: ALL_SPANS_COLOR
}
/>
))}
</Bar>
{hasSelection && (
<Bar
dataKey="inlierCount"
name="Background"
fill={getChartColorSuccess()}
isAnimationActive={false}
>
{chartData.map((entry, index) => (
<Cell
key={`in-${index}`}
fill={
entry.isOther
? OTHER_BUCKET_COLOR
: getChartColorSuccess()
}
/>
))}
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
@ -262,14 +271,19 @@ export function PropertyComparisonChart({
{clickedValue.length === 0 ? <i>Empty String</i> : clickedValue}
</Text>
<Flex gap={12} mb={8}>
<Text size="xs" c={getChartColorError()}>
Selection:{' '}
<Text
size="xs"
c={hasSelection ? getChartColorError() : ALL_SPANS_COLOR}
>
{hasSelection ? 'Selection' : 'All spans'}:{' '}
{(outlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}%
</Text>
<Text size="xs" c={getChartColorSuccess()}>
Background:{' '}
{(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}%
</Text>
{hasSelection && (
<Text size="xs" c={getChartColorSuccess()}>
Background:{' '}
{(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}%
</Text>
)}
</Flex>
<Flex gap={4} align="center">
{onAddFilter && (

View file

@ -14,8 +14,6 @@ import {
} from '@hyperdx/common-utils/dist/types';
import { Box, Flex } from '@mantine/core';
import { Button } from '@mantine/core';
import { Center } from '@mantine/core';
import { Text } from '@mantine/core';
import { IconPlayerPlay } from '@tabler/icons-react';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
@ -120,33 +118,19 @@ export function DBSearchHeatmapChart({
}}
/>
</div>
{fields.xMin != null &&
fields.xMax != null &&
fields.yMin != null &&
fields.yMax != null ? (
<DBDeltaChart
config={{
...chartConfig,
with: undefined,
}}
valueExpr={fields.value}
xMin={fields.xMin}
xMax={fields.xMax}
yMin={fields.yMin}
yMax={fields.yMax}
onAddFilter={
onAddFilter ? handleAddFilterAndClearSelection : undefined
}
spanIdExpression={source.spanIdExpression}
/>
) : (
<Center mih={100} h="100%">
<Text size="sm">
Please highlight an outlier range in the heatmap to view the delta
chart.
</Text>
</Center>
)}
<DBDeltaChart
config={{
...chartConfig,
with: undefined,
}}
valueExpr={fields.value}
xMin={fields.xMin}
xMax={fields.xMax}
yMin={fields.yMin}
yMax={fields.yMax}
onAddFilter={onAddFilter ? handleAddFilterAndClearSelection : undefined}
spanIdExpression={source.spanIdExpression}
/>
</Flex>
);
}

View file

@ -94,6 +94,9 @@ export const MAX_CHART_VALUES_UPPER = 8;
// Color for the "Other (N)" aggregated bucket — neutral gray.
export const OTHER_BUCKET_COLOR = 'var(--mantine-color-gray-5)';
// Color for the "All spans" distribution bar (no selection / comparison mode off).
export const ALL_SPANS_COLOR = 'var(--mantine-color-blue-6)';
export function mergeValueStatisticsMaps(
outlierValues: Map<string, number>, // value -> count
inlierValues: Map<string, number>,