Skip to content

[HDX-4120] feat(api): support heatmap tiles in external dashboards API#2200

Open
alex-fedotyev wants to merge 13 commits intomainfrom
alex/HDX-4120-external-api-heatmap
Open

[HDX-4120] feat(api): support heatmap tiles in external dashboards API#2200
alex-fedotyev wants to merge 13 commits intomainfrom
alex/HDX-4120-external-api-heatmap

Conversation

@alex-fedotyev
Copy link
Copy Markdown
Contributor

@alex-fedotyev alex-fedotyev commented May 5, 2026

Summary

Heatmap was the only builder-mode display type that did not round-trip through the external dashboards API. The serializer dropped it into the "unsupported" fall-through, so creating, fetching, and updating heatmap tiles via /api/v2/dashboards lost the config.

This wires up heatmap end-to-end on the external API: a dedicated select-item schema, an explicit case in both serialization directions, OpenAPI JSDoc, and tests.

Follow-up to #2107 (review feedback from @pulpdrew, who asked whether we had a follow-up ticket to update the external API for the new visualization type).

What's in the diff

  • Zod schemas (packages/api/src/utils/zod.ts): a heatmap select-item schema that exposes the literal aggFn: "heatmap" plus valueExpression, optional countExpression, alias, heatmapScaleType; and a heatmap chart-config schema with optional groupBy and numberFormat. Heatmap is added to the builder discriminated union only.
  • Conversion utilities (packages/api/src/routers/external-api/v2/utils/dashboards.ts):
    • Builder serializer: replaces the old case DisplayType.Heatmap: fall-through with an explicit case that reads heatmap-specific fields off config.select[0] and emits aggFn: "heatmap" on the external surface.
    • Builder deserializer: new case 'heatmap': mirroring the Pie pattern; maps the external aggFn: "heatmap" back to the internal aggFn: "count" that the editor form persists, while preserving countExpression, alias, and heatmapScaleType on the select item.
    • The raw-SQL switch is intentionally left untouched: heatmap stays in the unsupported fall-through there because heatmap rendering requires isBuilderChartConfig.
  • OpenAPI JSDoc (packages/api/src/routers/external-api/v2/dashboards.ts): HeatmapSelectItem and HeatmapBuilderChartConfig components, and heatmap added to the TileConfig oneOf and discriminator mapping.
  • Tests (packages/api/src/routers/external-api/__tests__/dashboards.test.ts): heatmap added to the existing "round-trip all supported chart types" tests for both POST and PUT, plus an explicit rejection test confirming raw-SQL heatmap tiles return 400.
  • packages/api/openapi.json: regenerated.

Notes for review

  • No raw-SQL heatmap variant. PR feat: Wire heatmap chart into dashboard editor and tile rendering #2107 made heatmap builder-only and DBDashboardPage.tsx requires isBuilderChartConfig for heatmap rendering, so the raw-SQL fall-through stays.
  • heatmapScaleType and countExpression are persisted on the per-select-item level (via DerivedColumnSchema in packages/common-utils/src/types.ts), not on the chart config root. The form binds them as series.0.heatmapScaleType / series.0.countExpression. The schema and conversion utilities follow that.
  • The aggFn translation (external "heatmap" ↔ internal "count") keeps the saved Mongo document identical to what the editor form produces, so heatmap tiles created via the API render the same way as ones created via the UI.

Tier

Lands as review/tier-4 because anything under packages/api/src/routers/external-api/ is on the critical-path list. Diff is ~250 prod lines (most of it OpenAPI JSDoc and Zod boilerplate); no schema migrations or auth changes.

Test plan

  • make ci-lint (yarn lint, tsc --noEmit, OpenAPI lint)
  • make ci-unit (common-utils + app)
  • CI runs the full integration suite (heatmap round-trip POST + PUT + raw-SQL rejection); local make dev-int requires Docker BuildKit which isn't available on this host.

Deep-review carryover (2026-05-07)

Heatmap is the only builder-mode display type that did not round-trip
through the external dashboards API. The serializer dropped it into the
"unsupported" fall-through, so creating, fetching, and updating heatmap
tiles via /api/v2/dashboards lost the config.

This adds:

- A heatmap select-item schema in zod.ts that exposes the literal
  aggFn 'heatmap', valueExpression, optional countExpression, alias,
  and heatmapScaleType. Heatmap is included in the builder
  discriminated union only; it is intentionally not added to the raw
  SQL union since heatmap rendering requires isBuilderChartConfig.
- A heatmap chart config schema with optional groupBy and numberFormat.
- Builder serialization that reads the heatmap-specific fields off
  config.select[0] and emits aggFn 'heatmap' on the external surface,
  passing through groupBy and numberFormat.
- Builder deserialization that maps the external aggFn 'heatmap' back
  to the internal aggFn 'count' the editor form persists, preserving
  countExpression, alias, and heatmapScaleType on the select item.
- OpenAPI JSDoc with HeatmapSelectItem and HeatmapBuilderChartConfig
  components, and heatmap added to the TileConfig discriminator.
- Integration tests: heatmap added to the existing "round-trip all
  supported chart types" tests for both POST and PUT, plus an explicit
  rejection test for raw SQL heatmap tiles.
- Regenerated openapi.json.

Follow-up to #2107 (review feedback from @pulpdrew).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 946412a

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

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api 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
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment May 7, 2026 6:03pm

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label May 5, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

🔴 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)

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: 6
  • Production lines changed: 457 (+ 229 in test files, excluded from tier calculation)
  • Branch: alex/HDX-4120-external-api-heatmap
  • Author: alex-fedotyev

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
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

E2E Test Results

All tests passed • 167 passed • 3 skipped • 1183s

Status Count
✅ Passed 167
❌ Failed 0
⚠️ Flaky 2
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

<!-- claude-code-review -->

PR Review

✅ No critical issues found.

Implementation is clean and well-scoped:

  • Explicit DisplayType.Heatmap case added in both serializer and deserializer (packages/api/src/routers/external-api/v2/utils/dashboards.ts), replacing the previous unsupported fall-through.
  • Dedicated Zod schemas (externalDashboardHeatmapSelectItemSchema, externalDashboardHeatmapChartConfigSchema) with valueExpression.min(1) and select.length(1) matching the editor's validateChartForm rule.
  • Heatmap added only to the builder discriminated union (raw-SQL stays rejected at the zod boundary), matching DBDashboardPage.tsx's isBuilderChartConfig requirement.
  • Trace-source enforcement via getHeatmapTilesWithNonTraceSources mirrors the UI's ChartEditorControls source-picker gating.
  • Test coverage is thorough: full round-trip (POST + PUT), minimal-fields round-trip, raw-SQL rejection, non-trace-source rejection, empty-valueExpression rejection, multi-select rejection.
  • OpenAPI JSDoc and openapi.json regeneration are in sync.

Minor (non-blocking) observations:

  • getSources() is now called 3× in parallel inside Promise.all (was 2×) since getHeatmapTilesWithNonTraceSources does its own fetch rather than receiving a shared existingSources map. Consistent with the pre-existing pattern (getMissingSources and getSourceConnectionMismatches already each fetch independently), so no regression — but a future refactor could deduplicate.
  • The PR description mentions an optional groupBy on the heatmap chart config schema, but the actual Zod/OpenAPI schema correctly omits it (matching HeatmapSeriesEditor). The code is right; the description copy is slightly out of date.
  • In convertToExternalTileChartConfig, numberFormat: config.numberFormat is emitted unconditionally where elsewhere in the file the conditional-spread pattern (...(x !== undefined ? { x } : {})) is used for optional fields. JSON serialization strips the undefined so behavior is correct, just stylistically inconsistent.

Alex Fedotyev and others added 2 commits May 5, 2026 19:47
knip flagged the exported type as unused. Extracted the inline heatmap
select-item construction into a small convertToExternalHeatmapSelectItem
helper that mirrors the existing convertToExternalSelectItem pattern and
returns the typed shape, kills the knip warning, and removes the
duplicated nullish-spread bookkeeping at the call site.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reviewer flagged that convertToExternalHeatmapSelectItem used truthy
checks (countExpression ? ...) while convertToInternalTileConfig used
!== undefined. An empty-string countExpression/alias round-trip would
silently drop the field. Use !== undefined consistently in both
directions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  • Optional field consistency (point 1): Good catch. Fixed in 9e09229convertToExternalHeatmapSelectItem now uses !== undefined to match convertToInternalTileConfig, so empty-string round-trips don't silently drop fields.

  • numberFormat: config.numberFormat (point 2): This is actually the prevailing pattern in the file (10+ uses across Line, StackedBar, Table, Number, Pie, and the raw-SQL variants). Leaving it for consistency with the surrounding code; happy to swap to the spread form here AND across the file in a follow-up if we want a sweep.

  • knip: previously addressed in c232564 (extracted convertToExternalHeatmapSelectItem helper that types its return as ExternalDashboardHeatmapSelectItem).

The HeatmapSeriesEditor in the UI binds a single SearchWhereInput to the
top-level `where` / `whereLanguage` and does not render any groupBy
input. The previous schema had it backwards: it carried groupBy (not
exposed in the UI, never set by the form) and was missing
where / whereLanguage (the only filter heatmap actually uses).

- zod: drop groupBy, add where + whereLanguage to
  externalDashboardHeatmapChartConfigSchema
- conversion utils: serializer emits chart-level where + whereLanguage;
  deserializer maps them onto BuilderSavedChartConfig.where /
  whereLanguage instead of stuffing them into select-item aggCondition
- OpenAPI: HeatmapBuilderChartConfig has where + whereLanguage instead
  of groupBy; description notes the row-level filter is at chart level
- tests: POST round-trip exercises sql where ("ServiceName = 'api'")
  with heatmapScaleType: 'log'; PUT round-trip exercises lucene where
  ("service:api") with heatmapScaleType: 'linear'. Both languages and
  both scale types are now covered end-to-end.
- regenerated openapi.json
…ression

Two more gaps found while auditing the schema against the heatmap UI surface:

1. The heatmap UI's source picker is restricted to SourceKind.Trace
   (ChartEditorControls.tsx:103-107: allowedSourceKinds={[SourceKind.Trace]}).
   The external API previously accepted any source kind, so a metric or log
   source would round-trip cleanly through /api/v2/dashboards and produce a
   tile that does not render in the UI. Adds getHeatmapTilesWithNonTraceSources
   and wires it into both POST and PUT alongside the existing source /
   connection validators, returning 400 with a descriptive message.

2. validateChartForm in the editor rejects empty valueExpression on heatmap
   ("Value expression is required for heatmap charts"). The Zod schema
   accepted empty string. Tightens to z.string().min(1).max(10000) and
   updates the OpenAPI minLength to match.

Tests added: rejection on non-Trace source (with assertion on the error
message), rejection on empty valueExpression, rejection on multi-item
select array.

Regenerated openapi.json.
alex-fedotyev added a commit that referenced this pull request May 6, 2026
The external API surface in PR #2200 exposes where/whereLanguage at the
chart-config level for heatmaps (UI HeatmapSeriesEditor doesn't render
per-series filters; the persisted shape stores them once via
SelectSQLStatementSchema). The MCP heatmap schema was dropping them, so
once #2200 lands a save through hyperdx_save_dashboard would silently
discard the filter on read-back.

Add `where` and `whereLanguage` to mcpHeatmapTileSchema.config with the
same defaults the search tile uses (where: '', whereLanguage: 'lucene').
Extend the round-trip test with a Lucene filter, add an explicit
whereLanguage="sql" round-trip, and a negative test for an invalid
whereLanguage. Update the example string and changeset to document the
new fields.
The existing realistic-payload round-trip exercises every optional field
populated. The minimal path (no countExpression / alias /
heatmapScaleType / where / whereLanguage / numberFormat) is what the
deserializer's !== undefined guards in v2/utils/dashboards.ts exist to
protect, and was uncovered until now.

