Skip to content

feat(app): stream metric names from the primary index - #3025

Open
MikeShi42 wants to merge 2 commits into
claude/deterministic-metric-name-listingfrom
mikeshi/stream-metric-names-from-index
Open

feat(app): stream metric names from the primary index#3025
MikeShi42 wants to merge 2 commits into
claude/deterministic-metric-name-listingfrom
mikeshi/stream-metric-names-from-index

Conversation

@MikeShi42

Copy link
Copy Markdown
Contributor

Stacked on #2747. Base is claude/deterministic-metric-name-listing, so this diff is only the streaming layer. Merge #2747 first.

Problem

The metric name select waited on a groupUniqArray aggregation over the data before showing anything. On a source reporting ~4,900 gauge metrics that is ~770ms of empty dropdown, paid on every chart editor mount — and the control is mounted per series, on every dashboard tile editor.

Fix

Read MetricName from the table's sparse primary index via the mergeTreeIndex table function instead of scanning the data: one row per granule mark. Measured on a 121M-row otel_metrics_gauge with 4,914 gauge names:

cold latency names returned
index read 7ms 1,986
exhaustive GROUP BY 767ms 4,914

DISTINCT is a streaming transform in ClickHouse, so results also arrive progressively rather than in one block — first options on screen ~30ms after the query starts, and a small spinner replaces the chevron while more land. ORDER BY is deliberately omitted, since the sort would have to complete before the first row; the client sorts.

The index returns a subset, and that is the design

The index only records the column's value at each granule boundary, so a metric confined to one granule never appears. On the test dataset that is 1,986 of 4,914 names — and the split is not arbitrary:

metric names median datapoints each
visible in index 1,991 32,060
invisible 2,923 14

The hidden ~2,900 are almost entirely metrics with a dozen datapoints. So browsing shows the metrics that actually carry data, and typing switches to the exhaustive relevance-ranked search from #2747, which reaches anything the index omitted. The placeholder reads Search metrics... to invite that. Verified end-to-end: a metric absent from the index is found by typing its name, ranked first.

Browsing also falls back to that same exhaustive query when the index cannot be read at all — a server older than 24.2, a Distributed or non-MergeTree metric table, or a primary key without MetricName — so no deployment loses the picker.

Two behaviours worth reviewing

The options never blank mid-keystroke. keepPreviousData serves the previous pattern's page when the search key changes, and results for http cannot match a search for billing — the client-side filter reduces them to nothing and the dropdown flashes empty for ~700ms. This ignores placeholder pages (isPlaceholderData) and holds the unfiltered browse list instead, which still filters to relevant matches immediately. Measured before/after with a MutationObserver on the option list.

Render cap raised 100 → 500 to match DEFAULT_METRIC_NAMES_LIMIT, so a search not reported as truncated is fully renderable. Mantine renders options as plain DOM, so this costs ~150ms of keystroke blocking at ~4.9k names that a cap of 100 avoided — an accepted trade, not an oversight.

Known limitation, not addressed here

getMetricOptions concatenates the four kind lists gauge-first, so kind rather than relevance decides what survives the render cap. Searching http on the test dataset returns 0 of 26 matching Sum metrics behind 325 gauge matches. This predates both PRs (called out in #2747 as tracked separately) and the fix is a relevance-ordered merge across kinds; raising the cap does not fix it, since gauge alone exceeds 500 while browsing.

Testing

  • Metadata.streamDistinctIndexValues: 13 unit tests over a faked BaseResultSet — chunk-per-chunk yielding, the emitted SQL, parts pruning, and each guard (old server, pointer table, non-MergeTree engine, column outside the primary key, including a column that only appears nested in a key expression).
  • useStreamingQuery: 5 tests — partial publication before completion, throttle-away, error discards partials, disabled, empty stream.
  • useMetricNames: 10 tests — per-kind streaming and sorting, mode switch, the hold, the stale-placeholder guard, the fallback.
  • MetricNameSelectSearch: fix(app): list metric names deterministically instead of sampling #2747's 11 cases retargeted to the two-mode behaviour, all passing.
  • E2E (metric-name-streaming.spec.ts): browses from the index, then finds an index-invisible metric by typing. Asserts the seed still has such a metric, so the test cannot pass vacuously.
  • make ci-lint clean, make ci-unit 3400 passed. Verified by hand against a 121M-row local instance.

Notes

streamToAsyncIterator moves from packages/app/src/sessions.ts (marked TO BE DEPRECATED) into common-utils beside the ClickHouse client. Metadata.streamDistinctIndexValues is generic over table and column, so ServiceName and other primary-key columns can be listed the same way.

🤖 Generated with Claude Code

Fill the metric name select from the table's sparse primary index via the
mergeTreeIndex table function, so it populates in ~30ms instead of ~770ms
on a source reporting ~4,900 gauge metrics, and streams in progressively
rather than arriving all at once.

Browsing reads the index -- one row per granule mark instead of a full
column scan. That list is a subset, weighted towards metrics that carry
data, so typing switches to the exhaustive relevance-ranked search added
in the parent PR, which reaches anything the index omitted. Browsing also
falls back to that search when the index cannot be read at all: a server
older than 24.2, a Distributed or non-MergeTree table, or a primary key
without MetricName.

While the first search for a pattern is in flight the browse list is held
and filtered client-side, so the options never blank out mid-keystroke,
and the render cap is raised to 500 to match the server-side page size.

Adds Metadata.streamDistinctIndexValues, an async generator generic over
table and column, and a useStreamingQuery hook that accumulates an async
iterable into a React Query cache entry. streamToAsyncIterator moves out
of the deprecated session code into common-utils.
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c5da4b0

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

This PR includes changesets to release 4 packages
Name Type
@hyperdx/common-utils Minor
@hyperdx/app Minor
@hyperdx/api Minor
@hyperdx/otel-collector Minor

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 28, 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 28, 2026 5:36pm
hyperdx-storybook Ready Ready Preview Aug 28, 2026 5:36pm

Request Review

@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes metric-name browsing to stream values from ClickHouse primary indexes while retaining exhaustive queries for typed searches and fallback behavior.

  • Adds a generic primary-index streaming API and ClickHouse version checks.
  • Adds a React Query hook that progressively publishes streamed chunks.
  • Updates the metric selector to switch between streamed browsing and exhaustive search.
  • Adds unit and end-to-end coverage for streaming, fallback, and index-invisible metrics.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/app/src/hooks/useMetricNames.ts Coordinates per-kind primary-index browsing, exhaustive typed search, fallback behavior, sorting, and loading state.
packages/app/src/hooks/useStreamingQuery.tsx Adds a React Query wrapper that publishes throttled partial results while accumulating the complete async stream.
packages/common-utils/src/core/metadata.ts Adds guarded streaming of distinct primary-index values for compatible MergeTree tables and server versions.
packages/app/src/components/MetricNameSelect.tsx Integrates streamed metric names, search-oriented messaging, expanded rendering, and streaming feedback into the selector.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant S as Metric selector
  participant H as useMetricNames
  participant I as Primary-index stream
  participant Q as Exhaustive query
  participant C as ClickHouse

  U->>S: Open metric selector
  S->>H: Request names without pattern
  H->>I: Stream MetricName values
  I->>C: Read mergeTreeIndex
  C-->>I: Progressive chunks
  I-->>H: Partial name lists
  H-->>S: Sorted browse options

  alt Index read fails
    H->>Q: Request exhaustive listing
    Q->>C: Grouped metric-name query
    C-->>S: Complete fallback options
  end

  U->>S: Type search text
  S->>H: Request names with pattern
  H->>Q: Run exhaustive ranked search
  Q->>C: Search metric data
  C-->>S: Authoritative matches
Loading

Reviews (2): Last reviewed commit: "fix(app): make the index read work on an..." | Re-trigger Greptile

@@ -0,0 +1,330 @@
import React from 'react';

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.

P2 Oversized metric-name test suite

This new 330-line test file exceeds the repository's 300-line limit, making the browse, search, fallback, and streaming cases harder to navigate and extend. Split these behaviors into focused test suites.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

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 taking this one. The 300-line guidance in code_style.md sits under Code Organization next to "Single Responsibility: one clear purpose per component/function" — it reads as a rule for source modules, and the repo's own test suite doesn't apply it: 61 of 201 test files in packages/app/src exceed 300 lines, the largest at 2,005 (ChartEditor/__tests__/utils.test.ts). The nearest sibling to this file, useOffsetPaginatedQuery.test.tsx, is 1,176 lines.

At 330 lines this file is smaller than roughly 30% of the existing suite, and its cases share one describe, one mock setup, and one fixture. Splitting them across files would duplicate that scaffolding to satisfy a threshold nothing else in the package observes.

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.

That's fair. The repository evidence supports treating the 300-line guidance as a source-module heuristic rather than a hard limit for tests, and this file's shared setup and single purpose make splitting it counterproductive. I’m withdrawing this comment.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The streaming/fallback wiring, SQL parameter binding, and error propagation all hold up under review. Findings below are one moderate perf trade-off and a few nits.

🟡 P2 — recommended

  • packages/app/src/components/MetricNameSelect.tsx:140 — Raising the Mantine Select render cap from 100 to 500 re-renders up to 500 option DOM nodes synchronously on every (undebounced) onSearchChange keystroke, self-measured at ~150ms of main-thread blocking at ~4.9k names.
    • Fix: Lower the render cap (e.g. ~150–200, since untruncated results remain reachable via the debounced server search) or virtualize the dropdown instead of rendering 500 options per keystroke.
    • performance
🔵 P3 nitpicks (2)
  • packages/app/src/hooks/useMetricNames.ts:165 — The useMemo depends on the four per-kind result objects, which get fresh identities every render, so all four name lists are re-sorted with localeCompare and rebuilt into the options array on every 100ms stream flush.
    • Fix: Memoize each kind's sort independently, keyed on that list's identity, so one kind's flush does not re-sort the other three.
    • performance
  • packages/app/src/hooks/__tests__/useMetricNames.test.tsx:1 — New test file is 330 lines, over the 300-line file-size guideline (AGENTS.md feat: add docker prod build stages and publish prod builds #4, agent_docs/code_style.md); the rules are framed around components and eslint does not enforce a max-lines rule (test rules are relaxed at eslint.config.mjs:251), so applicability to tests is a judgment call. Prior review comment on this remains unaddressed.
    • Fix: Split into per-mode suites (browse/streaming vs. search/exhaustive-fallback) or confirm the guideline is not intended for test files.
    • testing, project-standards, previous-comments

Reviewers (7): maintainability, performance, security, testing, reliability, project-standards, previous-comments.

Coverage note: The correctness, adversarial, kieran-typescript, and julik-frontend-races reviewers did not return before synthesis. Their lenses were partially covered by direct verification: SQL values (column/database/table/limit) are bound as ClickHouse Identifier/String/Int32 params (no injection); a mid-stream index error propagates through the async generator → queryFn reject → retry:falseisError, flipping useExhaustive so the exhaustive query engages (fallback confirmed); partsOverlapFilter safely returns WHERE 1 when the partition key is not time-based; and the exhaustive fallback is bounded by DEFAULT_METRIC_NAMES_LIMIT (500). Pre-existing items (metadata.ts >300 lines; no explicit max_execution_time on the index query) are noted but not introduced by this diff. Security review returned clean.

Testing gaps:

  • No test exercises AbortSignal propagation / cancellation on unmount or query-key change (the reader.releaseLock cleanup path is unverified).
  • assertIndexReadable's if (!tableMetadata) "table not found" reject branch is untested.
  • No test for a query-time error surfacing mid-iteration (permissions error thrown during the stream, not caught by assertIndexReadable) driving the useMetricNames fallback.
  • The LIMIT/DEFAULT_MAX_INDEX_VALUES runaway cap on the mergeTreeIndex query is not asserted by any test.

Two ways the index read could come back empty or broken without the
fallback noticing.

partsOverlapFilter matches on system.parts.min_time, which ClickHouse
only populates for time-based partition keys. On a table partitioned by
anything else -- or not partitioned at all -- every part sits at the
epoch, the predicate excludes all of them, and the query *succeeds* with
zero rows. Nothing throws, so streamed.isError stays false and the
exhaustive fallback never fires: the picker just shows an empty list.
Measured on live ClickHouse: 40 metric names became 0 for both `no
PARTITION BY` and `PARTITION BY ServiceName`. Now the parts filter is
only applied when the partition key derives from the timestamp column,
and skipped otherwise, accepting an unpruned window instead.

streamToAsyncIterator assumed a WHATWG ReadableStream, but the node
client's stream() returns a Node Readable with no getReader, so
streamDistinctIndexValues threw `getReader is not a function` outside the
browser. Metadata is platform-agnostic, so the adapter now handles both.

Both verified against a live 121M-row instance across three partition
schemes, with unit coverage for each.
@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label Aug 28, 2026
@github-actions

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: 645 production lines changed (Tier 2 max: < 250)
  • Cross-layer change: touches frontend (packages/app) + shared utils (packages/common-utils)

Additional context: touches the query rendering engine lightly (30 lines, under the 150-line bar for Tier 4)

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

Stats
  • Production files changed: 7
  • Production lines changed: 645 (+ 1010 in test files, excluded from tier calculation)
  • Branch: mikeshi/stream-metric-names-from-index
  • Author: MikeShi42

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

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 321 passed • 1 skipped • 1377s

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

Tests ran across 4 shards in parallel.

View full report →

@MikeShi42

Copy link
Copy Markdown
Contributor Author

On the Deep Review P2 about the limit={500} render cap — the numbers are right and match my own measurement, but the value is deliberate rather than an oversight, so I'm leaving it.

I benchmarked both against a local 121M-row instance with 4,914 gauge names:

limit=100 limit=500
dropdown open 100 opts, 372ms 500 opts, 525ms
8 keystrokes, no delay 195ms, 0 long tasks 304ms, 2 long tasks / 157ms
system search settle 1 long task, 67ms 3 long tasks, 264ms (max 108ms)

So the ~150ms of main-thread blocking the review cites is real and reproducible.

The reason for accepting it: 500 matches DEFAULT_METRIC_NAMES_LIMIT, the server-side page size in getMetricNames. With them aligned, any search the server does not report as truncated is fully renderable — the client cap can no longer silently hide results the server considered a complete page. Lowering to 150–200 reintroduces that gap.

Worth noting the suggested fix doesn't address the related problem either: getMetricOptions concatenates the four kind lists gauge-first, so kind rather than relevance decides what survives the cap. On this dataset browsing has 2,026 index-visible gauge names and searching system matches 856 — both exceed 500, so no cap value unhides the Sum matches. That needs a relevance-ordered merge across kinds, which is called out in the PR description as tracked separately.

Virtualizing the dropdown is the right long-term answer and I agree with it; it's a larger change than this PR should carry.

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.

1 participant