Skip to content

feat: upgrade clickhouse versions and use promql remote endpoints - #3142

Merged
kodiakhq[bot] merged 8 commits into
mainfrom
aaron/upgrade-ch-version-and-promql
Sep 17, 2026
Merged

kodiakhq[bot] merged 8 commits into
mainfrom
aaron/upgrade-ch-version-and-promql

Conversation

@knudtty

@knudtty knudtty commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Upgrades the version of ClickHouse used for our published all-in-one images as well as local development. Alongside that, some prometheus changes were required to make promql work with the newer version. This also introduces using the prometheus api endpoints (semi-stable) rather than prometheusQueryRange (unstable API across versions while the table is experimental).

The prometheus route will default to trying through the prometheus api. If it does not pass the health check, it will ball back to using prometheusQuery or prometheusQueryRange. The fallback will also do some checks to see the table version of the TimeSeries table and if so, use different labels when fetching the series (the time_series column was changed to samples for TimeSeries tables with a version > 2)

References

  • Linear Issue: Closes HDX-5407

@changeset-bot

changeset-bot Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 21cfdec

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

vercel Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Sep 17, 2026 9:27pm UTC
hyperdx-storybook Ready Ready Preview Sep 17, 2026 9:27pm UTC

Request Review

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

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Critical-path files (1) — tenancy, public API, or shipped database config:
    • docker/clickhouse/local/config.xml
  • Cross-layer change: touches backend (packages/api) + shared utils (packages/common-utils)

Additional context: touches background tasks or the delivery pipeline lightly (12 lines, under the 30-line bar for Tier 4)

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: 12
  • Production lines changed: 532 (+ 674 in test files, excluded from tier calculation)
  • Critical-path lines changed: 13
  • Branch: aaron/upgrade-ch-version-and-promql
  • Author: knudtty

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

@knudtty
knudtty force-pushed the aaron/upgrade-ch-version-and-promql branch from f0b98ef to 9741791 Compare September 16, 2026 19:34
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 359 passed • 1 skipped • 1560s

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

Tests ran across 4 shards in parallel.

View full report →

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Scope: ClickHouse 26.8 upgrade + PromQL routed through ClickHouse's Prometheus HTTP API with a version/handler-aware fallback to the prometheusQuery/prometheusQueryRange table functions. Reviewed against base 482d2cb0.

Most previously-flagged blockers are already fixed in the current head: the prometheus_api_v1 handler is present in both docker/clickhouse/local/config.xml and the all-in-one docker/hyperdx/clickhouseConfig.xml, <defaults/> is correctly ordered after the <rule>, execution-time/result-row caps are restored via clickhousePrometheusUpstream, and a preflight probe (clickhouseServesPrometheusHttpApi) now falls back to the table functions instead of hard-failing. The findings below are what remains.

🔴 P0/P1 -- must fix

  • packages/common-utils/src/core/metadata.ts:630 -- the extract(engine_full, 'version = (\\d+)') literal is under-escaped: chSql uses the cooked template string, so \\d reaches ClickHouse as \d, which ClickHouse unescapes to d inside a single-quoted literal, making the regex (d+) match nothing and getTimeSeriesTableVersion always return 0.
    • Fix: Write the pattern so ClickHouse receives a literal digit class — use [0-9]+ or quadruple-backslash \\\\d+ — and add a unit test asserting a non-zero version is parsed.
    • correctness, previous-comments

Consequence worth calling out: because timeSeriesSamplesColumn (packages/api/src/controllers/timeseriesEngine.ts:314) branches on version >= 3, an always-0 version forces the time_series column name for every table. On a fallback deployment that cannot enable the HTTP handler (e.g. ClickHouse Cloud) running a v3 TimeSeries table whose column is samples, queryRangeViaTableFunction emits SELECT ... time_series ... and every range/instant PromQL query 400s — defeating the exact v3 support this code adds.

