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,