Skip to content

fix(app): list metric names deterministically instead of sampling - #2747

Open
teeohhem wants to merge 4 commits into
mainfrom
claude/deterministic-metric-name-listing
Open

fix(app): list metric names deterministically instead of sampling#2747
teeohhem wants to merge 4 commits into
mainfrom
claude/deterministic-metric-name-listing

Conversation

@teeohhem

@teeohhem teeohhem commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

A customer could not select a metric that exists and is actively reporting: it was missing from the chart editor's dropdown, and typing its name returned nothing. The reported case was up.

The dropdown discovered names with groupUniqArray(3000)(MetricName), which keeps an arbitrary subset once a source has more than 3000 distinct names — a full Prometheus scrape of a Kubernetes cluster clears that easily. The browser then filtered that already-sampled list client-side, so a name that never left ClickHouse could not be typed into existence.

Fix

Metadata.getMetricNames() — a real GROUP BY MetricName … ORDER BY … LIMIT n+1 with the search pattern pushed into SQL as MetricName ILIKE. Each debounced keystroke re-queries, so the filter runs over the table rather than over a sample. The extra row makes truncation detectable, so the dropdown can report an incomplete list instead of implying it is whole.

Matches are ranked in SQL — exact, then prefix, then alphabetical — because searching up also matches names that sort ahead of it:

by name:      backup_size_bytes, group_reads, mongodb_up, node_uptime_seconds, up
by relevance: up, backup_size_bytes, group_reads, mongodb_up, node_uptime_seconds

Also forwards the chart's date range to the dropdown. useMetricNames always accepted a dateRange, but ChartSeriesEditor never passed one, so the listing window was pinned to the last 24h regardless of the range the chart showed.

getKeyValues is untouched — it is shared with the filter UI, so changing its semantics has a much larger blast radius.

Not fixed here

Search is now reliable in the query, but not yet end-to-end in the UI:

  • <Select limit={100}> renders a gauge-first concatenation of the four kinds, so an exact match in a Sum or Histogram table is unreachable once gauge matches alone fill the cap.
  • The listing window is clamped to the range's most recent 3 days, so a metric that stopped reporting earlier in a long range cannot be found by any search.

Neither affects the reported case — up is a gauge and reports continuously — and both are tracked separately.

Tests

Metric-name listing had no coverage anywhere. Added unit coverage of the rendered SQL (grouping, ordering, LIMIT n+1, wildcard escaping, truncation reporting) and integration coverage against a gauge-shaped table holding more distinct names than one page returns — including the regression itself: an alphabetically-late up that a capped page cannot reach comes back first when searched.

Fixes: HDX-5007

The chart editor's metric dropdown discovered names via
getKeyValues({ keys: ['MetricName'], limit: 3000 }), which renders
`groupUniqArray(3000)(MetricName)`. Once a metrics table holds more than
3000 distinct names, groupUniqArray keeps an arbitrary subset — the
survivors follow hash order, not name order — so metrics that exist and are
actively reporting could be unselectable, with no warning and no way to
search for what had been dropped. A full Prometheus scrape of a Kubernetes
cluster clears 3000 distinct gauge names easily, and the reported symptom was
`up` being present in otel_metrics_gauge but absent from the dropdown.

Measured on a deployment with 1209 distinct gauge names, capped at 1000: the
surviving page was not the alphabetically-first 1000 — `metric_00001` was
dropped while `metric_00002` survived.

Adds Metadata.getMetricNames(): a real
`GROUP BY MetricName ... ORDER BY ... LIMIT n+1` with an optional server-side
`MetricName ILIKE` predicate. The extra row makes truncation detectable, so
the dropdown can say the list is incomplete rather than implying otherwise.

Ordering is by relevance to the search pattern (exact, then prefix, then
alphabetical) in SQL rather than in the client. A page ordered purely by name
would still hide a short query like `up` behind the many names that merely
contain it — `group_reads`, `node_uptime_seconds` — which is the original
problem in a new form. Doing it in the query keeps the page returned the page
worth showing, and leaves the component with no ranking logic of its own.

