hyperdx/packages/app/src/hooks/useFetchMetricResourceAttrs.tsx
Aaron Knudtson ce8506478d
fix: better source validation and refine required source fields (#1895)
## Summary

Large refactor changing the TSource type to a true discriminated union. This means that the expected fields for `kind: 'log'` will differ from those for `'trace', 'session', 'metrics'`.  This avoids the current laissez faire source type that currently exists, and required extensive changes across the api and app packages. Also includes a nice addition to `useSource` - you can now specify a `kind` field, which will properly infer the type of the returned source. 

This also makes use of discriminators in mongoose. This does change a bit of the way that we create and update sources. Obvious changes to sources have also been made, namely making `timeValueExpression` required on sources. Care has been taken to avoid requiring a migration.

### How to test locally or on Vercel

1. `yarn dev`
2. Play around with the app, especially around source creation, source edits, and loading existing sources from a previous version

### References

- Linear Issue: References HDX-3352
- Related PRs:

Ref: HDX-3352
2026-03-19 12:56:08 +00:00

198 lines
5.1 KiB
TypeScript

import {
chSql,
ResponseJSON,
tableExpr,
} from '@hyperdx/common-utils/dist/clickhouse';
import { SourceKind, TMetricSource } from '@hyperdx/common-utils/dist/types';
import { useQuery } from '@tanstack/react-query';
import { getClickhouseClient } from '@/clickhouse';
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,
) => {
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 autocomplete attributes', e);
return [];
}
};
interface MetricResourceAttrsProps {
databaseName: string;
metricType?: string;
metricName?: string;
tableSource: TMetricSource | undefined;
isSql: boolean;
}
interface MetricAttributesResponse {
ScopeAttributes?: Record<string, string>;
ResourceAttributes?: Record<string, string>;
Attributes?: Record<string, string>;
}
export const useFetchMetricResourceAttrs = ({
databaseName,
metricType,
metricName,
tableSource,
isSql,
}: MetricResourceAttrsProps) => {
const tableName = tableSource
? (getMetricTableName(tableSource, metricType) ?? '')
: '';
const shouldFetch = Boolean(
databaseName &&
tableName &&
metricType &&
metricName &&
tableSource &&
tableSource?.kind === SourceKind.Metric,
);
return useQuery({
queryKey: ['metric-attributes', metricType, metricName, isSql, tableSource],
queryFn: async ({ signal }) => {
if (!shouldFetch || !metricName) {
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,
});
};