Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fair-walls-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/hdx-eval": patch
"@hyperdx/api": patch
---

feat(mcp): improve metric discovery, add quiet-saturation eval scenario
103 changes: 103 additions & 0 deletions packages/api/src/mcp/__tests__/sources.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,109 @@ describe('MCP Source Tools', () => {

await client2.close();
});

describe('metric-name previews', () => {
const createMetricSource = (name = 'Metrics') =>
Source.create({
kind: SourceKind.Metric,
team: team._id,
from: { databaseName: DEFAULT_DATABASE, tableName: '' },
metricTables: {
[MetricsDataType.Gauge.toLowerCase()]: 'otel_metrics_gauge',
[MetricsDataType.Sum.toLowerCase()]: 'otel_metrics_sum',
},
timestampValueExpression: 'TimeUnix',
connection: connection._id,
name,
});

it('includes metricNamesPreview and metricsUsage for metric sources with recent data', async () => {
const metricSource = await createMetricSource();
const now = new Date();
await bulkInsertMetricsGauge([
{
MetricName: 'system.cpu.utilization',
ResourceAttributes: { 'service.name': 'svc-a' },
ServiceName: 'svc-a',
TimeUnix: now,
Value: 0.42,
},
]);
await bulkInsertMetricsSum([
{
MetricName: 'http.server.request.count',
AggregationTemporality: 1,
IsMonotonic: true,
ResourceAttributes: { 'service.name': 'svc-a' },
ServiceName: 'svc-a',
TimeUnix: now,
Value: 100,
},
]);

const result = await callTool(client, 'clickstack_list_sources');
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));

const metric = output.sources.find(
(s: any) => s.id === metricSource._id.toString(),
);
expect(metric).toBeDefined();
expect(metric.metricNamesPreview).toBeDefined();
expect(metric.metricNamesPreview.gauge).toEqual(
expect.arrayContaining(['system.cpu.utilization']),
);
expect(metric.metricNamesPreview.sum).toEqual(
expect.arrayContaining(['http.server.request.count']),
);

// Top-level usage note explains direct metric querying.
expect(output.metricsUsage).toContain('metricType + metricName');
});

it('falls back to a wider lookback when no metrics reported in the last 24h', async () => {
const metricSource = await createMetricSource('Sparse Metrics');
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
await bulkInsertMetricsGauge([
{
MetricName: 'batch.job.duration',
ResourceAttributes: { 'service.name': 'batch-svc' },
ServiceName: 'batch-svc',
TimeUnix: threeDaysAgo,
Value: 60,
},
]);

const result = await callTool(client, 'clickstack_list_sources');
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));

const metric = output.sources.find(
(s: any) => s.id === metricSource._id.toString(),
);
expect(metric).toBeDefined();
expect(metric.metricNamesPreview?.gauge).toEqual(
expect.arrayContaining(['batch.job.duration']),
);
});

it('omits metricNamesPreview when the metric tables are empty', async () => {
const metricSource = await createMetricSource('Empty Metrics');

const result = await callTool(client, 'clickstack_list_sources');
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));

const metric = output.sources.find(
(s: any) => s.id === metricSource._id.toString(),
);
expect(metric).toBeDefined();
expect(metric.metricTables).toBeDefined();
expect(metric.metricNamesPreview).toBeUndefined();
// The usage note still appears — a metric source exists.
expect(output.metricsUsage).toBeDefined();
});
});
});

// ── clickstack_describe_source ───────────────────────────────────────────────
Expand Down
186 changes: 6 additions & 180 deletions packages/api/src/mcp/tools/sources/describeSource.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import {
chSql,
concatChSql,
convertCHDataTypeToJSType,
filterColumnMetaByType,
JSDataType,
tableExpr,
} from '@hyperdx/common-utils/dist/clickhouse';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata';
Expand All @@ -25,6 +22,10 @@ import {
type QueryableMetricKind,
sanitizeMetricTables,
} from './metricKinds';
import {
type MetricNameSample,
sampleMetricNamesWithLookback,
} from './metricNames';
import { extractSourceConfig } from './schemas';

// How far back to look when querying the rollup tables for value samples.
Expand All @@ -38,10 +39,6 @@ const MAX_LC_VALUES = 20;
const MAX_MAP_KEY_VALUES = 5;
const MAX_MAP_KEYS_TO_SAMPLE = 10;

// Max MetricName values returned per metric kind by the starter sample.
// clickstack_list_metrics provides paginated discovery beyond this cap.
const MAX_METRIC_NAMES_PER_KIND = 20;

/**
* Pick the representative metric table to use as the starting point for
* schema/attribute discovery on a metric source. Prefers gauge → sum →
Expand All @@ -61,177 +58,6 @@ function pickRepresentativeMetricTable(
return undefined;
}

type MetricNameSample = {
name: string;
unit?: string;
description?: string;
};

/**
* Sample distinct MetricName values for a single metric kind. Optionally
* enriches each name with MetricUnit / MetricDescription when those
* columns are present on the table (the OTel Collector default schema
* includes them; custom schemas may not).
*/
async function sampleMetricNamesForKind({
metadata,
clickhouseClient,
databaseName,
tableName,
connectionId,
dateRange,
timestampValueExpression,
signal,
cachedColumns,
}: {
metadata: ReturnType<typeof getMetadata>;
clickhouseClient: ClickhouseClient;
databaseName: string;
tableName: string;
connectionId: string;
dateRange: [Date, Date];
timestampValueExpression: string;
signal: AbortSignal;
cachedColumns?: { name: string }[];
}): Promise<MetricNameSample[]> {
// Defensive column presence check for MetricUnit / MetricDescription.
const kindColumns =
cachedColumns ??
(await metadata.getColumns({ databaseName, tableName, connectionId }));
const columnNames = new Set(kindColumns.map(c => c.name));
const hasUnit = columnNames.has('MetricUnit');
const hasDescription = columnNames.has('MetricDescription');

// First fetch the distinct metric names; this is the only step that
// strictly needs to succeed for the kind to appear in the response.
// Pass timestampValueExpression so the no-rollup fallback path scopes
// its scan to dateRange instead of going unbounded against the raw
// metric table on cold cache.
const nameResults = await metadata.getAllKeyValues({
databaseName,
tableName,
keyExpressions: ['MetricName'],
maxValuesPerKey: MAX_METRIC_NAMES_PER_KIND,
connectionId,
dateRange,
timestampValueExpression,
signal,
});
const names = nameResults[0]?.value.map(v => v.toString()) ?? [];
if (names.length === 0) return [];

// Best-effort enrichment with unit + description. One small query
// returns one row per metric name with the most-recent unit / desc.
let enrichments: Record<string, { unit?: string; description?: string }> = {};
if ((hasUnit || hasDescription) && !signal.aborted) {
try {
enrichments = await fetchMetricNameEnrichments({
clickhouseClient,
databaseName,
tableName,
connectionId,
names,
dateRange,
hasUnit,
hasDescription,
signal,
});
} catch (e) {
logger.warn(
{ databaseName, tableName, error: e },
'Failed to enrich metric names with unit/description',
);
}
}

return names.map(name => {
const enrichment = enrichments[name] ?? {};
const sample: MetricNameSample = { name };
if (enrichment.unit) sample.unit = enrichment.unit;
if (enrichment.description) sample.description = enrichment.description;
return sample;
});
}

/**
* Fetch MetricUnit and MetricDescription for a batch of metric names.
* Uses `anyLast` so the most-recent value wins when a metric has changed
* unit/description over time.
*/
async function fetchMetricNameEnrichments({
clickhouseClient,
databaseName,
tableName,
connectionId,
names,
dateRange,
hasUnit,
hasDescription,
signal,
}: {
clickhouseClient: ClickhouseClient;
databaseName: string;
tableName: string;
connectionId: string;
names: string[];
dateRange: [Date, Date];
hasUnit: boolean;
hasDescription: boolean;
signal: AbortSignal;
}): Promise<Record<string, { unit?: string; description?: string }>> {
// Build the projection fragments via the parameterised chSql DSL so
// identifiers are quoted and the unit/description columns only appear
// when present on the source table.
const projections = [
chSql`MetricName`,
...(hasUnit
? [chSql`anyLast(${{ Identifier: 'MetricUnit' }}) AS MetricUnit`]
: []),
...(hasDescription
? [
chSql`anyLast(${{ Identifier: 'MetricDescription' }}) AS MetricDescription`,
]
: []),
];
const namePlaceholders = concatChSql(
',',
names.map(name => chSql`${{ String: name }}`),
);
const sql = chSql`
SELECT ${concatChSql(', ', projections)}
FROM ${tableExpr({ database: databaseName, table: tableName })}
WHERE MetricName IN (${namePlaceholders})
AND TimeUnix >= fromUnixTimestamp64Milli(${{ Int64: dateRange[0].getTime() }})
AND TimeUnix <= fromUnixTimestamp64Milli(${{ Int64: dateRange[1].getTime() }})
GROUP BY MetricName
`;

type EnrichmentRow = {
MetricName: string;
MetricUnit?: string;
MetricDescription?: string;
};

const response = await clickhouseClient.query<'JSON'>({
query: sql.sql,
query_params: sql.params,
format: 'JSON',
connectionId,
abort_signal: signal,
});
const result = (await response.json()) as { data: EnrichmentRow[] };

const enrichments: Record<string, { unit?: string; description?: string }> =
{};
for (const row of result.data) {
enrichments[row.MetricName] = {
...(row.MetricUnit ? { unit: row.MetricUnit } : {}),
...(row.MetricDescription ? { description: row.MetricDescription } : {}),
};
}
return enrichments;
}

/**
* Core schema-discovery logic. Extracted so the caller can wrap it in
* Promise.race for wall-clock timeout enforcement.
Expand Down Expand Up @@ -535,13 +361,13 @@ async function describeSourceSchema(
const kindTableName = source.metricTables[kind];
if (!kindTableName) return;
try {
const samples = await sampleMetricNamesForKind({
const samples = await sampleMetricNamesWithLookback({
metadata,
clickhouseClient,
databaseName,
tableName: kindTableName,
connectionId,
dateRange,
now,
timestampValueExpression,
signal,
// Reuse representative columns when the kind matches the
Expand Down
Loading
Loading