Also restores the chart's time range to the dropdown. useMetricNames accepts
a dateRange and ChartEditorControls already passes one down, but
ChartSeriesEditor never read it, so the window was pinned to the last 24h and
the clamping logic was dead code — any metric last seen over a day ago was
invisible regardless of the range selected.

Three details worth noting for review:

- `timeout_overflow_mode` is pinned to `throw` rather than left unset. `break`
  returns a partial aggregate as HTTP 200, i.e. a short list reporting
  `truncated: false`, which is the silent incompleteness this replaces, and
  the settings spread it sits in is a mutable process-wide bag.
- Mantine mirrors a selected option's label into a searchable Select's input
  and reports it through `onSearchChange` exactly like typed text, so it is
  explicitly not forwarded as a name pattern; the input is also cleared on
  dropdown open so the label cannot be edited into a fragment and searched.
- The query sets `retry: false`, an explicit `staleTime` and no refetch on
  window focus. Four of these run per debounced keystroke, and the replaced
  MetadataCache path served repeats from memory, so without these the new path
  would be busier than the one it replaces.

Metric-name listing had no test coverage anywhere — metadata.int.test.ts was
logs-only and MetricNameSelect.test.ts only unit-tested getMetricOptions.
Adds coverage of the rendered SQL, integration coverage against a table with
more distinct names than one page returns (including that an escaped `_` still
matches, since over-escaping would break every Prometheus metric search), and
a guard on the dateRange wiring so a refactor cannot quietly drop it again.
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a20d251

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

This PR includes changesets to release 4 packages
Name Type
@hyperdx/app Patch
@hyperdx/common-utils Patch
@hyperdx/api 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 Jul 29, 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 27, 2026 6:58pm
hyperdx-storybook Ready Ready Preview Aug 27, 2026 6:58pm

Request Review

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

github-actions Bot commented Jul 29, 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: 392 production lines changed (Tier 2 max: < 250)
  • Cross-layer change: touches frontend (packages/app) + shared utils (packages/common-utils)

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

Stats
  • Production files changed: 5
  • Production lines changed: 392 (+ 508 in test files, excluded from tier calculation)
  • Branch: claude/deterministic-metric-name-listing
  • Author: teeohhem

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

Comment thread packages/app/src/components/MetricNameSelect.tsx
Comment thread packages/app/src/hooks/useMetadata.tsx
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces sampled metric-name discovery with deterministic, server-side searching.

  • Adds ordered and relevance-ranked ClickHouse metric-name queries with truncation detection.
  • Debounces dropdown searches and forwards the chart’s selected date range.
  • Surfaces partial query failures and truncated catalogs in the selector.
  • Adds unit and integration coverage for SQL generation, searching, ordering, and UI wiring.

Confidence Score: 4/5

The PR should not merge until the metric-name selector restores the exponential-histogram feature gate.

The selector still queries exponential-histogram metadata and turns returned names into selectable options regardless of the documented environment gate, so disabled functionality remains exposed. The previously reported transient-failure omission is now visibly disclosed and can recover when the search, range, or source changes.

Files Needing Attention: packages/app/src/components/MetricNameSelect.tsx

Important Files Changed

Filename Overview
packages/common-utils/src/core/metadata.ts Adds parameterized, deterministic metric-name discovery with relevance ordering and explicit truncation reporting.
packages/app/src/hooks/useMetadata.tsx Adds the React Query wrapper for metric-name discovery with bounded refetch behavior.
packages/app/src/components/MetricNameSelect.tsx Replaces sampled client-side filtering with debounced server-side search and visible completeness status.
packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx Forwards the chart date range into metric-name discovery.
packages/common-utils/src/tests/metadata.test.ts Covers generated SQL, wildcard escaping, relevance ordering, timeout behavior, and truncation.
packages/common-utils/src/tests/metadata.int.test.ts Adds ClickHouse integration coverage for deterministic browsing and exact-match search.

Sequence Diagram