🟡 P2 -- recommended

  • packages/api/src/routers/api/prometheus.ts:217 -- pinning max_result_rows = PROMETHEUS_MAX_RESULT_ROWS (100,000) on the prometheus_api_v1 URL applies the cap to the handler's internal sample scan rather than to output series as the old table-function query did, so an ordinary multi-series multi-day range query can exceed it and return a Prometheus 400.
    • Fix: Validate against a real 26.8 handler whether the cap counts raw samples; if so, drop max_result_rows here and rely on max_execution_time/PROMETHEUS_MAX_RESOLUTION, or introduce a separate sample-sized constant instead of reusing the series-row limit.
    • performance, previous-comments
  • packages/api/src/routers/api/prometheus.ts:361 -- the upstream fetch uses the default redirect: 'follow', and undici strips only Authorization/Cookie/Proxy-Authorization on a cross-origin redirect, so the X-ClickHouse-User/X-ClickHouse-Key headers survive a 30x to a different origin and hand the ClickHouse credentials to that host.
    • Fix: Pass redirect: 'error' (or 'manual') on the proxy and probe fetches so a redirecting connection host cannot capture credentials.
    • security, previous-comments
  • packages/api/src/routers/api/prometheus.ts:248 -- every ClickHouse PromQL request runs a SELECT version() plus a format_query probe before the actual query, and both use a throwaway MetadataCache, tripling ClickHouse round-trips on a hot dashboard path with no cross-request memoization.
    • Fix: Cache the version/handler-availability result per connection with a short TTL so repeated panels reuse it instead of re-probing on each request.
  • packages/api/src/routers/api/__tests__/prometheus.int.test.ts -- the new ClickHouse HTTP-API tests stub fetch and assert only the constructed URL and headers, so they pass whether or not ClickHouse actually serves /prometheus/api/v1 or returns a Prometheus-shaped body, leaving the PR's central behavior unexercised.
    • Fix: Add an integration test that lets the request reach the seeded CI ClickHouse and asserts a Prometheus matrix/vector body is returned.
    • testing, previous-comments
🔵 P3 nitpicks (5)
  • packages/api/src/routers/api/prometheus.ts:525 -- parseDuration(params.step ?? '60s') defaults step only for the resolution guard, but the raw params forwarded to the handler omit it, so a /query_range with no step now 400s upstream instead of running at 60s.
    • Fix: Forward the resolved step value (e.g. { ...params, step: params.step ?? '60s' }) to proxyToPrometheus.
  • packages/api/src/routers/api/prometheus.ts:555 -- database keeps its 'default' fallback on the table-function and label paths but not when forwarded to the HTTP-API /query+/query_range, so a source saved with a blank database resolves label dropdowns against default.<table> while the chart query hits the server's default database.
    • Fix: Default database to 'default' on both proxy paths, or remove the default everywhere for consistency.
  • packages/api/src/routers/api/prometheus.ts:215 -- clickhousePrometheusUpstream calls new URL(host) a second time, so a malformed host throws out of the handler (bare Invalid URL, increments query_errors) instead of routing through joinPrometheusUpstreamUrl's deliberate non-counted 400; in practice the preflight probe masks this, but the double-parse is still redundant.
    • Fix: Take the URL from joinPrometheusUpstreamUrl and set the two settings on it.
  • packages/api/src/controllers/__tests__/timeseriesEngine.test.ts:20 -- the formatter fixtures are cast with rows as any (also line 42), disabling the type check that the fixture matches the formatter's declared input.
    • Fix: Type the fixtures to the formatter's parameter shape and drop the any casts.
  • packages/app/tests/e2e/seed-clickhouse.ts:580 -- Math.round(timestampMs * 1000000) is a no-op because the product exceeds 2^53 and is already an integer; the quoting change is what fixed the insert.
    • Fix: Round before scaling (Math.round(t) * 1000000) as the file does elsewhere, or drop the call.

Reviewers (9): correctness, security, performance, reliability, api-contract, testing, maintainability, project-standards, previous-comments.

Testing gaps:

  • New HTTP-API proxy path is covered only by fetch-mocked tests; no real-ClickHouse round-trip asserts the Prometheus response shape.
  • No test covers getTimeSeriesTableVersion returning a non-zero version, which is why the escaping regression went uncaught.

Note: the shell/git environment was unavailable during this run (bwrap failure), so findings were derived from reading the head revision of the changed files rather than a computed diff; line citations reflect current file state.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The runtime changes appear sound, but the explicit type-safety and production file-size requirements must be satisfied before merging.

Fix All in Claude CodeFindings

  1. P2 Fallback responses use any ▶
  2. P2 Controller exceeds size limit ▶
  3. P2 Fixture typing is bypassed ▶

Summary

This PR upgrades bundled ClickHouse deployments to 26.8 and routes ClickHouse-backed PromQL queries through the Prometheus HTTP API when available, retaining table-function fallbacks for older or unconfigured servers.

  • Adds HTTP-handler configuration and server/table version detection.
  • Preserves execution and result limits for proxied and fallback queries.
  • Adds unit and integration coverage for HTTP routing, fallback behavior, schema versions, and response formatting.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[PromQL request] --> B{Prometheus connection?}
  B -->|Yes| C[Proxy to Prometheus API]
  B -->|No, ClickHouse| D{Server supports API and handler probe succeeds?}
  D -->|Yes| E[Proxy to ClickHouse prometheus_api_v1]
  D -->|No| F[Run prometheusQuery or prometheusQueryRange]
  F --> G{TimeSeries schema version}
  G -->|v3 or newer| H[Read samples column]
  G -->|v2 or older| I[Read time_series column]
Loading

Reviews (8) · Last reviewed commit: "Merge branch 'main' into aaron/upgrade-c..."

Comment thread docker/clickhouse/local/config.xml Resolved
Comment thread docker/clickhouse/local/config.xml Resolved
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

5 finding(s): 🔴 0 critical · 🟠 3 major · 🔵 2 minor

4 posted as inline comment(s) on the changed lines. 1 listed below.

Findings outside the changed lines

1 minor
  • 🔵 packages/app/tests/e2e/utils/constants.ts:61 — Comment on E2E_PROMQL_TABLE still describes the replaced seeding mechanism → seedPromqlSeries no longer writes through the timeSeries* table functions — packages/app/tests/e2e/seed-clickhouse.ts:1121-1136 now inserts into the TimeSeries table itself, which is the whole point of the rewrite — but the comment here still reads "The seeder writes through the timeSeries* table functions". Update it (and the prometheusQueryRange mention, now only the fallback route) so the next reader does not go looking for inner-table inserts.

Severity is the reviewer's own estimate and is used for ordering, not filtering.

