Skip to content

feat(mcp): improve metric discovery, add quiet-saturation eval scenario - #2861

Open
karl-power wants to merge 1 commit into
mainfrom
karl/improve-metric-mcp-adoption
Open

feat(mcp): improve metric discovery, add quiet-saturation eval scenario#2861
karl-power wants to merge 1 commit into
mainfrom
karl/improve-metric-mcp-adoption

Conversation

@karl-power

@karl-power karl-power commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Why

Eval transcripts showed agents solving investigations without ever touching metrics — not because metrics weren't useful, but because they were the only signal behind a discovery wall. After list_sources, logs and traces are queryable immediately (key columns are in the catalog), while a metric source showed only opaque table names: querying it cost 1–3 extra calls (describe_source/list_metrics) just to learn what exists. Under a turn budget, agents rationally skipped it — even when explicitly nudged (we tested this: prompt- and output-level hints naming the exact metrics were ignored in 7/7 deliveries when metrics weren't on the efficient path).

What changed

  • clickstack_list_sources: metric sources now include metricNamesPreview — up to 10 recently-reported metric names per kind, sampled from the team's own tables — plus a usage note that metrics are queried directly via clickstack_table/clickstack_timeseries with metricType + metricName, no describe hop needed. Best-effort under a 3s wall-clock budget with a concurrency pool and per-table dedup; omitted silently on timeout.
  • metricNames.ts (new): the metric-name sampler extracted from describeSource.ts into a shared module, with a widening lookback (24h → 30d, first non-empty window wins) so sparse or batch-emitted metrics still produce a sample. describe_source uses the same sampler, so its per-kind sample gets the lookback too.

Measured wins

Benchmarked with the hdx-eval framework on quiet-saturation (connection-pool leak where metric history is the efficient diagnostic path), branch vs main, identical seed/anchor/prompts, claude-fable-5 + claude-opus-4-6, 3 runs/cell, two independent batches.

First tool call whose args name a target metric (the load-bearing pool gauges), every run, both batches:

arm batch 1 batch 2 range
branch / fable-5 6, 5, 4 5, 5, 6 4–6
branch / opus-4-6 6, 7, 6 6, 7, 7 6–7
main / fable-5 8, 10, 9 8, 8, 8 8–10
main / opus-4-6 16, 14, 13 14, 13, 21 13–21

Zero overlap across all 24 runs (branch ≤7, main ≥8; p ≈ 0.001 by permutation): the preview removes the discovery hops entirely (list_sources → query), where main inserts list_metrics/describe_source detours or trace-grinds first.

Headline metrics from the second batch (which ran exactly this minimal build):

branch/fable main/fable branch/opus main/opus
Combined score 96% 91% 82% 84%
LLM-judge (weighted) 96% 92% 92% 84%
Programmatic checks 96% 96% 97% 93%
Pre-symptom onset attributed (distinguishes_true_onset) 100% 100% 67% 33%
Tool calls (mean) 13.0 13.0 19.7 20.7
Tool errors (mean) 0.0 0.3 2.3 0.7
Metric adoption 100% 100% 100% 100%
  • fable-5: +5pp combined here, +9pp in batch 1 (12.3 vs 15.7 calls) — consistently equal-or-fewer calls, zero tool errors, faster wall clock.
  • opus-4-6: the freed budget converts to depth rather than speed — judge +8pp and the pre-symptom onset attributed twice as often. Its combined score dips below main only because of a 12pp tool-error penalty from unrelated agent-side query-syntax mistakes (one run retried an identical malformed select 4×).
  • Metric adoption is 100% on both arms — the change lowers the cost of the metric path rather than nudging inclination; agents reach the load-bearing signal roughly twice as fast.

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7ad67

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/hdx-eval Patch
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview Aug 12, 2026 9:21am
hyperdx-storybook Ready Ready Preview Aug 12, 2026 9:21am

Request Review

@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 630 production lines changed (Tier 2 max: < 250)

Additional context: 8 file(s) in private internal-tooling packages, excluded from the line count

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 3
  • Production lines changed: 630 (+ 586 in test files, excluded from tier calculation)
  • Branch: karl/improve-metric-mcp-adoption
  • Author: karl-power

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes metric names discoverable directly from the MCP source catalog and adds a quiet-saturation evaluation scenario.

  • Adds bounded, concurrent metric-name preview sampling with widening lookback windows.
  • Reuses the shared sampler in source descriptions.
  • Adds informational adoption checks and corresponding aggregate/Markdown reporting.
  • Adds deterministic quiet-saturation telemetry, grading criteria, and tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/api/src/mcp/tools/sources/listSources.ts Adds team-scoped, timeout-bounded metric preview collection and direct-query guidance to the source catalog.
packages/api/src/mcp/tools/sources/metricNames.ts Extracts metric-name sampling into a shared helper and widens sparse-metric discovery from 24 hours to 30 days.
packages/api/src/mcp/tools/sources/describeSource.ts Replaces the local metric sampler with the shared widening-lookback implementation.
packages/hdx-eval/src/grading/programmatic.ts Evaluates informational adoption checks while excluding their zero weight from adoption scoring.
packages/hdx-eval/src/reports/aggregate.ts Propagates informational adoption metadata into aggregate reporting.
packages/hdx-eval/src/reports/markdown.ts Labels informational checks and explains their exclusion from adoption scores.
packages/hdx-eval/src/scenarios/quiet-saturation/generate.ts Generates deterministic telemetry for diagnosing a gradual connection-pool leak and its distractors.
packages/hdx-eval/src/scenarios/quiet-saturation/ground-truth.json Defines outcome, judge, and metric-adoption criteria for the new scenario.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[clickstack_list_sources] --> B[Load team sources and connections]
  B --> C[Build source summaries]
  C --> D[Sample metric tables concurrently]
  D --> E[Try 24-hour lookback]
  E -->|Empty| F[Try 30-day lookback]
  E -->|Names found| G[Attach metricNamesPreview]
  F -->|Names found| G
  F -->|Empty or timeout| H[Return summary without preview]
  G --> I[Return catalog and metrics usage guidance]
  H --> I
Loading

Reviews (3): Last reviewed commit: "feat(mcp): improve metric discovery, add..." | Re-trigger Greptile

Comment thread packages/api/src/mcp/tools/sources/listSources.ts Outdated
Comment thread packages/hdx-eval/src/scenarios/quiet-saturation/generate.ts
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found.

The change is defensively built: metric-name preview sampling in clickstack_list_sources is wrapped in a hard 3s AbortController budget raced against a bounded worker pool, every ClickHouse call is per-task try/catch'd so individual failures never fail the catalog call, the enrichment SQL is fully parameterized via the chSql DSL (customer-controlled metric names bind as String params, identifiers via tableExpr), and cross-team connection resolution correctly returns null and skips. The informational-adoption-check scoring guards its divisor (totalWeight === 0 ? 0 : hitWeight / totalWeight), so an all-informational rubric cannot divide by zero.

No P0/P1/P2 findings with a concrete failure mode surfaced from the completed analysis.

🔵 P3 nitpicks (1)
  • packages/api/src/mcp/tools/sources/listSources.ts:90 -- ClickhouseClient instances created per distinct connection in attachMetricNamePreviews are never disposed after the preview pass, mirroring the existing un-closed-client pattern in describeSource.ts.
    • Fix: If ClickhouseClient holds pollable transport resources, dispose the per-connection clients in a finally after the sampling race resolves; otherwise leave a short comment noting the transport is stateless so future readers don't re-flag it.

Reviewers (1): security (completed, no findings). The full panel (correctness, performance, reliability, adversarial, testing, maintainability, api-contract, project-standards, agent-native, learnings) was dispatched but had not returned at synthesis time; the assessment above reflects the security reviewer plus direct analysis of every changed production file (metricNames.ts, listSources.ts, describeSource.ts, and the hdx-eval grading/scenario changes). The 976-line scenarios/quiet-saturation/generate.ts is new eval-only scaffolding and was not line-by-line verified.