sequenceDiagram
  participant U as Chart editor
  participant S as MetricNameSelect
  participant Q as useGetMetricNames
  participant M as Metadata
  participant C as ClickHouse
  U->>S: date range and metric source
  S->>S: Debounce search text
  S->>Q: Query each configured metric table
  Q->>M: getMetricNames(pattern, range)
  M->>C: GROUP BY name, relevance ORDER BY, LIMIT n+1
  C-->>M: Ordered names
  M-->>Q: names and truncated flag
  Q-->>S: Per-kind results
  S-->>U: Selectable options and status
Loading

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 322 passed • 1 skipped • 1385s

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

Tests ran across 4 shards in parallel.

View full report →

A failed metric-name query previously just vanished from the dropdown. The
query is not retried and does not refetch on focus, so a transient error — or
one too slow to finish inside the execution cap — silently omitted that kind's
metrics from a list that looked perfectly healthy, which is the same class of
problem as the sampling it replaced.

Reported in the Select's description rather than its `error` slot, since that
slot carries form validation for this field and the series editor clears it on
focus.
The explicit 15s `max_execution_time` was tighter than what this path had
before. The dropdown called getKeyValues with `disableRowLimit: true`, which
sends `clickhouse_settings: undefined`, so the client filled in the
deployment's `queryTimeout` (60s by default, operator-configurable). Capping
at 15s would break enumeration on any metrics table that takes longer than
that to aggregate but previously succeeded.

Leaving `max_execution_time` unset restores that bound. The row cap stays at
`0`: bounding rows underneath `ORDER BY ... LIMIT` is precisely what made the
old result an arbitrary subset, so it would reintroduce the bug this replaces.
Superseded searches abort through the query's `signal`, so only the latest
pattern is ever in flight.
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Deep Review

No critical issues found. The reported bug (up unselectable on high-cardinality sources) is fixed, and the highest-risk surfaces check out: namePattern is fully parameterized through chSql {String} bindings with correct wildcard escaping (no injection), truncation math (LIMIT n+1slice(0, limit)length > limit) is correct including the exact-limit boundary, and getMetricOptions re-adds a saved-but-unlisted metric so a selection past the page still renders. The findings below are recommendations and nits, none blocking.

🟡 P2 -- recommended

  • packages/common-utils/src/core/metadata.ts:2660 -- Each debounced keystroke issues four per-kind GROUP BY MetricName ORDER BY … LIMIT n+1 scans with max_rows_to_read: '0' and a leading-wildcard ILIKE '%…%' that cannot use an index, so the exact high-cardinality tables this feature targets pay a full aggregation per kind per keystroke across concurrent editors.
    • Fix: Gate the server query behind a minimum pattern length and/or evaluate a LowCardinality-backed dictionary lookup for name discovery to avoid repeated full scans.
  • packages/app/src/components/MetricNameSelect.tsx:253 -- Options are concatenated gauge → histogram → sum → exponential-histogram and rendered under <Select limit={100}>, so once gauge matches alone fill the cap an exact match living only in a Sum or Histogram table is unreachable even though the server returned it.
    • Fix: Interleave the four result sets by relevance before the render cap, or raise/remove the client-side limit while searching.
🔵 P3 nitpicks (3)
  • packages/app/src/components/MetricNameSelect.tsx:110 -- When one kind's query fails, retry: false plus keepPreviousData omits that kind's metrics while a stale page may still show, and the only signal is the generic Some metrics could not be loaded description with no explicit retry affordance.
    • Fix: Name the failed kind in the notice and/or expose a manual refetch so a transient failure is recoverable without re-typing.
  • packages/app/src/components/MetricNameSelect.tsx:114 -- isLoading is derived from query.isLoading, which keepPreviousData keeps false on every refetch, so debounced re-queries swap results with no in-flight indicator.
    • Fix: Drive the loading affordance from isFetching for search refetches.
  • packages/app/src/components/__tests__/MetricNameSelectSearch.test.tsx:1 -- The suite does not cover the activeSearch label-suppression edge (a user legitimately typing the selected metric's exact label) or debounce/abort supersession, and the unit suite asserts raw SQL substrings, which couples tests to query text.
    • Fix: Add cases for the label-equals-search path and superseded-query abort; assert behavior over SQL string fragments where feasible.

Reviewers: correctness, security, performance, reliability, adversarial, kieran-typescript, frontend-races, testing, maintainability, api-contract, previous-comments.

Verified not issues: The prior NEXT_PUBLIC_ENABLE_EXPONENTIAL_HISTOGRAMS feature-gate-bypass concern does not apply — that env var does not exist in the codebase and the base version gated exponential-histogram discovery only on table presence, which the new useGetMetricNames enabled guard preserves; no regression. The prior "transient failures become silent omissions" concern is materially addressed by the new hasError surfacing. dateRange typing is consistent (DateRange['dateRange'] is exactly [Date, Date]).

Testing gaps: hook-level isError/isTruncated aggregation is exercised only through the mocked component, not the hook directly; the date-range clamp to the most recent 3 days (a metric that stopped reporting earlier in a long range is unfindable) is an acknowledged, pre-existing limitation carried unchanged.

Coverage note: Findings reflect direct verification of the diff against the surrounding code and the prior-comment history; automated sub-reviewer returns had not landed at synthesis time, so no cross-reviewer corroboration is claimed.

@hyperdxio hyperdxio deleted a comment from github-actions Bot Aug 14, 2026
@hyperdxio hyperdxio deleted a comment from github-actions Bot Aug 14, 2026
@teeohhem

Copy link
Copy Markdown
Contributor Author

packages/app/src/components/MetricNameSelect.tsx:249 -- onDropdownClose={() => setSearchValue(selectedLabel)} closes over the pre-change render's label, so switching from metric A to metric B leaves the input displaying A while the form value is B.

Not really an issue

Mantine repairs this on the next commit — Select.mjs has a useEffect(…, [value, selectedOption]) that writes selectedOption.label whenever the value changes. So A → B converges; worst case is one frame of the old label, not a persistent mismatch.

packages/common-utils/src/core/metadata.ts:2634 -- max_rows_to_read: '0' overrides the tenant's configured metadataMaxRowsToRead guardrail on the one metadata query driven by free-form user text

Both premises are inverted. The replaced call site passed disableRowLimit: trueclickhouse_settings: undefined, so no tenant settings were applied and there was no max_rows_to_read cap to override. The 15s is on that same !disableRowLimit branch — the old effective timeout was the client backfill, DEFAULT_QUERY_TIMEOUT = 60, which 313259d4e now inherits.

'0' is also right for this query: a finite row cap under ORDER BY … LIMIT either throws or returns an arbitrary partial aggregate, and the second is the bug this PR removes.

Four per keystroke is real, but cancel_http_readonly_queries_on_client_close: 1 is in defaultSettings, so superseded searches cancel in ClickHouse rather than piling up.

@wrn14897

Copy link
Copy Markdown
Member

Since MetricName is now a LowCardinality type, do you think we can use a dictionary lookup instead?

…metric-name-listing

# Conflicts:
#	packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx
#	packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx
#	packages/app/src/components/MetricNameSelect.tsx
@teeohhem

Copy link
Copy Markdown
Contributor Author

@wrn14897 I spoke with Mike about this as well.

I prototyped the dictionary lookup and it's not a complete win. lowCardinalityKeys() truncates its output to the block’s row count, so any part smaller than its dictionary silently loses keys — e.g. a metric that just started reporting and only exists in a fresh 1-row part just disappears until a merge (link). TL;DR it can (temporarily) introduce a new silent-omission problem.

It also benchmarks slower: on a 30M row / 29 partition metrics table it still reads the whole index stream (30M rows, ~30ms) while the PR's 3-day-clamped GROUP BY prunes to 3M rows (~13ms).

And since tables created before #2545 still have MetricName String (no ALTER migration), it would throw on older deployments anyway.

I vote we proceed with the current implementation and evaluate dictionary lookups more broadly when more time passes.

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.

2 participants