Metric Attribute Explorer (#1703)

<img width="1369" height="1157" alt="image" src="https://github.com/user-attachments/assets/592640b1-9e24-426d-886b-a3afb51410aa" />

Fixes HDX-2282
This commit is contained in:
Mike Shi 2026-02-06 07:34:09 -08:00 committed by GitHub
parent fa2b73cacc
commit 6241c38892
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 845 additions and 18 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": minor
---
feat: Add metrics attribute explorer in chart builder

View file

@ -80,7 +80,11 @@ import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
import { TimePicker } from '@/components/TimePicker';
import { IS_LOCAL_MODE } from '@/config';
import { GranularityPickerControlled } from '@/GranularityPicker';
import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs';
import { useFetchMetricMetadata } from '@/hooks/useFetchMetricMetadata';
import {
parseAttributeKeysFromSuggestions,
useFetchMetricResourceAttrs,
} from '@/hooks/useFetchMetricResourceAttrs';
import SearchInputV2 from '@/SearchInputV2';
import { getFirstTimestampValueExpression, useSource } from '@/source';
import {
@ -112,6 +116,7 @@ import {
InputControlled,
TextInputControlled,
} from './InputControlled';
import { MetricAttributeHelperPanel } from './MetricAttributeHelperPanel';
import { MetricNameSelect } from './MetricNameSelect';
import SaveToDashboardModal from './SaveToDashboardModal';
import SourceSchemaPreview from './SourceSchemaPreview';
@ -248,15 +253,54 @@ function ChartSeriesEditorComponent({
: _tableName;
const metricName = useWatch({ control, name: `${namePrefix}metricName` });
const { data: attributeKeys } = useFetchMetricResourceAttrs({
const aggCondition = useWatch({
control,
name: `${namePrefix}aggCondition`,
});
const groupBy = useWatch({ control, name: 'groupBy' });
const { data: attributeSuggestions, isLoading: isLoadingAttributes } =
useFetchMetricResourceAttrs({
databaseName,
metricType,
metricName,
tableSource,
isSql: aggConditionLanguage === 'sql',
});
const attributeKeys = useMemo(
() => parseAttributeKeysFromSuggestions(attributeSuggestions ?? []),
[attributeSuggestions],
);
const { data: metricMetadata } = useFetchMetricMetadata({
databaseName,
tableName: tableName || '',
metricType,
metricName,
tableSource,
isSql: aggConditionLanguage === 'sql',
});
const handleAddToWhere = useCallback(
(clause: string) => {
const currentValue = aggCondition || '';
const newValue = currentValue ? `${currentValue} AND ${clause}` : clause;
setValue(`${namePrefix}aggCondition`, newValue);
onSubmit();
},
[aggCondition, namePrefix, setValue, onSubmit],
);
const handleAddToGroupBy = useCallback(
(clause: string) => {
const currentValue = groupBy || '';
const newValue = currentValue ? `${currentValue}, ${clause}` : clause;
setValue('groupBy', newValue);
onSubmit();
},
[groupBy, setValue, onSubmit],
);
const showWhere = aggFn !== 'none';
const tableConnection = useMemo(
@ -414,7 +458,7 @@ function ChartSeriesEditorComponent({
onLanguageChange={lang =>
setValue(`${namePrefix}aggConditionLanguage`, lang)
}
additionalSuggestions={attributeKeys}
additionalSuggestions={attributeSuggestions}
language="sql"
onSubmit={onSubmit}
/>
@ -429,7 +473,7 @@ function ChartSeriesEditorComponent({
language="lucene"
placeholder="Search your events w/ Lucene ex. column:foo"
onSubmit={onSubmit}
additionalSuggestions={attributeKeys}
additionalSuggestions={attributeSuggestions}
/>
)}
</div>
@ -480,6 +524,20 @@ function ChartSeriesEditorComponent({
</div>
)}
</Flex>
{tableSource?.kind === SourceKind.Metric && metricName && (
<MetricAttributeHelperPanel
databaseName={databaseName}
metricType={metricType}
metricName={metricName}
tableSource={tableSource}
attributeKeys={attributeKeys}
isLoading={isLoadingAttributes}
language={aggConditionLanguage === 'sql' ? 'sql' : 'lucene'}
metricMetadata={metricMetadata}
onAddToWhere={handleAddToWhere}
onAddToGroupBy={handleAddToGroupBy}
/>
)}
</>
);
}

View file

@ -0,0 +1,493 @@
import { useCallback, useMemo, useState } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import {
Badge,
Box,
Button,
Collapse,
Flex,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Text,
TextInput,
UnstyledButton,
} from '@mantine/core';
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconFilter,
IconPlus,
IconSearch,
} from '@tabler/icons-react';
import { useFetchMetricAttributeValues } from '@/hooks/useFetchMetricAttributeValues';
import { MetricMetadata } from '@/hooks/useFetchMetricMetadata';
import {
AttributeCategory,
AttributeKey,
} from '@/hooks/useFetchMetricResourceAttrs';
interface MetricAttributeHelperPanelProps {
databaseName: string;
metricType: string;
metricName: string;
tableSource: TSource | undefined;
attributeKeys: AttributeKey[];
isLoading?: boolean;
language: 'sql' | 'lucene';
metricMetadata?: MetricMetadata | null;
onAddToWhere: (clause: string) => void;
onAddToGroupBy: (clause: string) => void;
}
const CATEGORY_LABELS: Record<AttributeCategory, string> = {
ResourceAttributes: 'Resource',
Attributes: 'Attributes',
ScopeAttributes: 'Scope',
};
// UCUM (Unified Code for Units of Measure) case-sensitive codes to human-readable names
// Reference: https://ucum.org/ucum
const UCUM_UNIT_NAMES: Record<string, string> = {
// Time units
s: 'Seconds',
ms: 'Milliseconds',
us: 'Microseconds',
ns: 'Nanoseconds',
min: 'Minutes',
h: 'Hours',
d: 'Days',
wk: 'Weeks',
mo: 'Months',
a: 'Years',
// Data units
By: 'Bytes',
KiBy: 'Kibibytes',
MiBy: 'Mebibytes',
GiBy: 'Gibibytes',
TiBy: 'Tebibytes',
kBy: 'Kilobytes',
MBy: 'Megabytes',
GBy: 'Gigabytes',
TBy: 'Terabytes',
bit: 'Bits',
Kibit: 'Kibibits',
Mibit: 'Mebibits',
Gibit: 'Gibibits',
// Frequency
Hz: 'Hertz',
kHz: 'Kilohertz',
MHz: 'Megahertz',
GHz: 'Gigahertz',
// Temperature
Cel: 'Celsius',
K: 'Kelvin',
'[degF]': 'Fahrenheit',
// Percentage and dimensionless
'%': 'Percent',
'1': 'Count',
'{request}': 'Requests',
'{connection}': 'Connections',
'{error}': 'Errors',
'{packet}': 'Packets',
'{thread}': 'Threads',
'{process}': 'Processes',
'{message}': 'Messages',
'{operation}': 'Operations',
'{call}': 'Calls',
'{fault}': 'Faults',
'{cpu}': 'CPUs',
// Physical units
m: 'Meters',
km: 'Kilometers',
g: 'Grams',
kg: 'Kilograms',
A: 'Amperes',
V: 'Volts',
W: 'Watts',
kW: 'Kilowatts',
J: 'Joules',
kJ: 'Kilojoules',
};
// Format UCUM unit code to human-readable name
function formatUnitDisplay(unit: string): string {
// Direct match
if (UCUM_UNIT_NAMES[unit]) {
return UCUM_UNIT_NAMES[unit];
}
// Handle compound units like "By/s" -> "Bytes/Second"
if (unit.includes('/')) {
const [numerator, denominator] = unit.split('/');
const numName = UCUM_UNIT_NAMES[numerator] || numerator;
const denomName = UCUM_UNIT_NAMES[denominator] || denominator;
// Singularize denominator (remove trailing 's' if present)
const singularDenom = denomName.endsWith('s')
? denomName.slice(0, -1)
: denomName;
return `${numName}/${singularDenom}`;
}
// Return original if no mapping found
return unit;
}
function formatWhereClause(
category: AttributeCategory,
name: string,
value: string,
language: 'sql' | 'lucene',
): string {
if (language === 'sql') {
return `${category}['${name}'] = '${value}'`;
}
return `${category}.${name}:"${value}"`;
}
function formatGroupByClause(
category: AttributeCategory,
name: string,
language: 'sql' | 'lucene',
): string {
if (language === 'sql') {
return `${category}['${name}']`;
}
return `${category}.${name}`;
}
interface AttributeValueListProps {
databaseName: string;
metricType: string;
metricName: string;
tableSource: TSource | undefined;
attribute: AttributeKey;
language: 'sql' | 'lucene';
onAddToWhere: (clause: string) => void;
onBack: () => void;
onAddToGroupBy: (clause: string) => void;
}
function AttributeValueList({
databaseName,
metricType,
metricName,
tableSource,
attribute,
language,
onAddToWhere,
onBack,
onAddToGroupBy,
}: AttributeValueListProps) {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch] = useDebouncedValue(searchTerm, 300);
const { data: values, isLoading } = useFetchMetricAttributeValues({
databaseName,
metricType,
metricName,
attributeName: attribute.name,
attributeCategory: attribute.category,
searchTerm: debouncedSearch,
tableSource,
});
const handleAddValueToWhere = useCallback(
(value: string) => {
const clause = formatWhereClause(
attribute.category,
attribute.name,
value,
language,
);
onAddToWhere(clause);
},
[attribute, language, onAddToWhere],
);
const handleAddToGroupBy = useCallback(() => {
// Group By is always SQL syntax, regardless of Where condition language
const clause = formatGroupByClause(
attribute.category,
attribute.name,
'sql',
);
onAddToGroupBy(clause);
}, [attribute, onAddToGroupBy]);
return (
<Stack gap="xs">
<Group justify="space-between">
<UnstyledButton onClick={onBack}>
<Group gap={4}>
<IconChevronLeft size={16} />
<Text size="sm" fw={500}>
{attribute.name}
</Text>
<Badge size="xs" variant="default">
{CATEGORY_LABELS[attribute.category]}
</Badge>
</Group>
</UnstyledButton>
<Button
variant="secondary"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={handleAddToGroupBy}
>
Group By
</Button>
</Group>
<TextInput
size="xs"
placeholder="Search values..."
leftSection={<IconSearch size={14} />}
value={searchTerm}
onChange={e => setSearchTerm(e.currentTarget.value)}
/>
{isLoading ? (
<Flex justify="center" py="md">
<Loader size="sm" />
</Flex>
) : values && values.length > 0 ? (
<ScrollArea.Autosize mah={200}>
<Stack gap={4}>
{values.map(value => (
<Group
key={value}
justify="space-between"
py={4}
px="xs"
style={{
borderRadius: 4,
cursor: 'pointer',
}}
className="hover-highlight"
>
<Text size="xs" style={{ wordBreak: 'break-all' }}>
{value}
</Text>
<Button
variant="secondary"
size="compact-xs"
leftSection={<IconFilter size={12} />}
onClick={() => handleAddValueToWhere(value)}
>
Where
</Button>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
) : (
<Text size="xs" c="dimmed" ta="center" py="md">
{searchTerm ? 'No matching values found' : 'No values found'}
</Text>
)}
</Stack>
);
}
interface AttributeListProps {
attributeKeys: AttributeKey[];
onSelectAttribute: (attr: AttributeKey) => void;
}
function AttributeList({
attributeKeys,
onSelectAttribute,
}: AttributeListProps) {
const [searchTerm, setSearchTerm] = useState('');
const filteredAttributes = useMemo(() => {
if (!searchTerm) return attributeKeys;
const lower = searchTerm.toLowerCase();
return attributeKeys.filter(attr =>
attr.name.toLowerCase().includes(lower),
);
}, [attributeKeys, searchTerm]);
const groupedAttributes = useMemo(() => {
const groups: Record<AttributeCategory, AttributeKey[]> = {
ResourceAttributes: [],
Attributes: [],
ScopeAttributes: [],
};
for (const attr of filteredAttributes) {
groups[attr.category].push(attr);
}
return groups;
}, [filteredAttributes]);
const categories: AttributeCategory[] = [
'ResourceAttributes',
'Attributes',
'ScopeAttributes',
];
return (
<Stack gap="xs">
<TextInput
size="xs"
placeholder="Search attributes..."
leftSection={<IconSearch size={14} />}
value={searchTerm}
onChange={e => setSearchTerm(e.currentTarget.value)}
/>
<ScrollArea.Autosize mah={250}>
<Stack gap="sm">
{categories.map(category => {
const attrs = groupedAttributes[category];
if (attrs.length === 0) return null;
return (
<Box key={category}>
<Group gap="xs" mb={4}>
<Text size="xs" fw={600} c="dimmed">
{CATEGORY_LABELS[category]}
</Text>
<Badge size="xs" variant="default">
{attrs.length}
</Badge>
</Group>
<Flex gap={6} wrap="wrap">
{attrs.map(attr => (
<UnstyledButton
key={`${attr.category}:${attr.name}`}
onClick={() => onSelectAttribute(attr)}
>
<Paper
py={4}
px={8}
withBorder
style={{ cursor: 'pointer' }}
className="hover-highlight"
>
<Group gap={4}>
<Text size="xs">{attr.name}</Text>
<IconChevronRight size={12} />
</Group>
</Paper>
</UnstyledButton>
))}
</Flex>
</Box>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
}
export function MetricAttributeHelperPanel({
databaseName,
metricName,
tableSource,
metricType,
attributeKeys,
isLoading,
language,
metricMetadata,
onAddToWhere,
onAddToGroupBy,
}: MetricAttributeHelperPanelProps) {
const [opened, { toggle }] = useDisclosure(false);
const [selectedAttribute, setSelectedAttribute] =
useState<AttributeKey | null>(null);
const handleSelectAttribute = useCallback((attr: AttributeKey) => {
setSelectedAttribute(attr);
}, []);
const handleBack = useCallback(() => {
setSelectedAttribute(null);
}, []);
if (!metricName) {
return null;
}
return (
<Paper withBorder p="xs" mt="xs">
<UnstyledButton onClick={toggle} w="100%">
<Group justify="space-between" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
{metricMetadata?.description ? (
<Text size="xs" c="dimmed" truncate>
{metricMetadata.description}
</Text>
) : (
<Text size="xs" c="dimmed">
{metricName}
</Text>
)}
{metricMetadata?.unit && (
<Group gap={4} mt={2}>
<Text size="xs" c="dimmed">
Unit:
</Text>
<Badge size="xs" variant="light">
{formatUnitDisplay(metricMetadata.unit)}
</Badge>
</Group>
)}
</Box>
<Group gap="xs" wrap="nowrap">
{attributeKeys.length > 0 && (
<Badge size="xs" variant="light">
{attributeKeys.length} attributes
</Badge>
)}
<IconChevronDown
size={16}
style={{
transform: opened ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 200ms',
}}
/>
</Group>
</Group>
</UnstyledButton>
<Collapse in={opened}>
<Box pt="xs">
{isLoading ? (
<Flex justify="center" py="md">
<Loader size="sm" />
</Flex>
) : attributeKeys.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="md">
No attributes found for this metric
</Text>
) : selectedAttribute ? (
<AttributeValueList
databaseName={databaseName}
metricType={metricType}
metricName={metricName}
tableSource={tableSource}
attribute={selectedAttribute}
language={language}
onAddToWhere={onAddToWhere}
onBack={handleBack}
onAddToGroupBy={onAddToGroupBy}
/>
) : (
<AttributeList
attributeKeys={attributeKeys}
onSelectAttribute={handleSelectAttribute}
/>
)}
</Box>
</Collapse>
</Paper>
);
}

View file

@ -18,6 +18,13 @@ jest.mock('@/hooks/useFetchMetricResourceAttrs', () => ({
useFetchMetricResourceAttrs: jest.fn().mockReturnValue({
data: [],
}),
parseAttributeKeysFromSuggestions: jest.fn().mockReturnValue([]),
}));
jest.mock('@/hooks/useFetchMetricMetadata', () => ({
useFetchMetricMetadata: jest.fn().mockReturnValue({
data: null,
}),
}));
jest.mock('@/hooks/useMetadata', () => ({

View file

@ -0,0 +1,112 @@
import {
chSql,
ResponseJSON,
tableExpr,
} from '@hyperdx/common-utils/dist/clickhouse';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { useQuery } from '@tanstack/react-query';
import { getClickhouseClient } from '@/clickhouse';
import { getMetricTableName } from '@/utils';
import { AttributeCategory } from './useFetchMetricResourceAttrs';
const ATTRIBUTE_VALUES_LIMIT = 100;
interface MetricAttributeValuesProps {
databaseName: string;
metricName: string;
attributeName: string;
attributeCategory: AttributeCategory;
searchTerm?: string;
tableSource: TSource | undefined;
metricType: string;
enabled?: boolean;
}
interface AttributeValueResponse {
value: string;
}
export const useFetchMetricAttributeValues = ({
databaseName,
metricType,
metricName,
attributeName,
attributeCategory,
searchTerm,
tableSource,
enabled = true,
}: MetricAttributeValuesProps) => {
const tableName = tableSource
? (getMetricTableName(tableSource, metricType) ?? '')
: '';
const shouldFetch = Boolean(
enabled &&
databaseName &&
tableName &&
metricType &&
metricName &&
attributeName &&
attributeCategory &&
tableSource &&
tableSource?.kind === SourceKind.Metric,
);
return useQuery({
queryKey: [
'metric-attribute-values',
metricName,
metricType,
attributeName,
attributeCategory,
searchTerm,
tableSource,
],
queryFn: async ({ signal }) => {
if (!shouldFetch) {
return [];
}
const clickhouseClient = getClickhouseClient();
// Build optional search filter
const searchFilter = searchTerm
? chSql` AND ${attributeCategory}[${{ String: attributeName }}] ILIKE ${{ String: `%${searchTerm}%` }}`
: chSql``;
const sql = chSql`
SELECT DISTINCT ${attributeCategory}[${{ String: attributeName }}] as value
FROM ${tableExpr({ database: databaseName, table: tableName })}
WHERE MetricName = ${{ String: metricName }}
AND ${attributeCategory}[${{ String: attributeName }}] != ''
${searchFilter}
ORDER BY value
LIMIT ${{ Int32: ATTRIBUTE_VALUES_LIMIT }}
`;
const result = (await clickhouseClient
.query<'JSON'>({
query: sql.sql,
query_params: sql.params,
format: 'JSON',
abort_signal: signal,
connectionId: tableSource!.connection,
clickhouse_settings: {
max_execution_time: 60,
timeout_overflow_mode: 'break',
},
})
.then(res => res.json())) as ResponseJSON<AttributeValueResponse>;
if (result?.data) {
return result.data.map(row => row.value).filter(Boolean);
}
return [];
},
enabled: shouldFetch,
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
});
};

View file

@ -0,0 +1,87 @@
import {
chSql,
ResponseJSON,
tableExpr,
} from '@hyperdx/common-utils/dist/clickhouse';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { useQuery } from '@tanstack/react-query';
import { getClickhouseClient } from '@/clickhouse';
import { getMetricTableName } from '@/utils';
export interface MetricMetadata {
unit: string;
description: string;
}
interface MetricMetadataProps {
databaseName: string;
metricType: string;
metricName: string;
tableSource: TSource | undefined;
}
interface MetricMetadataResponse {
MetricUnit: string;
MetricDescription: string;
}
export const useFetchMetricMetadata = ({
databaseName,
metricType,
metricName,
tableSource,
}: MetricMetadataProps) => {
const tableName = tableSource
? (getMetricTableName(tableSource, metricType) ?? '')
: '';
const shouldFetch = Boolean(
databaseName &&
metricType &&
metricName &&
tableSource &&
tableName &&
tableSource?.kind === SourceKind.Metric,
);
return useQuery({
queryKey: ['metric-metadata', databaseName, metricType, metricName],
queryFn: async ({ signal }) => {
if (!shouldFetch) {
return null;
}
const clickhouseClient = getClickhouseClient();
const sql = chSql`
SELECT
MetricUnit,
MetricDescription
FROM ${tableExpr({ database: databaseName, table: tableName })}
WHERE MetricName = ${{ String: metricName }}
LIMIT 1
`;
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<MetricMetadataResponse>;
if (result?.data?.[0]) {
return {
unit: result.data[0].MetricUnit || '',
description: result.data[0].MetricDescription || '',
};
}
return null;
},
enabled: shouldFetch,
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
});
};

View file

@ -8,10 +8,79 @@ import { TSource } from '@hyperdx/common-utils/dist/types';
import { useQuery } from '@tanstack/react-query';
import { getClickhouseClient } from '@/clickhouse';
import { formatAttributeClause } from '@/utils';
import { formatAttributeClause, getMetricTableName } from '@/utils';
const METRIC_FETCH_LIMIT = 10000;
export type AttributeCategory =
| 'ResourceAttributes'
| 'ScopeAttributes'
| 'Attributes';
export interface AttributeKey {
name: string;
category: AttributeCategory;
}
// Parse suggestion strings to extract unique attribute keys
// SQL format: ResourceAttributes['key']='value'
// Lucene format: ResourceAttributes.key:"value"
export const parseAttributeKeysFromSuggestions = (
suggestions: string[],
): AttributeKey[] => {
const categories: AttributeCategory[] = [
'ResourceAttributes',
'ScopeAttributes',
'Attributes',
];
const seen = new Set<string>();
const attributeKeys: AttributeKey[] = [];
for (const suggestion of suggestions) {
for (const category of categories) {
if (!suggestion.startsWith(category)) continue;
let name: string | null = null;
// Try SQL format: Category['key']
const sqlMatch = suggestion.match(
new RegExp(`^${category}\\['([^']+)'\\]`),
);
if (sqlMatch) {
name = sqlMatch[1];
} else {
// Try Lucene format: Category.key:
const luceneMatch = suggestion.match(
new RegExp(`^${category}\\.([^:]+):`),
);
if (luceneMatch) {
name = luceneMatch[1];
}
}
if (name) {
const uniqueKey = `${category}:${name}`;
if (!seen.has(uniqueKey)) {
seen.add(uniqueKey);
attributeKeys.push({ name, category });
}
}
break;
}
}
// Sort by category then name
attributeKeys.sort((a, b) => {
if (a.category !== b.category) {
const order = ['ResourceAttributes', 'Attributes', 'ScopeAttributes'];
return order.indexOf(a.category) - order.indexOf(b.category);
}
return a.name.localeCompare(b.name);
});
return attributeKeys;
};
const extractAttributeKeys = (
attributesArr: MetricAttributesResponse[],
isSql: boolean,
@ -52,14 +121,13 @@ const extractAttributeKeys = (
}
return Array.from(resultSet);
} catch (e) {
console.error('Error parsing metric autocompleteattributes', e);
console.error('Error parsing metric autocomplete attributes', e);
return [];
}
};
interface MetricResourceAttrsProps {
databaseName: string;
tableName: string;
metricType: string;
metricName: string;
tableSource: TSource | undefined;
@ -74,28 +142,25 @@ interface MetricAttributesResponse {
export const useFetchMetricResourceAttrs = ({
databaseName,
tableName,
metricType,
metricName,
tableSource,
isSql,
}: MetricResourceAttrsProps) => {
const tableName = tableSource
? (getMetricTableName(tableSource, metricType) ?? '')
: '';
const shouldFetch = Boolean(
databaseName &&
tableName &&
metricType &&
tableSource &&
tableSource?.kind === SourceKind.Metric,
);
return useQuery({
queryKey: [
'metric-attributes',
databaseName,
tableName,
metricType,
metricName,
isSql,
],
queryKey: ['metric-attributes', metricType, metricName, isSql, tableSource],
queryFn: async ({ signal }) => {
if (!shouldFetch) {
return [];