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 ( +
+ +
+ +
+
+ +
+ + + +
+
+ ); +}