Comment thread docker/clickhouse/local/config.xml Resolved
},
];
expect(formatMatrixResponse(rows as any)).toEqual([
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fixture typing is bypassed

The new formatter test passes rows as any, which violates the repository directive to avoid any and prevents TypeScript from checking that the fixture matches the formatter input. The vector formatter test repeats this at line 44. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

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

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

@pulpdrew pulpdrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The HTTP endpoint seems to work correctly locally with my existing source.

Just to confirm, is there anything we need to / can do for the labels and values endpoints?

Comment on lines +235 to +244
* Whether a ClickHouse connection answers PromQL over HTTP. Two conditions,
* cheapest first: the server is new enough (26.6+), and the prometheus_api_v1
* handler is actually configured — a self-managed server needs an
* `<http_handlers>` rule for that, which a stock install lacks. The handler
* answers every request, error or not, with Prometheus JSON (`{status, ...}`);
* a server without it answers a plain-text 404. `format_query` is used as the
* probe because it is the one endpoint that needs no table.
*
* Probed on every request, like the version check, so a handler enabled or
* removed on the server is reflected immediately.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be great in the future if we could set something on the source config that indicates the HTTP API should be used, instead of having to make an extra probe request on every query. Makes sense that this could be required for backwards compatibility though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking that too, it could be a setting that someone could alter if they desire. I didn't want to tackle that yet though. Will create a ticket for it

@knudtty

knudtty commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Just to confirm, is there anything we need to / can do for the labels and values endpoints?

I'm not sure they are fully implemented yet, but we should do that. I'll ticket it

@kodiakhq
kodiakhq Bot merged commit b217430 into main Sep 17, 2026
30 of 31 checks passed
@kodiakhq
kodiakhq Bot deleted the aaron/upgrade-ch-version-and-promql branch September 17, 2026 21:32
return this.cache.getOrFetch<number>(
`${connectionId}.${databaseName}.${tableName}.getTimeSeriesTableVersion`,
async () => {
const sql = chSql`SELECT toUInt32OrZero(extract(engine_full, 'version = (\\d+)')) AS version FROM system.tables WHERE database = ${{ String: databaseName }} AND name = ${{ String: tableName }}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — getTimeSeriesTableVersion regex is under-escaped, so it always returns 0 and the samples branch is unreachable

The template literal 'version = (\\d+)' yields the SQL text 'version = (\d+)', and ClickHouse string literals convert any unrecognised escape \c to c (see the repo's own escapeSqlString/escapeRegexValue usage in packages/common-utils/src/variables.ts:42 and the doubled-backslash expectations in packages/common-utils/src/__tests__/variables.test.ts:907). The regex ClickHouse actually compiles is version = (d+), which never matches version = 3, so extract returns '', toUInt32OrZero returns 0, and timeSeriesSamplesColumn (packages/api/src/controllers/timeseriesEngine.ts:314) always answers time_series. On the exact case this was written for — a stock ClickHouse 26.8 with no <http_handlers> rule, so the probe fails and the fallback runs, holding a version = 3 TimeSeries table — SELECT time_series fails with Unknown identifier and every PromQL chart 400s. Write 'version = (\\\\d+)' (SQL text \\d), or drop the DDL regex entirely and pick the column from metadata.getColumns({databaseName, tableName}) the way timeSeriesTagsTableHasTimeBounds already picks min_time/max_time from a column list. Either way add a test: getTimeSeriesTableVersion currently has none, which is why this passed CI (CI's tables are created without an explicit version, so time_series is right by accident). Also confirm engine_full even carries version = N for a table created by docker/otel-collector/schema/seed/00008_otel_metrics_timeseries.sql, which sets no version.

client: ClickhouseClient,
connection: { id: string; host: string; username: string; password?: string },
): Promise<boolean> {
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Capability probe runs uncached on every /query and /query_range, and the version gate is subsumed by the handler probe

clickhouseServesPrometheusHttpApi is called per request and caches nothing: connectionSupportsPrometheusHttpApi builds new Metadata(client, new MetadataCache()) (packages/api/src/controllers/timeseriesEngine.ts:410), so SELECT version() is re-issued every time, then a second fetch to /format_query follows. A dashboard of N PromQL tiles now costs 3N round trips instead of N, and for any connection without the handler (ClickHouse Cloud, or any server whose config.xml the user does not control) both extra trips are pure waste before the fallback runs. The version gate also cannot change the outcome: a server predating prometheus_api_v1 has no such handler, so the probe already returns false — drop connectionSupportsPrometheusHttpApi/supportsPrometheusHttpApi and keep only the probe, memoized per connection id (the shared getMetadata cache, or a small TTL map) rather than re-run per request. Related: the fallback is silent — getServerVersion swallows its error and the probe swallows every exception including a >5s PROBE_TIMEOUT_MS, and backend is 'clickhouse' for both routes, so a healthy 26.8 deployment quietly demoted to the legacy path produces no log line and no metric to distinguish it.

@@ -0,0 +1,7 @@
---
'@hyperdx/api': minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Changeset omits @hyperdx/common-utils, which this PR changes and the api package now imports new symbols from

The PR adds supportsPrometheusHttpApi (packages/common-utils/src/core/clickhouseVersion.ts:179) and Metadata.getTimeSeriesTableVersion (packages/common-utils/src/core/metadata.ts:618), both imported by packages/api/src/controllers/timeseriesEngine.ts, yet the changeset lists only '@hyperdx/api': minor. packages/api/package.json:24 depends on "@hyperdx/common-utils": "^0.28.1", so without a common-utils entry the published api release resolves a common-utils that lacks these exports. Every other changeset that touches two packages lists both (e.g. .changeset/quote-row-where-identifiers.md, .changeset/stream-metric-names-from-index.md) — add '@hyperdx/common-utils': minor. While editing it, the body says "ClickHouse 26.8+" but the code gates on 26.6 (PROMETHEUS_HTTP_API_MIN), and it does not mention that a self-managed server needs its own <http_handlers> rule or it silently keeps using the table functions.

password: connection.password,
requestTimeout: PROMETHEUS_CH_TIMEOUT_MS,
});
const client = newClickhouseClient(connection);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — /labels and /label/:name/values stay on the inner-table path the changeset calls non-forward-compatible

The changeset's stated reason for moving off the table functions is that ClickHouse only holds the Prometheus HTTP API forward-compatible while TimeSeries is in preview — and this PR proves it, having to drop all_tags from seedTimeSeriesTagsTable (packages/api/src/fixtures.ts:338) and from packages/common-utils/src/__tests__/metadata.int.test.ts because the 26.5→26.8 bump changed the tags inner table. handleLabelLookup still reads timeSeriesTags()/timeSeriesSelector() columns directly, so the two endpoints most coupled to that schema are the ones left behind, and they will break on the next engine change. The plumbing is already here (proxyToPrometheus takes headers, and database/table are allowlisted), so route these through ${CLICKHOUSE_PROMETHEUS_API_PREFIX}/labels and /label/{name}/values when the probe succeeds, keeping the table-function query as the same fallback.

wrn14897 added a commit that referenced this pull request Sep 18, 2026
The apk break that killed the last release came in from outside the
repo. The Dockerfile pins floating upstream tags, and Docker Hub
re-pointed clickhouse-server:26.8-alpine from ClickHouse 26.8.6 to
26.8.7 around 15:00 UTC, taking apk-tools from 3.0.6 to 3.0.8 with it.

Nothing we had would catch that:

  21:22 (D-1)  PR #3142 Docker Build   -> passes, base @9f10a193 (3.0.6)
  00:36        nightly                 -> passes, base @9f10a193 (3.0.6)
  ~15:01       upstream re-tags 26.8-alpine  -> @2bece7c6 (3.0.8)
  16:17        merge to main           -> no image build
  17:48        merge to main           -> no image build
  19:31        Release                 -> fails

Both gates were green because both ran before the re-tag, and the two
merges that followed it built no images: Docker Build only ran on pull
requests, and its path filter would have skipped those diffs anyway.
The next thing to touch the new base was the release itself.

So run Docker Build on pushes to main too, and ignore the path filter
there. The filter is right for pull requests, where the question is
"did this diff break the image" and staying fast matters; it is the
wrong instrument on main, where the question is "does the image still
build today". Both all-in-one targets and the end-to-end smoke test run
as they already do on pull requests, so a drifted base now shows up as
a red build on the next merge rather than in a release hours later.

The twelve copies of the changed-files condition collapse into one
`gate` step per job, since the condition now has two variables rather
than one. Pull-request and workflow_dispatch behaviour is unchanged.

Concurrency is keyed on the event as well as the ref: `github.ref` is
the same for every push to main, and the existing cancel-in-progress
group would have each merge cancel the one before it and leave that
commit unverified. Pull requests keep cancel-on-new-push.

Also set `pull: true` on the nightly's three image builds and on the
all-in-one PR build, so the floating tags are re-resolved against the
registry instead of whatever the runner happens to hold. Fresh runners
already re-resolve, so this is hygiene rather than the fix — it keeps
holding if these ever move to a persistent builder.
kodiakhq Bot pushed a commit that referenced this pull request Sep 22, 2026
## Summary

Fixes the otel smoke tests, which fail because #3142 added a prometheus related ClickHouse config property that causes the (deliberately older) ClickHouse compat version used in the otel smoke tests to fail on startup. This is fixed by removing the prometheus setting from the compat container's config.

To prevent this in the future, the conditions for running the smoke tests have been widened to include changes to the ClickHouse config.

Related: Smoke tests are failing on #3100.

### Screenshots or video

No behavior changes.

### How to test on Vercel preview

No behavior changes.

### References



- Linear Issue: Closes HDX-5434
- Related PRs:

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 21cfdec5 Deployed Sep 17, 2026 by vercel[bot]
Preview – hyperdx-storybook — 21cfdec5 Deployed Sep 17, 2026 by vercel[bot]
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