diff --git a/.changeset/promql-via-clickhouse-http-api.md b/.changeset/promql-via-clickhouse-http-api.md
new file mode 100644
index 0000000000..a4995bc598
--- /dev/null
+++ b/.changeset/promql-via-clickhouse-http-api.md
@@ -0,0 +1,7 @@
+---
+'@hyperdx/api': minor
+---
+
+feat: run PromQL against ClickHouse through its Prometheus HTTP API
+
+ClickHouse-backed `/query` and `/query_range` now proxy to the connection's `/prometheus/api/v1/*` endpoint (the `prometheus_api_v1` HTTP handler, ClickHouse 26.8+) instead of calling the `prometheusQuery`/`prometheusQueryRange` table functions. The `database` and `table` query parameters are forwarded so one handler serves any TimeSeries table. ClickHouse holds the Prometheus API forward-compatible while the TimeSeries engine is in preview; the table functions and inner-table schema are not. Bundled ClickHouse images move to 26.8.
diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
index bc65a426e5..bbc07b9799 100644
--- a/docker-compose.ci.yml
+++ b/docker-compose.ci.yml
@@ -22,7 +22,7 @@ services:
depends_on:
- ch-server
ch-server:
- image: clickhouse/clickhouse-server:26.5-alpine
+ image: clickhouse/clickhouse-server:26.8-alpine
environment:
# default settings
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index deae228acf..54200ca08a 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -105,7 +105,7 @@ services:
hdx.dev.service: clickhouse
hdx.dev.port: '${HDX_DEV_CH_HTTP_PORT:-8123}'
hdx.dev.url: 'http://localhost:${HDX_DEV_CH_HTTP_PORT:-8123}'
- image: clickhouse/clickhouse-server:26.5-alpine
+ image: clickhouse/clickhouse-server:26.8-alpine
ports:
- '${HDX_DEV_CH_HTTP_PORT:-8123}:8123' # http api
- '${HDX_DEV_CH_NATIVE_PORT:-9000}:9000' # native
diff --git a/docker-compose.yml b/docker-compose.yml
index feda9debd4..5a02572925 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -96,7 +96,7 @@ services:
- ch-server
- db
ch-server:
- image: clickhouse/clickhouse-server:26.5-alpine
+ image: clickhouse/clickhouse-server:26.8-alpine
# WARNING: Exposing the database port will make it accessible from outside the container,
# potentially allowing unauthorized access. If you uncomment the ports below,
# ensure to secure your database (e.g., with strong authentication, proper network rules, and firewalls).
diff --git a/docker/clickhouse/local/config.xml b/docker/clickhouse/local/config.xml
index cea179de32..9257ef6b0c 100644
--- a/docker/clickhouse/local/config.xml
+++ b/docker/clickhouse/local/config.xml
@@ -73,6 +73,19 @@
+
+
+
+ /prometheus/api/v1
+
+ prometheus_api_v1
+
+
+
+
+
system
diff --git a/docker/hyperdx/Dockerfile b/docker/hyperdx/Dockerfile
index 1568263449..d13edc5d21 100644
--- a/docker/hyperdx/Dockerfile
+++ b/docker/hyperdx/Dockerfile
@@ -8,7 +8,7 @@
# - Otel Collector (otelcol)
ARG NODE_VERSION=22.22
-ARG CLICKHOUSE_VERSION=26.5
+ARG CLICKHOUSE_VERSION=26.8
ARG OTEL_COLLECTOR_VERSION=0.155.0
ARG OTEL_COLLECTOR_CORE_VERSION=1.61.0
diff --git a/docker/hyperdx/clickhouseConfig.xml b/docker/hyperdx/clickhouseConfig.xml
index 50cab2ce7f..db8bbc1dc5 100644
--- a/docker/hyperdx/clickhouseConfig.xml
+++ b/docker/hyperdx/clickhouseConfig.xml
@@ -47,6 +47,16 @@
+
+
+ /prometheus/api/v1
+
+ prometheus_api_v1
+
+
+
+
+
system
diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts
index 45d8305646..3e270f01e9 100644
--- a/packages/api/jest.setup.ts
+++ b/packages/api/jest.setup.ts
@@ -30,7 +30,11 @@ jest.mock('@/utils/slack', () => ({
postMessageToWebhook: jest.fn().mockResolvedValue(null),
}));
-// Mock global fetch for generic webhook calls
+// Mock global fetch for generic webhook calls. The real one is kept on
+// `globalThis.realFetch` so a test that needs to reach a live service (e.g.
+// ClickHouse's Prometheus API) can `mockFetch.mockImplementation(realFetch)`
+// for its duration.
+Object.assign(globalThis, { realFetch: global.fetch });
global.fetch = jest.fn().mockResolvedValue({
ok: true,
text: jest.fn().mockResolvedValue(''),
diff --git a/packages/api/src/controllers/__tests__/timeseriesEngine.test.ts b/packages/api/src/controllers/__tests__/timeseriesEngine.test.ts
new file mode 100644
index 0000000000..f1f0d2b36e
--- /dev/null
+++ b/packages/api/src/controllers/__tests__/timeseriesEngine.test.ts
@@ -0,0 +1,47 @@
+import {
+ formatMatrixResponse,
+ formatVectorResponse,
+} from '@/controllers/timeseriesEngine';
+
+describe('formatMatrixResponse', () => {
+ it('converts prometheusQueryRange rows into Prometheus matrix shape', () => {
+ const rows = [
+ {
+ tags: [
+ ['__name__', 'http_requests_total'],
+ ['method', 'GET'],
+ ] as [string, string][],
+ samples: [
+ [1700000000, 5],
+ ['2023-11-14T22:14:20.000Z', 7],
+ ] as [string | number, number][],
+ },
+ ];
+ expect(formatMatrixResponse(rows as any)).toEqual([
+ {
+ metric: { __name__: 'http_requests_total', method: 'GET' },
+ values: [
+ [1700000000, '5'],
+ [1700000060, '7'],
+ ],
+ },
+ ]);
+ });
+
+ it('returns empty array for empty input', () => {
+ expect(formatMatrixResponse([])).toEqual([]);
+ });
+});
+
+describe('formatVectorResponse', () => {
+ it('converts prometheusQuery rows into Prometheus vector shape', () => {
+ const rows = [
+ { tags: [['service', 'api']], timestamp: 1700000000, value: 42 },
+ { tags: [], timestamp: '2023-11-14T22:13:20.000Z', value: 3 },
+ ];
+ expect(formatVectorResponse(rows as any)).toEqual([
+ { metric: { service: 'api' }, value: [1700000000, '42'] },
+ { metric: {}, value: [1700000000, '3'] },
+ ]);
+ });
+});
diff --git a/packages/api/src/controllers/timeseriesEngine.ts b/packages/api/src/controllers/timeseriesEngine.ts
index 2ce263f340..2280012fb3 100644
--- a/packages/api/src/controllers/timeseriesEngine.ts
+++ b/packages/api/src/controllers/timeseriesEngine.ts
@@ -3,6 +3,7 @@ import {
chSql,
concatChSql,
} from '@hyperdx/common-utils/dist/clickhouse';
+import { supportsPrometheusHttpApi } from '@hyperdx/common-utils/dist/core/clickhouseVersion';
import {
Metadata,
MetadataCache,
@@ -241,10 +242,173 @@ export async function queryLabelNames({
limit,
...args
}: TimeSeriesTagsQueryArgs): Promise {
- // `all_tags` is EPHEMERAL by default, so the label names come from `tags`,
- // which the engine strips `__name__` out of. It is folded back in explicitly.
+ // The engine keeps `__name__` in `metric_name`, not `tags`, so it is folded
+ // back in explicitly.
const value = chSql`arrayJoin(arrayConcat([${{ String: '__name__' }}], mapKeys(${{ Identifier: 'tags' }})))`;
const conditions = await getSeriesFilterConditions(args);
return queryDistinctTagsValues({ ...args, value, conditions, limit });
}
+
+// --------------------------
+// PromQL via table functions (ClickHouse < 26.6)
+// --------------------------
+//
+// Servers without the `prometheus_api_v1` HTTP handler can still evaluate
+// PromQL through `prometheusQuery`/`prometheusQueryRange`. ClickHouse only
+// holds the HTTP API forward-compatible while TimeSeries is in preview, so
+// this path is a fallback for old servers, not the primary route.
+
+type PrometheusMetric = Record;
+export type PrometheusMatrixResult = {
+ metric: PrometheusMetric;
+ values: [number, string][];
+};
+export type PrometheusVectorResult = {
+ metric: PrometheusMetric;
+ value: [number, string];
+};
+
+const toUnixSeconds = (timestamp: string | number) =>
+ typeof timestamp === 'string'
+ ? new Date(timestamp).getTime() / 1000
+ : Number(timestamp);
+
+export function formatMatrixResponse(
+ rows: { tags: [string, string][]; samples: [string, number][] }[],
+): PrometheusMatrixResult[] {
+ return rows.map(row => ({
+ metric: Object.fromEntries(row.tags),
+ values: row.samples.map(([ts, value]) => [
+ toUnixSeconds(ts),
+ String(value),
+ ]),
+ }));
+}
+
+/**
+ * The name of the per-series samples column, which the engine renamed from
+ * `time_series` to `samples` in TimeSeries schema version 3. The rename
+ * follows the table's pinned version, not the server's, so a v2 table on a new
+ * server still reads `time_series`. `prometheusQueryRange` returns whichever
+ * the table uses. Looked up fresh each call: a throwaway cache, so a table
+ * dropped and recreated at a new version is seen at once.
+ */
+async function timeSeriesSamplesColumn({
+ client,
+ connectionId,
+ databaseName,
+ tableName,
+}: {
+ client: ClickhouseClient;
+ connectionId: string;
+ databaseName: string;
+ tableName: string;
+}): Promise<'samples' | 'time_series'> {
+ const metadata = new Metadata(client, new MetadataCache());
+ const version = await metadata.getTimeSeriesTableVersion({
+ connectionId,
+ databaseName,
+ tableName,
+ });
+ return version >= 3 ? 'samples' : 'time_series';
+}
+
+export function formatVectorResponse(
+ rows: { tags: [string, string][]; timestamp: string; value: number }[],
+): PrometheusVectorResult[] {
+ return rows.map(row => ({
+ metric: Object.fromEntries(row.tags),
+ value: [toUnixSeconds(row.timestamp), String(row.value)],
+ }));
+}
+
+const PROMQL_TABLE_FUNCTION_SETTINGS = {
+ allow_experimental_time_series_table: 1,
+ max_execution_time: PROMETHEUS_MAX_EXECUTION_SEC,
+ max_result_rows: String(PROMETHEUS_MAX_RESULT_ROWS),
+} as const;
+
+export async function queryRangeViaTableFunction({
+ client,
+ connectionId,
+ databaseName,
+ tableName,
+ expr,
+ startMs,
+ endMs,
+ stepSec,
+}: {
+ client: ClickhouseClient;
+ connectionId: string;
+ databaseName: string;
+ tableName: string;
+ expr: string;
+ startMs: number;
+ endMs: number;
+ stepSec: number;
+}): Promise {
+ const samplesColumn = await timeSeriesSamplesColumn({
+ client,
+ connectionId,
+ databaseName,
+ tableName,
+ });
+ const resp = await client.query({
+ query: `SELECT tags, ${samplesColumn} AS samples FROM prometheusQueryRange({db:String}, {table:String}, {expr:String}, fromUnixTimestamp64Milli({startMs:Int64}), fromUnixTimestamp64Milli({endMs:Int64}), toIntervalSecond({stepSec:UInt32})) SETTINGS allow_experimental_time_series_table = 1`,
+ query_params: {
+ db: databaseName,
+ table: tableName,
+ expr,
+ startMs,
+ endMs,
+ stepSec,
+ },
+ format: 'JSON',
+ clickhouse_settings: PROMQL_TABLE_FUNCTION_SETTINGS,
+ });
+ const json = await resp.json();
+ return formatMatrixResponse(json.data);
+}
+
+export async function queryInstantViaTableFunction({
+ client,
+ databaseName,
+ tableName,
+ expr,
+ evalMs,
+}: {
+ client: ClickhouseClient;
+ databaseName: string;
+ tableName: string;
+ expr: string;
+ evalMs: number;
+}): Promise {
+ const resp = await client.query({
+ query: `SELECT tags, timestamp, value FROM prometheusQuery({db:String}, {table:String}, {expr:String}, fromUnixTimestamp64Milli({evalMs:Int64})) SETTINGS allow_experimental_time_series_table = 1`,
+ query_params: { db: databaseName, table: tableName, expr, evalMs },
+ format: 'JSON',
+ clickhouse_settings: PROMQL_TABLE_FUNCTION_SETTINGS,
+ });
+ const json = await resp.json();
+ return formatVectorResponse(json.data);
+}
+
+/**
+ * Whether the connection's ClickHouse is new enough for the Prometheus HTTP
+ * API. `SELECT version()` runs each call (throwaway cache) so an upgraded
+ * server is picked up without a restart; an unknown version is treated as
+ * old, matching the other version-gated features.
+ */
+export async function connectionSupportsPrometheusHttpApi({
+ client,
+ connectionId,
+}: {
+ client: ClickhouseClient;
+ connectionId: string;
+}): Promise {
+ const metadata = new Metadata(client, new MetadataCache());
+ return supportsPrometheusHttpApi(
+ await metadata.getServerVersion({ connectionId }),
+ );
+}
diff --git a/packages/api/src/fixtures.ts b/packages/api/src/fixtures.ts
index 407a1733cc..414ac9eebf 100644
--- a/packages/api/src/fixtures.ts
+++ b/packages/api/src/fixtures.ts
@@ -336,15 +336,11 @@ export const seedTimeSeriesTagsTable = async ({
.join(', ')})`;
const columns = storeTimeBounds
- ? '(metric_name, tags, all_tags, min_time, max_time)'
- : '(metric_name, tags, all_tags)';
+ ? '(metric_name, tags, min_time, max_time)'
+ : '(metric_name, tags)';
const values = series
.map(s => {
- const row = [
- quoted(s.metricName),
- mapLiteral(s.tags),
- mapLiteral({ __name__: s.metricName, ...s.tags }),
- ];
+ const row = [quoted(s.metricName), mapLiteral(s.tags)];
if (storeTimeBounds) {
row.push(
`toDateTime64(${s.startSec}, 3)`,
diff --git a/packages/api/src/routers/api/__tests__/prometheus.int.test.ts b/packages/api/src/routers/api/__tests__/prometheus.int.test.ts
index ac92c06efe..d7dbf94255 100644
--- a/packages/api/src/routers/api/__tests__/prometheus.int.test.ts
+++ b/packages/api/src/routers/api/__tests__/prometheus.int.test.ts
@@ -1,3 +1,4 @@
+import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import { Types } from 'mongoose';
import * as config from '@/config';
@@ -10,10 +11,19 @@ import {
seedTimeSeriesTagsTable,
} from '@/fixtures';
import Connection from '@/models/connection';
-import { PROMETHEUS_MAX_EXEMPLAR_WINDOW_SEC } from '@/routers/api/prometheus';
+import {
+ CLICKHOUSE_PROMETHEUS_API_PREFIX,
+ clickhousePrometheusUpstream,
+ joinPrometheusUpstreamUrl,
+ PROMETHEUS_MAX_EXEMPLAR_WINDOW_SEC,
+} from '@/routers/api/prometheus';
const mockFetch = jest.mocked(global.fetch);
+// Set by jest.setup.ts before it stubs `fetch`, for tests that must reach a
+// live service.
+const { realFetch } = globalThis as unknown as { realFetch: typeof fetch };
+
const matchQueryString = (selectors: string[]) =>
selectors.map(s => `match[]=${encodeURIComponent(s)}`).join('&');
@@ -43,6 +53,12 @@ function fakeUpstreamResponse(
} as unknown as Response;
}
+// What ClickHouse's prometheus_api_v1 handler answers the router's
+// `format_query` probe with. Queue this before the real upstream response in any
+// test that expects a ClickHouse connection to be proxied.
+const handlerProbeResponse = () =>
+ fakeUpstreamResponse({ status: 'success', data: 'up' });
+
describe('prometheus router', () => {
const server = getServer();
@@ -685,20 +701,390 @@ describe('prometheus router', () => {
expect(requested.searchParams.get('step')).toBe('15s');
});
- it('does NOT proxy to Prometheus when connection is not isPrometheusEndpoint', async () => {
+ it('proxies a ClickHouse connection to its prometheus_api_v1 handler with db/table and credentials', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+ const promResponse = {
+ status: 'success',
+ data: { resultType: 'matrix', result: [] },
+ };
+ mockFetch
+ .mockResolvedValueOnce(handlerProbeResponse())
+ .mockResolvedValueOnce(fakeUpstreamResponse(promResponse));
+
+ const res = await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'up',
+ start: '1700000000',
+ end: '1700000060',
+ step: '15s',
+ database: 'otel',
+ table: 'metrics_ts',
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ expect(res.body).toEqual(promResponse);
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ const [probeUrl, probeInit] = mockFetch.mock.calls[0];
+ expect(String(probeUrl)).toContain(
+ `${config.CLICKHOUSE_HOST}/prometheus/api/v1/format_query?query=up`,
+ );
+ expect(probeInit?.headers).toMatchObject({
+ 'X-ClickHouse-User': config.CLICKHOUSE_USER,
+ });
+ const [calledUrl, init] = mockFetch.mock.calls[1];
+ expect(calledUrl).toContain(
+ `${config.CLICKHOUSE_HOST}/prometheus/api/v1/query_range`,
+ );
+ expect(calledUrl).toContain('database=otel');
+ expect(calledUrl).toContain('table=metrics_ts');
+ expect(calledUrl).toContain('max_execution_time=30');
+ expect(calledUrl).toContain('max_result_rows=100000');
+ expect(calledUrl).not.toContain('connectionId');
+ expect(init?.headers).toMatchObject({
+ 'X-ClickHouse-User': config.CLICKHOUSE_USER,
+ 'X-ClickHouse-Key': config.CLICKHOUSE_PASSWORD,
+ });
+ });
+
+ it('does not let the request loosen the pinned ClickHouse limits', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+ mockFetch.mockResolvedValueOnce(handlerProbeResponse());
+
+ await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'up',
+ start: '1700000000',
+ end: '1700000060',
+ step: '15s',
+ table: 'metrics_ts',
+ max_execution_time: '9999',
+ max_result_rows: '0',
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ const calledUrl = new URL(String(mockFetch.mock.calls[1][0]));
+ expect(calledUrl.searchParams.getAll('max_execution_time')).toEqual([
+ '30',
+ ]);
+ expect(calledUrl.searchParams.getAll('max_result_rows')).toEqual([
+ '100000',
+ ]);
+ });
+
+ it('falls back to prometheusQueryRange() when the server has no prometheus_api_v1 handler', async () => {
const { agent, team } = await getLoggedInAgent(server);
const conn = await seedClickHouseConnection(team._id);
+ // A stock ClickHouse answers the probe with a plain-text 404.
+ mockFetch.mockResolvedValueOnce({
+ status: 404,
+ text: jest
+ .fn()
+ .mockResolvedValue(
+ 'There is no handle /prometheus/api/v1/format_query',
+ ),
+ } as unknown as Response);
+
+ const res = await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'up',
+ start: '1700000000',
+ end: '1700000060',
+ step: '60s',
+ table: 'no_such_table_for_fallback',
+ connectionId: conn._id.toString(),
+ })
+ .expect(400);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ // The table function ran (and failed on the missing table); the
+ // upstream was never asked to evaluate the query.
+ expect(res.body.error).toMatch(/no_such_table_for_fallback/);
+ });
+
+ // The one place the proxy meets a real prometheus_api_v1 handler: the CI
+ // ClickHouse (26.8, docker/clickhouse/local/config.xml) serves it, so the
+ // stubbed fetch is swapped for the real one and the answer has to be
+ // Prometheus-shaped data from a seeded table. Every other proxy test only
+ // pins the request shape.
+ describe('against the CI ClickHouse prometheus_api_v1 handler', () => {
+ const TABLE = 'prom_http_api_live';
+ const START = 1700000000;
+
+ beforeAll(async () => {
+ await seedTimeSeriesTagsTable({
+ table: TABLE,
+ series: [
+ {
+ metricName: 'live_up',
+ tags: { job: 'api' },
+ startSec: START,
+ endSec: START + 60,
+ },
+ {
+ metricName: 'live_up',
+ tags: { job: 'web' },
+ startSec: START,
+ endSec: START + 60,
+ },
+ ],
+ withSamples: true,
+ });
+ });
+
+ afterAll(async () => {
+ await dropTimeSeriesTable({ table: TABLE });
+ });
+
+ beforeEach(() => {
+ mockFetch.mockImplementation(realFetch);
+ });
+
+ it('answers a range query with the series from the requested table', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'sum by (job) (live_up)',
+ start: String(START),
+ end: String(START + 60),
+ step: '60s',
+ database: DEFAULT_DATABASE,
+ table: TABLE,
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ expect(res.headers['content-type']).toMatch(/application\/json/);
+ expect(res.body.status).toBe('success');
+ expect(res.body.data.resultType).toBe('matrix');
+ const byJob = Object.fromEntries(
+ res.body.data.result.map((r: any) => [r.metric.job, r.values]),
+ );
+ expect(Object.keys(byJob).sort()).toEqual(['api', 'web']);
+ expect(byJob.api).toEqual(
+ expect.arrayContaining([
+ [START, '1'],
+ [START + 60, '1'],
+ ]),
+ );
+ });
+
+ it('answers an instant query', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query')
+ .query({
+ query: 'count(live_up)',
+ time: String(START + 60),
+ database: DEFAULT_DATABASE,
+ table: TABLE,
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ expect(res.body).toEqual({
+ status: 'success',
+ data: {
+ resultType: 'vector',
+ result: [{ metric: {}, value: [START + 60, '2'] }],
+ },
+ });
+ });
+
+ // The router's limits are compile-time constants far above anything a
+ // test can exceed, so the same URL builder is driven at tiny values
+ // straight at the handler. This pins the property the router relies on:
+ // settings in the URL are applied to the PromQL evaluation and a breach
+ // comes back as a Prometheus 400, not as a truncated 200.
+ const liveRangeQuery = async (limits: {
+ maxExecutionSec?: number;
+ maxResultRows?: number;
+ }) => {
+ const url = joinPrometheusUpstreamUrl(
+ clickhousePrometheusUpstream(config.CLICKHOUSE_HOST, limits),
+ `${CLICKHOUSE_PROMETHEUS_API_PREFIX}/query_range`,
+ );
+ for (const [k, v] of Object.entries({
+ query: 'live_up',
+ start: String(START),
+ end: String(START + 60),
+ step: '60',
+ database: DEFAULT_DATABASE,
+ table: TABLE,
+ })) {
+ url.searchParams.set(k, v);
+ }
+ const resp = await realFetch(url, {
+ headers: {
+ 'X-ClickHouse-User': config.CLICKHOUSE_USER,
+ 'X-ClickHouse-Key': config.CLICKHOUSE_PASSWORD,
+ },
+ });
+ return { status: resp.status, body: await resp.json() };
+ };
+
+ it('enforces max_result_rows pinned on the upstream URL', async () => {
+ const { status, body } = await liveRangeQuery({ maxResultRows: 1 });
+ expect(status).toBe(400);
+ expect(body).toMatchObject({
+ status: 'error',
+ errorType: 'bad_data',
+ error: expect.stringMatching(/Limit for result exceeded/),
+ });
+ });
+
+ it('enforces max_execution_time pinned on the upstream URL', async () => {
+ const { status, body } = await liveRangeQuery({
+ maxExecutionSec: 0.0001,
+ });
+ expect(status).toBe(400);
+ expect(body).toMatchObject({
+ status: 'error',
+ errorType: 'bad_data',
+ error: expect.stringMatching(/Timeout exceeded/),
+ });
+ });
+
+ it('answers in full at the limits the router pins', async () => {
+ const { status, body } = await liveRangeQuery({});
+ expect(status).toBe(200);
+ expect(body.data.result).toHaveLength(2);
+ });
+
+ it('relays a PromQL parse error as a Prometheus 400', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query')
+ .query({
+ query: 'live_up(',
+ table: TABLE,
+ connectionId: conn._id.toString(),
+ })
+ .expect(400);
- // ClickHouse path: will likely fail with 400 because metrics_ts
- // is not seeded in the test CH, but the routing decision is what we
- // care about — fetch must not be called.
- await agent.get('/v1/prometheus/query_range').query({
- query: 'up',
- start: '1700000000',
- end: '1700000060',
- connectionId: conn._id.toString(),
+ expect(res.body).toMatchObject({
+ status: 'error',
+ errorType: 'bad_data',
+ error: expect.stringContaining('PromQL'),
+ });
+ });
+ });
+
+ describe('on ClickHouse without the Prometheus HTTP API (< 26.6)', () => {
+ const TABLE = 'prom_query_range_fallback';
+ const START = 1700000000;
+
+ beforeAll(async () => {
+ await seedTimeSeriesTagsTable({
+ table: TABLE,
+ series: [
+ {
+ metricName: 'fallback_up',
+ tags: { job: 'api' },
+ startSec: START,
+ endSec: START + 60,
+ },
+ ],
+ withSamples: true,
+ });
+ });
+
+ afterAll(async () => {
+ await dropTimeSeriesTable({ table: TABLE });
+ });
+
+ beforeEach(() => {
+ jest
+ .spyOn(Metadata.prototype, 'getServerVersion')
+ .mockResolvedValue([26, 5, 0, 0]);
});
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('evaluates through prometheusQueryRange() instead of proxying', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'fallback_up',
+ start: String(START),
+ end: String(START + 60),
+ step: '60s',
+ database: DEFAULT_DATABASE,
+ table: TABLE,
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ expect(res.body.status).toBe('success');
+ expect(res.body.data.resultType).toBe('matrix');
+ expect(res.body.data.result).toEqual([
+ {
+ metric: { __name__: 'fallback_up', job: 'api' },
+ values: expect.arrayContaining([[START, '1']]),
+ },
+ ]);
+ });
+
+ it('evaluates an instant query through prometheusQuery()', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query')
+ .query({
+ query: 'fallback_up',
+ time: String(START + 60),
+ database: DEFAULT_DATABASE,
+ table: TABLE,
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ expect(res.body.data.resultType).toBe('vector');
+ expect(res.body.data.result).toEqual([
+ {
+ metric: { __name__: 'fallback_up', job: 'api' },
+ value: [START + 60, '1'],
+ },
+ ]);
+ });
+ });
+
+ it('returns 400 before proxying when a ClickHouse connection has no table', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+
+ const res = await agent
+ .get('/v1/prometheus/query_range')
+ .query({
+ query: 'up',
+ start: '1700000000',
+ end: '1700000060',
+ connectionId: conn._id.toString(),
+ })
+ .expect(400);
+
+ expect(res.body.error).toContain('table parameter required');
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -775,6 +1161,39 @@ describe('prometheus router', () => {
expect(calledUrl).toContain('/api/v1/query');
expect(calledUrl).not.toContain('/api/v1/query_range');
});
+
+ it('proxies a ClickHouse connection to its prometheus_api_v1 handler', async () => {
+ const { agent, team } = await getLoggedInAgent(server);
+ const conn = await seedClickHouseConnection(team._id);
+ mockFetch
+ .mockResolvedValueOnce(handlerProbeResponse())
+ .mockResolvedValueOnce(
+ fakeUpstreamResponse({
+ status: 'success',
+ data: { resultType: 'vector', result: [] },
+ }),
+ );
+
+ await agent
+ .get('/v1/prometheus/query')
+ .query({
+ query: 'up',
+ table: 'metrics_ts',
+ connectionId: conn._id.toString(),
+ })
+ .expect(200);
+
+ const [calledUrl, init] = mockFetch.mock.calls[1];
+ expect(calledUrl).toContain(
+ `${config.CLICKHOUSE_HOST}/prometheus/api/v1/query?`,
+ );
+ expect(calledUrl).toContain('table=metrics_ts');
+ expect(calledUrl).toContain('max_execution_time=30');
+ expect(calledUrl).toContain('max_result_rows=100000');
+ expect(init?.headers).toMatchObject({
+ 'X-ClickHouse-User': config.CLICKHOUSE_USER,
+ });
+ });
});
describe('GET /v1/prometheus/label/:name/values', () => {
diff --git a/packages/api/src/routers/api/__tests__/prometheus.test.ts b/packages/api/src/routers/api/__tests__/prometheus.test.ts
index dbf11461bf..4fc42e1510 100644
--- a/packages/api/src/routers/api/__tests__/prometheus.test.ts
+++ b/packages/api/src/routers/api/__tests__/prometheus.test.ts
@@ -9,8 +9,6 @@ jest.mock('@/utils/instrumentation', () => {
});
import {
- formatMatrixResponse,
- formatVectorResponse,
isClientDisconnect,
joinPrometheusUpstreamUrl,
parseDuration,
@@ -73,81 +71,6 @@ describe('parseDuration', () => {
});
});
-describe('formatMatrixResponse', () => {
- it('converts ClickHouse rows into Prometheus matrix shape', () => {
- const rows = [
- {
- tags: [
- ['__name__', 'http_requests_total'],
- ['method', 'GET'],
- ] as [string, string][],
- time_series: [
- [1700000000, 5],
- [1700000060, 7],
- ] as [string | number, number][],
- },
- ];
- expect(formatMatrixResponse(rows as any)).toEqual([
- {
- metric: { __name__: 'http_requests_total', method: 'GET' },
- values: [
- [1700000000, '5'],
- [1700000060, '7'],
- ],
- },
- ]);
- });
-
- it('converts string timestamps to unix seconds', () => {
- const rows = [
- {
- tags: [] as [string, string][],
- time_series: [['2023-11-14T22:13:20.000Z', 1]] as [
- string | number,
- number,
- ][],
- },
- ];
- expect(formatMatrixResponse(rows as any)[0].values[0]).toEqual([
- 1700000000,
- '1',
- ]);
- });
-
- it('returns empty array for empty input', () => {
- expect(formatMatrixResponse([])).toEqual([]);
- });
-});
-
-describe('formatVectorResponse', () => {
- it('converts ClickHouse rows into Prometheus vector shape', () => {
- const rows = [
- {
- tags: [['service', 'api']] as [string, string][],
- timestamp: 1700000000 as unknown as string,
- value: 42,
- },
- ];
- expect(formatVectorResponse(rows as any)).toEqual([
- { metric: { service: 'api' }, value: [1700000000, '42'] },
- ]);
- });
-
- it('converts string timestamps to unix seconds', () => {
- const rows = [
- {
- tags: [] as [string, string][],
- timestamp: '2023-11-14T22:13:20.000Z',
- value: 3,
- },
- ];
- expect(formatVectorResponse(rows as any)[0].value).toEqual([
- 1700000000,
- '3',
- ]);
- });
-});
-
describe('resolveExemplarWindow', () => {
const DAY = 24 * 60 * 60;
const end = 1_700_000_000;
diff --git a/packages/api/src/routers/api/prometheus.ts b/packages/api/src/routers/api/prometheus.ts
index c0f018a0ec..ca88281827 100644
--- a/packages/api/src/routers/api/prometheus.ts
+++ b/packages/api/src/routers/api/prometheus.ts
@@ -7,10 +7,13 @@ import z from 'zod';
import { ClickhouseClient } from '@/clickhouse';
import { getConnectionById } from '@/controllers/connection';
import {
+ connectionSupportsPrometheusHttpApi,
PROMETHEUS_MAX_EXECUTION_SEC,
PROMETHEUS_MAX_RESULT_ROWS,
+ queryInstantViaTableFunction,
queryLabelNames,
queryLabelValues,
+ queryRangeViaTableFunction,
TimeSeriesTagsQueryArgs,
} from '@/controllers/timeseriesEngine';
import { getNonNullUserWithTeam } from '@/middleware/auth';
@@ -100,61 +103,6 @@ function getParams(req: express.Request): Record {
};
}
-// --------------------------
-// Prometheus-compatible response types
-// --------------------------
-
-type PrometheusMetric = Record;
-type PrometheusMatrixResult = {
- metric: PrometheusMetric;
- values: [number, string][];
-};
-type PrometheusVectorResult = {
- metric: PrometheusMetric;
- value: [number, string];
-};
-
-// --------------------------
-// ClickHouse → Prometheus response formatters
-// --------------------------
-
-export function formatMatrixResponse(
- rows: { tags: [string, string][]; time_series: [string, number][] }[],
-): PrometheusMatrixResult[] {
- return rows.map(row => {
- const metric: PrometheusMetric = {};
- for (const [key, value] of row.tags) {
- metric[key] = value;
- }
- const values: [number, string][] = row.time_series.map(
- ([timestamp, value]) => {
- const ts =
- typeof timestamp === 'string'
- ? new Date(timestamp).getTime() / 1000
- : Number(timestamp);
- return [ts, String(value)];
- },
- );
- return { metric, values };
- });
-}
-
-export function formatVectorResponse(
- rows: { tags: [string, string][]; timestamp: string; value: number }[],
-): PrometheusVectorResult[] {
- return rows.map(row => {
- const metric: PrometheusMetric = {};
- for (const [key, value] of row.tags) {
- metric[key] = value;
- }
- const ts =
- typeof row.timestamp === 'string'
- ? new Date(row.timestamp).getTime() / 1000
- : Number(row.timestamp);
- return { metric, value: [ts, String(row.value)] };
- });
-}
-
// --------------------------
// Prometheus proxy (for real Prometheus backends)
// --------------------------
@@ -238,8 +186,104 @@ const CALLER_SETTABLE_PARAM_KEYS = new Set([
'limit',
'timeout',
'stats',
+ // ClickHouse's prometheus_api_v1 handler selects the TimeSeries table from
+ // these; a real Prometheus ignores them.
+ 'database',
+ 'table',
]);
+// ClickHouse serves the Prometheus HTTP API under this prefix on its main HTTP
+// port when the `prometheus_api_v1` handler is configured (26.6+).
+export const CLICKHOUSE_PROMETHEUS_API_PREFIX = '/prometheus/api/v1';
+
+/**
+ * The ClickHouse connection host with the query limits pinned as ClickHouse
+ * HTTP settings. The prometheus_api_v1 handler honours settings given in the
+ * URL and reports a breach as a Prometheus 400 (pinned by the live int test),
+ * so this is the only place to bound a PromQL evaluation — the request carries
+ * no SQL to attach SETTINGS to. Pinned on the host rather than passed as params
+ * so the merge in proxyToPrometheus (which lets the caller override any
+ * allowlisted key) can't loosen them.
+ */
+export function clickhousePrometheusUpstream(
+ host: string,
+ {
+ maxExecutionSec = PROMETHEUS_MAX_EXECUTION_SEC,
+ maxResultRows = PROMETHEUS_MAX_RESULT_ROWS,
+ } = {},
+): string {
+ const url = new URL(host);
+ url.searchParams.set('max_execution_time', String(maxExecutionSec));
+ url.searchParams.set('max_result_rows', String(maxResultRows));
+ return url.toString();
+}
+
+function newClickhouseClient(connection: {
+ host: string;
+ username: string;
+ password?: string;
+}) {
+ return new ClickhouseClient({
+ host: connection.host,
+ username: connection.username,
+ password: connection.password,
+ requestTimeout: PROMETHEUS_CH_TIMEOUT_MS,
+ });
+}
+
+/**
+ * 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
+ * `` 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.
+ */
+const PROBE_TIMEOUT_MS = 5_000;
+
+async function clickhouseServesPrometheusHttpApi(
+ client: ClickhouseClient,
+ connection: { id: string; host: string; username: string; password?: string },
+): Promise {
+ if (
+ !(await connectionSupportsPrometheusHttpApi({
+ client,
+ connectionId: connection.id,
+ }))
+ ) {
+ return false;
+ }
+
+ try {
+ const url = joinPrometheusUpstreamUrl(
+ connection.host,
+ `${CLICKHOUSE_PROMETHEUS_API_PREFIX}/format_query`,
+ );
+ url.searchParams.set('query', 'up');
+ const resp = await fetch(url, {
+ headers: clickhouseAuthHeaders(connection),
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ });
+ return typeof JSON.parse(await resp.text())?.status === 'string';
+ } catch {
+ return false;
+ }
+}
+
+function clickhouseAuthHeaders(connection: {
+ username: string;
+ password?: string;
+}): Record {
+ return {
+ 'X-ClickHouse-User': connection.username,
+ 'X-ClickHouse-Key': connection.password ?? '',
+ };
+}
+
// Forwards the response straight from the upstream Prometheus to the
// HyperDX client. Returns the HTTP status it wrote, so callers can record an
// error metric: this helper handles its own failures by writing 400/502/504 and
@@ -255,6 +299,7 @@ async function proxyToPrometheus(
path: string,
params: Record,
res: express.Response,
+ headers?: Record,
): Promise {
let url: URL;
try {
@@ -314,6 +359,7 @@ async function proxyToPrometheus(
let upstreamResp: Response;
try {
upstreamResp = await fetch(target, {
+ headers,
signal: AbortSignal.timeout(PROMETHEUS_PROXY_TIMEOUT_MS),
});
} catch (err) {
@@ -465,12 +511,7 @@ const queryRangeHandler: express.RequestHandler = async (req, res) => {
return;
}
- // Otherwise, use ClickHouse prometheusQuery()
backend = 'clickhouse';
- const start = parseTimestamp(params.start);
- const end = parseTimestamp(params.end);
- const step = parseDuration(params.step ?? '60s');
- const database = params.database ?? 'default';
const table = params.table;
if (!table) {
return res.status(400).json({
@@ -479,7 +520,9 @@ const queryRangeHandler: express.RequestHandler = async (req, res) => {
error: `table parameter required for querying clickhouse via promql`,
});
}
-
+ const start = parseTimestamp(params.start);
+ const end = parseTimestamp(params.end);
+ const step = parseDuration(params.step ?? '60s');
if (step <= 0 || (end - start) / step > PROMETHEUS_MAX_RESOLUTION) {
return res.status(400).json({
status: 'error',
@@ -488,44 +531,37 @@ const queryRangeHandler: express.RequestHandler = async (req, res) => {
});
}
- const client = new ClickhouseClient({
- host: connection.host,
- username: connection.username,
- password: connection.password,
- requestTimeout: PROMETHEUS_CH_TIMEOUT_MS,
- });
+ const client = newClickhouseClient(connection);
- const startMs = Math.floor(start * 1000);
- const endMs = Math.floor(end * 1000);
- const stepSec = Math.max(Math.floor(step), 1);
-
- const resp = await client.query({
- query: `SELECT tags, time_series FROM prometheusQueryRange({db:String}, {table:String}, {expr:String}, fromUnixTimestamp64Milli({startMs:Int64}), fromUnixTimestamp64Milli({endMs:Int64}), toIntervalSecond({stepSec:UInt32})) SETTINGS allow_experimental_time_series_table = 1`,
- query_params: {
- db: database,
- table,
- expr: query,
- startMs,
- endMs,
- stepSec,
- },
- format: 'JSON',
- clickhouse_settings: {
- allow_experimental_time_series_table: 1,
- max_execution_time: PROMETHEUS_MAX_EXECUTION_SEC,
- max_result_rows: String(PROMETHEUS_MAX_RESULT_ROWS),
- },
- });
-
- const json = await resp.json();
- const result = formatMatrixResponse(json.data);
+ // The Prometheus HTTP API is the only surface ClickHouse holds
+ // forward-compatible while TimeSeries is in preview, so it is preferred
+ // wherever the server has it (26.6+); the table function is the fallback.
+ if (await clickhouseServesPrometheusHttpApi(client, connection)) {
+ const status = await proxyToPrometheus(
+ clickhousePrometheusUpstream(connection.host),
+ `${CLICKHOUSE_PROMETHEUS_API_PREFIX}/query_range`,
+ params,
+ res,
+ clickhouseAuthHeaders(connection),
+ );
+ recordProxyOutcome(status, 'query_range', backend);
+ return;
+ }
+ // fallback for clickhouse version < 26.6
+ const result = await queryRangeViaTableFunction({
+ client,
+ connectionId,
+ databaseName: params.database ?? 'default',
+ tableName: table,
+ expr: query,
+ startMs: Math.floor(start * 1000),
+ endMs: Math.floor(end * 1000),
+ stepSec: Math.max(Math.floor(step), 1),
+ });
return res.json({
status: 'success',
- data: {
- resultType: 'matrix',
- result,
- },
+ data: { resultType: 'matrix', result },
});
} catch (e) {
prometheusQueryErrors.add(1, { endpoint: 'query_range', backend });
@@ -600,8 +636,6 @@ const queryHandler: express.RequestHandler = async (req, res) => {
}
backend = 'clickhouse';
- const time = params.time ? parseTimestamp(params.time) : undefined;
- const database = params.database ?? 'default';
const table = params.table;
if (!table) {
return res.status(400).json({
@@ -611,35 +645,33 @@ const queryHandler: express.RequestHandler = async (req, res) => {
});
}
- const client = new ClickhouseClient({
- host: connection.host,
- username: connection.username,
- password: connection.password,
- requestTimeout: PROMETHEUS_CH_TIMEOUT_MS,
- });
+ const client = newClickhouseClient(connection);
- const evalMs = time ? Math.floor(time * 1000) : Date.now();
-
- const resp = await client.query({
- query: `SELECT tags, timestamp, value FROM prometheusQuery({db:String}, {table:String}, {expr:String}, fromUnixTimestamp64Milli({evalMs:Int64})) SETTINGS allow_experimental_time_series_table = 1`,
- query_params: { db: database, table, expr: query, evalMs },
- format: 'JSON',
- clickhouse_settings: {
- allow_experimental_time_series_table: 1,
- max_execution_time: PROMETHEUS_MAX_EXECUTION_SEC,
- max_result_rows: String(PROMETHEUS_MAX_RESULT_ROWS),
- },
- });
-
- const json = await resp.json();
- const result = formatVectorResponse(json.data);
+ if (await clickhouseServesPrometheusHttpApi(client, connection)) {
+ const status = await proxyToPrometheus(
+ clickhousePrometheusUpstream(connection.host),
+ `${CLICKHOUSE_PROMETHEUS_API_PREFIX}/query`,
+ params,
+ res,
+ clickhouseAuthHeaders(connection),
+ );
+ recordProxyOutcome(status, 'query', backend);
+ return;
+ }
+ // fallback for clickhouse version < 26.6
+ const result = await queryInstantViaTableFunction({
+ client,
+ databaseName: params.database ?? 'default',
+ tableName: table,
+ expr: query,
+ evalMs: params.time
+ ? Math.floor(parseTimestamp(params.time) * 1000)
+ : Date.now(),
+ });
return res.json({
status: 'success',
- data: {
- resultType: 'vector',
- result,
- },
+ data: { resultType: 'vector', result },
});
} catch (e) {
prometheusQueryErrors.add(1, { endpoint: 'query', backend });
@@ -965,12 +997,7 @@ async function handleLabelLookup(
});
}
- const client = new ClickhouseClient({
- host: connection.host,
- username: connection.username,
- password: connection.password,
- requestTimeout: PROMETHEUS_CH_TIMEOUT_MS,
- });
+ const client = newClickhouseClient(connection);
const startMs = start != null ? Math.floor(start * 1000) : undefined;
const endMs = end != null ? Math.ceil(end * 1000) : undefined;
diff --git a/packages/app/tests/e2e/docker-compose.yml b/packages/app/tests/e2e/docker-compose.yml
index fecb0bf937..b8152ccc6c 100644
--- a/packages/app/tests/e2e/docker-compose.yml
+++ b/packages/app/tests/e2e/docker-compose.yml
@@ -15,7 +15,7 @@ services:
memory: 512M
cpus: '0.5'
ch-server:
- image: clickhouse/clickhouse-server:26.5-alpine
+ image: clickhouse/clickhouse-server:26.8-alpine
ports:
- ${HDX_E2E_CH_PORT:-20500}:8123 # http api
- ${HDX_E2E_CH_NATIVE_PORT:-20600}:9000 # native
diff --git a/packages/app/tests/e2e/seed-clickhouse.ts b/packages/app/tests/e2e/seed-clickhouse.ts
index e910e34fa4..98c537dc6f 100644
--- a/packages/app/tests/e2e/seed-clickhouse.ts
+++ b/packages/app/tests/e2e/seed-clickhouse.ts
@@ -577,7 +577,7 @@ function generateK8sGaugeMetrics(
: startMs;
for (let sample = 0; sample < samplesPerPod; sample++) {
const timestampMs = podSlotStart + sample * stepMs;
- const timestampNs = timestampMs * 1000000;
+ const timestampNs = Math.round(timestampMs * 1000000);
const _timeUnix = timestampMs / 1000;
// CPU metrics (percentage, 0-100)
@@ -594,39 +594,39 @@ function generateK8sGaugeMetrics(
// k8s.pod.phase
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.phase', 'Pod phase', '', {}, ${timestampNs}, ${timestampNs}, ${phase}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.phase', 'Pod phase', '', {}, '${timestampNs}', '${timestampNs}', ${phase}, 0, [], [], [], [], [])`,
);
// k8s.container.restarts
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.container.restarts', 'Container restarts', '', {}, ${timestampNs}, ${timestampNs}, ${restarts}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.container.restarts', 'Container restarts', '', {}, '${timestampNs}', '${timestampNs}', ${restarts}, 0, [], [], [], [], [])`,
);
// container.cpu.utilization (0-100%)
const containerCpuUtilization =
5 + (podIdx % 30) + Math.sin(sample / 3) * 3;
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'container.cpu.utilization', 'Container CPU utilization', '%', {}, ${timestampNs}, ${timestampNs}, ${containerCpuUtilization}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'container.cpu.utilization', 'Container CPU utilization', '%', {}, '${timestampNs}', '${timestampNs}', ${containerCpuUtilization}, 0, [], [], [], [], [])`,
);
// k8s.pod.cpu.utilization
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.cpu.utilization', 'Pod CPU utilization', '', {}, ${timestampNs}, ${timestampNs}, ${cpuUsage}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.cpu.utilization', 'Pod CPU utilization', '', {}, '${timestampNs}', '${timestampNs}', ${cpuUsage}, 0, [], [], [], [], [])`,
);
// k8s.pod.cpu_limit_utilization
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.cpu_limit_utilization', 'Pod CPU limit utilization', '%', {}, ${timestampNs}, ${timestampNs}, ${cpuLimitUtilization}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.cpu_limit_utilization', 'Pod CPU limit utilization', '%', {}, '${timestampNs}', '${timestampNs}', ${cpuLimitUtilization}, 0, [], [], [], [], [])`,
);
// k8s.pod.memory.usage
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.memory.usage', 'Pod memory usage', 'bytes', {}, ${timestampNs}, ${timestampNs}, ${memoryUsage}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.memory.usage', 'Pod memory usage', 'bytes', {}, '${timestampNs}', '${timestampNs}', ${memoryUsage}, 0, [], [], [], [], [])`,
);
// k8s.pod.memory_limit_utilization
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.memory_limit_utilization', 'Pod memory limit utilization', '%', {}, ${timestampNs}, ${timestampNs}, ${memoryLimitUtilization}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.memory_limit_utilization', 'Pod memory limit utilization', '%', {}, '${timestampNs}', '${timestampNs}', ${memoryLimitUtilization}, 0, [], [], [], [], [])`,
);
}
}
@@ -643,7 +643,7 @@ function generateK8sGaugeMetrics(
for (let sample = 0; sample < samplesPerPod; sample++) {
const timestampMs = nodeSlotStart + sample * stepMs;
- const timestampNs = timestampMs * 1000000;
+ const timestampNs = Math.round(timestampMs * 1000000);
const nodeCpuUsage = 30 + (nodeIdx % 30) + Math.sin(sample / 2) * 10;
const nodeMemoryUsage = (2 + nodeIdx) * 1024 * 1024 * 1024; // 2-4 GB
@@ -653,17 +653,17 @@ function generateK8sGaugeMetrics(
// k8s.node.cpu.utilization
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.cpu.utilization', 'Node CPU utilization', '', {}, ${timestampNs}, ${timestampNs}, ${nodeCpuUsage}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.cpu.utilization', 'Node CPU utilization', '', {}, '${timestampNs}', '${timestampNs}', ${nodeCpuUsage}, 0, [], [], [], [], [])`,
);
// k8s.node.memory.usage
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.memory.usage', 'Node memory usage', 'bytes', {}, ${timestampNs}, ${timestampNs}, ${nodeMemoryUsage}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.memory.usage', 'Node memory usage', 'bytes', {}, '${timestampNs}', '${timestampNs}', ${nodeMemoryUsage}, 0, [], [], [], [], [])`,
);
// k8s.node.condition_ready
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.condition_ready', 'Node condition ready', '', {}, ${timestampNs}, ${timestampNs}, ${nodeConditionReady}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.node.condition_ready', 'Node condition ready', '', {}, '${timestampNs}', '${timestampNs}', ${nodeConditionReady}, 0, [], [], [], [], [])`,
);
}
}
@@ -681,13 +681,13 @@ function generateK8sGaugeMetrics(
for (let sample = 0; sample < samplesPerPod; sample++) {
const timestampMs = nsSlotStart + sample * stepMs;
- const timestampNs = timestampMs * 1000000;
+ const timestampNs = Math.round(timestampMs * 1000000);
const resourceAttrs = `{'k8s.cluster.name':'${cluster}','k8s.namespace.name':'${namespace}'}`;
// k8s.namespace.phase
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.namespace.phase', 'Namespace phase', '', {}, ${timestampNs}, ${timestampNs}, ${namespacePhase}, 0, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.namespace.phase', 'Namespace phase', '', {}, '${timestampNs}', '${timestampNs}', ${namespacePhase}, 0, [], [], [], [], [])`,
);
}
}
@@ -712,7 +712,7 @@ function generateK8sSumMetrics(
const timestampMs =
podCount > 1 ? startMs + (podIdx / (podCount - 1)) * span : startMs;
- const timestampNs = timestampMs * 1000000;
+ const timestampNs = Math.round(timestampMs * 1000000);
// Pod uptime in seconds (1-10 hours)
const uptimeSeconds = (1 + (podIdx % 10)) * 3600;
@@ -722,7 +722,7 @@ function generateK8sSumMetrics(
// k8s.pod.uptime (Sum metric)
// AggregationTemporality: 1 = Delta, 2 = Cumulative
rows.push(
- `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.uptime', 'Pod uptime', 's', {}, ${timestampNs}, ${timestampNs}, ${uptimeSeconds}, 0, 2, true, [], [], [], [], [])`,
+ `(${resourceAttrs}, '', '', '', {}, 0, '', 'k8s-metrics', 'k8s.pod.uptime', 'Pod uptime', 's', {}, '${timestampNs}', '${timestampNs}', ${uptimeSeconds}, 0, 2, true, [], [], [], [], [])`,
);
}
@@ -1105,54 +1105,35 @@ export async function seedClickHouse(): Promise {
}
/**
- * Seed one `E2E_PROMQL_METRIC_NAME` series per service into the TimeSeries
- * table, through the `timeSeries*` table functions that expose its inner
- * tables (the engine names those after the table's UUID, so they can't be
- * addressed directly).
- *
- * A series' identity lives in the tags table while its samples live in the data
- * table, tied together by `id`. Rather than recompute the engine's
- * `reinterpretAsUUID(sipHash128(metric_name, all_tags))` here — where drifting
- * from the schema would silently orphan every sample — the samples select their
- * `id` back out of the tags table.
+ * Seed one `E2E_PROMQL_METRIC_NAME` series per service, one sample a minute
+ * across the window. Written through the TimeSeries table itself, not its inner
+ * tables: the engine derives each series' `id` and splits samples across inner
+ * tables in ways that change between ClickHouse versions, and rows written
+ * around it are invisible to the PromQL engine.
*/
async function seedPromqlSeries(
client: ReturnType,
startMs: number,
endMs: number,
) {
- const ts = (part: 'Tags' | 'Data' | 'Metrics') =>
- `timeSeries${part}('${E2E_CLICKHOUSE_DATABASE}', '${E2E_PROMQL_TABLE}')`;
+ const table = `${E2E_CLICKHOUSE_DATABASE}.${E2E_PROMQL_TABLE}`;
await client.query(`
- INSERT INTO FUNCTION ${ts('Metrics')} (metric_family_name, type, unit, help)
+ INSERT INTO ${table} (metric_family, type, unit, help)
VALUES ('${E2E_PROMQL_METRIC_NAME}', 'gauge', '', 'E2E service liveness')
`);
- const tagRows = SERVICES.map(
- service =>
- `('${E2E_PROMQL_METRIC_NAME}', map('service', '${service}'), ` +
- `map('__name__', '${E2E_PROMQL_METRIC_NAME}', 'service', '${service}'), ` +
- `fromUnixTimestamp64Milli(toInt64(${startMs})), fromUnixTimestamp64Milli(toInt64(${endMs})))`,
- ).join(',\n');
-
- await client.query(`
- INSERT INTO FUNCTION ${ts('Tags')} (metric_name, tags, all_tags, min_time, max_time)
- VALUES ${tagRows}
- `);
-
- // One sample a minute across the seeded window, for every series.
const stepMs = 60000;
const sampleCount = Math.max(1, Math.floor((endMs - startMs) / stepMs));
+ const rows = SERVICES.map(
+ service =>
+ `SELECT '${E2E_PROMQL_METRIC_NAME}', map('service', '${service}'), ` +
+ `arrayMap(n -> (fromUnixTimestamp64Milli(toInt64(${startMs} + n * ${stepMs})), 1.0), range(${sampleCount}))`,
+ ).join('\n UNION ALL\n ');
+
await client.query(`
- INSERT INTO FUNCTION ${ts('Data')} (id, timestamp, value)
- SELECT
- t.id,
- fromUnixTimestamp64Milli(toInt64(${startMs} + (n * ${stepMs}))),
- 1
- FROM ${ts('Tags')} AS t
- CROSS JOIN (SELECT number AS n FROM numbers(${sampleCount})) AS steps
- WHERE t.metric_name = '${E2E_PROMQL_METRIC_NAME}'
+ INSERT INTO ${table} (metric_name, tags, time_series)
+ ${rows}
`);
}
diff --git a/packages/common-utils/src/__tests__/clickhouseVersion.test.ts b/packages/common-utils/src/__tests__/clickhouseVersion.test.ts
index 0df41e16d4..580bef9085 100644
--- a/packages/common-utils/src/__tests__/clickhouseVersion.test.ts
+++ b/packages/common-utils/src/__tests__/clickhouseVersion.test.ts
@@ -4,6 +4,7 @@ import {
parseClickHouseVersion,
supportsDirectReadMap,
supportsMergeTreeIndex,
+ supportsPrometheusHttpApi,
} from '@/core/clickhouseVersion';
type ClickHouseVersionTuple = readonly [number, number, number, number];
@@ -277,3 +278,26 @@ describe('supportsMergeTreeIndex', () => {
expect(supportsMergeTreeIndex(undefined)).toBe(false);
});
});
+
+describe('supportsPrometheusHttpApi', () => {
+ it.each([
+ [[26, 6, 0, 0]],
+ [[26, 6, 8, 7]],
+ [[26, 8, 6, 5]],
+ [[27, 0, 0, 0]],
+ ])('accepts %j', version => {
+ expect(supportsPrometheusHttpApi(version)).toBe(true);
+ });
+
+ it.each([
+ [[26, 5, 5, 8]],
+ [[26, 5, 99, 99]],
+ [[25, 12, 0, 0]],
+ ])('rejects %j', version => {
+ expect(supportsPrometheusHttpApi(version)).toBe(false);
+ });
+
+ it('returns false when the version is unknown', () => {
+ expect(supportsPrometheusHttpApi(undefined)).toBe(false);
+ });
+});
diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts
index 478a9a84a1..1e7b25588e 100644
--- a/packages/common-utils/src/__tests__/metadata.int.test.ts
+++ b/packages/common-utils/src/__tests__/metadata.int.test.ts
@@ -1189,7 +1189,6 @@ describe('Metadata Integration Tests', () => {
const byName = new Map(columns.map(c => [c.name, c]));
expect([...byName.keys()].sort()).toEqual([
- 'all_tags',
'id',
'max_time',
'metric_name',
@@ -1199,9 +1198,6 @@ describe('Metadata Integration Tests', () => {
expect(byName.get('min_time')?.type).toBe(
'SimpleAggregateFunction(min, Nullable(DateTime64(3)))',
);
- // Ephemeral, so it cannot be selected — the reason a caller has to
- // inspect the columns rather than assume the documented shape.
- expect(byName.get('all_tags')?.default_type).toBe('EPHEMERAL');
});
// What `store_min_time_and_max_time = 0` costs: the time-bound columns are
diff --git a/packages/common-utils/src/core/clickhouseVersion.ts b/packages/common-utils/src/core/clickhouseVersion.ts
index 3ad2657b8f..e4d1278075 100644
--- a/packages/common-utils/src/core/clickhouseVersion.ts
+++ b/packages/common-utils/src/core/clickhouseVersion.ts
@@ -173,3 +173,11 @@ export function supportsMergeTreeTextIndex(
): boolean {
return isClickHouseVersionAtLeast(version, MERGE_TREE_TEXT_INDEX_MIN);
}
+
+const PROMETHEUS_HTTP_API_MIN: ClickHouseVersion = [26, 6, 0, 0];
+
+export function supportsPrometheusHttpApi(
+ version: ClickHouseVersion | undefined,
+): boolean {
+ return isClickHouseVersionAtLeast(version, PROMETHEUS_HTTP_API_MIN);
+}
diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts
index 8cd8291ccc..80f5c3ebcd 100644
--- a/packages/common-utils/src/core/metadata.ts
+++ b/packages/common-utils/src/core/metadata.ts
@@ -615,6 +615,32 @@ export class Metadata {
);
}
+ async getTimeSeriesTableVersion({
+ connectionId,
+ databaseName,
+ tableName,
+ }: {
+ connectionId: string;
+ databaseName: string;
+ tableName: string;
+ }): Promise {
+ return this.cache.getOrFetch(
+ `${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 }}`;
+ const json = await this.clickhouseClient
+ .query<'JSON'>({
+ query: sql.sql,
+ query_params: sql.params,
+ connectionId,
+ clickhouse_settings: this.getClickHouseSettings(),
+ })
+ .then(res => res.json<{ version: number }>());
+ return Number(json.data[0]?.version ?? 0);
+ },
+ );
+ }
+
async getMaterializedColumnsLookupTable({
databaseName,
tableName,