Testing gaps:

  • No test asserts a source whose connectionId belongs to another team is skipped (getConnectionById returns null) during preview attachment.
  • No test covers metric names containing SQL metacharacters/quotes to lock in String-param binding against future regressions.
  • The 3s timeout/abort path (preview silently omitted on timeout) and the per-(connection,db,table,timestampExpr) dedup cache are not directly exercised.
  • sampleMetricNamesWithLookback widening is not covered for the 24h-empty→30d-hit and both-empty→[] cases.

Residual risk: under a quiet environment where the 24h window is empty across many metric sources, every kind falls through to a 30d scan; the client-side 3s budget bounds list_sources wall-clock, but ClickHouse may not honor the abort signal, so abandoned 30d scans can keep running server-side. Worth confirming the ClickHouse client actually cancels on abort.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 280 passed • 1 skipped • 1093s

Status Count
✅ Passed 280
❌ Failed 0
⚠️ Flaky 0
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

@karl-power
karl-power force-pushed the karl/improve-metric-mcp-adoption branch from 2e3c3f7 to 3b1a262 Compare August 12, 2026 09:15

@pulpdrew pulpdrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see my comments in packages/api/src/mcp/tools/sources/metricNames.ts are on code that was just relocated. It'd be nice to fix them but we can treat them as non-blocking.

Comment on lines +87 to +90
const kindColumns =
cachedColumns ??
(await metadata.getColumns({ databaseName, tableName, connectionId }));
const columnNames = new Set(kindColumns.map(c => c.name));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not rely on the caching already done in metadata.getColumns here? Wondering if we can get rid of this extra cachedColumns argument

Comment on lines +151 to +155
/**
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how important this is in regards to units and descriptions, but anyLast will not reliably return the most recent (by timestamp or insertion order). If you want the most recent, you'd need argMax(<column of interest>, <timestamp column>)

if (rest.signal.aborted) break;
const samples = await sampleMetricNamesForKind({
...rest,
dateRange: [new Date(now.getTime() - windowMs), now],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small optimization, but you could set the end of the date range to the start of the previous window instead of now - since you know there is no data in the previous shorter window, there's no need to rescan it. This probably matters little when the timestamp is early in the ordering or partition key.

const METRIC_NAME_LOOKBACK_WINDOWS_MS: readonly number[] = [
24 * 60 * 60 * 1000, // 24 hours
30 * 24 * 60 * 60 * 1000, // 30 days
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO 24 hours strikes me as kind of long for the initial window, especially with a 3s limit - the UI often defaults to ranges much shorter than that. Since this is best-effort (from my understanding), it seems like surfacing metrics from a smaller window may be enough, and likely much more performant at large scales.

Comment on lines +103 to +106
const timeoutId = setTimeout(
() => controller.abort(),
METRIC_PREVIEW_TIMEOUT_MS,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we able to set max_execution_time + timeout_overflow_mode = break instead of (or in addition to) an abort controller for these queries? If so that would allow us to return the information clickhouse was able to query within the 3s, instead of no data if the query times out.

Comment on lines +30 to +50
async function runWithConcurrency(
tasks: Array<() => Promise<void>>,
limit: number,
signal: AbortSignal,
): Promise<void> {
let next = 0;
const workers = Array.from(
{ length: Math.min(limit, tasks.length) },
async () => {
while (next < tasks.length && !signal.aborted) {
const task = tasks[next++];
try {
await task();
} catch {
// Best-effort: individual sampling failures never fail the call.
}
}
},
);
await Promise.all(workers);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be simpler to use PQueue here like we do elsewhere for bounding concurrency?

for (const entry of entries) {
const client = clients.get(entry.connectionId);
if (!client) continue;
for (const [kind, tableName] of Object.entries(entry.metricTables)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I think the preview offers summary metrics that the query tools reject.

Onboarding fills in all five metric tables, summary included. The loop here samples every one of them, so metricNamesPreview.summary lands in the response, and the new metricsUsage note tells the agent everything in the preview is queryable with clickstack_timeseries / clickstack_table.

Could we do something like this?

for (const [kind, tableName] of Object.entries(entry.metricTables)) {
  if (!QUERYABLE_METRIC_KINDS.includes(kind as QueryableMetricKind)) continue;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants