Skip to content

feat: Exemplars for metric & PromQL charts - #2536

Closed
jordan-simonovski wants to merge 24 commits into
mainfrom
feat/exemplars
Closed

feat: Exemplars for metric & PromQL charts#2536
jordan-simonovski wants to merge 24 commits into
mainfrom
feat/exemplars

Conversation

@jordan-simonovski

@jordan-simonovski jordan-simonovski commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Why

Engineers staring at a latency spike on a chart have no way to jump to a trace that caused it.
This change adds exemplars, clickable markers overlaid on time charts, each linking to a representative trace.
Works for metric and PromQL sources.

Also added some test-telemetry infrastructure to build and validate the feature against more complex data sets.

CleanShot.2026-06-29.at.13.50.52.mp4

Exemplar overlay (app)

  • Diamond markers on HDXMultiSeriesTimeChart, plotted at the trace's own value, thinned to a configurable target across the visible range (the slowest/most-notable trace per window).
  • Hover card showing trace metadata (service, span, duration, status) fetched from a configurable exemplar trace source, with an Inspect trace button that deep-links straight to that trace. The card flips/clamps to stay on-screen near chart edges.
  • Opt-in "Exemplars" toggle in the chart editor (next to "As Ratio" for builder/metric charts; in the PromQL editor for PromQL charts) — persisted on the chart config, not a runtime toggle.

Two data backends

  • Metric sources (ClickHouse): renderMetricExemplarsChartConfig reads the OTel metric tables' Exemplars.* columns, honoring the chart's time range, metric name, and filters.
  • PromQL sources (native Prometheus): new /v1/prometheus/query_exemplars proxy + Prometheus responses normalized to a shared Exemplar shape (defensive about trace_id/traceID label spellings).
  • Fetched in parallel via a new useExemplars hook, gated so it's a no-op unless the toggle is enabled.

Fully-OTLP coherent metrics (collector)

  • Added the spanmetrics connector to the collector build and wired it into the OpAMP-generated collector config (ENABLE_SPAN_METRICS, off by default). It derives traces.span.metrics.* (calls + duration histogram) with exemplars from spans, so the duration histogram lands in ClickHouse with Exemplars.* populated — no synthetic/seeded data.
  • Optionally remote-writes those metrics (with exemplars) to a Prometheus endpoint (SPAN_METRICS_PROM_RW_ENDPOINT), so the native PromQL exemplar path is testable against the same real data.

Telemetry generator (telemetry-generator/)

  • A Node service emitting realistic OTLP traces (6 services, weighted attribute pools, nested spans and several failure scenarios) with backfill + live emission. Replaces ad-hoc ClickHouse seeding; wired into docker-compose.dev.yml. The spanmetrics connector turns its traces into coherent metric exemplars.

Team setting

  • maxExemplars (Team Settings → Chart Settings; 0 = unlimited) controls overlay density.

Scoping

  • Exemplars are restricted to single, non-ratio, histogram (latency) metric series — the exemplar value shares the chart's y-axis unit. Toggle hidden and renderer returns null otherwise.

Out of scope (separate tickets)

  • Heatmap exemplars — needs trace-source exemplar generation + a uPlot overlay.
  • Ratio + Group By — the ratio engine isn't group-aware; tracked as a separate bugfix PR.

Testing

  • common-utils: SQL renderer tests (metric exemplar query shape; null for ratio/multi-series/non-histogram/non-metric).
  • app: normalizePrometheusExemplars label-variant tests; DBTimeChart updated to mock the new exemplars data path.
  • api: query_exemplars route integration tests (native proxy + ClickHouse-backed empty result).
  • Verified end-to-end in an isolated stack: the spanmetrics connector emits a traces.span.metrics.duration histogram with real exemplars (trace id + actual latency), 100% resolving back to seeded traces.
  • make ci-lint / per-package tsc + unit suites green.

Changesets

  • exemplar-mode-metrics.md — @hyperdx/common-utils, @hyperdx/api, @hyperdx/app (minor)
  • span-metrics-connector.md — @hyperdx/api, @hyperdx/otel-collector (minor)

Notes / caveats

  • ENABLE_SPAN_METRICS is off by default — no production behavior change; it's enabled in local dev.
  • HyperDX collectors enforce ingest auth with scheme: '' (raw token, no Bearer prefix) — set <INGESTION_API_KEY> accordingly.

Storybook: exemplar components

Extracted the two exemplar UI pieces — the ExemplarDot chart marker and the ExemplarHoverCard trace popover — out of HDXMultiSeriesTimeChart and DBTimeChart into a focused components/Exemplars/ directory, with a Storybook story for each. The card story covers every state (full/partial metadata, loading, trace-not-found, no-trace-source-configured) and the dot story renders the marker in isolation, so both can be reviewed across light/dark and both brand themes without a live ClickHouse query. Behaviour-preserving; ExemplarDot also gains a real props type in place of any.

Merged main to bring the branch current — this includes the recharts 2→3 upgrade, for which the exemplar ReferenceDot marker was adjusted to the v3 API.

Review hardening & feature gate (latest)

Follow-up addressing review feedback (Greptile P1 + Deep Review P2s) and gating the feature for a safe rollout:

  • Feature-flagged. The whole overlay — editor toggle, PromQL toggle, data fetch, and team setting — is now behind NEXT_PUBLIC_ENABLE_EXEMPLARS: off by default, enabled in local dev. It can ship dark while we finish testing against real data.
  • Filter scoping (Greptile P1). The metric-name predicate is now ANDed separately from the user filter group, so a chart using filtersLogicalOperator: 'OR' can no longer let the exemplar scan match other metrics.
  • Single-series enforced end-to-end. PromQL queries returning more than one series drop the overlay (a marker's value can't be attributed or scaled across series); the PromQL result is capped client-side (no native limit param); and enableExemplars is cleared when a chart leaves single-series so a stale flag can't linger.
  • Outliers no longer distort the axis. The y-axis follows the visible series and each marker clamps to the series max (the hover card still shows the true duration), so one slow trace can't flatten the p99 line. The series hover tooltip is suppressed while hovering a marker so the two cards don't overlap, and the marker has a higher-contrast outline in light mode.
  • Tests. Added SQL OR-filter and PromQL multi-series regression tests; the exemplar bucketing is extracted to a unit-tested pure helper.

Note: the exemplar marker value was verified correct — it matches the span's real duration to the nanosecond; the earlier "off by an order of magnitude" appearance was a single slow outlier stretching the y-axis, addressed above.

@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 178c980

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/api Minor
@hyperdx/app 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 Jun 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 5, 2026 6:14am
hyperdx-storybook Ready Ready Preview Aug 5, 2026 6:14am

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Jun 29, 2026
@github-actions

github-actions Bot commented Jun 29, 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 (5):
    • packages/api/src/config.ts
    • packages/api/src/models/team.ts
    • packages/api/src/routers/external-api/v2/dashboards.ts
    • packages/api/src/routers/external-api/v2/utils/dashboards.ts
    • packages/otel-collector/builder-config.yaml
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)

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: 61
  • Production lines changed: 53339 (+ 3183 in test files, excluded from tier calculation)
  • Branch: feat/exemplars
  • Author: jordan-simonovski

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

@jordan-simonovski
jordan-simonovski marked this pull request as draft June 29, 2026 05:57
@greptile-apps

greptile-apps Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds feature-gated exemplar overlays for eligible metric and PromQL charts, linking chart markers to representative traces.

  • Adds ClickHouse and Prometheus exemplar query paths, normalization, sampling, chart markers, hover metadata, and trace navigation.
  • Adds span-derived metric generation and optional Prometheus remote write to support coherent exemplars.
  • Adds editor controls, team-level marker limits, telemetry-generation infrastructure, and regression tests.
  • The latest changes correctly address the previously reported endpoint configuration, metric scoping, and series-filtering issues.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported endpoint-resolution, metric-scoping, and series-filtering defects are corrected in the current code.

Important Files Changed

Filename Overview
packages/api/src/config.ts Guards optional remote write on a configured API-side endpoint so generated collector configuration receives a concrete value.
packages/api/src/opamp/controllers/opampController.ts Adds feature-gated spanmetrics pipelines and embeds the resolved Prometheus remote-write endpoint.
packages/common-utils/src/core/renderChartConfig.ts Renders exemplar SQL while preserving series conditions and independently ANDing the required metric-name predicate.
packages/app/src/hooks/useExemplars/useExemplars.tsx Coordinates feature-gated exemplar retrieval for eligible metric and PromQL chart configurations.
packages/app/src/components/DBTimeChart/DBTimeChart.tsx Integrates sampled exemplar markers and trace metadata into time-chart interaction.
docker-compose.dev.yml Adds local Prometheus exemplar storage and coherent test telemetry generation for development validation.

Sequence Diagram

sequenceDiagram
  participant Span as Instrumented service
  participant Collector as OTel Collector
  participant Metrics as ClickHouse or Prometheus
  participant App as HyperDX chart
  participant Trace as Trace source
  Span->>Collector: Export trace
  Collector->>Collector: Derive span metric with exemplar
  Collector->>Metrics: Export metric and trace identifier
  App->>Metrics: Query chart series and exemplars
  Metrics-->>App: Series plus exemplar points
  App->>Trace: Fetch marker trace metadata
  Trace-->>App: Service, span, duration, status
Loading

Reviews (19): Last reviewed commit: "fix(exemplars): stop a hover card cancel..." | Re-trigger Greptile

Comment thread packages/api/src/opamp/controllers/opampController.ts Outdated
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 266 passed • 1 skipped • 1110s

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

Tests ran across 4 shards in parallel.

View full report →

@jordan-simonovski
jordan-simonovski marked this pull request as ready for review June 29, 2026 20:58
Comment thread packages/api/src/config.ts Outdated
…ated collector config, so the collector container no longer needs SPAN_METRICS_PROM_RW_ENDPOINT in its own environment.
Comment thread packages/common-utils/src/core/renderChartConfig.ts
Three P0/P1 and about sixteen P2 findings from the deep review, plus six more
found by reviewing the fixes themselves. That review ran degraded (no shell, 6
of 9 reviewers, scope read from the working tree rather than a diff), so each
finding was checked against real code first. Two needed a different fix than
suggested; both are called out below.

The P0/P1s:

- The hover card never showed the exemplar's value or time, yet BOTH clamps
  justified moving markers on the grounds that it did. It now shows them, above
  the trace-source section so a chart with no trace source still gets them.
  This was a comment asserting a guarantee the code did not provide, and two
  more of the same kind turned up later in this batch.

- placeholderData deliberately keeps the previous range's exemplars across a
  range change, and clampExemplarX pinned those out-of-domain markers onto the
  new axis edge: real, clickable traces on buckets they never occurred in, with
  both isLoading and isError reporting settled. The clamp now nudges within one
  bucket (the newest-partial-bucket case it exists for) and drops anything
  further. Both clamps moved into useExemplarMarkers so the hover and pin reset
  effects key on what is actually drawn.

- Single-series enforcement was inferred from the exemplar payload, which only
  contains series that carry a sampled exemplar — so it answered "how many
  series had exemplars", not "how many lines are drawn". The count now comes
  from the main query. Proving it from the expression instead, as the review
  suggested, was tried and rejected: it silently kills
  histogram_quantile(0.95, rate(x_bucket[5m])), which has no `by` clause.

The P2s, grouped:

Scale mismatches, where a marker was pinned onto an axis measured in something
else. isExemplarEligible now considers the aggregation, so a count-aggregated
histogram no longer gets duration markers in a count domain. clampExemplarY is
no longer symmetric: pinning down to the ceiling says "at least this high", but
raising a fast request to a fitted floor draws it level with the slowest ones,
so below-floor markers are dropped instead.

Windowing. The scan's time predicate was on the row's TimeUnix while the
projected value is the ARRAY JOINed ex_TimeUnix, so pre-window exemplars came
back and in-window ones whose data point landed later did not. There is now an
exact ex_TimeUnix bound, and the row bound is widened forward by the greater of
the granularity and one minute — the granularity alone resolves to 15s on a
short window, finer than a typical scrape. The bucket index is clamped, so an
inclusive range no longer produces 201 buckets and drops the newest exemplar.
renderMetricExemplarsChartConfig now returns null without a dateRange.

Caching. The query key rounded both range ends to 30s, which collided different
sub-minute windows. It now floors the start and ceils the end, and — the part
the first attempt got wrong — the quantised window is what gets FETCHED, not
just keyed. Keying on it while fetching the raw range left the entry holding
whichever window arrived first.

PromQL parsing. `{code!="200"}` matched the operator test and suppressed the
overlay on a query that does aggregate to one line. `by ("le")` produced a
keep-set matching no real label, collapsing every series into one group. And
stripping comments before strings truncated at a `#` inside a label value,
eating the `by (...)` clause — a fail-closed introduced while fixing the first
two, caught by reviewing the fix.

UI. The Inspect deep link dropped the timestamp, so the search page fell back
to the last 14 days and an older dashboard opened an empty trace view; it now
carries a window. "Compare to Previous Period" put a second line in lineData,
which made the new series-count guard blank the overlay and tell the user to
aggregate to a single line they already had. The pinned card unpins on a range
change, quantised so a live-tail tick does not yank it away a second after the
click.

Tests. Four gating tests asserted synchronously on the first render and passed
with the `enabled` gate hardcoded true; they now flush, assert both fetch
paths, and sit beside a control that proves the harness does fetch. Two tests
written in this batch were themselves vacuous and are fixed or removed. Added
coverage for the placeholder chart-identity guard, an ineligible PromQL
expression, the hover card's value and time, the widening floor, and
resolveExemplarWindow, which was extracted from the route handler so it can be
unit tested without Docker.

Also removed: visibleSeriesMax ran an O(rows x series) pass on every time chart
even with the overlay off and was returned but never read; ifOverflow is now
stated on ReferenceDot instead of relying on the default both clamps work
around; provesSingleSeries, the rejected alternative above, is gone rather than
left as dead code with a docblock reading like the live rule.

Not done: the three per-bucket capping implementations (client, SQL, render)
are still three. Unifying them is a real refactor and the divergence is
documented where it matters.

make ci-lint and make ci-unit pass (5296 tests); dashboard E2E 81 passed with
one unrelated retry. Several fixes were confirmed load-bearing by reverting
them and watching the new test fail.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

<!-- deep-review -->

Deep Review

🔴 P0/P1 — must fix

  • packages/api/src/routers/api/prometheus.ts:224proxyToPrometheus forwards the upstream content-type verbatim with no X-Content-Type-Options: nosniff, and the new /query_exemplars route is registered on router.get, so a text/html body from a member-configured connection host renders as script on the app's own origin (session cookie is sameSite: 'lax', and /api/* is same-origin-proxied by the app).
    • Fix: Send X-Content-Type-Options: nosniff unconditionally and only forward the upstream content-type when it is a JSON media type, otherwise force application/json.
    • security

🟡 P2 — recommended

  • packages/app/src/components/Exemplars/exemplarPoints.ts:129 — The window split triggers on ordered.length > ceil(maxExemplars * 0.75) rather than > maxExemplars, so at the default budget of 12 a chart with 12 populated buckets renders only 9 markers and three buckets get none, contradicting the docstring's "more buckets than the marker budget".
    • Fix: Gate the split on ordered.length <= maxExemplars and keep windowCount only as the split width so every bucket that fits the budget still emits its rank-0 marker.
    • correctness, adversarial
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:135 — A hovered marker's React key and data x are fixed to its timestamp while xAxisDomain shifts every live-tail tick, so the same <g> node slides out from under a stationary cursor with no mouseleave: the card keeps rendering at the x/y captured at mouseenter and isExemplarHovered stays true, suppressing the series tooltip until the pointer moves.
    • Fix: Re-read the hovered point's current x/y from exemplarPoints on every recompute to reposition the card, and re-validate the pointer against the marker's current node so hover-end fires when it no longer sits underneath.
    • julik-frontend-races
  • packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx:202ChartComponent swaps between AreaChart and BarChart, a different element type that fully remounts every ExemplarDot without dispatching mouseleave, and the reset guards only fire when the key is absent from exemplarPoints, so switching display type while a card is pinned orphans it over markers that no longer exist.
    • Fix: Add an effect keyed on displayType that clears the hovered and pinned exemplar state before the chart subtree remounts.
    • julik-frontend-races
  • packages/app/src/components/Exemplars/ExemplarHoverCard.tsx:147useExemplarCard destructures only data and isLoading from useExemplarTraceMeta, so a failed trace query and a source whose exemplarTraceSourceId is not a Trace kind both arrive as meta === undefined, isLoading === false and render "Trace not found in source", reporting a misconfiguration or query error as a missing trace.
    • Fix: Thread isError and an unsupported-source state through to the card and render a distinct "could not load trace details" message for each.
    • correctness
  • packages/app/src/HDXMultiSeriesTimeChart/useChartScales.ts:120hasSelection alone (without fitYAxisToData) switches yAxisDomain to a numeric pair whose lower bound is the data minimum, so one click on the sole legend entry of a single-series chart makes clampExemplarY return null for every below-floor marker and silently reverts the overlay to a max envelope with no notice.
    • Fix: Fit the y-axis floor only when fitYAxisToData is set, or surface the count of markers dropped by clampExemplarY in the exemplar notice.
    • adversarial
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:23collapsesHistogramBuckets tests the literal substring histogram_quantile( while isPromqlExemplarEligible allows \s* before the paren, so histogram_quantile (0.95, …) passes the toggle gate, keeps le in the group key, and returns dropped: 'multiple-series' with a notice telling the user to aggregate to a single line they already have.
    • Fix: Make collapsesHistogramBuckets use the same whitespace-tolerant, literal-stripped regex the eligibility gate uses so the two checks cannot disagree about one expression.
    • adversarial
  • packages/api/src/routers/api/prometheus.ts:232 — The pipeline() catch cannot distinguish a client disconnect from an upstream failure and always returns 502, so ordinary tab closes and live-tail supersessions increment hyperdx.prometheus.query_errors, the counter whose own docblock scopes it to backend health for alerts and SLOs.
    • Fix: Detect a client-initiated close in the pipeline catch and return the already-written upstream status so recordProxyOutcome does not count it.
    • adversarial, reliability
  • packages/api/src/routers/api/prometheus.ts:165proxyToPrometheus never receives req, so the only abort on the outbound fetch is the 90s AbortSignal.timeout; the client forwards its abort signal precisely because live-tail supersedes the request every tick, yet the API keeps executing the superseded upstream query to completion.
    • Fix: Pass req in and combine a signal from its close event with the timeout via AbortSignal.any so a client disconnect cancels the upstream fetch.
    • reliability
  • packages/app/src/hooks/useExemplars/quantize.ts:13EXEMPLAR_KEY_QUANTUM_MS is 30s while EXEMPLAR_STALE_TIME_MS is 60s, and fetchRange is part of the query key, so the key changes twice per stale window and the declared 60s staleness tolerance can never suppress a fetch on a live-tail chart.
    • Fix: Derive one constant from the other so the key quantum is at least the stale time.
    • adversarial
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:42 — The hover query is WHERE TraceId = {traceId:String} with no time predicate and enabled the instant a traceId appears, so sweeping the cursor across a marker cluster fires one unbounded trace-table lookup per 9px hit circle crossed with no debounce, even though the exemplar's own timestamp and an EXEMPLAR_TRACE_WINDOW_MS precedent are already available.
    • Fix: Bound the query with a window around the exemplar's timestamp and debounce the hovered traceId by the same grace the close timer already uses.
    • performance, adversarial
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:135 — The hover-reset, pin-reset, and suppressNextClickRef guards have no tests, and neither do useExemplarCard's quantized auto-unpin, Escape handler, or the two URL shapes in navigateToExemplarTrace, despite the hook's own docblock stating every bug in this layer came from a marker outliving its data.
    • Fix: Add renderHook tests that drop a hovered and a pinned marker from the rerendered exemplars array, assert the reset callbacks fire, and cover the same-window vs changed-window unpin cases.
    • testing, maintainability
  • packages/app/src/components/DBTimeChart/DBTimeChart.tsx:317plottedSeriesCount filters out isDashed comparison lines to fix a bug the surrounding comment describes, but DBTimeChart.test.tsx mocks @/hooks/useExemplars wholesale and never inspects its arguments, so re-counting the dashed previous-period line would pass every existing test.
    • Fix: Spy on useExemplars in one test, render with one solid plus one isDashed series, and assert plottedSeriesCount === 1.
    • testing
  • packages/api/src/routers/api/prometheus.ts:556prometheus.test.ts covers only the extracted pure resolveExemplarWindow, leaving the handler's isPrometheusEndpoint branch selection, the ClickHouse-backed empty-success response, and the deliberate 5xx-only error-counter rule unexercised.
    • Fix: Add a route-level test with mocked getConnectionById and fetch asserting the narrowed start is proxied, the ClickHouse branch returns data: [] without fetching, and a 400 does not increment the error counter.
    • testing
  • packages/api/src/routers/api/prometheus.ts:190 — The new proxy route reaches a user-supplied connection.host with no protocol or private-IP check and with fetch's default redirect: 'follow', while the sibling connection-test path in clickhouseProxy.ts already applies isPrivateIp to the same field.
    • Fix: Validate connection.host with isPrivateIp at write time and pass redirect: 'manual' in proxyToPrometheus.
    • security
🔵 P3 nitpicks (17)
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:111computeExemplarPoints runs over the 30s-quantized over-fetch window before clampExemplarX trims to the rendered domain, so for any granularity under 30s part of the marker budget is spent on buckets that are then dropped, concentrating the loss at both chart edges.
    • Fix: Filter exemplars through the clampExemplarX window test before calling computeExemplarPoints.
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:162 — Unpinning is keyed on crossing an absolute 30s quantum boundary rather than the pin's own age, so a click landing late in a quantum closes the card a few hundred milliseconds later while an identical click early in one keeps it for 30s.
    • Fix: Unpin on a change to the rendered x-domain rather than on the quantized range key.
  • packages/app/src/components/Exemplars/exemplarPoints.ts:233clampExemplarY pins an out-of-domain value to the ceiling and clampExemplarX nudges to the domain edge, but ExemplarDot draws one fixed diamond, so a 30s outlier is indistinguishable from a marker sitting exactly on the p95 peak until hovered.
    • Fix: Return a clamped flag alongside the coordinate and render a distinct marker shape for it.
  • packages/app/src/components/Exemplars/exemplarPoints.ts:67!Number.isFinite(opts.maxExemplars) routes a non-numeric value to the unlimited branch, so a maxExemplars that round-trips through storage as a string renders the whole capped set instead of the configured budget.
    • Fix: Coerce and validate maxExemplars to an integer at the team-settings boundary before it reaches the thinning helper.
  • packages/api/src/routers/api/prometheus.ts:622resolveExemplarWindow parses and returns both bounds but only start is rewritten when proxying, so a caller's end reaches Prometheus in whatever format it arrived in after being validated in a different unit system.
    • Fix: Forward end: String(window.end) alongside the rewritten start.
  • packages/api/src/routers/api/prometheus.ts:633getConnectionById is an unguarded Mongoose query inside the shared try, so a datastore outage is reported to the client as errorType: 'bad_data' with a 400, indistinguishable from a malformed expression.
    • Fix: Classify thrown errors and return a 5xx for connection-store failures.
  • packages/app/src/hooks/useExemplars/useExemplars.tsx:177retry: 1 is a bare count with no error-type check, so a permanently invalid request that the API answers with 400 bad_data is retried once on every live-tail re-key.
    • Fix: Replace the count with a retry predicate that skips 4xx responses.
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:127 — The sibling drill-down tooltip installs a capture-phase document mousedown listener precisely because the chart's stopPropagation defeats Mantine's outside-click handling, but no equivalent exists for a pinned exemplar card, so it survives a click anywhere off the chart.
    • Fix: Register the same capture-phase listener while pinnedExemplar is set, ignoring mousedowns inside the card.
  • packages/app/src/components/Exemplars/exemplarPoints.ts:126 — A comment recording a known algorithmic gap begins with a non-standard marker that matches no convention in AGENTS.md or agent_docs/code_style.md, so the deferred work is invisible to any TODO search.
    • Fix: Reword the marker to TODO: so the caveat is discoverable.
  • packages/app/src/hooks/useExemplars/useExemplars.tsx:109 — The comment points at "the note on quantizeStart above", but quantizeStart is only imported here; the note it refers to lives in quantize.ts.
    • Fix: Retarget the pointer to quantize.ts or inline the one-line rationale.
  • packages/app/src/HDXMultiSeriesTimeChart/chartData.ts:5 — A three-line comment describing RESPONSIVE_CONTAINER_DEBOUNCE_MS was left behind in chartData.ts where that constant does not exist, and the copy in MemoChart.tsx:58 now begins mid-sentence with "it the observer fires".
    • Fix: Delete the orphaned fragment and restore the missing opening sentence in MemoChart.tsx.
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:9promqlSeriesLabels.ts is pure PromQL string analysis with no UI, lives under components/Exemplars/, is absent from that directory's barrel, and is reached by a deep file import from hooks/useExemplars/, contradicting the barrel's stated purpose of keeping the internal layout free to change.
    • Fix: Move promqlSeriesLabels.ts into hooks/useExemplars/ and update the import.
  • packages/app/src/hooks/useExemplars/useExemplars.tsx:197enabled is already defined as wantsExemplars && !tooManySeries, so the enabled && !tooManySeries guard implies a state that cannot occur.
    • Fix: Reduce the condition to enabled.
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:202mapClickhouseExemplars(rows: Record<string, any>[]) and the matching resp.json<Record<string, any>>() disable checking across the whole body even though every field is only passed through String(), Number(), or a truthiness test.
    • Fix: Widen both to Record<string, unknown>.
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:60resp.json<ExemplarTraceMeta>() asserts the row shape with no runtime check, unlike the sibling normalizers that parse through ExemplarSchema, so a non-numeric durationMs column reaches the card as Number(...)NaN and renders "NaN ms".
    • Fix: Parse the row through a small Zod schema instead of asserting it via the generic parameter.
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:99if (!data) return rejects only null and undefined, so a truthy non-array data (or a scalar series.exemplars) makes the for…of throw a TypeError rather than degrading to an empty overlay, on a body the API only type-asserts.
    • Fix: Guard with Array.isArray on data and on each series.exemplars before iterating.
  • packages/api/src/routers/api/prometheus.ts:218 — The 502 and 504 bodies interpolate the full url.toString(), which preserves any user:pass@ userinfo embedded in connection.host, and useExemplars surfaces that message verbatim in the chart UI.
    • Fix: Report only url.origin in the proxy error strings.

Reviewers (11): correctness, security, adversarial, testing, maintainability, project-standards, reliability, performance, api-contract, kieran-typescript, julik-frontend-races.

Testing gaps:

  • No test for useExemplarTraceMeta's default-expression fallbacks or its root-span-preferring ORDER BY (parentExpr = '') DESC.
  • No test that useChartScales skips the visibleSeriesMax scan when hasExemplars is false, nor that MemoChart omits the recharts Tooltip while a marker is hovered or pinned.
  • No direct unit test for quantizeStart/quantizeEnd boundary arithmetic; it is only exercised indirectly through the placeholder-scoping cases.
  • collapsesHistogramBuckets and isPromqlExemplarEligible have no shared table-driven test asserting they never disagree about the same expression.
  • No test characterises capExemplarsPerBucket or normalizePrometheusExemplars against a very large upstream payload, which is the only case the missing server-side limit exposes.

Reviewer coverage caveat: Bash/git, Grep, Glob, and WebFetch were all unavailable in this environment, so no diff could be computed. Reviewers worked from the HEAD state of the exemplar surface located by path probing; findings in code shared with the pre-existing Prometheus proxy are labelled as such, but new-vs-existing attribution is less precise than a diff-scoped review, and the agent-native and learnings-researcher passes were skipped as they require repo-wide search.

@hyperdxio hyperdxio deleted a comment from github-actions Bot Aug 4, 2026
One P1 and six P2s. Each was checked against real code before being fixed.

The P1 is pre-existing on main (973d120), not from this branch:
proxyToPrometheus forwarded the upstream content-type with no nosniff, so a
member-configured connection host returning text/html would render as script on
our origin — /api/* is same-origin-proxied and the session cookie is sameSite
lax. This PR adds a GET route to that proxy, which widens the exposure, so it
is fixed here. nosniff always, and the content-type passes through only for a
JSON media type.

The P2s:

- The window split gated on ceil(budget * 0.75) rather than the budget, so 12
  populated buckets at the default budget of 12 drew 9 markers and left three
  bare.
- collapsesHistogramBuckets tested a literal `histogram_quantile(` while
  isPromqlExemplarEligible allows whitespace before the paren. The two
  disagreed about `histogram_quantile (0.95, ...)`: the toggle allowed it, `le`
  stayed in the group key, and the overlay came back suppressed telling the user
  to aggregate to a single line they already had.
- The hover card read "Trace not found in source" for three different states: a
  real miss, a failed query, and a source that is not a Trace kind. The last two
  now get their own message.
- Switching display type swaps AreaChart for BarChart, a different element type,
  so the whole subtree remounts and every marker unmounts without a mouseleave.
  An open card was left over markers that no longer exist. Cleared on the switch.
- A marker sliding out from under a stationary cursor on a live-tail tick also
  fires no mouseleave, leaving the card at stale coordinates and the series
  tooltip suppressed. The range-change effect now clears hover as well as pin.
- Markers dropped by the render-layer clamps were invisible to the fetch-layer
  notice, so the overlay could thin out with nothing on screen explaining why.
  The count is reported up to the toolbar.

Two of these were flagged by my own review last round and filed as residual
risks instead of being fixed — the legend-isolation y floor and the missing drop
count. They came back as findings, which is the argument against that
disposition.

On the y floor I took the review's second option (surface the drop) rather than
its first (fit the floor only under fitYAxisToData). The first changes y-axis
behaviour for every chart in the app, which is wider than this PR should reach.

make ci-lint and make ci-unit pass (5300 tests). Dashboard E2E run three times,
0 failed each time; the flake count moved 1/5/3 with different tests each run,
all in listing-page specs that draw no chart. The new integration tests for the
header hardening are typechecked but not executed — they need Docker.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

<!-- deep-review -->

Deep Review

🔴 P0/P1 — must fix

  • packages/app/src/components/Exemplars/ExemplarHoverCard.tsx:88 — The hover card is a pointer-events-live absolutely-positioned sibling of the chart, so a drag-to-zoom whose path crosses a marker mounts the card 12px to the right, the cursor then enters it, and the chart's onMouseLeave clears highlightStart/mouseDownPosRef — silently cancelling the zoom.
    • Fix: Render the unpinned card with pointer-events: none and only enable pointer events once it is pinned, or suppress onExemplarHover while a brush drag is in progress.
    • adversarial

🟡 P2 — recommended

  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:86'traceSourceId' in source is never true for SourceKind.Metric or SourceKind.Promql (neither MetricSourceSchema nor PromqlSourceSchema declares that field, and zod strips unknown keys), so the documented fallback is dead code and a chart without an explicit exemplarTraceSourceId can never resolve trace metadata.
    • Fix: Resolve the fallback by hopping through the metric source's logSourceId to that log source's traceSourceId, or drop the fallback and require exemplarTraceSourceId.
    • correctness
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:134 — Thinning runs before clamping and the 30s fetch quantum can place exemplars up to 45s past the rendered x-domain at 15-second granularity, so marker-budget slots are spent on points that clampExemplarX then drops, permanently raising a notice that blames a fitted y-axis floor on a correctly configured chart.
    • Fix: Trim exemplars to the drawn x-domain before thinning, and exclude quantization-surplus drops from the count reported through onExemplarsDropped.
    • correctness, adversarial
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:50 — The per-hover trace lookup filters only on TraceId with no time predicate and sets no retry, so each hovered marker can trigger a full trace-table scan retried three times with backoff before the card can report failure.
    • Fix: Pass the exemplar's timestamp in and add a bounded timestampValueExpression BETWEEN predicate, and set retry: 1 to match the sibling hook.
    • adversarial, correctness, performance, reliability
  • packages/app/src/components/Exemplars/exemplarPoints.ts:270 — The one-bucket tolerance is applied to the lower bound as well as the upper one, so exemplars fetched by quantizeStart's widened window are snapped forward onto the first plotted bucket and drawn at a time they did not occur, without being counted as dropped.
    • Fix: Make the tolerance one-sided — if (x < min || x > max + tolerance) return null; — since only the end-exclusive upper bound needs it.
    • adversarial
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:106 — The full Prometheus body is materialised and run through one ExemplarSchema.safeParse per exemplar on the UI thread before any cap applies, and is discarded wholesale when the response spans multiple series.
    • Fix: Decide the multiple-series drop from seriesLabels before parsing any exemplar, and short-circuit once the parsed count exceeds a hard multiple of EXEMPLAR_QUERY_LIMIT.
    • adversarial, reliability
  • packages/api/src/routers/api/prometheus.ts:646 — A PromQL chart on a ClickHouse-backed connection passes every client gate and receives {status:'success', data: []}, so the toggle is on, no markers appear, no notice explains it, and a proxy round-trip is paid per quantised window.
    • Fix: Return a distinguishable marker such as unsupported: true for the non-Prometheus branch and surface it as an exemplar notice, or gate the editor toggle on connection.isPrometheusEndpoint.
    • correctness, adversarial
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:36 — Database and table names from the source document are hand-quoted with backticks and spliced into the query, unlike every sibling renderer which binds them as {Identifier} parameters, so a name containing a backtick terminates the quoted identifier.
    • Fix: Build the FROM clause through the parameterized { Identifier: ... } path that renderFrom uses instead of string concatenation.
    • security
  • packages/api/src/routers/api/prometheus.ts:633 — The new route adds another entry point into proxyToPrometheus, which fetches a member-writable connection.host server-side with no scheme or address validation and default redirect-following, then streams the body back to the caller.
    • Fix: Harden proxyToPrometheus once for all four callers by rejecting non-http(s) schemes, blocking loopback/link-local/RFC1918 targets, and setting redirect: 'manual'.
    • security
  • packages/api/src/routers/api/prometheus.ts:647 — The handler's catch maps every thrown error to HTTP 400 bad_data and increments prometheusQueryErrors unconditionally, so a Mongo failure in getConnectionById reads as a client mistake while a malformed timestamp pollutes the counter that recordProxyOutcome deliberately keeps 5xx-only.
    • Fix: Return 5xx for errors that are not recognised input-validation failures, and restrict the counter increment to those.
    • reliability
  • packages/app/src/components/Exemplars/ExemplarDot.tsx:51 — The marker hard-codes hex fallbacks and reuses --color-text-default for its stroke, which agent_docs/data_viz_colors.md explicitly forbids in chart components ("No new hex strings in chart components").
    • Fix: Source the fill from getChartColorWarning() and the outline from a chart border token rather than a text token or a literal hex.
    • project-standards
  • packages/app/src/components/Exemplars/ExemplarDot.tsx:37 — The marker <g> carries only onMouseEnter/onMouseLeave/onClick with no tabIndex, role, aria-label, or key handler, and the card only appears on marker hover, so the entire exemplar-to-trace path is unreachable without a pointer.
    • Fix: Give the marker a focusable role with an accessible label and open the card on focus and Enter/Space.
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:9 — The hooks layer imports labelDistinguishesSeries/promqlSeriesLabelRule directly from @/components/Exemplars/promqlSeriesLabels, which components/Exemplars/index.ts does not re-export despite documenting itself as the folder's public surface.
    • Fix: Move promqlSeriesLabels.ts into hooks/useExemplars/ since it is pure string logic, or re-export it from the barrel.
    • maintainability
  • packages/api/src/mcp/tools/dashboards/schemas.ts:537enableExemplars and exemplarTraceSourceId are settable through the MCP dashboard tools, but clickstack_query_tile and clickstack_timeseries only run the tile's main series query, so an agent can enable the overlay and never read back whether markers exist or what traces they point at.
    • Fix: Extend the tile/timeseries query tools to run the exemplar query and return the resulting Exemplar[].
    • agent-native
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:57 — Neither this hook nor packages/app/src/components/DBTimeChart/useExemplarCard.ts has any test, leaving the clamp wiring, drop-count reporting, hover/pin reset-on-disappear, post-zoom click swallow, Escape-to-close, and range-quantized unpin entirely unverified while DBTimeChart.test.tsx mocks the data hooks away.
    • Fix: Add renderHook tests for both hooks covering the reset guards, the drop-count effect, the click-swallow branch, and the pin/hover precedence rules.
    • testing, typescript
  • packages/api/src/routers/api/prometheus.ts:570packages/api/src/routers/api/__tests__/prometheus.test.ts covers only the pure helpers, so the new handler's branch dispatch, missing-param 400s, connection-not-found 404, unauthenticated rejection, and the 502/504 proxy paths have no route-level coverage.
    • Fix: Add supertest cases for the Prometheus-proxy and ClickHouse branches plus the 400/404/502/504 paths.
    • testing, security, api-contract
🔵 P3 nitpicks (12)
  • packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx:113 — The maxExemplars prop default re-hardcodes 12 instead of importing DEFAULT_MAX_EXEMPLARS from @/defaults.
    • Fix: Import DEFAULT_MAX_EXEMPLARS and use it as the prop default.
  • packages/common-utils/src/types.ts:1232 — The enableExemplars and ExemplarSchema comments state exemplars are "generated per time bucket for trace sources", but EXEMPLAR_SUPPORTED_KINDS only contains Metric and Promql.
    • Fix: Reword both comments to say trace-source generation is not yet implemented.
  • packages/common-utils/src/types.ts:1397ExemplarSchema.attributes is never populated by either normalizer nor read by any consumer.
    • Fix: Remove the field until a producer and consumer exist.
  • packages/common-utils/src/types.ts:1721maxExemplars accepts up to 1000 while EXEMPLAR_QUERY_LIMIT = 200 bounds the fetched set, so any value above 200 behaves identically and 0 means "unlimited up to 200".
    • Fix: Cap the setting at EXEMPLAR_QUERY_LIMIT and reword the "0 = unlimited" comment.
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:267placeholderData keeps the previous range's exemplars across a zoom, all of which fail clampExemplarX, so every zoom briefly raises the "fall outside the chart's plotted range" notice with text blaming a y-axis floor the user never set.
    • Fix: Suppress the clamp notice while the exemplar query is fetching a new key.
  • packages/app/src/hooks/useExemplars/useExemplars.tsx:76isPromqlExemplarEligible rejects histogram_quantile(...) * 1000, the standard seconds-to-milliseconds idiom, and the overlay then silently does nothing with no notice.
    • Fix: Return a distinct dropped reason when enableExemplars is set but promqlEligible is false.
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:171 — The unpin guard compares a 30s-quantised range key, so a zoom entirely inside one quantum rescales every marker while leaving the pinned card at its stale click-time coordinates.
    • Fix: Compare the rendered x-domain rather than the quantised fetch window.
  • packages/app/src/components/Exemplars/exemplarPoints.ts:151 — The maxExemplars <= 0 branch dedupes points by traceId + timestamp but the thinned branch does not, so two exemplars sharing both can reach recharts with the same React key and the same hover/pin identity.
    • Fix: Move the dedupe pass to just after points is built so both branches share it.
  • packages/app/src/api.ts:569 — When an upstream error body is not JSON, e.response.json() throws a SyntaxError whose message differs from the HTTPError's, so the guard rethrows it and the real HTTP status is replaced by Unexpected token '<' in the exemplar notice.
    • Fix: Track whether the throw came from new Error(body.error) and otherwise fall through to throw e.
    • correctness, reliability
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:28 — The query key carries only traceId and traceSource?.id, while the SQL is built from six source expressions plus from, so editing a source's field mappings serves a stale row for up to the 5-minute staleTime.
    • Fix: Include the source fields the query depends on in the key.
    • typescript
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:65Number(row.durationMs) on a non-numeric duration expression yields NaN, which still passes the != null check and renders as "Duration: NaN ms".
    • Fix: Drop durationMs when Number.isFinite is false.
    • correctness, typescript
  • telemetry-generator/src/index.js:12 — The header comment says exemplars are written to ClickHouse directly "because ... our collector has no spanmetrics connector", contradicting the README and the file's own comment at emitTrace.
    • Fix: Rewrite the header to describe the connector-derived flow.
    • collector-infra

Reviewers (12): correctness, security, adversarial, reliability, performance, testing, maintainability, project-standards, api-contract, typescript, agent-native, collector-infra.

Testing gaps:

  • No test asserts that clamp-dropped markers do not consume the maxExemplars budget, nor that the quantizeStart/quantizeEnd surplus is excluded from droppedCount.
  • clampExemplarX covers the upper-bound tolerance but has no case asserting a marker between min - bucketSeconds and min is dropped.
  • No test pins the SQL renderer's AS timestamp / value / traceId / spanId aliases, so the cross-package contract with mapClickhouseExemplars could break silently; mapClickhouseExemplars's reject branch is also untested while its Prometheus twin is covered.
  • packages/app/src/hooks/useExemplars/quantize.ts has no test despite being split out to stay testable.
  • No test covers the persisted-config matrix: enableExemplars: true with the feature flag off, with an ineligible PromQL expression, and on a ClickHouse-backed PromQL connection.
  • The collector's ENABLE_SPAN_METRICS gate is unit-tested against the generated JSON but never against a live collector startup.

Coverage note: git, grep, and glob were unavailable in this environment (the shell fails with a bwrap sandbox error), so the diff itself could not be read; reviewers worked from the current on-disk state of the exemplar feature. The learnings-researcher and previous-comments reviewers were skipped for the same reason. Three claims were investigated and cleared rather than reported: the metric-name predicate is correctly ANDed outside an OR filter group (renderWhere parenthesizes the group, and renderChartConfig.test.ts:3913 pins it); toUnixTimestamp64Milli returns epoch milliseconds so mapClickhouseExemplars parses correctly; and the maxExemplars team setting is wired through DBTimeChart.tsx:535.

…tted reports

The P1 from the latest review: the hover card is a pointer-live absolutely
positioned sibling of the chart, so a drag-to-zoom whose path crossed a marker
mounted the card under the moving cursor. The cursor entered the card, the chart
saw mouseleave and cleared highlightStart and mouseDownPosRef, and the zoom was
cancelled halfway through. Exemplar hover is now suppressed while a brush drag
is in progress. The alternative — pointer-events: none on the unpinned card —
was not taken because it also stops the cursor travelling into the card to press
Inspect, which is what the close delay exists for.

Also removes packages/common-utils/reports/ from the repo and gitignores it. I
committed 46,985 lines of generated Stryker mutation-test output in d93f0f8 by
running `git add -A packages` while that untracked directory was present. It is
not on main and was never meant to be here; it was most of this PR's apparent
size. The ignore is scoped to packages/*/reports/ rather than a bare reports/,
which would have hidden real directories elsewhere in the tree.

make ci-lint and make ci-unit pass.
@jordan-simonovski

Copy link
Copy Markdown
Contributor Author

Latest P0/P1 fixed in 178c980 (hover card was cancelling a brush-zoom by mounting under the moving cursor).

Also removed packages/common-utils/reports/ — I'd accidentally committed 46,985 lines of generated Stryker output in d93f0f8 via git add -A packages. Real diff is 8.5k insertions, not 55k.

Outstanding P2/P3s tracked in #2804 rather than another round here. The overlay is behind NEXT_PUBLIC_ENABLE_EXEMPLARS (off by default) and per-chart enableExemplars, so none of it is reachable in a default deployment.

@jordan-simonovski

Copy link
Copy Markdown
Contributor Author

Closing this in favour of a stack of smaller PRs. Nothing is lost — the
feat/exemplars branch stays put and is the source the stack is carved from,
and every review comment here remains readable.

Why

This PR reached 85 files. It went through four rounds of deep review, each
returning roughly one P0/P1 and about fifteen P2s — not a growing defect count,
but a full re-review of the whole surface every time. Reviewing a feature, a
1,500-line mechanical refactor, a collector build change and an API route in one
diff is what made each round that expensive.

Two packaging errors are worth naming, both mine:

  • The collector and OpAMP wiring has its own rollout ordering (a bad component
    type leaves the collector with no usable config and stops ingestion). A round-1
    review said it should not share a revert unit with a flag-gated UI overlay. It
    should have shipped first, on its own.
  • I committed 46,985 lines of generated Stryker mutation output via
    git add -A packages. The PR read as 55k insertions; the real change is 8.5k.
    Removed in 178c980, but it made the diff look far worse than it was for most
    of this PR's life.

The replacement stack

Each targets the one before it where there is a real dependency, and each builds
and passes CI on its own.

  1. collector spanmetrics + OpAMP wiring (~235 lines) — independent, ships
    first for rollout ordering.
  2. /query_exemplars route + proxy hardening — self-contained; the route has
    no real dependency on the shared exemplar code.
  3. chart file refactor — splitting DBTimeChart.tsx and
    HDXMultiSeriesTimeChart.tsx into directories, zero behaviour change.
  4. exemplar overlay — the feature. The common-utils primitives ship with the
    app here rather than separately: every one of those exports is consumed only by
    the app, so landing them alone would fail the unused-export check.
  5. agent-facing schema fields — external v2 and MCP tile schemas.

Outstanding P2s and P3s are tracked in #2804 rather than being re-litigated each
round. The overlay is behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and
per-chart behind enableExemplars.

I will link each PR here as it goes up.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Deep Review

No critical issues found. This is unusually defensive code: both ingest boundaries (normalizePrometheusExemplars, mapClickhouseExemplars) parse through ExemplarSchema rather than coercing, the exemplar SQL binds its time bounds and ANDs the metric-name predicate outside the user filter group, renderMetricExemplarsChartConfig refuses a null dateRange so it can never become a whole-table ARRAY JOIN, and the new /v1/prometheus/query_exemplars route is team-scoped, timeout-bounded, streamed, and nosniff-hardened identically to its siblings. Verified clean: authz/IDOR, SSRF, SQL injection, XSS, and marker-count bounds (EXEMPLAR_QUERY_LIMIT caps both backends at ~200 ReferenceDots even with maxExemplars = 0).

🟡 P2 -- recommended

  • packages/api/src/opamp/controllers/opampController.ts:417 -- The operator-supplied remote-write endpoint is inlined verbatim into the generated collector config and validated only for truthiness, unlike the sibling exporter at line 349 which uses ${env:...} indirection resolved inside the collector container.
    • Fix: Emit the endpoint as an ${env:...} reference resolved in the collector container, or strip userinfo and query string and validate it as an http(s) URL at config load, failing closed when it is malformed.
    • security, reliability
  • packages/api/src/opamp/controllers/opampController.ts:385 -- Enabling ENABLE_SPAN_METRICS broadcasts a config referencing the spanmetrics connector to every connected agent with no version or capability check, and because docker/otel-collector/config.yaml declares no pipelines of its own, an agent whose binary lacks the connector rejects the whole config and stops ingesting all signals.
    • Fix: Gate the connector on the agent's reported service_version from the OpAMP AgentDescription so older binaries keep receiving the previous config shape.
  • packages/app/src/components/Exemplars/ExemplarDot.tsx:55 -- var(--color-text-default, #1a1a1a) names a token that exists in neither brand's _tokens.scss nor semanticColorsGrouped.ts, so the stroke always resolves to the literal dark hex and the marker outline is low-contrast in dark mode, the opposite of what the adjacent comment states.
    • Fix: Replace with an existing semantic token such as var(--color-text) and drop the hex fallback.
  • packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts:50 -- openExemplarCard fires synchronously on mouseenter with no debounce and the query key is the bare traceId, so a cursor sweeping a cluster of markers issues one fresh ClickHouse query per distinct marker, each with no timestamp predicate to narrow the TraceId lookup.
    • Fix: Debounce hover before enabling the query and bound the lookup by exemplar.timestamp ± a window so it can use the table's time ordering.
  • packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx:168 -- The effect that clears a stale enableExemplars when a chart leaves exemplar-compatible shape, and the canShowExemplars visibility gate, have no test file at all, so the guard ordering around the async tableSource is unpinned.
    • Fix: Add a ChartEditorControls test covering toggle visibility under the deployment flag and shape rules, and that the flag clears only after tableSource resolves while exemplarTraceSourceId is preserved.
  • packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts:95 -- The brushOriginRef and suppressNextClickRef guards have no test anywhere; all three DBTimeChart exemplar suites replace the chart with jest.mock('@/HDXMultiSeriesTimeChart'), and this exact interaction already regressed once on this branch.
    • Fix: Add a renderHook test for useExemplarMarkers covering click suppression after a brush-zoom, hover suppression mid-drag, and the hover/pin reset effects when a marker leaves the rendered set.
  • telemetry-generator/package.json:1 -- The new top-level directory sits outside the root workspaces: ["packages/*"] glob and is listed in knip.json's ignore set, so it is covered by neither lint, typecheck, nor unit CI, and its Dockerfile installs dependencies with npm install --no-package-lock.
    • Fix: Either move it under packages/ as a workspace member or document it in AGENTS.md as an intentional non-workspace dev tool and pin its dependencies with a lockfile.
    • project-standards, maintainability
  • packages/api/src/mcp/tools/query/helpers.ts:579 -- enableExemplars and exemplarTraceSourceId are writable through both the external v2 dashboards API and the MCP dashboard tools, but no endpoint or tool ever calls renderMetricExemplarsChartConfig, so an agent can turn the overlay on yet never read the markers or the trace ids it produces.
    • Fix: Add a query tool or endpoint that runs the same exemplar render path the browser hook uses and returns the resulting Exemplar[].
🔵 P3 nitpicks (10)
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:163 -- The if (!pinnedExemplar) return; guard short-circuits before setHoveredExemplar(null), so a range change with only a hover card open leaves it at stale coordinates and keeps the series tooltip suppressed, contradicting the effect's own comment.
    • Fix: Clear hoveredExemplar on any rangeKey change rather than only inside the pinned branch.
  • packages/app/src/components/DBTimeChart/useExemplarCard.ts:191 -- clampDroppedCount is only ever written by MemoChart, which unmounts when graphResults empties, while toolbarItemsMemo renders unconditionally — so the "markers fall outside the plotted range" notice persists beside "No data found within time range".
    • Fix: Reset the count to zero when the marker layer is not mounted.
  • packages/app/src/hooks/useExemplars/quantize.ts:13 -- The 30s key quantum can push the fetched window up to 30s past the chart's end, but clampExemplarX's tolerance is a single granularity, so at 15s granularity genuinely-fetched exemplars are dropped and counted into the spurious out-of-range warning.
    • Fix: Make the clamp tolerance at least the key quantum, or scale the quantum down to the granularity when it is finer than 30s.
  • packages/app/src/hooks/useExemplars/exemplarNormalize.ts:106 -- data and series.exemplars are trusted from a type assertion rather than a runtime check, so a non-array payload throws a TypeError inside queryFn and surfaces as an opaque fetch error instead of an empty overlay.
    • Fix: Guard both with Array.isArray before iterating.
  • packages/app/src/components/Exemplars/ExemplarHoverCard.tsx:153 -- Number(row.durationMs) is never NaN-checked, and meta.durationMs != null passes for NaN, so a non-numeric duration column renders literally as "Duration: NaN ms".
    • Fix: Assign durationMs only when Number.isFinite holds on the coerced value.
  • packages/app/src/components/Exemplars/ExemplarDot.tsx:51 -- The #f5a623 fallback on --color-chart-warning adds a hex literal to a chart component even though the token is defined in both brands.
    • Fix: Drop the fallback and use the bare var(--color-chart-warning).
  • packages/app/src/components/Exemplars/ExemplarDot.stories.tsx:36 -- var(--color-bg-default) is not a defined token, so the story canvas renders with no background, defeating its purpose as the light/dark review surface; ExemplarHoverCard.stories.tsx:46 has the same value.
    • Fix: Use var(--color-bg-surface) in both story files.
  • packages/api/src/opamp/controllers/opampController.ts:418 -- tls: { insecure: true } is set unconditionally for an endpoint that may be any public https:// URL, disabling certificate verification for the metrics stream.
    • Fix: Emit the tls block only for plaintext or loopback endpoints and let the collector verify certificates otherwise.
  • packages/api/src/routers/api/prometheus.ts:558 -- resolveExemplarWindow silently narrows any window wider than seven days and the response carries no indication of the effective range, so a 30-day chart shows only trailing-week markers with no signal.
    • Fix: Return the applied window in the response or surface a notice when the requested range was narrowed.
  • packages/app/src/config.ts:71 -- IS_EXEMPLARS_ENABLED is a client-bundle-only value with no server-side counterpart, so an API or MCP caller that sets enableExemplars cannot discover that the overlay is disabled deployment-wide; maxExemplars is likewise reachable only through the session-authenticated team route.
    • Fix: Surface an exemplarsEnabled boolean alongside the existing feature flags on the me payload and expose maxExemplars on the external team API.

Reviewers (10): correctness, security, testing, maintainability, project-standards, api-contract, reliability, performance, kieran-typescript, agent-native. An adversarial reviewer was also dispatched but did not return before synthesis; its focus areas (unbounded marker counts, feature-flag bypass, stale persisted config, hover/pin/zoom races, malformed upstream shapes) were verified directly against the code instead.

Testing gaps:

  • No test proves the overlay is fully inert with the deployment flag off — the fetch gate is covered, but the editor toggles have no test file and MemoChart's marker rendering is never rendered for real.
  • useExemplarMarkers, useChartScales' visibleSeriesMax, useExemplarCard's hover/pin machine, quantize.ts, and useExemplarTraceMeta have no direct tests; the latter is stubbed in every suite that touches it.
  • No test asserts /v1/prometheus/query_exemplars rejects a connectionId from another team, nor covers resolveExemplarWindow's seven-day boundary through the Express route.
  • No test covers buildOtelCollectorConfig with a credentialed or malformed remote-write endpoint.
  • Note: the proxy's nosniff/content-type sanitization is covered on the exemplars path, and the connector's off-path omission is asserted — both were checked and are not gaps.

jordan-simonovski added a commit that referenced this pull request Aug 5, 2026
Time charts on metric and PromQL sources can overlay exemplars — individual
trace-linked points — behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and
per-chart behind enableExemplars. Hovering a marker shows the exemplar's own
value and time plus trace metadata, with a button to open the trace.

The shared common-utils primitives ship here rather than separately: every one of
those exports is consumed only by the app, so landing them alone would fail the
unused-export check.

The rule the whole feature turns on is that a marker sits at the trace's own
measurement on the chart's shared axis, so it is only honest when the chart draws
one line in the same unit. That is enforced in four places: a single non-ratio
histogram series with no group by; an aggregation that leaves the axis on the
observation scale (a count of observations is not a duration); for PromQL an
expression that plots a duration, with the duration call spanning the whole
expression; and a rendered-series count taken from the main query rather than the
exemplar response, since Prometheus only returns series that carry a sampled
exemplar and so cannot say how many lines are drawn.

Where a marker cannot be drawn honestly it is dropped, not moved. Out of the
rendered window by more than one bucket, or below a fitted y-axis floor, and it
does not render — with the count surfaced on the chart, because a silently
thinning overlay is worse than an explained one. The exception is above the
ceiling, where pinning reads as "at least this high" and the card carries the
real number.

Includes the four rounds of review fixes from #2536: the hover card showing value
and time (both clamps cite it as their justification), the PromQL aggregation
parsing that decides series identity, the ClickHouse scan's ex_TimeUnix bound,
the query-key quantisation, and the card lifecycle across zoom, live tail and
display-type switches.

make ci-lint and make ci-unit pass (5350 tests). Dashboard E2E 80 passed, 0
failed, 2 unrelated flakes on listing-page specs.

Stacked on the chart-file refactor so this reads as a feature diff.
jordan-simonovski added a commit that referenced this pull request Aug 5, 2026
Time charts on metric and PromQL sources can overlay exemplars — individual
trace-linked points — behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and
per-chart behind enableExemplars. Hovering a marker shows the exemplar's own
value and time plus trace metadata, with a button to open the trace.

The shared common-utils primitives ship here rather than separately: every one of
those exports is consumed only by the app, so landing them alone would fail the
unused-export check.

The rule the whole feature turns on is that a marker sits at the trace's own
measurement on the chart's shared axis, so it is only honest when the chart draws
one line in the same unit. That is enforced in four places: a single non-ratio
histogram series with no group by; an aggregation that leaves the axis on the
observation scale (a count of observations is not a duration); for PromQL an
expression that plots a duration, with the duration call spanning the whole
expression; and a rendered-series count taken from the main query rather than the
exemplar response, since Prometheus only returns series that carry a sampled
exemplar and so cannot say how many lines are drawn.

Where a marker cannot be drawn honestly it is dropped, not moved. Out of the
rendered window by more than one bucket, or below a fitted y-axis floor, and it
does not render — with the count surfaced on the chart, because a silently
thinning overlay is worse than an explained one. The exception is above the
ceiling, where pinning reads as "at least this high" and the card carries the
real number.

Includes the four rounds of review fixes from #2536: the hover card showing value
and time (both clamps cite it as their justification), the PromQL aggregation
parsing that decides series identity, the ClickHouse scan's ex_TimeUnix bound,
the query-key quantisation, and the card lifecycle across zoom, live tail and
display-type switches.

make ci-lint and make ci-unit pass (5350 tests). Dashboard E2E 80 passed, 0
failed, 2 unrelated flakes on listing-page specs.

Stacked on the chart-file refactor so this reads as a feature diff.
jordan-simonovski added a commit that referenced this pull request Aug 6, 2026
  HDXMultiSeriesTimeChart.tsx  1503 -> 720 (+ 8 files)
  DBTimeChart.tsx              1022 -> 469 (+ 6 files)

Each becomes a directory with a barrel, so every import path and every
jest.mock('@/...') keeps resolving and the change is invisible to consumers.

The seams follow what the code already separated rather than cutting by length:

- searchUrl.ts takes buildSearchUrl out of a useCallback as a pure function,
  which makes its branching testable for the first time — which value column a
  series key resolves to, and whether that column's aggregation is attributable
  to individual events at all. A non-attributable aggregation must not produce a
  value filter, or drill-down returns rows that never contributed to the clicked
  point. Nine new tests cover it; that is the only new test surface here.
- useChartScales holds the axis domains and the annotation elements. Pure
  derivation from props, no state, no recharts tree.
- The tooltip, legend, recharts shape shims, layout constants, cross-chart pin
  registry and pure data helpers each move to a file named after what they are.

MemoChart stays at 720 because what is left is one recharts element whose
children must remain siblings, plus interaction state that genuinely shares a
click-suppression flag with the brush-zoom. Splitting that tree would trade a
real risk of dropping a render branch for a smaller number.

Two deliberate changes beyond pure movement, both small:

- xAxisDomain is typed as the [number, number] tuple it already returned rather
  than the wider AxisDomain, which removes two unsafe type assertions at its
  consumers.
- dismissPinned and buildSearchUrl became block-bodied and a thin delegate
  respectively. Same behaviour.

Verified by diffing every non-import line of the two originals against the new
directories: the only lines that do not appear are import fragments that were
redistributed and the two changes above.

make ci-lint, make ci-unit (5205 tests) and the dashboard E2E suite (81 passed)
all pass. E2E matters most here — it is the only check that catches a
runtime-only breakage from moving code.

First of the pieces split out of #2536. No feature code: this lands ahead of the
exemplars overlay so that PR is a feature diff rather than a feature plus a
1,500-line move.
jordan-simonovski added a commit that referenced this pull request Aug 6, 2026
  HDXMultiSeriesTimeChart.tsx  1503 -> 720 (+ 8 files)
  DBTimeChart.tsx              1022 -> 469 (+ 6 files)

Each becomes a directory with a barrel, so every import path and every
jest.mock('@/...') keeps resolving and the change is invisible to consumers.

The seams follow what the code already separated rather than cutting by length:

- searchUrl.ts takes buildSearchUrl out of a useCallback as a pure function,
  which makes its branching testable for the first time — which value column a
  series key resolves to, and whether that column's aggregation is attributable
  to individual events at all. A non-attributable aggregation must not produce a
  value filter, or drill-down returns rows that never contributed to the clicked
  point. Nine new tests cover it; that is the only new test surface here.
- useChartScales holds the axis domains and the annotation elements. Pure
  derivation from props, no state, no recharts tree.
- The tooltip, legend, recharts shape shims, layout constants, cross-chart pin
  registry and pure data helpers each move to a file named after what they are.

MemoChart stays at 720 because what is left is one recharts element whose
children must remain siblings, plus interaction state that genuinely shares a
click-suppression flag with the brush-zoom. Splitting that tree would trade a
real risk of dropping a render branch for a smaller number.

Two deliberate changes beyond pure movement, both small:

- xAxisDomain is typed as the [number, number] tuple it already returned rather
  than the wider AxisDomain, which removes two unsafe type assertions at its
  consumers.
- dismissPinned and buildSearchUrl became block-bodied and a thin delegate
  respectively. Same behaviour.

Verified by diffing every non-import line of the two originals against the new
directories: the only lines that do not appear are import fragments that were
redistributed and the two changes above.

make ci-lint, make ci-unit (5205 tests) and the dashboard E2E suite (81 passed)
all pass. E2E matters most here — it is the only check that catches a
runtime-only breakage from moving code.

First of the pieces split out of #2536. No feature code: this lands ahead of the
exemplars overlay so that PR is a feature diff rather than a feature plus a
1,500-line move.
jordan-simonovski added a commit that referenced this pull request Aug 6, 2026
Time charts on metric and PromQL sources can overlay exemplars — individual
trace-linked points — behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and
per-chart behind enableExemplars. Hovering a marker shows the exemplar's own
value and time plus trace metadata, with a button to open the trace.

The shared common-utils primitives ship here rather than separately: every one of
those exports is consumed only by the app, so landing them alone would fail the
unused-export check.

The rule the whole feature turns on is that a marker sits at the trace's own
measurement on the chart's shared axis, so it is only honest when the chart draws
one line in the same unit. That is enforced in four places: a single non-ratio
histogram series with no group by; an aggregation that leaves the axis on the
observation scale (a count of observations is not a duration); for PromQL an
expression that plots a duration, with the duration call spanning the whole
expression; and a rendered-series count taken from the main query rather than the
exemplar response, since Prometheus only returns series that carry a sampled
exemplar and so cannot say how many lines are drawn.

Where a marker cannot be drawn honestly it is dropped, not moved. Out of the
rendered window by more than one bucket, or below a fitted y-axis floor, and it
does not render — with the count surfaced on the chart, because a silently
thinning overlay is worse than an explained one. The exception is above the
ceiling, where pinning reads as "at least this high" and the card carries the
real number.

Includes the four rounds of review fixes from #2536: the hover card showing value
and time (both clamps cite it as their justification), the PromQL aggregation
parsing that decides series identity, the ClickHouse scan's ex_TimeUnix bound,
the query-key quantisation, and the card lifecycle across zoom, live tail and
display-type switches.

make ci-lint and make ci-unit pass (5350 tests). Dashboard E2E 80 passed, 0
failed, 2 unrelated flakes on listing-page specs.

Stacked on the chart-file refactor so this reads as a feature diff.
jordan-simonovski added a commit that referenced this pull request Aug 6, 2026
  HDXMultiSeriesTimeChart.tsx  1503 -> 720 (+ 8 files)
  DBTimeChart.tsx              1022 -> 469 (+ 6 files)

Each becomes a directory with a barrel, so every import path and every
jest.mock('@/...') keeps resolving and the change is invisible to consumers.

The seams follow what the code already separated rather than cutting by length:

- searchUrl.ts takes buildSearchUrl out of a useCallback as a pure function,
  which makes its branching testable for the first time — which value column a
  series key resolves to, and whether that column's aggregation is attributable
  to individual events at all. A non-attributable aggregation must not produce a
  value filter, or drill-down returns rows that never contributed to the clicked
  point. Nine new tests cover it; that is the only new test surface here.
- useChartScales holds the axis domains and the annotation elements. Pure
  derivation from props, no state, no recharts tree.
- The tooltip, legend, recharts shape shims, layout constants, cross-chart pin
  registry and pure data helpers each move to a file named after what they are.

MemoChart stays at 720 because what is left is one recharts element whose
children must remain siblings, plus interaction state that genuinely shares a
click-suppression flag with the brush-zoom. Splitting that tree would trade a
real risk of dropping a render branch for a smaller number.

Two deliberate changes beyond pure movement, both small:

- xAxisDomain is typed as the [number, number] tuple it already returned rather
  than the wider AxisDomain, which removes two unsafe type assertions at its
  consumers.
- dismissPinned and buildSearchUrl became block-bodied and a thin delegate
  respectively. Same behaviour.

Verified by diffing every non-import line of the two originals against the new
directories: the only lines that do not appear are import fragments that were
redistributed and the two changes above.

make ci-lint, make ci-unit (5205 tests) and the dashboard E2E suite (81 passed)
all pass. E2E matters most here — it is the only check that catches a
runtime-only breakage from moving code.

First of the pieces split out of #2536. No feature code: this lands ahead of the
exemplars overlay so that PR is a feature diff rather than a feature plus a
1,500-line move.
jordan-simonovski added a commit that referenced this pull request Aug 6, 2026
Time charts on metric and PromQL sources can overlay exemplars — individual
trace-linked points — behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and
per-chart behind enableExemplars. Hovering a marker shows the exemplar's own
value and time plus trace metadata, with a button to open the trace.

The shared common-utils primitives ship here rather than separately: every one of
those exports is consumed only by the app, so landing them alone would fail the
unused-export check.

The rule the whole feature turns on is that a marker sits at the trace's own
measurement on the chart's shared axis, so it is only honest when the chart draws
one line in the same unit. That is enforced in four places: a single non-ratio
histogram series with no group by; an aggregation that leaves the axis on the
observation scale (a count of observations is not a duration); for PromQL an
expression that plots a duration, with the duration call spanning the whole
expression; and a rendered-series count taken from the main query rather than the
exemplar response, since Prometheus only returns series that carry a sampled
exemplar and so cannot say how many lines are drawn.

Where a marker cannot be drawn honestly it is dropped, not moved. Out of the
rendered window by more than one bucket, or below a fitted y-axis floor, and it
does not render — with the count surfaced on the chart, because a silently
thinning overlay is worse than an explained one. The exception is above the
ceiling, where pinning reads as "at least this high" and the card carries the
real number.

Includes the four rounds of review fixes from #2536: the hover card showing value
and time (both clamps cite it as their justification), the PromQL aggregation
parsing that decides series identity, the ClickHouse scan's ex_TimeUnix bound,
the query-key quantisation, and the card lifecycle across zoom, live tail and
display-type switches.

make ci-lint and make ci-unit pass (5350 tests). Dashboard E2E 80 passed, 0
failed, 2 unrelated flakes on listing-page specs.

Stacked on the chart-file refactor so this reads as a feature diff.
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.

1 participant