diff --git a/.changeset/always-on-distribution.md b/.changeset/always-on-distribution.md new file mode 100644 index 0000000000..e6a06ec54d --- /dev/null +++ b/.changeset/always-on-distribution.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: always-on attribute distribution mode for Event Deltas diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index a64154a525..341db2bc82 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -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: inlierData } = useQueriedChartConfig({ - ...config, - with: buildWithClauses(false), - select: '*', - filters: buildFilters(false), - 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, 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 */} + + {hasSelection ? ( + <> + + + + Selection + + + + + + Background + + + + ) : ( + <> + + + + All spans + + + + {isLoading + ? 'Loading\u2026' + : 'Select an area on the chart above to enable comparisons'} + + + )} + + {/* Loading state */} + {isLoading && visibleOnPage.length === 0 && hiddenOnPage.length === 0 && ( + + + Loading attribute distributions\u2026 + + + )} {/* Primary fields */} {visibleOnPage.length > 0 && (
))}
@@ -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 && (
))}
diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index 2cad624304..69c5919f18 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -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; inlierValueOccurences: Map; onAddFilter?: AddFilterFn; + hasSelection?: boolean; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, @@ -206,41 +209,47 @@ export function PropertyComparisonChart({ /> } - allowEscapeViewBox={{ x: false, y: true }} + allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} /> {chartData.map((entry, index) => ( - ))} - - - {chartData.map((entry, index) => ( - ))} + {hasSelection && ( + + {chartData.map((entry, index) => ( + + ))} + + )} @@ -262,14 +271,19 @@ export function PropertyComparisonChart({ {clickedValue.length === 0 ? Empty String : clickedValue} - - Selection:{' '} + + {hasSelection ? 'Selection' : 'All spans'}:{' '} {(outlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}% - - Background:{' '} - {(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}% - + {hasSelection && ( + + Background:{' '} + {(inlierValueOccurences.get(clickedValue) ?? 0).toFixed(1)}% + + )} {onAddFilter && ( diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 7fb8792846..8b3fedd186 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -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({ }} /> - {fields.xMin != null && - fields.xMax != null && - fields.yMin != null && - fields.yMax != null ? ( - - ) : ( -
- - Please highlight an outlier range in the heatmap to view the delta - chart. - -
- )} +
); } diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index 21250cf021..d9c20f57f3 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -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, // value -> count inlierValues: Map,