feat: BETA metrics support (sum + gauge) (#629)

<img width="1310" alt="Screenshot 2025-02-25 at 3 43 11 PM" src="https://github.com/user-attachments/assets/38c98bc2-2ff2-412c-b26d-4ed9952439f2" />


Co-authored-by: Mike Shi <2781687+MikeShi42@users.noreply.github.com>
Co-authored-by: Dan Hable <418679+dhable@users.noreply.github.com>
Co-authored-by: Tom Alexander <3245235+teeohhem@users.noreply.github.com>
This commit is contained in:
Warren 2025-02-25 16:00:48 -08:00 committed by GitHub
parent c389b69dc9
commit 57a6bc399f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 790 additions and 740 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/app": minor
---
feat: BETA metrics support (sum + gauge)

View file

@ -15,6 +15,7 @@ import {
Select as MSelect,
} from '@mantine/core';
import { MetricNameSelect } from './components/MetricNameSelect';
import { NumberFormatInput } from './components/NumberFormat';
import api from './api';
import Checkbox from './Checkbox';
@ -406,71 +407,6 @@ export function MetricTagSelect({
);
}
export function MetricSelect({
aggFn,
isRate,
metricName,
setAggFn,
setFieldAndAggFn,
setMetricName,
}: {
aggFn: AggFn;
isRate: boolean;
metricName: string | undefined | null;
setAggFn: (fn: AggFn) => void;
setFieldAndAggFn: (field: string | undefined, fn: AggFn) => void;
setMetricName: (value: string | undefined) => void;
}) {
// TODO: Dedup with metric rate checkbox
const { data: metricNamesData, isLoading, isError } = api.useMetricsNames();
const aggFnWithMaybeRate = (aggFn: AggFn, isRate: boolean) => {
if (isRate) {
if (aggFn.includes('_rate')) {
return aggFn;
} else {
return `${aggFn}_rate` as AggFn;
}
} else {
if (aggFn.includes('_rate')) {
return aggFn.replace('_rate', '') as AggFn;
} else {
return aggFn;
}
}
};
return (
<>
<div className="flex-grow-1">
<MetricNameSelect
isLoading={isLoading}
isError={isError}
value={metricName}
setValue={name => {
const metricType = metricNamesData?.data?.find(
metric => metric.name === name,
)?.data_type;
const newAggFn = aggFnWithMaybeRate(aggFn, metricType === 'Sum');
setFieldAndAggFn(name, newAggFn);
}}
/>
</div>
<div className="flex-shrink-1 ms-3">
<MetricRateSelect
metricName={metricName}
isRate={isRate}
setIsRate={(isRate: boolean) => {
setAggFn(aggFnWithMaybeRate(aggFn, isRate));
}}
/>
</div>
</>
);
}
export function MetricRateSelect({
metricName,
isRate,
@ -504,55 +440,6 @@ export function MetricRateSelect({
);
}
export function MetricNameSelect({
value,
setValue,
isLoading,
isError,
}: {
value: string | undefined | null;
setValue: (value: string | undefined) => void;
isLoading?: boolean;
isError?: boolean;
}) {
const { data: metricNamesData } = api.useMetricsNames();
const options = useMemo(() => {
return (
metricNamesData?.data?.map(entry => ({
value: entry.name,
label: entry.name,
})) ?? []
);
}, [metricNamesData]);
return (
<MSelect
disabled={isLoading || isError}
autoFocus={!value}
variant="filled"
placeholder={
isLoading
? 'Loading...'
: isError
? 'Unable to load metrics'
: 'Select a metric...'
}
data={options}
limit={100}
comboboxProps={{
position: 'bottom-start',
width: 'auto',
zIndex: 1111,
}}
value={value ?? undefined}
searchable
clearable
onChange={value => setValue(value ?? undefined)}
/>
);
}
export function FieldSelect({
value,
setValue,
@ -603,261 +490,6 @@ export function FieldSelect({
);
}
export function ChartSeriesForm({
aggFn,
field,
groupBy,
setAggFn,
setField,
setFieldAndAggFn,
setTableAndAggFn,
setGroupBy,
setSortOrder,
setWhere,
sortOrder,
table,
where,
numberFormat,
setNumberFormat,
}: {
aggFn: AggFn;
field: string | undefined;
groupBy: string | undefined;
setAggFn: (fn: AggFn) => void;
setField: (field: string | undefined) => void;
setFieldAndAggFn: (field: string | undefined, fn: AggFn) => void;
setTableAndAggFn: (table: SourceTable, fn: AggFn) => void;
setGroupBy: (groupBy: string | undefined) => void;
setSortOrder?: (sortOrder: SortOrder) => void;
setWhere: (where: string) => void;
sortOrder?: string;
table: string;
where: string;
numberFormat?: NumberFormat;
setNumberFormat?: (format?: NumberFormat) => void;
}) {
const labelWidth = 350;
const searchInputRef = useRef<HTMLInputElement>(null);
const isRate = useMemo(() => {
return aggFn.includes('_rate');
}, [aggFn]);
const _setAggFn = (fn: AggFn, _isRate?: boolean) => {
if (_isRate ?? isRate) {
if (fn.includes('_rate')) {
setAggFn(fn);
} else {
setAggFn(`${fn}_rate` as AggFn);
}
} else {
if (fn.includes('_rate')) {
setAggFn(fn.replace('_rate', '') as AggFn);
} else {
setAggFn(fn);
}
}
};
const metricAggFns = getMetricAggFns(
legacyMetricNameToNameAndDataType(field)?.dataType,
);
return (
<div>
<div className="d-flex align-items-center">
<div style={{ width: labelWidth }}>
<Select
options={TABLES}
className="ds-select"
value={TABLES.find(v => v.value === table)}
onChange={opt => {
const val = opt?.value ?? 'logs';
if (val === 'logs') {
setTableAndAggFn('logs', 'count');
} else if (val === 'metrics') {
// TODO: This should set rate if metric field is a sum
// or we should just reset the field if changing tables
setTableAndAggFn('metrics', 'max');
}
}}
classNamePrefix="ds-react-select"
/>
</div>
<div className="ms-3" style={{ width: labelWidth }}>
{table === 'logs' ? (
<Select
options={AGG_FNS}
className="ds-select"
value={AGG_FNS.find(v => v.value === aggFn)}
onChange={opt => _setAggFn(opt?.value ?? 'count')}
classNamePrefix="ds-react-select"
/>
) : (
<Select
options={metricAggFns}
className="ds-select"
value={metricAggFns.find(
v => v.value === aggFn.replace('_rate', ''),
)}
onChange={opt => _setAggFn(opt?.value ?? 'sum')}
classNamePrefix="ds-react-select"
/>
)}
</div>
{table === 'logs' && aggFn != 'count' && aggFn != 'count_distinct' ? (
<div className="ms-3 flex-grow-1">
<FieldSelect
value={field}
setValue={setField}
types={['number']}
autoFocus={!field}
/>
</div>
) : null}
{table === 'logs' && aggFn != 'count' && aggFn == 'count_distinct' ? (
<div className="ms-3 flex-grow-1">
<FieldSelect
value={field}
setValue={setField}
types={['string', 'number', 'bool']}
autoFocus={!field}
/>
</div>
) : null}
{table === 'metrics' ? (
<div className="d-flex align-items-center align-middle flex-grow-1 ms-3">
<MetricSelect
metricName={field}
setMetricName={setField}
isRate={isRate}
setAggFn={setAggFn}
setFieldAndAggFn={setFieldAndAggFn}
aggFn={aggFn}
/>
</div>
) : null}
</div>
{table === 'logs' ? (
<>
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Where
</div>
<div className="ms-3 flex-grow-1">
<SearchInput
inputRef={searchInputRef}
placeholder={'Filter results by a search query'}
value={where}
onChange={v => setWhere(v)}
onSearch={() => {}}
/>
</div>
</div>
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Group By
</div>
<div className="ms-3 flex-grow-1" style={{ minWidth: 300 }}>
<FieldSelect
value={groupBy}
setValue={setGroupBy}
types={['number', 'bool', 'string']}
/>
</div>
</div>
</>
) : (
<>
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Where
</div>
<div className="ms-3 flex-grow-1">
<MetricTagFilterInput
placeholder={
field
? 'Filter metric by tag...'
: 'Select a metric above to start filtering by tag...'
}
inputRef={searchInputRef}
value={where}
onChange={v => setWhere(v)}
metricName={field}
/>
</div>
</div>
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Group By
</div>
<div className="ms-3 flex-grow-1" style={{ minWidth: 300 }}>
<MetricTagSelect
value={groupBy}
setValue={setGroupBy}
metricNames={field != null ? [field] : []}
/>
</div>
</div>
</>
)}
{
// TODO: support metrics
sortOrder != null && setSortOrder != null && table === 'logs' && (
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Sort Order
</div>
<div className="ms-3 flex-grow-1">
<Select
options={SORT_ORDER}
className="ds-select"
value={SORT_ORDER.find(v => v.value === sortOrder)}
onChange={opt => setSortOrder(opt?.value ?? 'desc')}
classNamePrefix="ds-react-select"
/>
</div>
</div>
)
}
{setNumberFormat && (
<div className="ms-2 mt-2 mb-3">
<Divider
label={
<>
<i className="bi bi-gear me-1" />
Chart Settings
</>
}
c="dark.2"
mb={8}
/>
<Group>
<div className="fs-8 text-slate-300">Number Format</div>
<NumberFormatInput
value={numberFormat}
onChange={setNumberFormat}
/>
</Group>
</div>
)}
</div>
);
}
export function TableSelect({
table,
setTableAndAggFn,
@ -949,251 +581,6 @@ export function GroupBySelect(
);
}
export function ChartSeriesFormCompact({
aggFn,
field,
groupBy,
setAggFn,
setField,
setFieldAndAggFn,
setTableAndAggFn,
setGroupBy,
setSortOrder,
setWhere,
sortOrder,
table,
where,
numberFormat,
setNumberFormat,
}: {
aggFn: AggFn;
field: string | undefined;
groupBy?: string[] | undefined;
setAggFn: (fn: AggFn) => void;
setField: (field: string | undefined) => void;
setFieldAndAggFn: (field: string | undefined, fn: AggFn) => void;
setTableAndAggFn?: (table: SourceTable, fn: AggFn) => void;
setGroupBy?: (groupBy: string[] | undefined) => void;
setSortOrder?: (sortOrder: SortOrder) => void;
setWhere: (where: string) => void;
sortOrder?: string;
table?: string;
where: string;
numberFormat?: NumberFormat;
setNumberFormat?: (format?: NumberFormat) => void;
}) {
const labelWidth = 350;
const searchInputRef = useRef<HTMLInputElement>(null);
const isRate = useMemo(() => {
return aggFn.includes('_rate');
}, [aggFn]);
const _setAggFn = (fn: AggFn, _isRate?: boolean) => {
if (_isRate ?? isRate) {
if (fn.includes('_rate')) {
setAggFn(fn);
} else {
setAggFn(`${fn}_rate` as AggFn);
}
} else {
if (fn.includes('_rate')) {
setAggFn(fn.replace('_rate', '') as AggFn);
} else {
setAggFn(fn);
}
}
};
const metricAggFns = getMetricAggFns(
legacyMetricNameToNameAndDataType(field)?.dataType,
);
return (
<div>
<div
className="d-flex align-items-center flex-wrap"
style={{ rowGap: '1rem', columnGap: '1rem' }}
>
{setTableAndAggFn && (
<TableToggle
table={table ?? 'logs'}
setTableAndAggFn={setTableAndAggFn}
/>
)}
<div className="">
{table === 'logs' ? (
<Select
options={AGG_FNS}
className="ds-select w-auto text-nowrap"
value={AGG_FNS.find(v => v.value === aggFn)}
onChange={opt => _setAggFn(opt?.value ?? 'count')}
classNamePrefix="ds-react-select"
/>
) : (
<Select
options={metricAggFns}
className="ds-select w-auto text-nowrap"
value={metricAggFns.find(
v => v.value === aggFn.replace('_rate', ''),
)}
onChange={opt => _setAggFn(opt?.value ?? 'sum')}
classNamePrefix="ds-react-select"
/>
)}
</div>
{table === 'logs' && aggFn != 'count' && aggFn != 'count_distinct' ? (
<div className="flex-grow-1">
<FieldSelect
className="w-auto text-nowrap"
value={field}
setValue={setField}
types={['number']}
autoFocus={!field}
/>
</div>
) : null}
{table === 'logs' && aggFn != 'count' && aggFn == 'count_distinct' ? (
<div className="flex-grow-1">
<FieldSelect
className="w-auto text-nowrap"
value={field}
setValue={setField}
types={['string', 'number', 'bool']}
autoFocus={!field}
/>
</div>
) : null}
{table === 'logs' && (
<div
className="d-flex flex-grow-1 align-items-center"
style={{
minWidth: where.length > 30 ? '50%' : 'auto',
}}
>
<div className="text-muted">Where</div>
<div className="ms-3 flex-grow-1">
<SearchInput
inputRef={searchInputRef}
placeholder={'Filter results by a search query'}
value={where}
onChange={v => setWhere(v)}
onSearch={() => {}}
zIndex={99999}
/>
</div>
</div>
)}
{table === 'logs' && setGroupBy != null && (
<div className="d-flex align-items-center">
<div className="text-muted">Group By</div>
<div className="ms-3 flex-grow-1" style={{ minWidth: 300 }}>
<FieldMultiSelect
types={['number', 'bool', 'string']}
values={groupBy ?? []}
setValues={(values: string[]) => {
setGroupBy(values);
}}
/>
</div>
</div>
)}
{table === 'metrics' ? (
<div className="d-flex align-items-center align-middle flex-grow-1">
<MetricSelect
metricName={field}
setMetricName={setField}
isRate={isRate}
setAggFn={setAggFn}
setFieldAndAggFn={setFieldAndAggFn}
aggFn={aggFn}
/>
</div>
) : null}
{table === 'metrics' && (
<>
<div className="d-flex align-items-center flex-grow-1">
<div className="text-muted fw-500">Where</div>
<div className="ms-3 flex-grow-1">
<MetricTagFilterInput
placeholder={
field
? 'Filter metric by tag...'
: 'Select a metric above to start filtering by tag...'
}
inputRef={searchInputRef}
value={where}
onChange={v => setWhere(v)}
metricName={field}
/>
</div>
</div>
{setGroupBy != null && (
<div className="d-flex align-items-center">
<div className="text-muted fw-500">Group By</div>
<div className="ms-3 flex-grow-1" style={{ minWidth: 300 }}>
<GroupBySelect
groupBy={groupBy?.[0]}
fields={field != null ? [field] : []}
table={table}
setGroupBy={g => setGroupBy(g != null ? [g] : undefined)}
/>
{/* <MetricTagSelect
value={groupBy}
setValue={setGroupBy}
metricName={field}
/> */}
</div>
</div>
)}
</>
)}
</div>
{
// TODO: support metrics
sortOrder != null && setSortOrder != null && table === 'logs' && (
<div className="d-flex mt-3 align-items-center">
<div
style={{ width: labelWidth }}
className="text-muted fw-500 ps-2"
>
Sort Order
</div>
<div className="ms-3 flex-grow-1">
<Select
options={SORT_ORDER}
className="ds-select"
value={SORT_ORDER.find(v => v.value === sortOrder)}
onChange={opt => setSortOrder(opt?.value ?? 'desc')}
classNamePrefix="ds-react-select"
/>
</div>
</div>
)
}
{setNumberFormat && (
<div className="ms-2 mt-2 mb-3">
<Divider
label={
<>
<i className="bi bi-gear me-1" />
Chart Settings
</>
}
c="dark.2"
mb={8}
/>
<Group>
<div className="fs-8 text-slate-300">Number Format</div>
<NumberFormatInput
value={numberFormat}
onChange={setNumberFormat}
/>
</Group>
</div>
)}
</div>
);
}
export function convertDateRangeToGranularityString(
dateRange: [Date, Date],
maxNumBuckets: number,

View file

@ -223,6 +223,12 @@ function SaveSearchModal({
{
id: savedSearchId,
name,
select: searchedConfig.select ?? '',
where: searchedConfig.where ?? '',
whereLanguage: searchedConfig.whereLanguage ?? 'lucene',
source: searchedConfig.source ?? '',
orderBy: searchedConfig.orderBy ?? '',
tags: [],
},
{
onSuccess: () => {
@ -517,6 +523,7 @@ function DBSearchPage() {
getValues,
formState,
setError,
resetField,
} = useForm<z.infer<typeof SearchConfigSchema>>({
values: {
select: searchedConfig.select || '',
@ -746,10 +753,25 @@ function DBSearchPage() {
[inputSourceObj?.timestampValueExpression],
);
// If the source changes, reset the form to default values
const prevInputSource = usePrevious(inputSource);
useEffect(() => {
setValue('select', inputSourceObj?.defaultTableSelectExpression ?? '');
setValue('orderBy', defaultOrderBy);
}, [inputSource, inputSourceObj, defaultOrderBy]);
if (prevInputSource !== inputSource) {
resetField('select', {
defaultValue: inputSourceObj?.defaultTableSelectExpression ?? '',
});
resetField('orderBy', {
defaultValue: defaultOrderBy,
});
}
}, [
resetField,
inputSource,
inputSourceObj,
defaultOrderBy,
reset,
prevInputSource,
]);
const [isAlertModalOpen, { open: openAlertModal, close: closeAlertModal }] =
useDisclosure();
@ -825,15 +847,17 @@ function DBSearchPage() {
</Box>
{!IS_LOCAL_MODE && (
<>
<Button
variant="outline"
color="dark.2"
px="xs"
size="xs"
onClick={onSaveSearch}
>
Save
</Button>
{!savedSearchId && (
<Button
variant="outline"
color="dark.2"
px="xs"
size="xs"
onClick={onSaveSearch}
>
Save
</Button>
)}
{IS_DEV && (
<Button
variant="outline"

View file

@ -17,6 +17,7 @@ export default function SearchInputV2({
connectionId,
enableHotkey,
onSubmit,
additionalSuggestions,
...props
}: {
database?: string;
@ -29,6 +30,7 @@ export default function SearchInputV2({
language?: 'sql' | 'lucene';
enableHotkey?: boolean;
onSubmit?: () => void;
additionalSuggestions?: string[];
} & UseControllerProps<any>) {
const {
field: { onChange, value },
@ -49,11 +51,19 @@ export default function SearchInputV2({
const autoCompleteOptions = useMemo(() => {
const _columns = (fields ?? []).filter(c => c.jsType !== null);
return _columns.map(c => ({
const baseOptions = _columns.map(c => ({
value: c.path.join('.'),
label: `${c.path.join('.')} (${c.jsType})`,
}));
}, [fields]);
const suggestionOptions =
additionalSuggestions?.map(column => ({
value: column,
label: column,
})) ?? [];
return [...baseOptions, ...suggestionOptions];
}, [fields, additionalSuggestions]);
const [parsedEnglishQuery, setParsedEnglishQuery] = useState<string>('');

View file

@ -158,6 +158,7 @@ function ConnectionsSection() {
setEditedConnectionId(null);
}}
showCancelButton={false}
showDeleteButton
/>
)}
<Divider my="md" />

View file

@ -1,4 +1,4 @@
import { formatDate } from '../utils';
import { formatAttributeClause, formatDate } from '../utils';
describe('utils', () => {
it('12h utc', () => {
@ -43,3 +43,33 @@ describe('utils', () => {
).toEqual('Jan 1 12:00:00.000');
});
});
describe('formatAttributeClause', () => {
it('should format SQL attribute clause correctly', () => {
expect(
formatAttributeClause('ResourceAttributes', 'service', 'nginx', true),
).toBe("ResourceAttributes['service']='nginx'");
expect(formatAttributeClause('metadata', 'environment', 'prod', true)).toBe(
"metadata['environment']='prod'",
);
expect(formatAttributeClause('data', 'user-id', 'abc-123', true)).toBe(
"data['user-id']='abc-123'",
);
});
it('should format lucene attribute clause correctly', () => {
expect(formatAttributeClause('attrs', 'service', 'nginx', false)).toBe(
'attrs.service:"nginx"',
);
expect(
formatAttributeClause('metadata', 'environment', 'prod', false),
).toBe('metadata.environment:"prod"');
expect(formatAttributeClause('data', 'user-id', 'abc-123', false)).toBe(
'data.user-id:"abc-123"',
);
});
});

View file

@ -12,6 +12,7 @@ import { useDebouncedValue } from '@mantine/hooks';
import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
import SearchInputV2 from '@/SearchInputV2';
import { formatAttributeClause } from '@/utils';
import { DBSqlRowTable } from './DBRowTable';
@ -118,17 +119,6 @@ export default function ContextSubpanel({
return isSql ? `${original} AND ${addition}` : `${original} ${addition}`;
}
// Helper function to format resource attribute clause
function formatResourceAttributeClause(
field: string,
value: string,
isSql: boolean,
): string {
return isSql
? `ResourceAttributes['${field}'] = '${value}'`
: `ResourceAttributes.${field}:"${value}"`;
}
// Main function to generate WHERE clause based on context
function getWhereClause(contextBy: ContextBy): string {
const isSql = originalLanguage === 'sql';
@ -142,7 +132,8 @@ export default function ContextSubpanel({
return combineWhereClauses(originalWhere, debouncedWhere.trim(), isSql);
}
const attributeClause = formatResourceAttributeClause(
const attributeClause = formatAttributeClause(
'ResourceAttributes',
mapping.field,
mapping.value,
isSql,

View file

@ -15,8 +15,11 @@ import {
ChartConfigWithDateRange,
DisplayType,
Filter,
MetricsDataType,
SavedChartConfig,
SelectList,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Accordion,
@ -43,6 +46,7 @@ import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { TimePicker } from '@/components/TimePicker';
import { IS_DEV } from '@/config';
import { GranularityPickerControlled } from '@/GranularityPicker';
import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs';
import SearchInputV2 from '@/SearchInputV2';
import { getFirstTimestampValueExpression, useSource } from '@/source';
import { parseTimeQuery } from '@/timeQuery';
@ -61,9 +65,24 @@ import HDXMarkdownChart from '../HDXMarkdownChart';
import { AggFnSelectControlled } from './AggFnSelect';
import DBNumberChart from './DBNumberChart';
import { InputControlled } from './InputControlled';
import { MetricNameSelect } from './MetricNameSelect';
import { NumberFormatInput } from './NumberFormat';
import { SourceSelectControlled } from './SourceSelect';
const isQueryReady = (queriedConfig: ChartConfigWithDateRange | undefined) =>
((queriedConfig?.select?.length ?? 0) > 0 ||
typeof queriedConfig?.select === 'string') &&
queriedConfig?.from?.databaseName &&
// tableName is emptry for metric sources
(queriedConfig?.from?.tableName || queriedConfig?.metricTables) &&
queriedConfig?.timestampValueExpression;
const getMetricTableName = (source: TSource, metricType?: MetricsDataType) =>
metricType == null
? source.from.tableName
: // @ts-ignore
(source.metricTables?.[metricType.toLowerCase()] as any);
const NumberFormatInputControlled = ({
control,
}: {
@ -90,7 +109,7 @@ function ChartSeriesEditor({
onSubmit,
setValue,
showGroupBy,
tableName,
tableName: _tableName,
watch,
}: {
control: Control<any>;
@ -111,6 +130,24 @@ function ChartSeriesEditor({
'lucene',
);
const metricType = watch(`${namePrefix}metricType`);
const selectedSourceId = watch('source');
const { data: tableSource } = useSource({ id: selectedSourceId });
const tableName =
tableSource?.kind === SourceKind.Metric
? getMetricTableName(tableSource, metricType)
: _tableName;
const { data: attributeKeys } = useFetchMetricResourceAttrs({
databaseName,
tableName,
metricType,
metricName: watch(`${namePrefix}metricName`),
tableSource,
isSql: aggConditionLanguage === 'sql',
});
return (
<>
<Divider
@ -146,7 +183,19 @@ function ChartSeriesEditor({
control={control}
/>
</div>
{aggFn !== 'count' && (
{tableSource?.kind === SourceKind.Metric && (
<MetricNameSelect
metricName={watch(`${namePrefix}metricName`)}
metricType={metricType}
setMetricName={value => {
setValue(`${namePrefix}metricName`, value);
setValue(`${namePrefix}valueExpression`, 'Value');
}}
setMetricType={value => setValue(`${namePrefix}metricType`, value)}
metricSource={tableSource}
/>
)}
{tableSource?.kind !== SourceKind.Metric && aggFn !== 'count' && (
<div style={{ minWidth: 220 }}>
<SQLInlineEditorControlled
database={databaseName}
@ -171,6 +220,7 @@ function ChartSeriesEditor({
onLanguageChange={lang =>
setValue(`${namePrefix}aggConditionLanguage`, lang)
}
additionalSuggestions={attributeKeys}
language="sql"
onSubmit={onSubmit}
/>
@ -187,6 +237,7 @@ function ChartSeriesEditor({
language="lucene"
placeholder="Search your events w/ Lucene ex. column:foo"
onSubmit={onSubmit}
additionalSuggestions={attributeKeys}
/>
)}
{showGroupBy && (
@ -304,6 +355,7 @@ export default function EditTimeChartForm({
}, [displayType]);
const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview
const showSampleEvents = tableSource?.kind !== SourceKind.Metric;
// const queriedConfig: ChartConfigWithDateRange | undefined = useMemo(() => {
// if (queriedTableSource == null) {
@ -328,7 +380,6 @@ export default function EditTimeChartForm({
const onSubmit = useCallback(() => {
handleSubmit(form => {
setChartConfig(form);
if (tableSource != null) {
setQueriedConfig({
...form,
@ -337,6 +388,7 @@ export default function EditTimeChartForm({
dateRange,
connection: tableSource.connection,
implicitColumnExpression: tableSource.implicitColumnExpression,
metricTables: tableSource.metricTables,
});
}
})();
@ -380,12 +432,7 @@ export default function EditTimeChartForm({
});
}, [dateRange]);
const queryReady =
((queriedConfig?.select?.length ?? 0) > 0 ||
typeof queriedConfig?.select === 'string') &&
queriedConfig?.from?.databaseName &&
queriedConfig?.from?.tableName &&
queriedConfig?.timestampValueExpression;
const queryReady = isQueryReady(queriedConfig);
const sampleEventsConfig = useMemo(
() =>
@ -842,31 +889,33 @@ export default function EditTimeChartForm({
{showGeneratedSql && (
<>
<Divider mt="md" />
<Accordion defaultValue="sample">
<Accordion.Item value="sample">
<Accordion.Control icon={<i className="bi bi-card-list"></i>}>
<Text size="sm" style={{ alignSelf: 'center' }}>
Sample Matched Events
</Text>
</Accordion.Control>
<Accordion.Panel>
{sampleEventsConfig != null && (
<div
className="flex-grow-1 d-flex flex-column"
style={{ height: 400 }}
>
<DBSqlRowTable
config={sampleEventsConfig}
highlightedLineId={undefined}
enabled
isLive={false}
queryKeyPrefix={'search'}
/>
</div>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{showSampleEvents && (
<Accordion defaultValue="sample">
<Accordion.Item value="sample">
<Accordion.Control icon={<i className="bi bi-card-list"></i>}>
<Text size="sm" style={{ alignSelf: 'center' }}>
Sample Matched Events
</Text>
</Accordion.Control>
<Accordion.Panel>
{sampleEventsConfig != null && (
<div
className="flex-grow-1 d-flex flex-column"
style={{ height: 400 }}
>
<DBSqlRowTable
config={sampleEventsConfig}
highlightedLineId={undefined}
enabled
isLive={false}
queryKeyPrefix={'search'}
/>
</div>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
<Accordion defaultValue="">
<Accordion.Item value={'SQL'}>
<Accordion.Control icon={<i className="bi bi-code-square"></i>}>

View file

@ -61,6 +61,7 @@ export function DBTimeChart({
granularity,
limit: { limit: 100000 },
};
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
useQueriedChartConfig(queriedConfig, {
placeholderData: (prev: any) => prev,

View file

@ -0,0 +1,132 @@
import { useMemo } from 'react';
import { Control, UseControllerProps } from 'react-hook-form';
import { MetricsDataType, TSource } from '@hyperdx/common-utils/dist/types';
import { Select } from '@mantine/core';
import { useGetKeyValues } from '@/hooks/useMetadata';
const dateRange = [new Date(Date.now() - 1000 * 60 * 60 * 24), new Date()] as [
Date,
Date,
];
const chartConfigByMetricType = (
metricSource: TSource,
metricType: MetricsDataType,
) => ({
// metricSource,
from: {
databaseName: metricSource.from.databaseName,
tableName: metricSource.metricTables?.[metricType] ?? '',
},
where: '',
whereLanguage: 'sql' as const,
select: '',
timestampValueExpression: metricSource.timestampValueExpression ?? '',
connection: metricSource.connection,
// TODO: Set proper date range (optional)
dateRange,
});
function useMetricNames(metricSource: TSource) {
const { gaugeConfig, histogramConfig, sumConfig } = useMemo(() => {
return {
gaugeConfig: chartConfigByMetricType(metricSource, MetricsDataType.Gauge),
histogramConfig: chartConfigByMetricType(
metricSource,
MetricsDataType.Histogram,
),
sumConfig: chartConfigByMetricType(metricSource, MetricsDataType.Sum),
};
}, [metricSource]);
const { data: gaugeMetrics } = useGetKeyValues({
chartConfig: gaugeConfig,
keys: ['MetricName'],
});
const { data: histogramMetrics } = useGetKeyValues({
chartConfig: histogramConfig,
keys: ['MetricName'],
});
const { data: sumMetrics } = useGetKeyValues({
chartConfig: sumConfig,
keys: ['MetricName'],
});
return {
gaugeMetrics: gaugeMetrics?.[0].value,
histogramMetrics: histogramMetrics?.[0].value,
sumMetrics: sumMetrics?.[0].value,
};
}
export function MetricNameSelect({
metricType,
metricName,
setMetricType,
setMetricName,
isLoading,
isError,
metricSource,
}: {
metricType: MetricsDataType;
metricName: string | undefined | null;
setMetricType: (metricType: MetricsDataType) => void;
setMetricName: (metricName: string) => void;
isLoading?: boolean;
isError?: boolean;
metricSource: TSource;
}) {
const SEPARATOR = ':::::::';
const { gaugeMetrics, histogramMetrics, sumMetrics } =
useMetricNames(metricSource);
const options = useMemo(() => {
return [
...(gaugeMetrics?.map(metric => ({
value: `${metric}${SEPARATOR}gauge`,
label: `${metric} (Gauge)`,
})) ?? []),
...(histogramMetrics?.map(metric => ({
value: `${metric}${SEPARATOR}histogram`,
label: `${metric} (Histogram)`,
})) ?? []),
...(sumMetrics?.map(metric => ({
value: `${metric}${SEPARATOR}sum`,
label: `${metric} (Sum)`,
})) ?? []),
];
}, [gaugeMetrics, histogramMetrics, sumMetrics]);
return (
<Select
disabled={isLoading || isError}
variant="filled"
placeholder={
isLoading
? 'Loading...'
: isError
? 'Unable to load metrics'
: 'Select a metric...'
}
data={options}
limit={100}
comboboxProps={{
position: 'bottom-start',
width: 'auto',
zIndex: 1111,
}}
value={`${metricName}${SEPARATOR}${metricType}`}
searchable
clearable
onChange={value => {
const [_metricName, _metricType] = value?.split(SEPARATOR) ?? [];
setMetricName(_metricName ?? '');
if (_metricType) {
setMetricType(_metricType.toLowerCase() as MetricsDataType);
}
}}
/>
);
}

View file

@ -103,6 +103,7 @@ type SQLInlineEditorProps = {
disableKeywordAutocomplete?: boolean;
connectionId: string | undefined;
enableHotkey?: boolean;
additionalSuggestions?: string[];
};
const styleTheme = EditorView.baseTheme({
@ -132,6 +133,7 @@ export default function SQLInlineEditor({
disableKeywordAutocomplete,
connectionId,
enableHotkey,
additionalSuggestions = [],
}: SQLInlineEditorProps) {
const { data: fields } = useAllFields(
{
@ -156,30 +158,30 @@ export default function SQLInlineEditor({
const updateAutocompleteColumns = useCallback(
(viewRef: EditorView) => {
const keywords =
filteredFields?.map(column => {
const keywords = [
...(filteredFields?.map(column => {
if (column.path.length > 1) {
return `${column.path[0]}['${column.path[1]}']`;
}
return column.path[0];
}) ?? [];
}) ?? []),
...additionalSuggestions,
];
viewRef.dispatch({
effects: compartmentRef.current.reconfigure(
sql({
defaultTable: table ?? '',
// schema, // FIXME: maybe we want to use schema. need to figure out the identifier issue
dialect: SQLDialect.define({
keywords:
keywords.join(' ') +
(disableKeywordAutocomplete ? '' : AUTOCOMPLETE_LIST_STRING),
// identifierQuotes: '`',
}),
}),
),
});
},
[filteredFields, table],
[filteredFields, table, additionalSuggestions],
);
useEffect(() => {
@ -231,8 +233,12 @@ export default function SQLInlineEditor({
value={value}
onChange={onChange}
theme={'dark'}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onFocus={() => {
setIsFocused(true);
}}
onBlur={() => {
setIsFocused(false);
}}
extensions={[
styleTheme,
compartmentRef.current.of(
@ -265,16 +271,6 @@ export default function SQLInlineEditor({
onCreateEditor={view => {
updateAutocompleteColumns(view);
}}
onUpdate={update => {
// Always open completion window as much as possible
if (
update.focusChanged &&
update.view.hasFocus &&
ref.current?.view
) {
startCompletion(ref.current?.view);
}
}}
basicSetup={{
lineNumbers: false,
foldGutter: false,
@ -301,6 +297,7 @@ export function SQLInlineEditorControlled({
placeholder,
filterField,
connectionId,
additionalSuggestions,
...props
}: Omit<SQLInlineEditorProps, 'value' | 'onChange'> & UseControllerProps<any>) {
const { field } = useController(props);
@ -314,6 +311,7 @@ export function SQLInlineEditorControlled({
table={table}
value={field.value || props.defaultValue}
connectionId={connectionId}
additionalSuggestions={additionalSuggestions}
{...props}
/>
);

View file

@ -52,6 +52,12 @@ import { SQLInlineEditorControlled } from './SQLInlineEditor';
const DEFAULT_DATABASE = 'default';
// TODO: maybe otel clickhouse export migrate the schema?
const OTEL_CLICKHOUSE_EXPRESSIONS = {
timestampValueExpression: 'TimeUnix',
resourceAttributesExpression: 'ResourceAttributes',
};
function FormRow({
label,
children,
@ -649,7 +655,9 @@ export function MetricTableModelForm({
const connectionId = watch(`connection`);
useEffect(() => {
setValue('timestampValueExpression', 'TimeUnix');
for (const [_key, _value] of Object.entries(OTEL_CLICKHOUSE_EXPRESSIONS)) {
setValue(_key as any, _value);
}
const { unsubscribe } = watch(async (value, { name, type }) => {
try {
if (name && type === 'change') {

View file

@ -0,0 +1,130 @@
import { ResponseJSON } from '@clickhouse/client';
import { chSql, tableExpr } from '@hyperdx/common-utils/dist/clickhouse';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { useQuery } from '@tanstack/react-query';
import { getClickhouseClient } from '@/clickhouse';
import { formatAttributeClause } from '@/utils';
const METRIC_FETCH_LIMIT = 10000;
const extractAttributeKeys = (
attributesArr: MetricAttributesResponse[],
isSql: boolean,
) => {
try {
const resultSet = new Set<string>();
for (const attribute of attributesArr) {
if (attribute.ScopeAttributes) {
Object.entries(attribute.ScopeAttributes).forEach(([key, value]) => {
const clause = formatAttributeClause(
'ScopeAttributes',
key,
value,
isSql,
);
resultSet.add(clause);
});
}
if (attribute.ResourceAttributes) {
Object.entries(attribute.ResourceAttributes).forEach(([key, value]) => {
const clause = formatAttributeClause(
'ResourceAttributes',
key,
value,
isSql,
);
resultSet.add(clause);
});
}
if (attribute.Attributes) {
Object.entries(attribute.Attributes).forEach(([key, value]) => {
const clause = formatAttributeClause('Attributes', key, value, isSql);
resultSet.add(clause);
});
}
}
return Array.from(resultSet);
} catch (e) {
console.error('Error parsing metric autocompleteattributes', e);
return [];
}
};
interface MetricResourceAttrsProps {
databaseName: string;
tableName: string;
metricType: string;
metricName: string;
tableSource: TSource | undefined;
isSql: boolean;
}
interface MetricAttributesResponse {
ScopeAttributes?: Record<string, string>;
ResourceAttributes?: Record<string, string>;
Attributes?: Record<string, string>;
}
export const useFetchMetricResourceAttrs = ({
databaseName,
tableName,
metricType,
metricName,
tableSource,
isSql,
}: MetricResourceAttrsProps) => {
const shouldFetch = Boolean(
databaseName &&
tableName &&
tableSource &&
tableSource?.kind === SourceKind.Metric,
);
return useQuery({
queryKey: [
'metric-attributes',
databaseName,
tableName,
metricType,
metricName,
isSql,
],
queryFn: async ({ signal }) => {
if (!shouldFetch) {
return [];
}
const clickhouseClient = getClickhouseClient();
const sql = chSql`
SELECT DISTINCT
ScopeAttributes,
ResourceAttributes,
Attributes
FROM ${tableExpr({ database: databaseName, table: tableName })}
WHERE MetricName=${{ String: metricName }}
LIMIT ${{ Int32: METRIC_FETCH_LIMIT }}
`;
const result = (await clickhouseClient
.query<'JSON'>({
query: sql.sql,
query_params: sql.params,
format: 'JSON',
abort_signal: signal,
connectionId: tableSource!.connection,
})
.then(res => res.json())) as ResponseJSON<MetricAttributesResponse>;
if (result?.data) {
return extractAttributeKeys(result.data, isSql);
}
return [];
},
enabled: shouldFetch,
});
};

View file

@ -588,3 +588,15 @@ export const parseJSON = <T = any>(json: string) => {
export const optionsToSelectData = (options: Record<string, string>) =>
Object.entries(options).map(([value, label]) => ({ value, label }));
// Helper function to format attribute clause
export function formatAttributeClause(
column: string,
field: string,
value: string,
isSql: boolean,
): string {
return isSql
? `${column}['${field}']='${value}'`
: `${column}.${field}:"${value}"`;
}

View file

@ -0,0 +1,123 @@
import { parameterizedQueryToSql } from '@/clickhouse';
import { Metadata } from '@/metadata';
import {
ChartConfigWithOptDateRange,
DisplayType,
MetricsDataType,
} from '@/types';
import { renderChartConfig } from '../renderChartConfig';
describe('renderChartConfig', () => {
let mockMetadata: Metadata;
beforeEach(() => {
mockMetadata = {
getColumns: jest.fn().mockResolvedValue([
{ name: 'timestamp', type: 'DateTime' },
{ name: 'value', type: 'Float64' },
]),
getMaterializedColumnsLookupTable: jest.fn().mockResolvedValue({}),
getColumn: jest.fn().mockResolvedValue({ type: 'DateTime' }),
} as unknown as Metadata;
});
it('should generate sql for a single gauge metric', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
// metricTables is added from the Source object via spread operator
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: 'Value',
level: 0.95,
metricName: 'nodejs.event_loop.utilization',
metricType: MetricsDataType.Gauge,
},
],
where: '',
whereLanguage: 'lucene',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '1 minute',
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toBe(
'SELECT quantile(0.95)(toFloat64OrNull(toString(Value))),toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket`' +
' FROM default.otel_metrics_gauge WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) AND' +
" (MetricName = 'nodejs.event_loop.utilization') GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket` " +
'ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket` LIMIT 10',
);
});
it('should generate sql for a single sum metric', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
// metricTables is added from the Source object via spread operator
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'avg',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: 'Value',
metricName: 'db.client.connections.usage',
metricType: MetricsDataType.Sum,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '5 minutes',
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toBe(
'WITH RawSum AS (SELECT MetricName,Value,TimeUnix,Attributes,\n' +
' any(Value) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevValue,\n' +
' any(AttributesHash) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevAttributesHash,\n' +
' IF(AggregationTemporality = 1,\n' +
' Value,IF(Value - PrevValue < 0 AND AttributesHash = PrevAttributesHash, Value,\n' +
' IF(AttributesHash != PrevAttributesHash, 0, Value - PrevValue))) as Rate\n' +
' FROM (\n' +
' SELECT mapConcat(ScopeAttributes, ResourceAttributes, Attributes) AS Attributes,\n' +
' cityHash64(Attributes) AS AttributesHash, Value, MetricName, TimeUnix, AggregationTemporality\n' +
' FROM default.otel_metrics_sum\n' +
" WHERE MetricName = 'db.client.connections.usage'\n" +
' ORDER BY Attributes, TimeUnix ASC\n' +
' ) )SELECT avg(\n' +
' toFloat64OrNull(toString(Rate))\n' +
' ),toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` ' +
'FROM RawSum WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) ' +
"AND (MetricName = 'db.client.connections.usage') GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` " +
'ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` LIMIT 10',
);
});
});

View file

@ -9,9 +9,11 @@ import {
AggregateFunctionWithCombinators,
ChartConfigWithDateRange,
ChartConfigWithOptDateRange,
MetricsDataType,
SearchCondition,
SearchConditionLanguage,
SelectList,
SelectSQLStatement,
SortSpecificationList,
SqlAstFilter,
SQLInterval,
@ -28,6 +30,14 @@ type ColumnRef = SQLParser.ColumnRef & {
}[];
};
function determineTableName(select: SelectSQLStatement): string {
if ('metricTables' in select.from) {
return select.from.tableName;
}
return '';
}
export const FIXED_TIME_BUCKET_EXPR_ALIAS = '__hdx_time_bucket';
export function isUsingGroupBy(
@ -282,18 +292,23 @@ const aggFnExpr = ({
async function renderSelectList(
selectList: SelectList,
chartConfig: ChartConfigWithOptDateRange,
chartConfig: ChartConfigWithOptDateRateAndCte,
metadata: Metadata,
) {
if (typeof selectList === 'string') {
return chSql`${{ UNSAFE_RAW_SQL: selectList }}`;
}
const materializedFields = await metadata.getMaterializedColumnsLookupTable({
connectionId: chartConfig.connection,
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
});
// This metadata query is executed in an attempt tp optimize the selects by favoring materialized fields
// on a view/table that already perform the computation in select. This optimization is not currently
// supported for queries using CTEs so skip the metadata fetch if there are CTE objects in the config.
const materializedFields = chartConfig.with?.length
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId: chartConfig.connection,
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
});
return Promise.all(
selectList.map(async select => {
@ -304,6 +319,7 @@ async function renderSelectList(
implicitColumnExpression: chartConfig.implicitColumnExpression,
metadata,
connectionId: chartConfig.connection,
with: chartConfig.with,
});
let expr: ChSql;
@ -326,10 +342,11 @@ async function renderSelectList(
}
const rawSQL = `SELECT ${expr.sql} FROM \`t\``;
// strip 'SELECT * FROM `t` WHERE ' from the sql
expr.sql = fastifySQL({ materializedFields, rawSQL })
.replace(/^SELECT\s+/i, '') // Remove 'SELECT ' from the start
.replace(/\s+FROM `t`$/i, ''); // Remove ' FROM t' from the end
if (materializedFields) {
expr.sql = fastifySQL({ materializedFields, rawSQL })
.replace(/^SELECT\s+/i, '') // Remove 'SELECT ' from the start
.replace(/\s+FROM `t`$/i, ''); // Remove ' FROM t' from the end
}
return chSql`${expr}${
select.alias != null
@ -388,6 +405,7 @@ async function timeFilterExpr({
tableName,
metadata,
connectionId,
with: withClauses,
}: {
timestampValueExpression: string;
dateRange: [Date, Date];
@ -396,6 +414,7 @@ async function timeFilterExpr({
connectionId: string;
databaseName: string;
tableName: string;
with?: { name: string; sql: ChSql }[];
}) {
const valueExpressions = timestampValueExpression.split(',');
const startTime = dateRange[0].getTime();
@ -404,18 +423,20 @@ async function timeFilterExpr({
const whereExprs = await Promise.all(
valueExpressions.map(async expr => {
const col = expr.trim();
const columnMeta = await metadata.getColumn({
databaseName,
tableName,
column: col,
connectionId,
});
const columnMeta = withClauses?.length
? null
: await metadata.getColumn({
databaseName,
tableName,
column: col,
connectionId,
});
const unsafeTimestampValueExpression = {
UNSAFE_RAW_SQL: col,
};
if (columnMeta == null) {
if (columnMeta == null && !withClauses?.length) {
console.warn(
`Column ${col} not found in ${databaseName}.${tableName} while inferring type for time filter`,
);
@ -446,7 +467,7 @@ async function timeFilterExpr({
}
async function renderSelect(
chartConfig: ChartConfigWithOptDateRange,
chartConfig: ChartConfigWithOptDateRateAndCte,
metadata: Metadata,
): Promise<ChSql> {
/**
@ -480,9 +501,13 @@ function renderFrom({
}: {
from: ChartConfigWithDateRange['from'];
}): ChSql {
return chSql`${{ Identifier: from.databaseName }}.${{
Identifier: from.tableName,
}}`;
return concatChSql(
'.',
chSql`${from.databaseName === '' ? '' : { Identifier: from.databaseName }}`,
chSql`${{
Identifier: from.tableName,
}}`,
);
}
async function renderWhereExpression({
@ -492,6 +517,7 @@ async function renderWhereExpression({
from,
implicitColumnExpression,
connectionId,
with: withClauses,
}: {
condition: SearchCondition;
language: SearchConditionLanguage;
@ -499,6 +525,7 @@ async function renderWhereExpression({
from: ChartConfigWithDateRange['from'];
implicitColumnExpression?: string;
connectionId: string;
with?: { name: string; sql: ChSql }[];
}): Promise<ChSql> {
let _condition = condition;
if (language === 'lucene') {
@ -513,24 +540,32 @@ async function renderWhereExpression({
_condition = await builder.build();
}
const materializedFields = await metadata.getMaterializedColumnsLookupTable({
connectionId,
databaseName: from.databaseName,
tableName: from.tableName,
});
// This metadata query is executed in an attempt tp optimize the selects by favoring materialized fields
// on a view/table that already perform the computation in select. This optimization is not currently
// supported for queries using CTEs so skip the metadata fetch if there are CTE objects in the config.
const materializedFields = withClauses?.length
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId,
databaseName: from.databaseName,
tableName: from.tableName,
});
const _sqlPrefix = 'SELECT * FROM `t` WHERE ';
const rawSQL = `${_sqlPrefix}${_condition}`;
// strip 'SELECT * FROM `t` WHERE ' from the sql
_condition = fastifySQL({ materializedFields, rawSQL }).replace(
_sqlPrefix,
'',
);
if (materializedFields) {
_condition = fastifySQL({ materializedFields, rawSQL }).replace(
_sqlPrefix,
'',
);
}
return chSql`${{ UNSAFE_RAW_SQL: _condition }}`;
}
async function renderWhere(
chartConfig: ChartConfigWithOptDateRange,
chartConfig: ChartConfigWithOptDateRateAndCte,
metadata: Metadata,
): Promise<ChSql> {
let whereSearchCondition: ChSql | [] = [];
@ -543,6 +578,7 @@ async function renderWhere(
implicitColumnExpression: chartConfig.implicitColumnExpression,
metadata,
connectionId: chartConfig.connection,
with: chartConfig.with,
}),
'(',
')',
@ -567,6 +603,7 @@ async function renderWhere(
implicitColumnExpression: chartConfig.implicitColumnExpression,
metadata,
connectionId: chartConfig.connection,
with: chartConfig.with,
});
}
return null;
@ -592,6 +629,7 @@ async function renderWhere(
implicitColumnExpression: chartConfig.implicitColumnExpression,
metadata,
connectionId: chartConfig.connection,
with: chartConfig.with,
}),
'(',
')',
@ -614,6 +652,7 @@ async function renderWhere(
connectionId: chartConfig.connection,
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
with: chartConfig.with,
})
: [],
whereSearchCondition,
@ -688,10 +727,109 @@ function renderLimit(
return chSql`${{ Int32: chartConfig.limit.limit }}${offset}`;
}
export async function renderChartConfig(
// CTE (Common Table Expressions) isn't exported at this time. It's only used internally
// for metric SQL generation.
type ChartConfigWithOptDateRateAndCte = ChartConfigWithOptDateRange & {
with?: { name: string; sql: ChSql }[];
};
function renderWith(
chartConfig: ChartConfigWithOptDateRateAndCte,
metadata: Metadata,
): ChSql | undefined {
const { with: withClauses } = chartConfig;
if (withClauses) {
return concatChSql(
'',
withClauses.map(clause => chSql`WITH ${clause.name} AS (${clause.sql})`),
);
}
return undefined;
}
function translateMetricChartConfig(
chartConfig: ChartConfigWithOptDateRange,
): ChartConfigWithOptDateRateAndCte {
const metricTables = chartConfig.metricTables;
if (!metricTables) {
return chartConfig;
}
// assumes all the selects are from a single metric type, for now
const { select, from, ...restChartConfig } = chartConfig;
if (!select || !Array.isArray(select)) {
throw new Error('multi select or string select on metrics not supported');
}
const { metricType, metricName, ..._select } = select[0]; // Initial impl only supports one metric select per chart config
if (metricType === MetricsDataType.Gauge && metricName) {
return {
...restChartConfig,
select: [
{
..._select,
valueExpression: 'Value',
},
],
from: {
...from,
tableName: metricTables[MetricsDataType.Gauge],
},
where: `MetricName = '${metricName}'`,
whereLanguage: 'sql',
};
} else if (metricType === MetricsDataType.Sum && metricName) {
return {
...restChartConfig,
with: [
{
name: 'RawSum',
sql: chSql`SELECT MetricName,Value,TimeUnix,Attributes,
any(Value) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevValue,
any(AttributesHash) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevAttributesHash,
IF(AggregationTemporality = 1,
Value,IF(Value - PrevValue < 0 AND AttributesHash = PrevAttributesHash, Value,
IF(AttributesHash != PrevAttributesHash, 0, Value - PrevValue))) as Rate
FROM (
SELECT mapConcat(ScopeAttributes, ResourceAttributes, Attributes) AS Attributes,
cityHash64(Attributes) AS AttributesHash, Value, MetricName, TimeUnix, AggregationTemporality
FROM ${renderFrom({ from: { ...from, tableName: metricTables[MetricsDataType.Sum] } })}
WHERE MetricName = '${metricName}'
ORDER BY Attributes, TimeUnix ASC
) `,
},
],
select: [
{
..._select,
valueExpression: 'Rate',
},
],
from: {
databaseName: '',
tableName: 'RawSum',
},
where: `MetricName = '${metricName}'`,
whereLanguage: 'sql',
};
}
throw new Error(`no query support for metric type=${metricType}`);
}
export async function renderChartConfig(
rawChartConfig: ChartConfigWithOptDateRange,
metadata: Metadata,
): Promise<ChSql> {
// metric types require more rewriting since we know more about the schema
// but goes through the same generation process
const chartConfig =
rawChartConfig.metricTables != null
? translateMetricChartConfig(rawChartConfig)
: rawChartConfig;
const withClauses = renderWith(chartConfig, metadata);
const select = await renderSelect(chartConfig, metadata);
const from = renderFrom(chartConfig);
const where = await renderWhere(chartConfig, metadata);
@ -699,7 +837,9 @@ export async function renderChartConfig(
const orderBy = renderOrderBy(chartConfig);
const limit = renderLimit(chartConfig);
return chSql`SELECT ${select} FROM ${from} ${where?.sql ? chSql`WHERE ${where}` : ''} ${
return chSql`${
withClauses?.sql ? chSql`${withClauses}` : ''
}SELECT ${select} FROM ${from} ${where?.sql ? chSql`WHERE ${where}` : ''} ${
groupBy?.sql ? chSql`GROUP BY ${groupBy}` : ''
} ${orderBy?.sql ? chSql`ORDER BY ${orderBy}` : ''} ${
limit?.sql ? chSql`LIMIT ${limit}` : ''

View file

@ -1,5 +1,12 @@
import { z } from 'zod';
// Basic Enums
export enum MetricsDataType {
Gauge = 'gauge',
Histogram = 'histogram',
Sum = 'sum',
}
// --------------------------
// UI
// --------------------------
@ -13,6 +20,14 @@ export enum DisplayType {
Markdown = 'markdown',
}
export const MetricTableSchema = z.object({
[MetricsDataType.Gauge]: z.string(),
[MetricsDataType.Histogram]: z.string(),
[MetricsDataType.Sum]: z.string(),
});
export type MetricTable = z.infer<typeof MetricTableSchema>;
// --------------------------
// SQL TYPES
// --------------------------
@ -36,6 +51,7 @@ export const AggregateFunctionSchema = z.enum([
export const AggregateFunctionWithCombinatorsSchema = z
.string()
.regex(/^(\w+)If(State|Merge)$/);
export const RootValueExpressionSchema = z
.object({
aggFn: z.union([
@ -61,12 +77,15 @@ export const RootValueExpressionSchema = z
aggCondition: z.string().optional(),
aggConditionLanguage: SearchConditionLanguageSchema,
valueExpression: z.string(),
metricType: z.nativeEnum(MetricsDataType).optional(),
}),
);
export const DerivedColumnSchema = z.intersection(
RootValueExpressionSchema,
z.object({
alias: z.string().optional(),
metricType: z.nativeEnum(MetricsDataType).optional(),
metricName: z.string().optional(),
}),
);
export const SelectListSchema = z.array(DerivedColumnSchema).or(z.string());
@ -319,6 +338,7 @@ export const _ChartConfigSchema = z.object({
connection: z.string(),
fillNulls: z.union([z.number(), z.literal(false)]).optional(),
selectGroupBy: z.boolean().optional(),
metricTables: MetricTableSchema.optional(),
});
export const ChartConfigSchema = z.intersection(
@ -399,12 +419,6 @@ export enum SourceKind {
Metric = 'metric',
}
export enum MetricsDataType {
Gauge = 'gauge',
Histogram = 'histogram',
Sum = 'sum',
}
export const SourceSchema = z.object({
from: z.object({
databaseName: z.string(),
@ -450,13 +464,7 @@ export const SourceSchema = z.object({
logSourceId: z.string().optional(),
// OTEL Metrics
metricTables: z
.object({
[MetricsDataType.Gauge]: z.string(),
[MetricsDataType.Histogram]: z.string(),
[MetricsDataType.Sum]: z.string(),
})
.optional(),
metricTables: MetricTableSchema.optional(),
});
export type TSource = z.infer<typeof SourceSchema>;