The Zod schema applies where: z.string().optional().default(''), so the
round-trip surfaces an empty string for where while the other optional
fields stay undefined. The expected response keeps explicit on the
applied default rather than asserting strict equality with the request.
When the external API request omits whereLanguage on a heatmap tile,
the deserializer at v2/utils/dashboards.ts:514 fills it with 'lucene'
so the Mongo doc stays consistent across heatmap and non-heatmap chart
types. The minimal-fields round-trip needs to expect whereLanguage on
the response, not undefined.

The schema-level constraint stays optional (matching the OpenAPI
shape); the default lives at the persistence boundary, not the
contract boundary.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

PR Review

✅ No critical issues found.

Heatmap round-tripping is wired correctly through both directions of the v2 dashboards converter:

  • Builder-only constraint is enforced at the Zod layer (externalDashboardHeatmapChartConfigSchema is in the builder union; not in the raw-SQL union at packages/api/src/utils/zod.ts:367-376), so raw-SQL heatmap requests fail validation before reaching the converter.
  • Source-kind validation (getHeatmapTilesWithIncompatibleSources) correctly filters out missing sources (already covered by getMissingSources) so it never produces false positives — and the new HEATMAP_ALLOWED_SOURCE_KINDS constant is shared with the UI's ChartEditorControls, so UI and API gates stay in sync.
  • The whereLanguage ?? 'lucene' fallback in both the serializer and deserializer is needed (SearchConditionLanguageSchema is .optional() in packages/common-utils/src/types.ts:56-58) and matches sibling chart arms.
  • Internal-vs-external aggFn mapping (heatmapcount) plus the aggConditionLanguage: 'lucene' divergence from the editor's getStoredLanguage() are documented in code comments and the PR description; the renderer doesn't read that field for heatmap, so it's invisible at render time.
  • Tests cover: round-trip POST + PUT with full payload, minimal payload (defaults), raw-SQL rejection, empty valueExpression rejection, multiple-select-items rejection, and non-Trace source rejection — good coverage of the new code paths.

Minor (non-blocking) observations:

  • convertToExternalTileChartConfig's heatmap arm returns undefined for legacy docs missing valueExpression, falling back to defaultTileConfig. Worth confirming the parent caller logs/surfaces this enough that an admin could notice; otherwise these tiles silently appear blank to API consumers.
  • The convertToExternalHeatmapSelectItem helper uses !== undefined spreads (intentional, called out in a comment) — consistent with the deserializer side. Good symmetry.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

Compound Engineering Review

✅ No critical issues found. Security audit returned no findings (tenant scoping, IDOR, info-leak, auth all sound). Type safety, test parity, and adherence to sibling patterns (Pie/Search/Markdown) are clean.

P2 — Important

  • P2 packages/app/src/components/SourceSelect.tsx:85 + ChartEditorControls.tsx:108allowedSourceKinds prop typed as mutable SourceKind[] forces [...HEATMAP_ALLOWED_SOURCE_KINDS] spread (no other call site spreads); allocates a new array each render → widen prop to ReadonlyArray<SourceKind> and drop the spread.
  • P2 packages/api/src/routers/external-api/v2/utils/dashboards.ts:103-109, 530-536 — conditional-spread ...(item.x !== undefined ? { x: item.x } : {}) repeated 6× for countExpression/alias/heatmapScaleType → replace with pick(item, ['countExpression','alias','heatmapScaleType']) (lodash already imported); preserves empty-string round-trip and removes the comment that motivated the verbose form.
  • P2 packages/api/src/routers/external-api/v2/dashboards.ts:1683-1722, 1918-1957 — POST/PUT each run 4 sibling validators in Promise.all, several re-querying sources; create+update branches are now byte-identical → extract validateDashboardReferences(teamId, tiles, filters) that fetches sources/connections once and returns a structured result, plus a respondIfInvalidReferences(res, result) helper to dedupe the 4 error blocks.
  • P2 packages/api/src/routers/external-api/v2/utils/dashboards.ts:514-528 — internal aggFn: 'count' + aggCondition: '' are encoding applyHeatmapDefaults's UI-form quirk in the API converter; if the editor default ever shifts (e.g. histogram), API-built and UI-built tiles diverge silently → share the default-builder via common-utils (buildInternalHeatmapSelectItem) imported from both the form and the converter, or at minimum add a unit test asserting parity so the next editor change fails loudly.
  • P2 packages/common-utils/src/guards.ts:13-35HEATMAP_ALLOWED_SOURCE_KINDS is a 1-element array wrapped in a ReadonlyArray<SourceKind> const + 4-line predicate + 9-line JSDoc with two consumers using different shapes (UI spreads array, API calls predicate); when metric-heatmaps need a richer "kind + metricType" rule, the kinds-array contract breaks → simplify to a single as const constant and let the API call .includes(...) inline, or expose only isHeatmapCompatibleSource(source) + a getHeatmapAllowedSourceKinds() accessor so the predicate can grow without the UI ossifying around the array.
  • P2 packages/api/src/routers/external-api/v2/utils/dashboards.ts:95-110convertToExternalHeatmapSelectItem param typed as Exclude<BuilderSavedChartConfig['select'][number], string> reads heatmap-only fields (countExpression, heatmapScaleType) that only exist on DerivedColumn → tighten the param to DerivedColumn (or a Pick<DerivedColumn, …>) so the contract reflects what the body requires.
  • P2 packages/api/src/routers/external-api/v2/utils/dashboards.ts:291-319, 524-538 — aggFn round-trip is silently lossy: serializer always emits 'heatmap', deserializer always writes 'count', regardless of the internal value; legacy/manually-written docs with aggFn: 'avg' would be relabeled without notice → either log a warning when item.aggFn !== 'count' && item.aggFn !== 'heatmap' in the serializer, or document the lossiness explicitly in the existing comment.
  • P2 packages/api/src/routers/external-api/v2/utils/dashboards.ts:303, 644-651 — two dead defensive guards: (a) typeof item === 'string' on a select element where the schema guarantees objects (string case is already caught one level up by Array.isArray), and (b) && tile.config.sourceId truthiness check on a field that objectIdSchema already validates as a non-empty string → drop both for clarity (or leave a one-line "belt-and-suspenders" comment).

Compound-review feedback on #2200, all P2:

- Drop redundant `whereLanguage.optional()`; the underlying
  `SearchConditionLanguageSchema` (alias `whereLanguageSchema`) is
  already `.optional()` and the sibling
  `externalDashboardSearchChartConfigSchema` writes it without the
  outer wrap.
- Drop the `where: externalConfig.where ?? ''` redundancy in the
  deserializer; the Zod schema declares `.default('')` so the field is
  always a string post-parse.
- Rename schema `HeatmapBuilderChartConfig` -> `HeatmapChartConfig`
  to match the sibling `SearchChartConfig` / `MarkdownChartConfig`
  naming. The `Builder` suffix is reserved for cases that disambiguate
  from a sibling `RawSqlChartConfig`; heatmap has no raw-SQL variant.
- `convertToExternalHeatmapSelectItem` now takes a non-optional item.
  The case-arm caller checks for missing/string/empty-valueExpression
  items and falls through to `defaultTileConfig` with a logger.warn
  instead of silently emitting an out-of-contract payload that
  violates the external schema's `min(1)` rule.
- Extract `HEATMAP_ALLOWED_SOURCE_KINDS` and
  `isHeatmapCompatibleSource` into common-utils. Both
  `ChartEditorControls.tsx` (UI) and the API's
  `getHeatmapTilesWithIncompatibleSources` (renamed from
  `...WithNonTraceSources`) reference the same set so UI and API
  gates move together.
- Add a parity comment on the deserializer pointing to
  `applyHeatmapDefaults` so the `aggCondition`/`aggConditionLanguage`
  defaults stay coupled to the editor's behaviour.
- Consolidate three pure-Zod schema-rejection tests
  (raw-SQL-heatmap absent, empty valueExpression, multiple select
  items) into a single parametrized `it.each` block; the runtime
  non-Trace-source rejection stays as its own test because it
  exercises the new `getHeatmapTilesWithIncompatibleSources` path.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

P2 cleanups addressed in e89989d:

  • Dropped redundant whereLanguage.optional() and where: ?? '' to match sibling chart-config arms
  • Renamed HeatmapBuilderChartConfigHeatmapChartConfig (the Builder suffix is reserved for cases that disambiguate from a sibling RawSql type; heatmap has no raw-SQL variant)
  • convertToExternalHeatmapSelectItem is now non-optional; the case-arm caller falls through to defaultTileConfig with logger.warn for legacy/corrupted Mongo docs that lack valueExpression
  • Extracted HEATMAP_ALLOWED_SOURCE_KINDS and isHeatmapCompatibleSource to common-utils so ChartEditorControls.tsx and getHeatmapTilesWithIncompatibleSources (renamed) reference the same set; UI and API gates now move together
  • Parity comment on the deserializer pointing at applyHeatmapDefaults for aggCondition/aggConditionLanguage
  • Three pure-Zod rejection tests consolidated into a single parametrized it.each; the runtime non-Trace-source rejection stays as its own test because it exercises the new code path

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Deep Review

🔴 P0/P1 -- must fix

  • packages/api/src/mcp/tools/dashboards/schemas.ts:312 -- mcpTilesParam does not include heatmap, so an agent calling hyperdx_save_dashboard with a heatmap tile fails MCP validation while the same payload is accepted by REST.
    • Fix: Add an mcpHeatmapTileSchema mirroring externalDashboardHeatmapChartConfigSchema and include it in the mcpTilesParam z.union.
    • previous-comments
  • packages/api/src/mcp/tools/dashboards/saveDashboard.ts:115 -- MCP createDashboard / updateDashboard does not run getHeatmapTilesWithIncompatibleSources, so MCP can persist a heatmap on a non-Trace source that REST rejects with 400.
    • Fix: Run the new heatmap source-kind gate inside the MCP save path (and align with getSourceConnectionMismatches for symmetry).
    • previous-comments
  • packages/api/src/mcp/prompts/dashboards/content.ts:27 -- The MCP prompt's TILE TYPE GUIDE, COMMON MISTAKES, and PER-TILE TYPE CONSTRAINTS blocks do not mention heatmap, so agents have no signal that the new capability exists.
    • Fix: Add a heatmap entry to all three sections in buildQueryGuidePrompt, including the Trace-source and valueExpression-required rules.
    • previous-comments

🟡 P2 -- recommended

  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:309 -- A heatmap tile whose stored Mongo doc has empty/missing select[0].valueExpression is returned to the client as displayType: 'line' via defaultTileConfig, so a routine GET → mutate-sibling → PUT silently overwrites the heatmap with the line stub.
    • Fix: Surface bad-state heatmaps with a heatmap-shaped error placeholder (or omit the tile) instead of falling through to the line default, so callers cannot unknowingly destroy config on round-trip.
    • adversarial, api-contract, previous-comments
  • packages/api/src/routers/external-api/v2/dashboards.ts:1921 -- If a referenced source's kind is later changed away from Trace, every PUT against any dashboard using it (even edits to unrelated tiles) re-runs getHeatmapTilesWithIncompatibleSources over the round-tripped heatmap and 400s, wedging the dashboard.
    • Fix: Scope the PUT-time heatmap source-kind check to tiles whose sourceId or displayType actually changed in this request, or block source-kind changes when a heatmap references the source.
    • adversarial, previous-comments
  • packages/api/src/routers/external-api/v2/dashboards.ts:1691 -- getMissingSources, getSourceConnectionMismatches, and the new getHeatmapTilesWithIncompatibleSources each independently call getSources(team) inside Promise.all, so every POST/PUT now fans out to three identical Source.find({ team }) round-trips (was two pre-PR).
    • Fix: Hoist a single getSources(team) at the handler and pass the resulting array (or a Map<id, Source>) into all three helpers; same change applies to the PUT handler at :1926.
    • correctness, performance
  • packages/api/src/routers/external-api/v2/dashboards.ts:1686 -- POST and PUT now contain byte-identical 4-call Promise.all + 4-block error ladders; every future tile-validation rule has to be edited in lockstep across both sites.
    • Fix: Extract validateDashboardReferences(teamId, tiles, filters) (returning a structured ok/err) and a respondIfInvalidReferences(res, result) helper, and call them from both handlers.
    • maintainability, previous-comments
  • packages/common-utils/src/guards.ts:25 -- HEATMAP_ALLOWED_SOURCE_KINDS: ReadonlyArray<SourceKind> forces every consumer to spread-copy because SourceSelectControlled.allowedSourceKinds is typed SourceKind[], costing an allocation per render and reading as defensive ambiguity.
    • Fix: Widen allowedSourceKinds in packages/app/src/components/SourceSelect.tsx to readonly SourceKind[] (the prop only .includes()s the value) and drop the [...HEATMAP_ALLOWED_SOURCE_KINDS] spread in ChartEditorControls.tsx:108.
    • maintainability, kieran-typescript
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:656 -- getHeatmapTilesWithIncompatibleSources returns Promise<string[]> of source IDs, not tiles; the JSDoc, the 400 error message, and the sibling getMissing* helpers all refer to IDs.
    • Fix: Rename to getIncompatibleHeatmapSourceIds (or similar) at the declaration plus the two route-handler call sites.
    • maintainability
  • packages/api/src/routers/external-api/__tests__/dashboards.test.ts:3399 -- The PUT round-trip test adds heatmap success but no rejection-symmetry coverage; the four POST rejection cases (raw-SQL heatmap, empty valueExpression, multi-item select, non-Trace source) are not exercised on PUT.
    • Fix: Mirror the POST it.each([...]) rejection table and the non-Trace-source it(...) inside the PUT describe block.
    • testing
  • packages/api/src/routers/external-api/__tests__/dashboards.test.ts:2415 -- The empty-valueExpression rejection asserts only expect(400); if the schema later gains another required field, the same payload will keep returning 400 for an unrelated reason and silently stop covering min(1).
    • Fix: Capture the response and assert response.body.message mentions valueExpression (or pin the Zod path) so the assertion stays bound to the rule under test; apply the same to the multi-item-select and raw-SQL cases.
    • testing
  • packages/api/src/routers/external-api/__tests__/dashboards.test.ts:2354 -- The external→external round-trip masks the silent aggFn: 'heatmap' → 'count' translation; a regression that left aggFn: 'heatmap' on the internal document would still pass these assertions while breaking the editor.
    • Fix: After the POST round-trip, fetch the persisted document with Dashboard.findById(...).lean() and assert tiles[i].config.select[0].aggFn === 'count' plus aggCondition === ''.
    • testing
  • packages/api/src/routers/external-api/v2/charts.ts:259 -- The /api/v2/charts/series endpoint hardcodes displayType: DisplayType.Line and rejects heatmap inputs, so agents can now define a heatmap tile via this PR but still cannot fetch its rendered bucket data the way the UI renderer can.
    • Fix: Add a heatmap-aware path on /api/v2/charts/series (or document the gap and link a follow-up) so create/edit/read parity extends to the data-query surface.
    • agent-native
🔵 P3 nitpicks (8)
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:306 -- The warn log key tileId is set to sourceId, mislabeling the value for triage.
    • Fix: Rename the key to sourceId (or plumb the actual tile.id from the caller).
    • correctness
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:546 -- aggConditionLanguage: 'lucene' is hardcoded; a UI-saved heatmap with 'sql' is silently downgraded on any external-API GET → PUT round-trip.
    • Fix: Read-modify-write the existing internal value rather than always overwriting, or cover the documented downgrade with a regression test pinning the behavior.
    • correctness, testing
  • packages/api/openapi.json:1527 -- OpenAPI declares default: "lucene" for heatmap whereLanguage, but Zod's whereLanguageSchema has no .default() and most OpenAPI 3.0 generators ignore default next to a $ref.
    • Fix: Either drop the default from openapi.json (matching sibling configs) or tighten Zod with whereLanguageSchema.default('lucene') so the contract documented matches the parsed type.
    • api-contract
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:103 -- The ...(item.x !== undefined ? { x: item.x } : {}) pattern repeats 6× across convertToExternalHeatmapSelectItem and the deserializer.
    • Fix: Replace with a typed omitUndefined helper or use pick (already imported) to compress the conditional spreads.
    • previous-comments
  • packages/api/src/utils/zod.ts:510 -- externalDashboardTileListSchema has no .max(), and the heatmap path inherits the unbounded shape with three new 10000-char fields per tile.
    • Fix: Add .max(N) to the tile array sized to a sensible dashboard grid cap.
    • adversarial
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:673 -- getMissingSources and getHeatmapTilesWithIncompatibleSources both call getSources(team) independently inside Promise.all, opening a TOCTOU window where a heatmap tile can pass both gates while the source is being deleted.
    • Fix: Folded into the Triple-getSources dedup above; sharing a single snapshot also closes this race.
    • adversarial
  • packages/api/src/routers/external-api/__tests__/dashboards.test.ts -- No test inserts a heatmap tile directly via Dashboard.create with empty select[0].valueExpression to exercise the serializer's documented defaultTileConfig fall-through.
    • Fix: Add a unit test that bypasses the API write path, GETs the dashboard, and asserts the fall-through behavior so the 30-line guard comment has enforcement.
    • testing
  • packages/api/src/routers/external-api/v2/utils/dashboards.ts:517 -- The deserializer's heatmap block comment hard-codes a path in packages/app/... and the function name applyHeatmapDefaults; refactors in the app package will silently rot it.
    • Fix: Replace the path/name with a phrase that won't rot ("the heatmap editor's default-application step") or export applyHeatmapDefaults from common-utils so the converter can call it directly.
    • maintainability

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

Testing gaps:

  • Read path: no test seeds Mongo with a malformed heatmap (empty/missing valueExpression, non-array select) and asserts the GET response, so the read-side fallback is unreachable from the existing supertest suite.
  • getHeatmapTilesWithIncompatibleSources is only exercised with one bad source on one tile; multi-tile aggregation and the comma-joined error message are not asserted.
  • No direct unit tests for isHeatmapCompatibleSource / HEATMAP_ALLOWED_SOURCE_KINDS in packages/common-utils/src/__tests__/guards.test.ts, despite sibling guards having coverage there.
  • No round-trip test that exercises all heatmap optional fields together (countExpression, alias, heatmapScaleType, numberFormat, where, whereLanguage='sql') to lock in field preservation and the documented aggConditionLanguage downgrade.
  • No test pins ordering between getMissingSources and getHeatmapTilesWithIncompatibleSources; a refactor that reorders them would silently change the user-facing error.

Deep-review on #2200 flagged the previous comment as overstating
parity. The editor's `applyHeatmapDefaults` writes `numberFormat:
{ output: 'duration', factor: 0.001 }`, `series.0.countExpression:
'count()'`, and `aggConditionLanguage: getStoredLanguage() ?? 'lucene'`,
while this converter hardcodes `'lucene'` for the language and passes
`numberFormat`/`countExpression` through verbatim from the external
payload (or leaves them absent).

Comment now enumerates the divergences and the rendering implications:
the renderer does not read `aggConditionLanguage` for heatmap tiles
(heatmap has no per-select where), and an API-built tile renders
without duration formatting unless the caller specifies `numberFormat`.
No behavior change.
alex-fedotyev added a commit that referenced this pull request May 7, 2026
Deep-review on #2200 flagged that buildCreateDashboardPrompt and
buildQueryGuidePrompt enumerate every supported displayType but
omit heatmap, hiding the new capability from agents reading the
prompt.

Adds heatmap to:

  - TILE TYPE GUIDE (buildCreateDashboardPrompt): one-line description
    plus the Trace-source-only and aggFn:"heatmap" + valueExpression
    requirements.
  - COMMON MISTAKES TO AVOID: a single bullet covering the source-kind
    and select-item rules.
  - PER-TILE TYPE CONSTRAINTS (buildQueryGuidePrompt): 1 select item,
    no groupBy, Trace sources only.

Not in this commit: the MCP source-kind runtime gate
(getHeatmapTilesWithIncompatibleSources) flagged as P0/P1 #2 in the
same deep-review. The helper is added in #2200 and is not yet
importable from this branch; the gate will land in the rebase commit
after #2200 merges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge 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