diff --git a/.changeset/many-donkeys-help.md b/.changeset/many-donkeys-help.md
new file mode 100644
index 0000000000..7d0c91f5e6
--- /dev/null
+++ b/.changeset/many-donkeys-help.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+Add ability to customize event deltas heat map y, count, and grouping attributes
diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx
index 1f6d64926e..005ce51f4a 100644
--- a/packages/app/src/ChartUtils.tsx
+++ b/packages/app/src/ChartUtils.tsx
@@ -385,6 +385,23 @@ export function timeBucketByGranularity(
return buckets;
}
+export const isAggregateFunction = (value: string) => {
+ const fns = [
+ 'count',
+ 'countIf',
+ 'countDistinct',
+ 'sum',
+ 'avg',
+ 'distinct',
+ 'min',
+ 'max',
+ 'quantile',
+ 'any',
+ 'none',
+ ];
+ return fns.some(fn => value.includes(fn + '('));
+};
+
export const INTEGER_NUMBER_FORMAT: NumberFormat = {
factor: 1,
output: 'number',
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index f6801374d8..b9cfc6c319 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -43,6 +43,7 @@ import {
Flex,
Grid,
Group,
+ Input,
Menu,
Modal,
Paper,
@@ -60,8 +61,6 @@ import { SortingState } from '@tanstack/react-table';
import CodeMirror from '@uiw/react-codemirror';
import { ContactSupportText } from '@/components/ContactSupportText';
-import DBDeltaChart from '@/components/DBDeltaChart';
-import DBHeatmapChart from '@/components/DBHeatmapChart';
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
import { DBTimeChart } from '@/components/DBTimeChart';
import { ErrorBoundary } from '@/components/ErrorBoundary';
@@ -88,7 +87,6 @@ import {
import { useSearchPageFilterState } from '@/searchFilters';
import SearchInputV2 from '@/SearchInputV2';
import {
- getDurationMsExpression,
getFirstTimestampValueExpression,
useSource,
useSources,
@@ -99,6 +97,7 @@ import { QUERY_LOCAL_STORAGE, useLocalStorage, usePrevious } from '@/utils';
import { SQLPreview } from './components/ChartSQLPreview';
import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
import PatternTable from './components/PatternTable';
+import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { useTableMetadata } from './hooks/useMetadata';
import { useSqlSuggestions } from './hooks/useSqlSuggestions';
@@ -1583,57 +1582,15 @@ function DBSearchPage() {
)}
{analysisMode === 'delta' && searchedSource != null && (
-
-
- {
- setOutlierSqlCondition(
- [
- `${searchedSource.durationExpression} >= ${yMin} * 1e${(searchedSource.durationPrecision ?? 9) - 3}`,
- `${searchedSource.durationExpression} <= ${yMax} * 1e${(searchedSource.durationPrecision ?? 9) - 3}`,
- `${getFirstTimestampValueExpression(chartConfig.timestampValueExpression)} >= ${xMin}`,
- `${getFirstTimestampValueExpression(chartConfig.timestampValueExpression)} <= ${xMax}`,
- ].join(' AND '),
- );
- }}
- />
-
- {outlierSqlCondition ? (
-
- ) : (
-
-
-
- Please highlight an outlier range in the heatmap to
- view the delta chart.
-
-
-
- )}
-
+
)}
{analysisMode === 'results' &&
diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx
index cd6966f9ba..3e1f9adb72 100644
--- a/packages/app/src/components/DBDeltaChart.tsx
+++ b/packages/app/src/components/DBDeltaChart.tsx
@@ -334,7 +334,12 @@ export default function DBDeltaChart({
const { percentageOccurences: inlierValueOccurences } =
getPropertyStatistics(inlierData?.data ?? []);
- const sortedProperties = Array.from(outlierValueOccurences.keys())
+ // Get all the unique keys from both maps, and process them to get the merged arrays
+ const uniqueKeys = new Set([
+ ...outlierValueOccurences.keys(),
+ ...inlierValueOccurences.keys(),
+ ]);
+ const sortedProperties = Array.from(uniqueKeys)
.map(key => {
const inlierCount =
inlierValueOccurences.get(key) ?? new Map
();
diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx
index 8b594aaa7d..ee0ee1c578 100644
--- a/packages/app/src/components/DBHeatmapChart.tsx
+++ b/packages/app/src/components/DBHeatmapChart.tsx
@@ -3,21 +3,36 @@ import dynamic from 'next/dynamic';
import type { Plugin } from 'uplot';
import uPlot from 'uplot';
import UplotReact from 'uplot-react';
-import { inferTimestampColumn } from '@hyperdx/common-utils/dist/clickhouse';
+import {
+ ClickHouseQueryError,
+ inferTimestampColumn,
+} from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
-import { Divider, Paper, Text } from '@mantine/core';
-import { useElementSize } from '@mantine/hooks';
+import {
+ Button,
+ Code,
+ Divider,
+ Group,
+ Modal,
+ Paper,
+ Text,
+} from '@mantine/core';
+import { useDisclosure, useElementSize } from '@mantine/hooks';
+import { IconArrowsDiagonal } from '@tabler/icons-react';
import {
convertDateRangeToGranularityString,
+ isAggregateFunction,
timeBucketByGranularity,
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
-import { Chart, NumberFormat } from '@/types';
+import { NumberFormat } from '@/types';
import { FormatTime } from '@/useFormatTime';
import { formatNumber } from '@/utils';
+import { SQLPreview } from './ChartSQLPreview';
+
type Mode2DataArray = [number[], number[], number[]];
// From: https://github.com/leeoniya/uPlot/blob/a4edb297a9b80baf781f4d05a40fb52fae737bff/demos/latency-heatmap.html#L436
@@ -255,11 +270,15 @@ const opt: uPlot.Options = {
],
};
-// const d = genData();
-
type HeatmapChartConfig = {
displayType: DisplayType.Heatmap;
- select: [{ aggFn: 'heatmap'; valueExpression: string }];
+ select: [
+ {
+ aggFn: 'heatmap';
+ valueExpression: string;
+ countExpression?: string;
+ },
+ ];
from: ChartConfigWithDateRange['from'];
where: ChartConfigWithDateRange['where'];
dateRange: ChartConfigWithDateRange['dateRange'];
@@ -286,53 +305,134 @@ function HeatmapContainer({
const nBuckets = 80;
const valueExpression = config.select[0].valueExpression;
+ const countExpression = config.select[0].countExpression ?? 'count()';
+
+ // When valueExpression is an aggregate like count(), we need to use a CTE to calculate the heatmap
+ const isAggregateExpression = isAggregateFunction(valueExpression);
+
+ const minMaxConfig: ChartConfigWithDateRange = isAggregateExpression
+ ? {
+ ...config,
+ where: '',
+ orderBy: undefined,
+ granularity: undefined,
+ select: [
+ {
+ aggFn: 'min',
+ // TODO: Select if we can be negative
+ aggCondition: `value_calc >= 0`,
+ aggConditionLanguage: 'sql',
+ valueExpression: 'value_calc',
+ alias: 'min',
+ },
+ {
+ aggFn: 'max',
+ valueExpression: 'value_calc',
+ alias: 'max',
+ },
+ ],
+ with: [
+ {
+ name: 'min_max_calc',
+ chartConfig: {
+ ...config,
+ select: [{ valueExpression, alias: 'value_calc' }],
+ orderBy: undefined,
+ },
+ },
+ ],
+ timestampValueExpression: '__hdx_time_bucket',
+ from: { databaseName: '', tableName: 'min_max_calc' },
+ }
+ : {
+ ...config,
+ orderBy: undefined,
+ granularity: undefined,
+ select: [
+ {
+ aggFn: 'min',
+ valueExpression,
+ // TODO: Select if we can be negative
+ aggCondition: `${valueExpression} >= 0`,
+ aggConditionLanguage: 'sql',
+ alias: 'min',
+ },
+ { aggFn: 'max', valueExpression, aggCondition: '', alias: 'max' },
+ ],
+ };
- const minMaxConfig: ChartConfigWithDateRange = {
- ...config,
- orderBy: undefined,
- granularity: undefined,
- select: [
- {
- aggFn: 'min',
- valueExpression,
- // TODO: Select if we can be negative
- aggCondition: `${valueExpression} >= 0`,
- aggConditionLanguage: 'sql',
- alias: 'min',
- },
- { aggFn: 'max', valueExpression, aggCondition: '', alias: 'max' },
- ],
- };
const {
data: minMaxData,
isLoading: isMinMaxLoading,
- isError: isMinMaxError,
+ error: minMaxError,
} = useQueriedChartConfig(minMaxConfig, {
queryKey: ['heatmap', minMaxConfig],
enabled: enabled,
});
+ const [errorModal, errorModalControls] = useDisclosure();
+
// UInt64 are returned as strings
const min = Number.parseInt(minMaxData?.data?.[0]?.['min'] ?? '0', 10);
const max = Number.parseInt(minMaxData?.data?.[0]?.['max'] ?? '0', 10);
- const bucketConfig: ChartConfigWithDateRange = {
- ...config,
- select: [
- { aggFn: 'count', valueExpression: '', aggCondition: '', alias: 'count' },
- ],
- groupBy: [
- {
- // TODO: ChSQL?
- valueExpression: `widthBucket(${valueExpression}, ${min}, ${max}, ${nBuckets})`,
- alias: 'bucket',
- },
- ],
- orderBy: [{ valueExpression: 'bucket', ordering: 'ASC' }],
- granularity,
- };
+ const bucketConfig: ChartConfigWithDateRange = isAggregateExpression
+ ? {
+ ...config,
+ where: '',
+ select: [
+ {
+ valueExpression: 'sum(value_count)',
+ alias: 'count',
+ },
+ ],
+ groupBy: [
+ {
+ valueExpression: `widthBucket(value_calc, ${min}, ${max}, ${nBuckets})`,
+ alias: 'x_bucket',
+ },
+ ],
+ with: [
+ {
+ name: 'bucket_calc',
+ chartConfig: {
+ ...config,
+ select: [
+ { valueExpression, alias: 'value_calc' },
+ {
+ valueExpression: countExpression,
+ alias: 'value_count',
+ },
+ ],
+ granularity,
+ orderBy: undefined,
+ },
+ },
+ ],
+ timestampValueExpression: '__hdx_time_bucket',
+ from: { databaseName: '', tableName: 'bucket_calc' },
+ orderBy: [{ valueExpression: 'x_bucket', ordering: 'ASC' }],
+ granularity,
+ }
+ : {
+ ...config,
+ select: [
+ {
+ valueExpression: countExpression,
+ alias: 'count',
+ },
+ ],
+ groupBy: [
+ {
+ valueExpression: `widthBucket(${valueExpression}, ${min}, ${max}, ${nBuckets})`,
+ alias: 'x_bucket',
+ },
+ ],
+ orderBy: [{ valueExpression: 'x_bucket', ordering: 'ASC' }],
+ granularity,
+ };
- const { data, isLoading, isError } = useQueriedChartConfig(bucketConfig, {
+ const { data, isLoading, error } = useQueriedChartConfig(bucketConfig, {
queryKey: ['heatmap_bucket', bucketConfig],
enabled: !!minMaxData && bucketConfig != null,
});
@@ -342,12 +442,12 @@ function HeatmapContainer({
dateRange[1],
granularity,
);
- const timestampColumn = inferTimestampColumn(data?.meta ?? []);
- const time: number[] = [];
- const bucket: number[] = [];
- const count: number[] = [];
+ const timestampColumn = inferTimestampColumn(data?.meta ?? []);
+ const time: number[] = []; // x values
+ const bucket: number[] = []; // y value series 1
+ const count: number[] = []; // y value series 2
if (data != null && timestampColumn != null) {
let dataIndex = 0;
@@ -362,10 +462,10 @@ function HeatmapContainer({
if (
row != null &&
new Date(row[timestampColumn.name]).getTime() == generatedTs &&
- row['bucket'] == j
+ row['x_bucket'] == j
) {
time.push(new Date(row[timestampColumn.name]).getTime());
- bucket.push(min + row['bucket'] * ((max - min) / nBuckets));
+ bucket.push(min + row['x_bucket'] * ((max - min) / nBuckets));
count.push(Number.parseInt(row['count'], 10)); // UInt64 returns as string
dataIndex++;
@@ -387,6 +487,55 @@ function HeatmapContainer({
);
}
+ if (error || minMaxError) {
+ const _error: Error = error || minMaxError!;
+ return (
+
+
+ Error loading chart, please check your query or try again later.
+
+
+ errorModalControls.close()}
+ title="Error Details"
+ >
+
+
+ Error Message:
+
+
+ {_error.message}
+
+ {_error instanceof ClickHouseQueryError && (
+ <>
+
+ Sent Query:
+
+
+ >
+ )}
+
+
+
+ );
+ }
+
if (time.length < 2 || generatedTsBuckets?.length < 2) {
return (
@@ -397,6 +546,7 @@ function HeatmapContainer({
);
}
+
return (
@@ -721,10 +871,10 @@ function Heatmap({
- Duration: {tickFormatter(highlightedPoint.yVal)}
+ Y Value: {tickFormatter(highlightedPoint.yVal)}
-
Count:{' '}
+
Count Value:{' '}
{new Intl.NumberFormat('en-US', {
notation: 'standard',
compactDisplay: 'short',
diff --git a/packages/app/src/components/SQLInlineEditor.tsx b/packages/app/src/components/SQLInlineEditor.tsx
index c1956d0407..1d6bb3728d 100644
--- a/packages/app/src/components/SQLInlineEditor.tsx
+++ b/packages/app/src/components/SQLInlineEditor.tsx
@@ -13,7 +13,8 @@ import {
Field,
TableConnectionChoice,
} from '@hyperdx/common-utils/dist/metadata';
-import { Paper, Text } from '@mantine/core';
+import { Flex, Paper, Text, Tooltip } from '@mantine/core';
+import { IconInfoCircle } from '@tabler/icons-react';
import CodeMirror, {
Compartment,
EditorView,
@@ -113,6 +114,7 @@ type SQLInlineEditorProps = {
label?: React.ReactNode;
disableKeywordAutocomplete?: boolean;
enableHotkey?: boolean;
+ tooltipText?: string;
additionalSuggestions?: string[];
queryHistoryType?: string;
parentRef?: HTMLElement | null;
@@ -159,6 +161,7 @@ export default function SQLInlineEditor({
label,
disableKeywordAutocomplete,
enableHotkey,
+ tooltipText,
additionalSuggestions = [],
queryHistoryType,
parentRef,
@@ -320,9 +323,16 @@ export default function SQLInlineEditor({
mx="4px"
size="xs"
fw="bold"
- style={{ whiteSpace: 'nowrap' }}
+ style={{
+ whiteSpace: 'nowrap',
+ }}
>
- {label}
+
+
+ {label}
+ {tooltipText && }
+
+
)}
diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx
new file mode 100644
index 0000000000..406802bcb6
--- /dev/null
+++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx
@@ -0,0 +1,183 @@
+import { parseAsString, useQueryStates } from 'nuqs';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ TableConnection,
+ tcFromSource,
+} from '@hyperdx/common-utils/dist/metadata';
+import {
+ ChartConfigWithDateRange,
+ DisplayType,
+ TSource,
+} from '@hyperdx/common-utils/dist/types';
+import { Box, Button, Collapse, Flex } from '@mantine/core';
+import { ActionIcon } from '@mantine/core';
+import { Paper } from '@mantine/core';
+import { Center } from '@mantine/core';
+import { Text } from '@mantine/core';
+import { useDisclosure } from '@mantine/hooks';
+import { IconPlayerPlay, IconSettings } from '@tabler/icons-react';
+
+import {
+ getDurationMsExpression,
+ getFirstTimestampValueExpression,
+} from '@/source';
+
+import DBDeltaChart from '../DBDeltaChart';
+import DBHeatmapChart from '../DBHeatmapChart';
+import { SQLInlineEditorControlled } from '../SQLInlineEditor';
+
+const Schema = z.object({
+ value: z.string().trim().min(1),
+ count: z.string().trim().optional(),
+});
+
+export function DBSearchHeatmapChart({
+ chartConfig,
+ source,
+ isReady,
+}: {
+ chartConfig: ChartConfigWithDateRange;
+ source: TSource;
+ isReady: boolean;
+}) {
+ const [fields, setFields] = useQueryStates({
+ value: parseAsString.withDefault(getDurationMsExpression(source)),
+ count: parseAsString.withDefault('count()'),
+ outlierSqlCondition: parseAsString,
+ });
+
+ return (
+
+
+ {
+ setFields({
+ value: data.value,
+ count: data.count,
+ });
+ }}
+ />
+
+
+ {
+ setFields({
+ outlierSqlCondition: [
+ `${source.durationExpression} >= ${yMin} * 1e${(source.durationPrecision ?? 9) - 3}`,
+ `${source.durationExpression} <= ${yMax} * 1e${(source.durationPrecision ?? 9) - 3}`,
+ `${getFirstTimestampValueExpression(chartConfig.timestampValueExpression)} >= ${xMin}`,
+ `${getFirstTimestampValueExpression(chartConfig.timestampValueExpression)} <= ${xMax}`,
+ ].join(' AND '),
+ });
+ }}
+ />
+
+ {fields.outlierSqlCondition ? (
+
+ ) : (
+
+
+
+ Please highlight an outlier range in the heatmap to view the delta
+ chart.
+
+
+
+ )}
+
+ );
+}
+
+function DBSearchHeatmapForm({
+ connection,
+ defaultValues,
+ onSubmit,
+}: {
+ connection: TableConnection;
+ defaultValues: z.infer
;
+ onSubmit: (v: z.infer) => void;
+}) {
+ const form = useForm({
+ resolver: zodResolver(Schema),
+ defaultValues,
+ });
+
+ return (
+
+ );
+}