mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
a8216d7e36
commit
60d1bbaf58
5 changed files with 214 additions and 85 deletions
5
.changeset/always-on-distribution.md
Normal file
5
.changeset/always-on-distribution.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: always-on attribute distribution mode for Event Deltas
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue