Skip to content

perf(dashboard): cap high-cardinality time-chart series with load-all escape hatches - #2802

Open
brandon-pereira wants to merge 12 commits into
mainfrom
claude/dashboard-per-tile-series-limit
Open

perf(dashboard): cap high-cardinality time-chart series with load-all escape hatches#2802
brandon-pereira wants to merge 12 commits into
mainfrom
claude/dashboard-per-tile-series-limit

Conversation

@brandon-pereira

@brandon-pereira brandon-pereira commented Aug 4, 2026

Copy link
Copy Markdown
Member

Why

High-cardinality time charts — especially raw SQL tiles — can return tens of thousands of series. The client held all of them in memory while only ~100 lines were ever drawn, and every hover re-serialized a huge chart SVG. On a heavy dashboard this pinned the browser tab at several GB of memory and made panning, hovering, and editing sluggish.

This PR caps what gets fetched and rendered, gives users explicit escape hatches to override those caps, and lands a batch of transform/render optimizations. Together they bring a heavy dashboard down from ~8 GB to ~2 GB in some scenarios and make the app noticeably more responsive (smoother hover, faster tile edits, less jank while scrolling).

What changed

Per-tile "Series Limit" control

  • seriesLimit now applies to all time charts (builder and raw SQL) via SharedChartSettingsSchema, with unified three-state semantics:
    • null/undefined → default materialization cap (MAX_RENDERED_TIME_CHART_SERIES = 250)
    • positive N → keep the top N series by peak value
    • 0 → unlimited (explicit opt-out)
  • New hasPositiveSeriesLimit() helper centralizes the != null && > 0 check that gates the SQL __hdx_series_limit CTE, the pie/bar LIMIT, and the chunked-ranking window. Schema relaxed from .positive().nonnegative() so 0 is storable; renderChartConfig skips the CTE for 0 (avoids emitting LIMIT 0).
  • Display Settings drawer shows the control for raw SQL too (min=0), with distinct copy for the raw-SQL render cap vs. the builder fetch cap.

"Load all series" escape hatches

  • The hidden-series warning icon and the pinned tooltip's +N more line both become clickable, letting users render every series on demand.
  • "Load all" is bounded by MAX_LOADABLE_TIME_CHART_SERIES (5000) rather than Infinity, so a runaway result still can't exhaust memory; drawn lines remain capped by HARD_LINES_LIMIT.
  • The opt-in resets whenever the underlying query shape changes (time window excluded), so a stale "load all" can't bypass a newly-authored cap, while hover/live-range ticks don't reset it.
Screenshot 2026-08-04 at 3 10 24 PM

Render & transform optimizations (responsiveness)

  • rr-block on the chart root — tells the rrweb session-replay recorder to capture each chart as a placeholder instead of serializing its large SVG DOM on every mutation (the dominant session-replay cost on high-cardinality dashboards).
  • Build each series object once, not once per rowaddResponseToFormattedData previously rebuilt a LineData on every result row (~250k allocations) instead of once per distinct series.
  • Timestamp parse caching in the time-chart transform — per-row new Date(...) parsing dominated; now cached per raw value.
  • CSS-based nearest-cursor emphasis — hovering used to swap <Area> props (rebuilding every line each frame); now a scoped CSS class handles thicken/fade. The anomaly band is explicitly excluded so it stays shaded.
  • Tooltip row caps — top 20 rows (+ +N more) for both hover and pinned tooltips (always keeping the cursor-nearest series); pinned tooltip lifts to MAX_EXPANDED_TOOLTIP_ROWS (500) and scrolls when "load all" is active.
  • Memoized tooltip element + stable mouse handlers, and an appendChunk fast path that reuses the chunk array for the first/only chunk instead of an O(rows) spread copy.
  • Edit-modal EXPLAIN skip — the chart preview no longer fires the extra MV-optimization EXPLAIN query on every modal open/submit.

External API v2 fix

  • Line/Bar/Table dashboard tiles now emit the external limit as absent (not 0/null), since the external field is positive-only via hasPositiveSeriesLimit() — keeps a GET→PUT round-trip from being rejected by the write-body schema.

Impact

Before After
Heavy-dashboard tab memory ~8 GB ~2 GB (in some scenarios)
Series materialized by the transform unbounded (one object per group, ~10k+) capped at 250 top-by-peak (MAX_RENDERED_TIME_CHART_SERIES), per-tile overridable
Lines actually drawn 100 (HARD_LINES_LIMIT) 100 (unchanged)
Session-replay chart cost full SVG per mutation placeholder (rr-block)

Tests

  • hasPositiveSeriesLimit unit tests + renderChartConfig "no CTE when seriesLimit=0"
  • ChartUtils maxSeries capping behavior
  • HiddenSeriesIndicator and ChartSeriesTooltip load-all affordance (passive vs. clickable +N more)
  • DBTimeChart load-all escape-hatch behavior (appears/disappears; seriesLimit=0 shows no affordance)
  • useChartConfig appendChunk fast path
  • ChartDisplaySettingsDrawer control visibility

Notes for reviewers

  • A changeset is included (minor bump for @hyperdx/common-utils, @hyperdx/app, @hyperdx/api).
  • A dev-only high-cardinality seed script was used to reproduce these scenarios but is intentionally not included.

… escape hatches

Time charts with many groups could render thousands of <Area>/<Bar> series at
once, spiking memory and causing severe hover jank. Bound the number of series
materialized and drawn per tile, and give users ways to see the rest:

- A per-tile Series Limit control (0 = unlimited) in Display Settings, defaulting
  to a safe cap; series beyond the cap are dropped lowest-peak-first and surfaced
  via a hidden-series notice.
- +N more affordances in the hover and pinned tooltips, and a load-all-series
  action that lifts the cap on demand.
- Tooltips cap how many rows they render per frame so a wide bucket cannot mount
  thousands of popovers; the pinned tooltip scrolls the full set once loaded.
- The external dashboards API round-trips the limit as a positive-only limit so
  a GET -> PUT of a tile with no limit is not rejected by the write schema.
@vercel

vercel Bot commented Aug 4, 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 6, 2026 9:02pm
hyperdx-storybook Ready Ready Preview Aug 6, 2026 9:02pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 622731d

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

@brandon-pereira
brandon-pereira marked this pull request as ready for review August 4, 2026 21:29
@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD.

Why this tier:

  • Critical-path files (2):
    • packages/api/src/routers/external-api/v2/dashboards.ts
    • packages/api/src/routers/external-api/v2/utils/dashboards.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)

Additional context: agent branch (claude/dashboard-per-tile-series-limit)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 19
  • Production lines changed: 1532 (+ 1107 in test files, excluded from tier calculation)
  • Branch: claude/dashboard-per-tile-series-limit
  • Author: brandon-pereira

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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 269 passed • 1 skipped • 1096s

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

Tests ran across 4 shards in parallel.

View full report →

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR limits materialized high-cardinality time-chart series while retaining bounded, explicit load-all behavior and reducing chart transformation and rendering overhead.

  • Adds shared three-state series-limit semantics across builder and raw-SQL time charts.
  • Ranks and caps complete logical groups, preserving comparison pairs and multi-value series.
  • Adds bounded load-all controls and capped hover/pinned tooltip rows.
  • Optimizes sparse peak calculation, timestamp parsing, series construction, event handlers, and session-replay capture.
  • Keeps external dashboard API serialization compatible with positive-only limit fields.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported comparison-group ranking, sparse peak scanning, group-key collision, previous-only handling, and bounded load-all issues are addressed in the current code.

Important Files Changed

Filename Overview
packages/app/src/ChartUtils.tsx Adds bounded logical-group materialization, sparse peak ranking, timestamp caching, and complete comparison/multi-value group retention.
packages/app/src/components/DBTimeChart.tsx Introduces query-shape-scoped bounded load-all state and correctly disables actions once the cap cannot be raised.
packages/app/src/HDXMultiSeriesTimeChart.tsx Reduces tooltip and hover rendering costs while preserving bounded expanded tooltip behavior.
packages/app/src/components/charts/ChartSeriesTooltip.tsx Caps tooltip rows and exposes load-all only when an actionable handler is available.
packages/app/src/components/charts/HiddenSeriesIndicator.tsx Provides clickable or passive hidden-series indicators according to whether loading additional series is possible.
packages/app/src/hooks/useChartConfig.tsx Adds a first-chunk fast path that avoids an unnecessary full-array copy.
packages/common-utils/src/core/renderChartConfig.ts Applies server-side series limiting only for positive limits, preserving zero as unlimited.
packages/common-utils/src/types.ts Unifies nonnegative series-limit semantics in shared chart configuration schemas.
packages/api/src/routers/external-api/v2/utils/dashboards.ts Omits nonpositive external limits so dashboard GET-to-PUT round trips satisfy positive-only write schemas.
packages/api/src/mcp/tools/dashboards/schemas.ts Aligns MCP dashboard schemas and descriptions with shared three-state series-limit behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Config[Tile seriesLimit] --> Resolve[Resolve materialization cap]
  Query[Current and previous query results] --> Format[Build logical series groups]
  Resolve --> Rank[Rank groups by peak]
  Format --> Rank
  Rank --> Cap[Retain complete groups up to cap]
  Cap --> Render[Render up to hard line limit]
  Cap --> Hidden{Hidden groups remain?}
  Hidden -->|Yes, cap can increase| Load[Load all up to bounded maximum]
  Load --> Rank
  Hidden -->|Yes, bound reached| Passive[Show passive hidden-series count]
  Hidden -->|No| Render
Loading

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

Comment thread packages/app/src/ChartUtils.tsx Outdated
Comment thread packages/app/src/ChartUtils.tsx Outdated
Comment thread packages/app/src/ChartUtils.tsx Outdated
Comment thread packages/app/src/components/DBTimeChart.tsx Outdated
- Cap by logical series (grouped by currentPeriodKey) so comparison
  current/previous pairs are kept or dropped together instead of being
  orphaned by a flat entry-list slice.
- Peak-rank scan iterates each bucket's populated cells only, so sparse
  high-cardinality results cost O(populated cells) not O(buckets*series).
- HiddenSeriesIndicator goes passive (no onLoadAll) once showAllSeries is
  already on, so a result past MAX_LOADABLE_TIME_CHART_SERIES doesn't offer
  a no-op click; stopPropagation on the button so it can't start a tile drag.
- resolveRenderedSeriesCap falls back to the default cap for NaN/negative/
  non-integer values instead of disabling the guard.
- queryShapeIdentity folds in the raw seriesLimit so a null<->0 edit resets
  the load-all opt-in.
- Clarify the seriesLimit doc comment (fetch vs render cap, null/0/N).
PR #2772 (merged into this branch via main) added seriesLimit to the
external Line/StackedBar tile schemas as positive-only, and the converter
emitted `config.seriesLimit ?? undefined`. This branch relaxes the internal
schema to allow 0 (unlimited), so a stored 0 would serialize verbatim and be
rejected on a GET->PUT round-trip by the positive-only write schema. Gate both
cases on hasPositiveSeriesLimit so 0/null map to absent, matching the existing
Pie/Bar limit mappings. Adds unit coverage for 0/null/positive.
- Rewrite the external seriesLimit test to round-trip through JSON instead
  of `as any` casts, so it no longer adds no-unsafe-type-assertion warnings
  that pushed the api package over its --max-warnings budget.
- Fold the line/stacked_bar 0-gating note into the single series-limit
  changeset (no separate entry).
Store the query shape the 'load all' opt-in was enabled at and derive
showAllSeries from `shape === queryShapeIdentity` during render, rather than
resetting a boolean in a queryShapeIdentity-keyed effect. This drops the opt-in
in the same render the query changes (no one-commit window where a stale opt-in
pairs with the new shape) and removes the react-hooks/set-state-in-effect
warning. The pin-dismiss stays in its effect as a genuine side effect.
The main merge left two identical .chartTooltipContentClipped blocks in
HDXLineChart.module.scss, which stylelint's no-duplicate-selectors rejected as
an error — failing the app ci:lint (yarn lint:styles) step with a cryptic
exit 2 that surfaced no eslint/tsc summary. Drop the duplicate.
…review findings

External API (allow 0 = unlimited):
- Relax external line/stacked_bar seriesLimit and pie/bar limit schemas from
  .positive() to .nonnegative(), so 0 (unlimited) is a valid, round-tripping
  value instead of being rejected/dropped on GET->PUT.
- Converter emits 0 (?? undefined) across all four tile types; OpenAPI docs
  updated to minimum:0 with three-state description; openapi.json regenerated.

Deep-review fixes:
- Return a logical renderedSeriesCount from formatResponseForTimeChart so the
  hidden-series notice isn't double-counted on comparison charts.
- Rank only current-period logical series when capping, so a previous-only
  group can't evict a current-period series the query explicitly selected.
- HiddenSeriesIndicator uses the defined --color-text-warning token (not an
  undefined --color-warning) and an ActionIcon variant=subtle per convention.
- Strengthen the load-all opt-in test to rerender with a genuinely shifted
  dateRange, guarding against dateRange re-entering the shape identity.
Comment thread packages/app/src/ChartUtils.tsx Outdated
The previous-only-eviction fix ranked only current-period groups so a
previous-only group couldn't evict a current-period series, but the keptGroups
filter then dropped those previous-only groups entirely when over the cap, and
they weren't counted in hiddenSeriesCount — so a dashed previous-period-only
series vanished with no accounting. Add every previous-only group back into
keptGroups unconditionally (the previous query's own CTE already bounds them),
and add a regression test.
Comment thread packages/app/src/ChartUtils.tsx Outdated
…w 0 in MCP

Addresses deep-review + Greptile findings on the series cap:

- Cap by GROUP identity (strip the value-column prefix from currentPeriodKey)
  so a multi-value chart (e.g. avg+max) keeps all value columns of a surviving
  group together instead of a large-magnitude column evicting a smaller one.
- Bound the TOTAL logical groups by maxSeries in comparison mode: current-period
  groups take priority, then previous-only groups fill remaining slots. Fixes
  both a previous-only group evicting a current one and disjoint result sets
  exceeding the cap while underreporting hiddenSeriesCount.
- MCP seriesLimitSchema and pie/bar limit relaxed .positive() -> .nonnegative()
  so a seriesLimit: 0 (unlimited) tile no longer rejects the whole MCP save;
  descriptions corrected.
- Load-all uses Math.max(bound, tile cap) so it can only raise the rendered
  count, never reduce it when a tile's seriesLimit already exceeds the bound.
- hasPositiveSeriesLimit now requires Number.isInteger, matching
  resolveRenderedSeriesCap, so a non-integer stored value can't reach the CTE
  and bind as { Int32: 0.5 }.
- Pie/bar external limit OpenAPI docs updated to minimum:0; openapi.json
  regenerated.

Adds ChartUtils tests for multi-value grouping, current-period priority with a
bounded total, and previous-only retention under the cap.
Comment thread packages/app/src/ChartUtils.tsx
- groupKeyOf stripped the '<valueColumn> · ' prefix even on single-value
  charts that never add it, so a group literally named 'value · x' collapsed
  into group 'x'. Only strip when the key builder added the prefix (multiple
  value columns AND group columns). Adds a collision regression test.
- Load-all is offered only when it would actually raise the cap: when a tile's
  own seriesLimit already meets/exceeds MAX_LOADABLE_TIME_CHART_SERIES, both
  maxSeries branches were equal, so the click was a no-op that then hid the
  affordance permanently. Gated via loadAllHandler.
- Assert renderedSeriesCount (logical group count) in the multi-value and
  comparison tests so a regression to lineData.length is caught.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

<!-- deep-review -->

Deep Review

Warning

This review is incomplete and diff-blind — read this before trusting the findings below.

The runner environment is broken in a way that prevented the requested multi-agent review from completing:

  • Bash fails on every invocation (bwrap: Can't create file at /home/.mcp.json: Permission denied), sandboxed and with the sandbox override, for me and for every subagent. So git diff, git log, and gh were all unavailable.
  • Grep and Glob are not registered tools at all in this session, and WebFetch was not permitted — so the PR diff could not be retrieved over HTTP either.
  • Read was the only working tool. Files were located by walking import statements and by probing filenames.

Consequences: the diff was never obtained, so nothing below is attributed to specific added lines — these are defects in the code as it currently stands on the PR head, some of which may predate this PR. No test file could be located or read, so this review contains no test-coverage assessment. Eight persona reviewers were dispatched but none returned before output was required; the findings below come from my own direct reading of the source.

Re-run this review in a working environment before treating it as a merge gate.

✅ No P0/P1 issues found — but read that as "none substantiated by the partial inspection that was possible", not as a clean bill of health.

🟡 P2 -- recommended

  • packages/app/src/components/DBTimeChart.tsx:415 -- queryShapeIdentity runs JSON.stringify over the entire chart config on every render, because its queriedConfig dependency is itself rebuilt every render.
    • Fix: Derive the shape identity from the primitive fields that actually distinguish queries, or cache the serialization against a ref, so hover-driven re-renders do not re-serialize the whole config.
  • packages/app/src/defaults.ts:47 -- seriesLimit === 0 resolves to Number.POSITIVE_INFINITY, so the client transform materializes every returned series with no upper bound and no hidden-series indicator, while the deliberate "load all" path is bounded at 5000.
    • Fix: Clamp the unlimited case to MAX_LOADABLE_TIME_CHART_SERIES as well, so the explicit opt-out is no less safe than the escape hatch.
🔵 P3 nitpicks (3)
  • packages/app/src/defaults.ts:16 -- At least six interacting series/row limits now span two packages (MAX_RENDERED_TIME_CHART_SERIES, MAX_EXPANDED_TOOLTIP_ROWS, MAX_LOADABLE_TIME_CHART_SERIES, DEFAULT_SERIES_LIMIT, plus MAX_TOOLTIP_ROWS at HDXMultiSeriesTimeChart.tsx:65 and MAX_TIME_CHART_SERIES imported at HDXMultiSeriesTimeChart.tsx:52), with their ordering relationships asserted only in prose comments.
    • Fix: Colocate the limits and add a runtime or type-level assertion of the required ordering so a future edit to one cannot silently invert another.
  • packages/common-utils/src/core/utils.ts:52 -- The three-state seriesLimit semantics are encoded twice in different packages, here and in resolveRenderedSeriesCap at packages/app/src/defaults.ts:44, each re-implementing the integer and negative checks.
    • Fix: Export a single resolver/predicate pair from common-utils and have the app consume it rather than restating the rules.
  • packages/app/src/components/charts/ChartSeriesTooltip.tsx:234 -- In expanded mode the pinned tooltip renders up to 500 rows, each of which mounts up to three Mantine Tooltip popovers via SeriesRow.
    • Fix: Virtualize the expanded row list, or mount the per-row action cluster only for the row under the cursor.

Reviewers (1): orchestrator direct source inspection. Eight persona reviewers (correctness ×2, performance, testing, adversarial, api-contract, maintainability, kieran-typescript) were dispatched but returned no results before output was required.

Testing gaps:

  • No test file could be located or read in this environment, so the author's stated test coverage was not verified at all — treat test adequacy as entirely unreviewed.
  • Two behaviors are worth confirming are covered, since both are easy to get wrong and neither is observable from the source alone: that the load-all opt-in resets when the query shape changes but not on a live-range time tick, and that resolveRenderedSeriesCap falls back to the default cap for NaN, negative, and non-integer input.
  • The seriesLimit = 0 path deserves an explicit test that it is genuinely unbounded, since that is the one state where the memory guard is fully disabled.

Verified as non-issues: appendChunk (packages/app/src/hooks/useChartConfig.tsx:254) is purely functional — the fast path shares the chunk array reference but never mutates it, and every append allocates a fresh result object, so there is no aliasing or stale-state bug there.

@hyperdxio hyperdxio deleted a comment from github-actions Bot Aug 5, 2026
@brandon-pereira
brandon-pereira requested review from a team and pulpdrew and removed request for a team August 5, 2026 18:40
@hyperdxio hyperdxio deleted a comment from github-actions Bot Aug 5, 2026
pulpdrew
pulpdrew previously approved these changes Aug 6, 2026
…tile-series-limit

# Conflicts:
#	packages/app/src/HDXMultiSeriesTimeChart.tsx
#	packages/app/src/components/DBTimeChart.tsx
#	packages/app/src/components/charts/ChartSeriesTooltip.tsx
#	packages/app/src/components/charts/__tests__/ChartSeriesTooltip.test.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants