mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
c389b69dc9
commit
57a6bc399f
17 changed files with 790 additions and 740 deletions
6
.changeset/spicy-guests-bow.md
Normal file
6
.changeset/spicy-guests-bow.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": minor
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
feat: BETA metrics support (sum + gauge)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>('');
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ function ConnectionsSection() {
|
|||
setEditedConnectionId(null);
|
||||
}}
|
||||
showCancelButton={false}
|
||||
showDeleteButton
|
||||
/>
|
||||
)}
|
||||
<Divider my="md" />
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>}>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export function DBTimeChart({
|
|||
granularity,
|
||||
limit: { limit: 100000 },
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
|
||||
useQueriedChartConfig(queriedConfig, {
|
||||
placeholderData: (prev: any) => prev,
|
||||
|
|
|
|||
132
packages/app/src/components/MetricNameSelect.tsx
Normal file
132
packages/app/src/components/MetricNameSelect.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
130
packages/app/src/hooks/useFetchMetricResourceAttrs.tsx
Normal file
130
packages/app/src/hooks/useFetchMetricResourceAttrs.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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}"`;
|
||||
}
|
||||
|
|
|
|||
123
packages/common-utils/src/__tests__/renderChartConfig.test.ts
Normal file
123
packages/common-utils/src/__tests__/renderChartConfig.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}` : ''
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue