Skip to content

fix(mcp): list PromQL catalog names from the tags table, without a time window - #3669

Open
bcanvural wants to merge 2 commits into
ClickHouse:mainfrom
bcanvural:fix-promql-catalog-scale
Open

bcanvural wants to merge 2 commits into
ClickHouse:mainfrom
bcanvural:fix-promql-catalog-scale

Conversation

@bcanvural

Copy link
Copy Markdown
Contributor

Summary

Fixes #3648 (parts 1 and 2).

clickstack_describe_source on a PromQL source listed label and metric names for the last 24 hours. On a ClickHouse TimeSeries table, that window costs a pass over every series id, whatever the window's length: the time ranges GROUP BY id on the SQL path, and a join with every series on ClickHouse's Prometheus HTTP API. On a table with about 217M series every call failed at an 8 GiB memory limit, so agents had no way to find metric names.

This PR:

  • reads metric names from the tags table alone (SELECT DISTINCT metric_name FROM timeSeriesTags(...)), with no time window, for every ClickHouse PromQL source, whichever backend evaluates its queries. The result notes that the names include series with no recent samples;
  • adds an optional metricName argument: on a ClickHouse table it lists that metric's label names with metric_name = …, which uses the tags table's sort key. Without it, a ClickHouse source returns metric names only, with a note on how to get label names;
  • leaves a Prometheus endpoint source as it was (window and /labels), and passes metricName to it as match[]={__name__="…"}.

The label routes (/labels, /label/:name/values) are unchanged, including the HTTP API path from #3628.

ClickHouse #127095 (open) rewrites the handler's label endpoints to read the tags table directly. Once it ships, the catalog could go back to the HTTP API, still without a window: with start/end and no match[], it joins the time ranges table as well.

Tests

  • promqlSources.int.test.ts: metric names come from the whole table (a series two days old is listed); metricName lists one metric's label names; a Prometheus endpoint gets metricName as match[]; an auto source on a server whose handler serves label lookups still reads names with SQL. The three tests that checked catalog lookups through the handler are replaced by that one.
  • PromQL int suites (promqlSources, timeseriesEngine, prometheus, promqlQueryTool): 208/208 on ClickHouse 26.10.3.7.
  • The repro test from PromQL describe_source can't list metric or label names on a large TimeSeries table: both catalog paths read every series id in the table #3648 with 2M series: a describe_source call takes 11–23 ms (was 656 ms on the SQL path and 3.2 s through the handler). The metric-name query uses 4.3 MiB (was 316 MiB and 811 MiB).
  • eslint, prettier and the ratchet are clean. tsc reports one error in checkAlerts/template.ts, which is already on main.

How to reproduce

Starting point: yarn dev with ClickHouse 26.10, and a PromQL source on a TimeSeries table with many series (or the repro test in #3648).

On main:

  1. Call clickstack_describe_source on the source: on a large table it fails with a memory limit error (partial: true, no names).

On this branch:

  1. The same call returns the metric names.
  2. With metricName, it returns that metric's label names.

References

…me window

describe_source listed a PromQL source's names for the last 24 hours. On a
ClickHouse TimeSeries table the window costs a pass over every series id (the
time ranges GROUP BY on the SQL path, a join on the HTTP API), which runs out
of memory on large tables. Read metric names from the tags table alone, and a
metric's label names with the new metricName argument.
@bcanvural
bcanvural requested a review from a team as a code owner November 14, 2026 07:33
@changeset-bot

changeset-bot Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: be348cb

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

This PR includes changesets to release 3 packages
Name Type
@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 Nov 14, 2026

Copy link
Copy Markdown

@bcanvural is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

'client' | 'databaseName' | 'tableName' | 'limit' | 'signal'
> & { metricName?: string }): Promise<string[]> {
const metric = chSql`${{ Identifier: 'metric_name' }}`;
if (metricName == null)

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.

🔵 minor — queryTimeSeriesNames copies two queries that queryLabelValues and queryLabelNames in this file already run

Without metricName, this runs the same SELECT DISTINCT metric_name … ORDER BY val LIMIT n as queryLabelValues({ labelName: '__name__' }) with no startMs/endMs/match, because getSeriesFilterConditions returns [] when none of them is given (line 164). The arrayJoin(arrayConcat(['__name__'], mapKeys(tags))) expression is also copied word for word from queryLabelNames (line 319). Call queryLabelValues without a window to get the metric names. For the per-metric label names, let queryLabelNames take extra conditions, or pull the expression into a shared constant, so the label-name SQL has one source of truth.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in be348cb: the catalog calls queryLabelValues({ labelName: '__name__' }) without a window, and queryLabelNames takes an optional metricName. The new function is gone.

from: { databaseName: string; tableName: string };
promqlBackend?: PromqlSourceBackend;
};
startMs: number;

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.

🔵 minor — Callers must still pass startMs/endMs to getPromqlLabelCatalog, but ClickHouse sources now ignore them

After this change only the isPrometheusEndpoint branch reads the window. Make startMs/endMs optional, or have the Prometheus caller compute them, so the signature no longer implies that ClickHouse sources are filtered by time. describePromqlSource.ts still computes LABEL_LOOKBACK_MS and passes it for both backends.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changing: the doc comment on getPromqlLabelCatalog says only a Prometheus endpoint answers for the window, and its only caller makes one call for both backends.

@github-actions

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

2 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 2 minor

2 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

Copy link
Copy Markdown
Contributor

Deep Review

Scope: PromQL catalog listing in clickstack_describe_source — reads metric names from the ClickHouse tags table without a time window (OOM fix), adds an optional metricName argument for per-metric label names. Base 0096a355c.

The core change is sound. The SQL path is correctly parameterized (metricName binds via { String: … } through query_params — no injection), the Prometheus match[] selector escaping via JSON.stringify is a safe subset of PromQL/Go string escapes, the OOM fix genuinely drops the time-ranges GROUP BY id scan, the optional labelNames is null-safe at every consumer, and no dead exports were left behind. One recommended fix and a handful of nits follow.

🟡 P2 — recommended

  • packages/api/src/mcp/tools/sources/describePromqlSource.ts:70 — When metricName is supplied but matches no series, labelNames is [], which is truthy at line 85 and null at line 70's guard never fires, so the result is labelNames: [] with no note; an agent cannot distinguish a typo'd/stale metric name from a real metric that carries only __name__, and may conclude a nonexistent metric exists.
    • Fix: When metricName is provided and the label lookup returns an empty list, emit a note stating no series with that metric name was found and suggest re-describing without metricName to list valid metric names.
    • ce-correctness-reviewer, ce-adversarial-reviewer, ce-agent-native-reviewer
🔵 P3 nitpicks (6)
  • packages/api/src/mcp/tools/sources/describePromqlSource.ts:85 — For a ClickHouse source, labelNames is omitted entirely from the output unless metricName is given, so the output key toggles presence by argument and backend; a deterministic (non-LLM) consumer reading output.labelNames silently sees fewer fields.
    • Fix: Consider returning labelNames: [] rather than omitting the key for a stable shape, and note the output-shape change in the changeset.
    • ce-api-contract-reviewer
  • packages/api/src/mcp/tools/sources/describeSource.ts:121 — metricName is accepted on every source but only threaded into the PromQL branch, so passing it to a logs/trace source is a silent no-op despite the schema text saying "PromQL sources only".
    • Fix: Optionally surface a soft warning in the output when metricName is supplied to a non-PromQL source so an agent learns the argument had no effect.
    • ce-api-contract-reviewer
  • packages/api/src/controllers/timeseriesEngine.ts:345 — The label-names expression arrayJoin(arrayConcat([__name__], mapKeys(tags))) is duplicated verbatim between queryLabelNames and queryTimeSeriesNames, so the "fold __name__ back in" rule must be edited in lockstep.
    • Fix: Extract the shared expression into a small named helper and call it from both functions.
    • ce-maintainability-reviewer
  • packages/api/src/controllers/timeseriesEngine.ts:332 — queryTimeSeriesNames returns metric names or one metric's label names depending on its argument; the name conveys neither case and reads as "series names".
    • Fix: Rename to convey the dual role or split into queryMetricNames() and queryMetricLabelNames(metricName).
    • ce-maintainability-reviewer
  • packages/api/src/mcp/__tests__/promqlSources.int.test.ts:222 — The metricName→match[] test only inspects the /api/v1/labels URL; a regression that also appended match[] to /label/__name__/values (wrongly scoping metricNames) would still pass.
    • Fix: Assert the /label/__name__/values call's searchParams has no match[].
    • ce-testing-reviewer, ce-correctness-reviewer, ce-adversarial-reviewer
  • packages/api/src/mcp/tools/sources/describeSource.ts:594 — The tool's top-level description documents only the column-schema output shape and never mentions the PromQL metricNames/labelNames/note outputs or the two-step metricName flow (pre-existing, mildly amplified by this change).
    • Fix: Add a one-line reference to the PromQL output path in the tool description.
    • ce-agent-native-reviewer, ce-api-contract-reviewer

Reviewers (12): correctness, testing, maintainability, project-standards, kieran-typescript, performance, api-contract, adversarial, security, agent-native, previous-comments, learnings-researcher.

Testing gaps:

  • No test for the ClickHouse path where metricName matches no series (empty labelNames vs a label-less metric).
  • No test that metricName containing PromQL-sensitive characters (", \, {, newline) stays confined to a single match[] selector value.
  • Truncation/cap note path (MAX_LABEL_NAMES/MAX_METRIC_NAMES, limit + 1 "list was cut") is unexercised after the refactor removed the prior limit=201 assertion.
  • No test that a throw from either queryTimeSeriesNames branch inside Promise.all is caught and returned as a partial: true result rather than rejecting the tool call.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

Without a window, queryLabelValues('__name__') already reads only the tags
table, so call it, and give queryLabelNames an optional metricName instead of
a second copy of its query. Say when no series has the metricName.
@bcanvural

Copy link
Copy Markdown
Contributor Author

be348cb reuses the existing label lookups (thread on timeseriesEngine.ts), adds a note when no series has the metricName (an existing metric always returns __name__), and checks that match[] reaches only /labels.

Not changing:

  • labelNames missing without metricName on a ClickHouse source: on purpose, listing every series' label names means reading the whole tags table. The note says how to get them.
  • metricName on a non-PromQL source: the argument description says PromQL only, and other sources answer as before.
  • The tool's top-level description: it already sends PromQL sources here through PROMQL_SOURCE_USAGE, which now mentions metricName.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PromQL describe_source can't list metric or label names on a large TimeSeries table: both catalog paths read every series id in the table

1 participant