From 59641f264efa12ab8b2b810151253cd9da67f48b Mon Sep 17 00:00:00 2001 From: Karl Power Date: Mon, 9 Nov 2026 11:37:51 +0100 Subject: [PATCH 1/5] feat(api): keep the PromQL backend probe answer for 5 minutes --- .changeset/promql-source-backend.md | 7 + .../__tests__/promqlBackend.test.ts | 152 ++++++++++++++++++ packages/api/src/controllers/promql.ts | 101 +++++++++--- .../api/src/controllers/timeseriesEngine.ts | 13 +- .../api/__tests__/prometheus.int.test.ts | 29 ++++ 5 files changed, 274 insertions(+), 28 deletions(-) create mode 100644 .changeset/promql-source-backend.md create mode 100644 packages/api/src/controllers/__tests__/promqlBackend.test.ts diff --git a/.changeset/promql-source-backend.md b/.changeset/promql-source-backend.md new file mode 100644 index 0000000000..cf8afe7064 --- /dev/null +++ b/.changeset/promql-source-backend.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/api': patch +'@hyperdx/app': patch +'@hyperdx/common-utils': patch +--- + +feat: Choose how a PromQL source on ClickHouse is evaluated (the Prometheus HTTP API or the `prometheusQuery` table functions) instead of probing the server on every query. Without a choice the server is still probed, but the answer is now kept for 5 minutes. A source set to the HTTP API against a server that does not serve it fails with a clear error. diff --git a/packages/api/src/controllers/__tests__/promqlBackend.test.ts b/packages/api/src/controllers/__tests__/promqlBackend.test.ts new file mode 100644 index 0000000000..63b62b51d9 --- /dev/null +++ b/packages/api/src/controllers/__tests__/promqlBackend.test.ts @@ -0,0 +1,152 @@ +import { + clearBackendProbeCache, + resolveClickhouseQueryBackend, +} from '@/controllers/promql'; +import { connectionSupportsPrometheusHttpApi } from '@/controllers/timeseriesEngine'; + +jest.mock('@/controllers/timeseriesEngine', () => ({ + ...jest.requireActual('@/controllers/timeseriesEngine'), + connectionSupportsPrometheusHttpApi: jest.fn(), +})); + +const mockFetch = jest.mocked(global.fetch); +const mockVersionCheck = jest.mocked(connectionSupportsPrometheusHttpApi); + +const handlerAnswer = () => + new Response(JSON.stringify({ status: 'success', data: 'up' })); +const noHandlerAnswer = () => + new Response('There is no handle /prometheus/api/v1/format_query', { + status: 404, + }); + +const target = (host = 'http://ch:8123') => ({ + connection: { id: 'conn-1', host, username: 'default', password: '' }, + databaseName: 'db', + tableName: 'ts', +}); + +const resolveKind = async (host?: string) => { + const backend = await resolveClickhouseQueryBackend( + // @ts-expect-error mock connection carries only the fields the resolver reads + target(host), + ); + return backend.kind; +}; + +describe('resolveClickhouseQueryBackend', () => { + beforeEach(() => { + clearBackendProbeCache(); + mockVersionCheck.mockReset(); + mockVersionCheck.mockResolvedValue(true); + mockFetch.mockReset(); + }); + + describe('probe cache', () => { + it('probes the handler once and keeps the answer', async () => { + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind()).resolves.toBe('clickhouse-http'); + await expect(resolveKind()).resolves.toBe('clickhouse-http'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0][0])).toContain( + '/prometheus/api/v1/format_query', + ); + expect(mockVersionCheck).toHaveBeenCalledTimes(1); + }); + + it('keeps "no handler" so a stock server is not probed on every query', async () => { + mockFetch.mockResolvedValueOnce(noHandlerAnswer()); + + await expect(resolveKind()).resolves.toBe('table-function'); + await expect(resolveKind()).resolves.toBe('table-function'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('keeps a server that is too old without probing the handler', async () => { + mockVersionCheck.mockResolvedValue(false); + + await expect(resolveKind()).resolves.toBe('table-function'); + await expect(resolveKind()).resolves.toBe('table-function'); + + expect(mockVersionCheck).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not keep an unreadable server version', async () => { + mockVersionCheck.mockResolvedValueOnce(undefined); + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind()).resolves.toBe('table-function'); + await expect(resolveKind()).resolves.toBe('clickhouse-http'); + + expect(mockVersionCheck).toHaveBeenCalledTimes(2); + }); + + it('does not keep a probe that failed to reach the server', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind()).resolves.toBe('table-function'); + await expect(resolveKind()).resolves.toBe('clickhouse-http'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not keep an answer that says nothing about the handler, like a proxy's 502", async () => { + mockFetch.mockResolvedValueOnce( + new Response('Bad gateway', { status: 502 }), + ); + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind()).resolves.toBe('table-function'); + await expect(resolveKind()).resolves.toBe('clickhouse-http'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('probes again once the answer has expired', async () => { + const now = jest.spyOn(Date, 'now'); + try { + mockFetch.mockImplementation(async () => handlerAnswer()); + + now.mockReturnValue(1_000); + await resolveKind(); + now.mockReturnValue(1_000 + 5 * 60_000 - 1); + await resolveKind(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + now.mockReturnValue(1_000 + 5 * 60_000); + await resolveKind(); + expect(mockFetch).toHaveBeenCalledTimes(2); + } finally { + now.mockRestore(); + } + }); + + it('keeps a separate answer for each host of the same connection', async () => { + mockFetch.mockResolvedValueOnce(noHandlerAnswer()); + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind('http://old:8123')).resolves.toBe( + 'table-function', + ); + await expect(resolveKind('http://new:8123')).resolves.toBe( + 'clickhouse-http', + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('shares one probe between concurrent queries', async () => { + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect( + Promise.all([resolveKind(), resolveKind()]), + ).resolves.toEqual(['clickhouse-http', 'clickhouse-http']); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/api/src/controllers/promql.ts b/packages/api/src/controllers/promql.ts index e68d9a084b..6657c35c33 100644 --- a/packages/api/src/controllers/promql.ts +++ b/packages/api/src/controllers/promql.ts @@ -3,9 +3,11 @@ import { buildQueryId, } from '@hyperdx/common-utils/dist/clickhouse'; import { getCurrentQueryAttribution } from '@hyperdx/common-utils/dist/clickhouse/node'; +import { MetadataCache } from '@hyperdx/common-utils/dist/core/metadata'; import type { PromqlQuery } from '@hyperdx/common-utils/dist/core/promqlChart'; import { parseJSON } from '@hyperdx/common-utils/dist/core/utils'; import { SourceKind, type SQLInterval } from '@hyperdx/common-utils/dist/types'; +import { trace } from '@opentelemetry/api'; import { z } from 'zod'; import { ClickhouseClient } from '@/clickhouse'; @@ -442,33 +444,50 @@ export function newPromqlClickhouseClient(connection: { }); } +const PROBE_TIMEOUT_MS = 5_000; + +// How long a probe answer is kept. A server's version and handler config +// change rarely, and a stale answer only costs errors until the entry expires. +const BACKEND_PROBE_TTL_MS = 5 * 60_000; + +let backendProbeCache = new MetadataCache(); + +/** Forgets every kept probe answer. For tests. */ +export function clearBackendProbeCache(): void { + backendProbeCache = new MetadataCache(); +} + +/** The probe could not tell, so its outcome must not be kept. */ +class InconclusiveProbeError extends Error {} + /** - * 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. + * Asks a ClickHouse connection whether it 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. + * Resolves only with a definitive answer. Rejects with + * InconclusiveProbeError when the server could not be asked or answered with + * something that says nothing about the handler (a timeout, a proxy's 502, a + * rejected login), so a blip is never kept as "no". */ -const PROBE_TIMEOUT_MS = 5_000; - -async function clickhouseServesPrometheusHttpApi( +async function probePrometheusHttpApi( client: ClickhouseClient, connection: { id: string; host: string; username: string; password?: string }, ): Promise { - if ( - !(await connectionSupportsPrometheusHttpApi({ - client, - connectionId: connection.id, - })) - ) { - return false; - } + const supported = await connectionSupportsPrometheusHttpApi({ + client, + connectionId: connection.id, + }); + if (supported === undefined) throw new InconclusiveProbeError(); + if (!supported) return false; + let status: number; + let text: string; try { const { url, headers } = buildPrometheusRequest( connection.host, @@ -480,8 +499,48 @@ async function clickhouseServesPrometheusHttpApi( headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); - return typeof JSON.parse(await resp.text())?.status === 'string'; + status = resp.status; + text = await resp.text(); + } catch { + throw new InconclusiveProbeError(); + } + + if (typeof parseJSON(text)?.status === 'string') return true; + if (status === 404) return false; + throw new InconclusiveProbeError(); +} + +/** + * Whether a ClickHouse connection answers PromQL over HTTP, keeping a + * definitive answer for BACKEND_PROBE_TTL_MS so each query does not pay for a + * `SELECT version()` and a probe request first. Keyed on the host as well as + * the connection, so editing a connection's host drops its answer. Concurrent + * callers share one probe. An inconclusive probe is answered "no" for this + * request only. + */ +async function clickhouseServesPrometheusHttpApi( + client: ClickhouseClient, + connection: { id: string; host: string; username: string; password?: string }, +): Promise { + const key = `${connection.id}|${connection.host}`; + const span = trace.getActiveSpan(); + + const kept = backendProbeCache.get(key); + if (kept !== undefined) { + span?.setAttribute('hyperdx.promql.backend_probe', 'cached'); + return kept; + } + + try { + const served = await backendProbeCache.getOrFetch( + key, + () => probePrometheusHttpApi(client, connection), + { ttlMs: BACKEND_PROBE_TTL_MS }, + ); + span?.setAttribute('hyperdx.promql.backend_probe', 'live'); + return served; } catch { + span?.setAttribute('hyperdx.promql.backend_probe', 'inconclusive'); return false; } } diff --git a/packages/api/src/controllers/timeseriesEngine.ts b/packages/api/src/controllers/timeseriesEngine.ts index 3f63e8dc17..d5aa5527ac 100644 --- a/packages/api/src/controllers/timeseriesEngine.ts +++ b/packages/api/src/controllers/timeseriesEngine.ts @@ -442,9 +442,9 @@ export async function queryInstantViaTableFunction({ /** * 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. + * API, or `undefined` when its version could not be read. `SELECT version()` + * runs each call (throwaway cache); callers that keep the answer decide how + * long to. */ export async function connectionSupportsPrometheusHttpApi({ client, @@ -452,9 +452,8 @@ export async function connectionSupportsPrometheusHttpApi({ }: { client: ClickhouseClient; connectionId: string; -}): Promise { +}): Promise { const metadata = new Metadata(client, new MetadataCache()); - return supportsPrometheusHttpApi( - await metadata.getServerVersion({ connectionId }), - ); + const version = await metadata.getServerVersion({ connectionId }); + return version == null ? undefined : supportsPrometheusHttpApi(version); } 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 d82be84618..4b44bdd545 100644 --- a/packages/api/src/routers/api/__tests__/prometheus.int.test.ts +++ b/packages/api/src/routers/api/__tests__/prometheus.int.test.ts @@ -8,6 +8,7 @@ import { Types } from 'mongoose'; import * as config from '@/config'; import { + clearBackendProbeCache, CLICKHOUSE_PROMETHEUS_API_PREFIX, clickhousePrometheusUpstream, joinPrometheusUpstreamUrl, @@ -74,6 +75,7 @@ describe('prometheus router', () => { afterEach(async () => { await server.clearDBs(); + clearBackendProbeCache(); mockFetch.mockReset(); mockFetch.mockResolvedValue(fakeUpstreamResponse({})); }); @@ -1326,6 +1328,33 @@ describe('prometheus router', () => { 'X-ClickHouse-User': config.CLICKHOUSE_USER, }); }); + + it('probes the handler once across queries on the same connection', async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedClickHouseSource(team._id); + const answer = () => + fakeUpstreamResponse({ + status: 'success', + data: { resultType: 'vector', result: [] }, + }); + mockFetch + .mockResolvedValueOnce(handlerProbeResponse()) + .mockResolvedValueOnce(answer()) + .mockResolvedValueOnce(answer()); + + for (let i = 0; i < 2; i++) { + await agent + .get('/v1/prometheus/query') + .query({ query: 'up', sourceId: source._id.toString() }) + .expect(200); + } + + const urls = mockFetch.mock.calls.map(([url]) => String(url)); + expect(urls.filter(url => url.includes('/format_query'))).toHaveLength(1); + expect( + urls.filter(url => url.includes('/prometheus/api/v1/query?')), + ).toHaveLength(2); + }); }); describe('GET /v1/prometheus/label/:name/values', () => { From 25035b093b064feade0301c78013eada5f59df46 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Mon, 9 Nov 2026 11:46:24 +0100 Subject: [PATCH 2/5] feat: Add a PromQL backend setting to PromQL sources --- .../src/controllers/__tests__/promql.test.ts | 55 ++++++ .../__tests__/promqlBackend.test.ts | 52 +++++- packages/api/src/controllers/promql.ts | 47 +++++- .../mcp/__tests__/promqlSources.int.test.ts | 60 +++++++ packages/api/src/mcp/tools/sources/schemas.ts | 15 +- packages/api/src/models/source.ts | 2 + .../api/__tests__/prometheus.int.test.ts | 159 +++++++++++++++++- .../routers/api/__tests__/sources.int.test.ts | 80 +++++++++ packages/api/src/routers/api/prometheus.ts | 21 +++ .../common-utils/src/__tests__/types.test.ts | 37 ++++ packages/common-utils/src/types.ts | 17 ++ 11 files changed, 534 insertions(+), 11 deletions(-) diff --git a/packages/api/src/controllers/__tests__/promql.test.ts b/packages/api/src/controllers/__tests__/promql.test.ts index 770a55c275..11a706c47a 100644 --- a/packages/api/src/controllers/__tests__/promql.test.ts +++ b/packages/api/src/controllers/__tests__/promql.test.ts @@ -1,5 +1,6 @@ import { buildPrometheusRequest, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, PROMETHEUS_MAX_RESPONSE_BYTES, runPromqlQuery, } from '@/controllers/promql'; @@ -128,6 +129,60 @@ describe('runPromqlQuery', () => { }); }); + describe('when ClickHouse does not serve the Prometheus HTTP API', () => { + it('names the missing handler when the server answers a plain-text 404', async () => { + mockFetch.mockResolvedValueOnce( + new Response('There is no handle /prometheus/api/v1/query', { + status: 404, + }), + ); + + await expect( + runPromqlQuery(clickhouseHttp, instant), + ).rejects.toMatchObject({ + message: CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, + category: 'user', + }); + }); + + it("reports the handler's own failure by its message", async () => { + respond(400, { + status: 'error', + errorType: 'bad_data', + error: 'table default.metrics does not exist', + }); + + await expect( + runPromqlQuery(clickhouseHttp, instant), + ).rejects.toMatchObject({ + message: 'table default.metrics does not exist', + category: 'user', + }); + }); + + it('reports a 404 as a missing handler whatever its body', async () => { + respond(404, { status: 'error', errorType: 'not_found', error: 'nope' }); + + await expect( + runPromqlQuery(clickhouseHttp, instant), + ).rejects.toMatchObject({ + message: CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, + category: 'user', + }); + }); + + it('leaves a 404 from a Prometheus endpoint to its status', async () => { + mockFetch.mockResolvedValueOnce( + new Response('page not found', { status: 404 }), + ); + + await expect(runPromqlQuery(prometheus, instant)).rejects.toMatchObject({ + message: + 'Prometheus at http://prom:9090/api/v1/query returned HTTP 404', + }); + }); + }); + it('gives each query on one ClickHouse backend its own query ID', async () => { respond(200, emptyVector); respond(200, emptyVector); diff --git a/packages/api/src/controllers/__tests__/promqlBackend.test.ts b/packages/api/src/controllers/__tests__/promqlBackend.test.ts index 63b62b51d9..99e8af0567 100644 --- a/packages/api/src/controllers/__tests__/promqlBackend.test.ts +++ b/packages/api/src/controllers/__tests__/promqlBackend.test.ts @@ -1,3 +1,5 @@ +import type { PromqlSourceBackend } from '@hyperdx/common-utils/dist/types'; + import { clearBackendProbeCache, resolveClickhouseQueryBackend, @@ -19,16 +21,23 @@ const noHandlerAnswer = () => status: 404, }); -const target = (host = 'http://ch:8123') => ({ +const target = ( + host = 'http://ch:8123', + promqlBackend?: PromqlSourceBackend, +) => ({ connection: { id: 'conn-1', host, username: 'default', password: '' }, databaseName: 'db', tableName: 'ts', + promqlBackend, }); -const resolveKind = async (host?: string) => { +const resolveKind = async ( + host?: string, + promqlBackend?: PromqlSourceBackend, +) => { const backend = await resolveClickhouseQueryBackend( // @ts-expect-error mock connection carries only the fields the resolver reads - target(host), + target(host, promqlBackend), ); return backend.kind; }; @@ -149,4 +158,41 @@ describe('resolveClickhouseQueryBackend', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); }); + + describe('source setting', () => { + it.each([ + ['clickhouse-http', 'clickhouse-http'], + ['table-function', 'table-function'], + ] as const)( + 'takes %s at its word, without a version check or probe', + async (setting, kind) => { + await expect(resolveKind(undefined, setting)).resolves.toBe(kind); + + expect(mockVersionCheck).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + + it.each([[undefined], ['auto' as const]])( + 'probes when the setting is %s', + async setting => { + mockFetch.mockResolvedValueOnce(handlerAnswer()); + + await expect(resolveKind(undefined, setting)).resolves.toBe( + 'clickhouse-http', + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }, + ); + + it('ignores a kept probe answer once the source names a backend', async () => { + mockFetch.mockResolvedValueOnce(handlerAnswer()); + await resolveKind(); + + await expect(resolveKind(undefined, 'table-function')).resolves.toBe( + 'table-function', + ); + }); + }); }); diff --git a/packages/api/src/controllers/promql.ts b/packages/api/src/controllers/promql.ts index 6657c35c33..68a023f145 100644 --- a/packages/api/src/controllers/promql.ts +++ b/packages/api/src/controllers/promql.ts @@ -6,7 +6,11 @@ import { getCurrentQueryAttribution } from '@hyperdx/common-utils/dist/clickhous import { MetadataCache } from '@hyperdx/common-utils/dist/core/metadata'; import type { PromqlQuery } from '@hyperdx/common-utils/dist/core/promqlChart'; import { parseJSON } from '@hyperdx/common-utils/dist/core/utils'; -import { SourceKind, type SQLInterval } from '@hyperdx/common-utils/dist/types'; +import { + type PromqlSourceBackend, + SourceKind, + type SQLInterval, +} from '@hyperdx/common-utils/dist/types'; import { trace } from '@opentelemetry/api'; import { z } from 'zod'; @@ -162,11 +166,24 @@ const SERVER_ERROR_TYPES = new Set(['timeout', 'canceled', 'unavailable']); const labelListSchema = z.array(z.string()); +/** + * What a user is told when a source set to the Prometheus HTTP API reaches a + * ClickHouse server that does not serve it. Without it they would see a bare + * 404 from a path they never chose. + */ +export const CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE = + "This ClickHouse connection does not serve the Prometheus HTTP API. Enable the prometheus_api_v1 HTTP handler on the server, or set the source's PromQL backend to Auto."; + /** * GETs a Prometheus API path from a Prometheus-compatible endpoint and returns * the response's `data`, checked against `dataSchema`. Errors name only the * origin and path: the host may carry credentials in its userinfo or query * string. + * + * `handlerMissingMessage` is for ClickHouse's own Prometheus API: a 404 there + * means the handler is not configured (the handler itself answers failures + * with a 400), which is reported with this message as a user error instead of + * by its status. */ async function fetchPrometheusData( host: string, @@ -175,6 +192,7 @@ async function fetchPrometheusData( dataSchema: T, signal?: AbortSignal, headers?: Record, + handlerMissingMessage?: string, ): Promise> { const request = buildPrometheusRequest(host, path, params, headers); const { redactedTarget } = request; @@ -195,6 +213,11 @@ async function fetchPrometheusData( ); } + if (handlerMissingMessage && resp.status === 404) { + await resp.body?.cancel().catch(() => {}); + throw new PromQLQueryError(handlerMissingMessage, 'user'); + } + let text: string; try { text = await readBoundedBody(resp, PROMETHEUS_MAX_RESPONSE_BYTES); @@ -321,6 +344,8 @@ type PromqlTarget = { tableName: string; /** The source's scrape interval floor, e.g. "1 minute". */ minAutoGranularity?: SQLInterval; + /** How the source is evaluated on ClickHouse; unset means `auto`. */ + promqlBackend?: PromqlSourceBackend; }; /** @@ -358,6 +383,7 @@ export async function resolvePromqlTarget( databaseName: source.from.databaseName, tableName: source.from.tableName, minAutoGranularity: source.minAutoGranularity, + promqlBackend: source.promqlBackend, }; } @@ -568,16 +594,28 @@ type PromqlQueryBackend = /** * How to evaluate PromQL on a ClickHouse-backed target. 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. + * is in preview, so under `auto` it is preferred wherever the server has it + * (26.6+) and the table function is the fallback. A source set to either one + * is taken at its word, with no probe. */ export async function resolveClickhouseQueryBackend({ connection, databaseName, tableName, + promqlBackend = 'auto', }: PromqlTarget): Promise { const client = newPromqlClickhouseClient(connection); - if (await clickhouseServesPrometheusHttpApi(client, connection)) { + const span = trace.getActiveSpan(); + span?.setAttribute('hyperdx.promql.backend_setting', promqlBackend); + + let useHttpApi: boolean; + if (promqlBackend === 'auto') { + useHttpApi = await clickhouseServesPrometheusHttpApi(client, connection); + } else { + span?.setAttribute('hyperdx.promql.backend_probe', 'skipped'); + useHttpApi = promqlBackend === 'clickhouse-http'; + } + if (useHttpApi) { return { kind: 'clickhouse-http', connection: { @@ -674,6 +712,7 @@ async function evaluatePromqlQuery( promqlQueryDataSchema, signal, headers, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, ); } case 'table-function': diff --git a/packages/api/src/mcp/__tests__/promqlSources.int.test.ts b/packages/api/src/mcp/__tests__/promqlSources.int.test.ts index acfc5c9da6..8d4d794d34 100644 --- a/packages/api/src/mcp/__tests__/promqlSources.int.test.ts +++ b/packages/api/src/mcp/__tests__/promqlSources.int.test.ts @@ -249,6 +249,66 @@ describe('MCP PromQL sources', () => { expect(output.partial).toBe(true); expect(output.error).toContain('returned more than 8 MB'); }); + + it('keeps promqlBackend through a describe -> rename -> save round trip', async () => { + const pinned = await Source.create({ + kind: SourceKind.Promql, + team: team._id, + connection: promqlSource.connection, + name: 'Rename Me', + from: { databaseName: DEFAULT_DATABASE, tableName: TABLE }, + timestampValueExpression: 'timestamp', + promqlBackend: 'clickhouse-http', + }); + + const described = await callTool(client, 'clickstack_describe_source', { + sourceId: pinned._id.toString(), + }); + const sourceConfig = JSON.parse(getFirstText(described)).source.config; + expect(sourceConfig.promqlBackend).toBe('clickhouse-http'); + + const result = await callTool(client, 'clickstack_save_source', { + ...sourceConfig, + id: pinned._id.toString(), + name: 'Renamed', + }); + expect(result.isError).toBeFalsy(); + + const stored = await Source.findById(pinned._id); + expect(stored?.name).toBe('Renamed'); + expect(stored?.get('promqlBackend')).toBe('clickhouse-http'); + }); + + it('sets promqlBackend when saving a new PromQL source', async () => { + const result = await callTool(client, 'clickstack_save_source', { + kind: 'promql', + name: 'Pinned PromQL', + connection: promqlSource.connection.toString(), + databaseName: DEFAULT_DATABASE, + tableName: TABLE, + timestampValueExpression: 'timestamp', + promqlBackend: 'table-function', + }); + + expect(result.isError).toBeFalsy(); + const stored = await Source.findOne({ name: 'Pinned PromQL' }); + expect(stored?.get('promqlBackend')).toBe('table-function'); + }); + + it('rejects a promqlBackend that is not one of the known backends', async () => { + const result = await callTool(client, 'clickstack_save_source', { + kind: 'promql', + name: 'Bad PromQL', + connection: promqlSource.connection.toString(), + databaseName: DEFAULT_DATABASE, + tableName: TABLE, + timestampValueExpression: 'timestamp', + promqlBackend: 'http', + }); + + expect(result.isError).toBe(true); + expect(await Source.findOne({ name: 'Bad PromQL' })).toBeNull(); + }); }); describe('with PromQL disabled', () => { diff --git a/packages/api/src/mcp/tools/sources/schemas.ts b/packages/api/src/mcp/tools/sources/schemas.ts index f2cc515173..2af60c1c8d 100644 --- a/packages/api/src/mcp/tools/sources/schemas.ts +++ b/packages/api/src/mcp/tools/sources/schemas.ts @@ -1,4 +1,7 @@ -import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { + PromqlSourceBackendSchema, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; import { z } from 'zod'; import { sanitizeMetricTables } from './metricKinds'; @@ -435,6 +438,16 @@ const mcpSaveSourceSchema = z.object({ 'for no minimum.', ), + // ── PromQL ── + promqlBackend: PromqlSourceBackendSchema.optional().describe( + 'Promql sources on a ClickHouse connection. How PromQL is evaluated: ' + + '"clickhouse-http" uses ClickHouse\'s Prometheus HTTP API (ClickHouse ' + + '26.6+ with the prometheus_api_v1 handler configured), "table-function" ' + + 'uses the prometheusQuery table functions, and "auto" (the default when ' + + 'omitted) probes the server and prefers the HTTP API. Ignored when the ' + + 'connection is a Prometheus endpoint.', + ), + // ── Materialized views (advanced; granularities use " " form) ── materializedViews: materializedViewsSchema .optional() diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index 19902a41a0..6b162640d7 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -3,6 +3,7 @@ import { LogSourceSchema, MetricsDataType, MetricSourceSchema, + PROMQL_SOURCE_BACKENDS, PromqlSourceSchema, QuerySettings, SessionSourceSchema, @@ -307,5 +308,6 @@ export const PromqlSource = Source.discriminator( SourceKind.Promql, new Schema>({ minAutoGranularity: minAutoGranularityField, + promqlBackend: { type: String, enum: PROMQL_SOURCE_BACKENDS }, }), ); 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 4b44bdd545..b5ed7ddfab 100644 --- a/packages/api/src/routers/api/__tests__/prometheus.int.test.ts +++ b/packages/api/src/routers/api/__tests__/prometheus.int.test.ts @@ -3,12 +3,16 @@ import { QUERY_ATTRIBUTION_HEADER, } from '@hyperdx/common-utils/dist/clickhouse'; import { Metadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { + type PromqlSourceBackend, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; import { Types } from 'mongoose'; import * as config from '@/config'; import { clearBackendProbeCache, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, CLICKHOUSE_PROMETHEUS_API_PREFIX, clickhousePrometheusUpstream, joinPrometheusUpstreamUrl, @@ -90,7 +94,13 @@ describe('prometheus router', () => { name = 'PromQL', databaseName = DEFAULT_DATABASE, tableName = 'metrics_ts', - }: { name?: string; databaseName?: string; tableName?: string } = {}, + promqlBackend, + }: { + name?: string; + databaseName?: string; + tableName?: string; + promqlBackend?: PromqlSourceBackend; + } = {}, ) => Source.create({ kind: SourceKind.Promql, @@ -99,6 +109,7 @@ describe('prometheus router', () => { name, from: { databaseName, tableName }, timestampValueExpression: 'timestamp', + promqlBackend, }); const seedPrometheusSource = async ( @@ -118,7 +129,11 @@ describe('prometheus router', () => { const seedClickHouseSource = async ( teamId: Types.ObjectId, - from?: { databaseName?: string; tableName?: string }, + from?: { + databaseName?: string; + tableName?: string; + promqlBackend?: PromqlSourceBackend; + }, ) => { const connection = await Connection.create({ team: teamId, @@ -930,6 +945,144 @@ describe('prometheus router', () => { expect(res.body.error).toMatch(/no_such_table_for_fallback/); }); + describe('on a source that sets its PromQL backend', () => { + const range = { + query: 'up', + start: '1700000000', + end: '1700000060', + step: '60s', + }; + + it('proxies to the handler without probing when set to clickhouse-http', async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedClickHouseSource(team._id, { + promqlBackend: 'clickhouse-http', + }); + mockFetch.mockResolvedValueOnce( + fakeUpstreamResponse({ + status: 'success', + data: { resultType: 'matrix', result: [] }, + }), + ); + + await agent + .get('/v1/prometheus/query_range') + .query({ ...range, sourceId: source._id.toString() }) + .expect(200); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0][0])).toContain( + `${CLICKHOUSE_PROMETHEUS_API_PREFIX}/query_range`, + ); + }); + + it('evaluates through prometheusQueryRange() without probing when set to table-function', async () => { + const { agent, team } = await getLoggedInAgent(server); + // The CI server does serve the handler, so only the setting can have + // sent this query down the table-function path. + const source = await seedClickHouseSource(team._id, { + tableName: 'no_such_table_for_pinned', + promqlBackend: 'table-function', + }); + + const res = await agent + .get('/v1/prometheus/query_range') + .query({ ...range, sourceId: source._id.toString() }) + .expect(400); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(res.body.error).toMatch(/no_such_table_for_pinned/); + }); + + it('says so when clickhouse-http reaches a server without the handler', async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedClickHouseSource(team._id, { + promqlBackend: 'clickhouse-http', + }); + mockFetch.mockResolvedValueOnce( + new Response('There is no handle /prometheus/api/v1/query_range', { + status: 404, + }), + ); + + const res = await agent + .get('/v1/prometheus/query_range') + .query({ ...range, sourceId: source._id.toString() }) + .expect(400); + + expect(res.body).toEqual({ + status: 'error', + errorType: 'bad_data', + error: CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, + }); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('says so for an instant query too', async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedClickHouseSource(team._id, { + promqlBackend: 'clickhouse-http', + }); + mockFetch.mockResolvedValueOnce( + new Response('There is no handle /prometheus/api/v1/query', { + status: 404, + }), + ); + + const res = await agent + .get('/v1/prometheus/query') + .query({ query: 'up', sourceId: source._id.toString() }) + .expect(400); + + expect(res.body.error).toBe(CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE); + }); + + it("passes the handler's own failure through unchanged", async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedClickHouseSource(team._id, { + promqlBackend: 'clickhouse-http', + }); + const handlerError = { + status: 'error', + errorType: 'bad_data', + error: 'Table default.metrics_ts does not exist', + }; + mockFetch.mockResolvedValueOnce( + fakeUpstreamResponse(handlerError, 400), + ); + + const res = await agent + .get('/v1/prometheus/query_range') + .query({ ...range, sourceId: source._id.toString() }) + .expect(400); + + expect(res.body).toEqual(handlerError); + }); + + it('does not rewrite a 404 from a Prometheus endpoint', async () => { + const { agent, team } = await getLoggedInAgent(server); + const source = await seedPrometheusSource(team._id); + mockFetch.mockResolvedValueOnce( + new Response('page not found', { status: 404 }), + ); + + // The proxy labels every body as JSON, so read this one raw. + const res = await agent + .get('/v1/prometheus/query_range') + .query({ ...range, sourceId: source._id.toString() }) + .buffer(true) + .parse((upstream, done) => { + let body = ''; + upstream.setEncoding('utf8'); + upstream.on('data', chunk => (body += chunk)); + upstream.on('end', () => done(null, body)); + }) + .expect(404); + + expect(res.body).toBe('page not found'); + }); + }); + // 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 diff --git a/packages/api/src/routers/api/__tests__/sources.int.test.ts b/packages/api/src/routers/api/__tests__/sources.int.test.ts index 7cb4f072d6..ea83a0874d 100644 --- a/packages/api/src/routers/api/__tests__/sources.int.test.ts +++ b/packages/api/src/routers/api/__tests__/sources.int.test.ts @@ -930,6 +930,86 @@ describe('sources router', () => { }); }); + describe('promqlBackend on a PromQL source', () => { + it('POST / - creates a PromQL source with promqlBackend and it round-trips', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/sources') + .send({ ...MOCK_PROMQL_SOURCE, promqlBackend: 'clickhouse-http' }) + .expect(200); + + expect(response.body.promqlBackend).toBe('clickhouse-http'); + + const sources = await Source.find({}).lean(); + expect(sources).toHaveLength(1); + expect(sources[0]).toHaveProperty('promqlBackend', 'clickhouse-http'); + }); + + it('POST / - leaves promqlBackend unset when the request omits it', async () => { + const { agent } = await getLoggedInAgent(server); + + const response = await agent + .post('/sources') + .send(MOCK_PROMQL_SOURCE) + .expect(200); + + expect(response.body).not.toHaveProperty('promqlBackend'); + expect(await Source.find({}).lean()).toEqual([ + expect.not.objectContaining({ promqlBackend: expect.anything() }), + ]); + }); + + it('POST / - rejects a promqlBackend that is not one of the known backends', async () => { + const { agent } = await getLoggedInAgent(server); + + await agent + .post('/sources') + .send({ ...MOCK_PROMQL_SOURCE, promqlBackend: 'http' }) + .expect(400); + + expect(await Source.countDocuments()).toBe(0); + }); + + it('PUT /:id - updates promqlBackend', async () => { + const { agent, team } = await getLoggedInAgent(server); + const promqlSource = await Source.create({ + ...MOCK_PROMQL_SOURCE, + promqlBackend: 'clickhouse-http', + team: team._id, + }); + + await agent + .put(`/sources/${promqlSource._id}`) + .send({ + id: promqlSource._id.toString(), + ...MOCK_PROMQL_SOURCE, + promqlBackend: 'table-function', + }) + .expect(200); + + const updatedSource = await Source.findById(promqlSource._id).lean(); + expect(updatedSource).toHaveProperty('promqlBackend', 'table-function'); + }); + + it('PUT /:id - removes promqlBackend when omitted from the update payload', async () => { + const { agent, team } = await getLoggedInAgent(server); + const promqlSource = await Source.create({ + ...MOCK_PROMQL_SOURCE, + promqlBackend: 'clickhouse-http', + team: team._id, + }); + + await agent + .put(`/sources/${promqlSource._id}`) + .send({ id: promqlSource._id.toString(), ...MOCK_PROMQL_SOURCE }) + .expect(200); + + const updatedSource = await Source.findById(promqlSource._id).lean(); + expect(updatedSource).not.toHaveProperty('promqlBackend'); + }); + }); + it('DELETE /:id - deletes a source', async () => { const { agent, team } = await getLoggedInAgent(server); diff --git a/packages/api/src/routers/api/prometheus.ts b/packages/api/src/routers/api/prometheus.ts index 8ad5bc86a2..b266bc8a31 100644 --- a/packages/api/src/routers/api/prometheus.ts +++ b/packages/api/src/routers/api/prometheus.ts @@ -12,6 +12,7 @@ import z from 'zod'; import { buildPrometheusRequest, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, CLICKHOUSE_PROMETHEUS_API_PREFIX, clickhousePrometheusRequest, newPromqlClickhouseClient, @@ -205,12 +206,17 @@ const CALLER_SETTABLE_PARAM_KEYS = new Set([ // Prometheus's native response shape (`{status, data}` / `{status, errorType, // error}`) is already what HyperDX clients expect, so we forward the status code // as-is — but never the content-type, which is always relabelled (see below). +// +// `handlerMissingMessage` is for ClickHouse's own Prometheus API: a 404 means +// the server has no such handler, and is answered with a 400 carrying this +// message instead. async function proxyToPrometheus( upstreamHost: string, path: string, params: Record, res: express.Response, headers?: Record, + handlerMissingMessage?: string, ): Promise { // For a key in CALLER_SETTABLE_PARAM_KEYS, the request always wins // outright, including repeatable ones like `match[]` -- so a Connection @@ -286,6 +292,19 @@ async function proxyToPrometheus( return 502; } + // A server without the handler answers 404 for any path under it, while the + // handler answers every failure (a missing table, a bad query) with a 400 + // and Prometheus JSON, so the status alone tells them apart. + if (handlerMissingMessage && upstreamResp.status === 404) { + await upstreamResp.body?.cancel().catch(() => {}); + res.status(400).json({ + status: 'error', + errorType: 'bad_data', + error: handlerMissingMessage, + }); + return 400; + } + res.status(upstreamResp.status); // The connection host is member-configured, so its response is untrusted output @@ -420,6 +439,7 @@ const queryRangeHandler: express.RequestHandler = async (req, res) => { { ...params, database: databaseName, table: tableName }, res, headers, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, ); recordProxyOutcome(status, 'query_range', backend); return; @@ -515,6 +535,7 @@ const queryHandler: express.RequestHandler = async (req, res) => { { ...params, database: databaseName, table: tableName }, res, headers, + CLICKHOUSE_HTTP_API_UNAVAILABLE_MESSAGE, ); recordProxyOutcome(status, 'query', backend); return; diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index 166f004708..1640578a2e 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -951,6 +951,43 @@ describe('PromqlSourceSchema.minAutoGranularity', () => { }); }); +describe('PromqlSourceSchema.promqlBackend', () => { + const base = { + id: 'source-1', + kind: 'promql' as const, + name: 'Prometheus', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'metrics_ts' }, + timestampValueExpression: 'timestamp', + }; + const parse = (promqlBackend: unknown) => + PromqlSourceSchema.safeParse({ ...base, promqlBackend }); + + it.each(['auto', 'clickhouse-http', 'table-function'])( + 'accepts %s', + value => { + const result = parse(value); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.promqlBackend).toBe(value); + } + }, + ); + + it('accepts an absent setting', () => { + const result = parse(undefined); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.promqlBackend).toBeUndefined(); + } + }); + + it('rejects anything else', () => { + expect(parse('http').success).toBe(false); + expect(parse('').success).toBe(false); + }); +}); + describe('MetricFormulaSchema', () => { it('parses an expression-only formula', () => { const result = MetricFormulaSchema.safeParse({ diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index e3dd7f52a5..3dbf694a1a 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2833,6 +2833,22 @@ export const MinAutoGranularitySchema = z.preprocess( SQLIntervalSchema.optional(), ); +/** + * How a PromQL source on a ClickHouse connection is evaluated: through the + * Prometheus HTTP API (`clickhouse-http`, which needs ClickHouse 26.6+ with the + * `prometheus_api_v1` handler configured) or through the `prometheusQuery` + * table functions (`table-function`). `auto`, which is also what an unset + * value means, probes the server on first use and prefers the HTTP API. A + * connection that is itself a Prometheus endpoint ignores it. + */ +export const PROMQL_SOURCE_BACKENDS = [ + 'auto', + 'clickhouse-http', + 'table-function', +] as const; +export const PromqlSourceBackendSchema = z.enum(PROMQL_SOURCE_BACKENDS); +export type PromqlSourceBackend = z.infer; + // Metric source form schema export const MetricSourceSchema = BaseSourceSchema.extend({ kind: z.literal(SourceKind.Metric), @@ -2860,6 +2876,7 @@ export const MetricSourceSchema = BaseSourceSchema.extend({ export const PromqlSourceSchema = BaseSourceSchema.extend({ kind: z.literal(SourceKind.Promql), minAutoGranularity: MinAutoGranularitySchema, + promqlBackend: PromqlSourceBackendSchema.optional(), }); // Union of all source form schemas for validation From f7b55322b49716cda75c846ca8d2291a63ee4af0 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Mon, 9 Nov 2026 11:49:12 +0100 Subject: [PATCH 3/5] feat(app): add a PromQL backend select to the PromQL source form --- .../SourceForm/PromqlTableModelForm.tsx | 39 ++++++-- .../Sources/SourceForm/SourceForm.tsx | 3 +- .../Sources/SourceForm/TableModelForm.tsx | 10 +- .../Sources/SourceForm/constants.ts | 15 ++- .../components/Sources/SourceForm/types.ts | 2 + .../__tests__/PromqlTableModelForm.test.tsx | 95 +++++++++++++++++++ 6 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/components/__tests__/PromqlTableModelForm.test.tsx diff --git a/packages/app/src/components/Sources/SourceForm/PromqlTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/PromqlTableModelForm.tsx index 2f228c4583..07f909843d 100644 --- a/packages/app/src/components/Sources/SourceForm/PromqlTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/PromqlTableModelForm.tsx @@ -1,24 +1,51 @@ import { useEffect } from 'react'; +import { Stack } from '@mantine/core'; +import SelectControlled from '@/components/SelectControlled'; import { PROMQL_TIMESTAMP_EXPRESSION } from '@/source'; +import { PROMQL_BACKEND_OPTIONS } from './constants'; +import { FormRow } from './FormRow'; import { MinAutoGranularityFormRow } from './MinAutoGranularityFormRow'; import { TableModelProps } from './types'; const PROMQL_MIN_AUTO_GRANULARITY_HELP_TEXT = "Floor for 'auto granularity' on charts querying this source. Set this to your metrics' scrape interval to avoid sparse-looking charts on short time ranges. Doesn't affect an explicitly chosen (non-auto) granularity. $__rate_interval is also sized from it, assuming a 15 second scrape interval when no minimum is set."; -export function PromqlTableModelForm({ control, setValue }: TableModelProps) { +const PROMQL_BACKEND_HELP_TEXT = + 'How ClickHouse evaluates queries on this source. Auto uses the Prometheus HTTP API when the server serves it (ClickHouse 26.6+ with the prometheus_api_v1 handler configured) and the prometheusQuery table functions otherwise, and re-checks every few minutes. Pick one to skip that check.'; + +export function PromqlTableModelForm({ + control, + setValue, + isPrometheusEndpoint, +}: TableModelProps) { useEffect(() => { - setValue('timestampValueExpression' as any, PROMQL_TIMESTAMP_EXPRESSION); + setValue('timestampValueExpression', PROMQL_TIMESTAMP_EXPRESSION); }, [setValue]); // PromQL sources use the standard database + table fields from BaseSourceSchema. // The table should point to the TimeSeries engine table. return ( - + + {/* A Prometheus endpoint is always queried through its own API. */} + {!isPrometheusEndpoint && ( + + + + )} + + ); } diff --git a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx index f3d9f5e792..329a8d54e5 100644 --- a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx @@ -734,7 +734,7 @@ export function TableSourceForm({ variant="secondary" size="sm" color="gray" - mt="md" + mt={querySettingFields.length ? 'md' : 0} disabled={querySettingFields.length >= 10} onClick={() => { if (querySettingFields.length < 10) { @@ -752,6 +752,7 @@ export function TableSourceForm({ setValue={setValue} kind={kind} sourceId={sourceId} + isPrometheusEndpoint={isPrometheusOnlyConnection} /> {onCancel && ( diff --git a/packages/app/src/components/Sources/SourceForm/TableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/TableModelForm.tsx index 4fb5c6d464..e36b981409 100644 --- a/packages/app/src/components/Sources/SourceForm/TableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/TableModelForm.tsx @@ -19,11 +19,13 @@ export function TableModelForm({ setValue, kind, sourceId, + isPrometheusEndpoint, }: { control: Control; setValue: UseFormSetValue; kind: SourceKind; sourceId?: string; + isPrometheusEndpoint?: boolean; }) { switch (kind) { case SourceKind.Log: @@ -41,6 +43,12 @@ export function TableModelForm({ /> ); case SourceKind.Promql: - return ; + return ( + + ); } } diff --git a/packages/app/src/components/Sources/SourceForm/constants.ts b/packages/app/src/components/Sources/SourceForm/constants.ts index 9671e7aa12..9cb5f00e4d 100644 --- a/packages/app/src/components/Sources/SourceForm/constants.ts +++ b/packages/app/src/components/Sources/SourceForm/constants.ts @@ -1,4 +1,8 @@ -import { SourceKind, UseTextIndex } from '@hyperdx/common-utils/dist/types'; +import { + type PromqlSourceBackend, + SourceKind, + UseTextIndex, +} from '@hyperdx/common-utils/dist/types'; import { MV_AGGREGATE_FUNCTIONS, @@ -50,6 +54,15 @@ export const MIN_AUTO_GRANULARITY_OPTIONS = [ ...MV_GRANULARITY_OPTIONS.filter(option => option.value !== '1 second'), ]; +export const PROMQL_BACKEND_OPTIONS: { + value: PromqlSourceBackend; + label: string; +}[] = [ + { value: 'auto', label: 'Auto (detect from server)' }, + { value: 'clickhouse-http', label: 'Prometheus HTTP API' }, + { value: 'table-function', label: 'Table functions' }, +]; + export const USE_TEXT_INDEX_OPTIONS = [ { value: UseTextIndex.Auto, diff --git a/packages/app/src/components/Sources/SourceForm/types.ts b/packages/app/src/components/Sources/SourceForm/types.ts index 4e46912c19..e47f4c8ea1 100644 --- a/packages/app/src/components/Sources/SourceForm/types.ts +++ b/packages/app/src/components/Sources/SourceForm/types.ts @@ -6,4 +6,6 @@ export interface TableModelProps { setValue: UseFormSetValue; // Set when editing an existing source sourceId?: string; + // The selected connection is a Prometheus-compatible endpoint, not ClickHouse + isPrometheusEndpoint?: boolean; } diff --git a/packages/app/src/components/__tests__/PromqlTableModelForm.test.tsx b/packages/app/src/components/__tests__/PromqlTableModelForm.test.tsx new file mode 100644 index 0000000000..c9de0976bb --- /dev/null +++ b/packages/app/src/components/__tests__/PromqlTableModelForm.test.tsx @@ -0,0 +1,95 @@ +import { useForm, useWatch } from 'react-hook-form'; +import { + PromqlSourceBackend, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { PromqlTableModelForm } from '@/components/Sources/SourceForm/PromqlTableModelForm'; + +// Mantine's Combobox calls scrollIntoView when its dropdown opens; jsdom lacks it. +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +function Harness({ + promqlBackend, + isPrometheusEndpoint, +}: { + promqlBackend?: PromqlSourceBackend; + isPrometheusEndpoint?: boolean; +}) { + const { control, setValue } = useForm({ + defaultValues: { + kind: SourceKind.Promql, + name: 'PromQL', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'metrics_ts' }, + timestampValueExpression: 'timestamp', + promqlBackend, + }, + }); + const backend = useWatch({ control, name: 'promqlBackend' }); + return ( + <> + +
{backend ?? 'unset'}
+ + ); +} + +const backendSelect = () => screen.getByTestId('promql-backend-select'); + +describe('PromqlTableModelForm', () => { + it('shows Auto for a source that has no backend set', () => { + renderWithMantine(); + + expect(backendSelect()).toHaveAttribute( + 'placeholder', + 'Auto (detect from server)', + ); + expect(backendSelect()).toHaveValue(''); + expect(screen.getByTestId('backend-value')).toHaveTextContent('unset'); + }); + + it('shows the backend a source has set', () => { + renderWithMantine(); + + expect(backendSelect()).toHaveValue('Prometheus HTTP API'); + }); + + it('sets the backend from the option picked', async () => { + const user = userEvent.setup(); + renderWithMantine(); + + await user.click(backendSelect()); + await user.click(await screen.findByText('Table functions')); + + expect(screen.getByTestId('backend-value')).toHaveTextContent( + 'table-function', + ); + }); + + it('can go back to Auto after another backend was picked', async () => { + const user = userEvent.setup(); + renderWithMantine(); + + await user.click(backendSelect()); + await user.click(await screen.findByText('Auto (detect from server)')); + + expect(screen.getByTestId('backend-value')).toHaveTextContent('auto'); + }); + + it('hides the backend for a Prometheus endpoint, which is always queried through its own API', () => { + renderWithMantine(); + + expect(screen.queryByTestId('promql-backend-select')).toBeNull(); + expect(screen.queryByText('PromQL backend')).toBeNull(); + // The rest of the form is unaffected. + expect(screen.getByText('Minimum auto granularity')).toBeInTheDocument(); + }); +}); From 3e234d6001f6dbb3534c4ea5b8eecbf85c318f72 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Mon, 9 Nov 2026 11:50:30 +0100 Subject: [PATCH 4/5] docs(api): document promqlBackend on PromQL sources in the v2 spec --- packages/api/openapi.json | 10 +++++ .../__tests__/sources-crud.int.test.ts | 45 +++++++++++++++++++ .../src/routers/external-api/v2/sources.ts | 5 +++ 3 files changed, 60 insertions(+) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index bffa35d627..d027f90a9b 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5748,6 +5748,16 @@ "description": "Floor for the auto-inferred chart granularity, and the scrape interval that `$__rate_interval` is computed from. Set it to the Prometheus scrape interval so a short time range does not produce buckets smaller than that interval. Only constrains \"Auto\" granularity; an explicit granularity picked on a tile is never overridden. Omit for no minimum. Uses the SQL interval format (a positive integer followed by second, minute, hour, or day), not the short form of the materialized view granularity fields.", "pattern": "^\\d+ (second|minute|hour|day)$", "example": "1 minute" + }, + "promqlBackend": { + "type": "string", + "enum": [ + "auto", + "clickhouse-http", + "table-function" + ], + "description": "How PromQL is evaluated on a ClickHouse connection. \"clickhouse-http\" uses ClickHouse's Prometheus HTTP API, which needs ClickHouse 26.6+ with the prometheus_api_v1 handler configured; queries fail with an error when the server does not serve it. \"table-function\" uses the prometheusQuery table functions. \"auto\" (the default when omitted) checks the server and prefers the HTTP API. Ignored when the connection is a Prometheus-compatible endpoint.", + "example": "auto" } } }, diff --git a/packages/api/src/routers/external-api/__tests__/sources-crud.int.test.ts b/packages/api/src/routers/external-api/__tests__/sources-crud.int.test.ts index 7ad19429cd..d188607f9f 100644 --- a/packages/api/src/routers/external-api/__tests__/sources-crud.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/sources-crud.int.test.ts @@ -286,6 +286,51 @@ describe('External API v2 Sources CRUD', () => { .expect(400); }); + it('should round-trip promqlBackend on a promql source', async () => { + const body = { + kind: SourceKind.Promql, + name: 'Prometheus Metrics', + from: { databaseName: 'default', tableName: 'default' }, + timestampValueExpression: 'timestamp', + connection: connection._id.toString(), + promqlBackend: 'clickhouse-http', + }; + + const created = await authRequest('post', BASE_URL) + .send(body) + .expect(200); + expect(created.body.data.promqlBackend).toBe('clickhouse-http'); + + const updated = await authRequest( + 'put', + `${BASE_URL}/${created.body.data.id}`, + ) + .send({ ...body, promqlBackend: 'table-function' }) + .expect(200); + expect(updated.body.data.promqlBackend).toBe('table-function'); + + const fetched = await authRequest( + 'get', + `${BASE_URL}/${created.body.data.id}`, + ).expect(200); + expect(fetched.body.data.promqlBackend).toBe('table-function'); + }); + + it('should return 400 for a promqlBackend that is not a known backend', async () => { + await authRequest('post', BASE_URL) + .send({ + kind: SourceKind.Promql, + name: 'Prometheus Metrics', + from: { databaseName: 'default', tableName: 'default' }, + timestampValueExpression: 'timestamp', + connection: connection._id.toString(), + promqlBackend: 'http', + }) + .expect(400); + + expect(await Source.findOne({ team: team._id })).toBeNull(); + }); + it('should return 400 when connection is not a valid id', async () => { await authRequest('post', BASE_URL) .send({ ...logSourceBody(), connection: 'my-connection-name' }) diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 0a30bd7bed..561625bc65 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -840,6 +840,11 @@ function formatExternalSource(source: SourceDocument) { * description: Floor for the auto-inferred chart granularity, and the scrape interval that `$__rate_interval` is computed from. Set it to the Prometheus scrape interval so a short time range does not produce buckets smaller than that interval. Only constrains "Auto" granularity; an explicit granularity picked on a tile is never overridden. Omit for no minimum. Uses the SQL interval format (a positive integer followed by second, minute, hour, or day), not the short form of the materialized view granularity fields. * pattern: '^\d+ (second|minute|hour|day)$' * example: 1 minute + * promqlBackend: + * type: string + * enum: [auto, clickhouse-http, table-function] + * description: How PromQL is evaluated on a ClickHouse connection. "clickhouse-http" uses ClickHouse's Prometheus HTTP API, which needs ClickHouse 26.6+ with the prometheus_api_v1 handler configured; queries fail with an error when the server does not serve it. "table-function" uses the prometheusQuery table functions. "auto" (the default when omitted) checks the server and prefers the HTTP API. Ignored when the connection is a Prometheus-compatible endpoint. + * example: auto * Source: * oneOf: * - $ref: '#/components/schemas/LogSource' From f768d998593c0323a924638135137df88238bbec Mon Sep 17 00:00:00 2001 From: Karl Power Date: Mon, 9 Nov 2026 11:57:59 +0100 Subject: [PATCH 5/5] feat: Pin onboarded PromQL sources to the HTTP API when the server serves it --- .changeset/promql-source-backend.md | 2 +- .../api/src/__tests__/setupDefaults.test.ts | 2 + packages/api/src/controllers/promql.ts | 21 +++ .../api/__tests__/connections.int.test.ts | 127 +++++++++++++++++- packages/api/src/routers/api/connections.ts | 32 +++++ packages/app/src/__tests__/connection.test.ts | 54 ++++++++ .../app/src/components/OnboardingModal.tsx | 8 ++ .../__tests__/OnboardingModal.test.tsx | 28 ++++ packages/app/src/connection.ts | 20 +++ 9 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/__tests__/connection.test.ts diff --git a/.changeset/promql-source-backend.md b/.changeset/promql-source-backend.md index cf8afe7064..41fc3063b3 100644 --- a/.changeset/promql-source-backend.md +++ b/.changeset/promql-source-backend.md @@ -4,4 +4,4 @@ '@hyperdx/common-utils': patch --- -feat: Choose how a PromQL source on ClickHouse is evaluated (the Prometheus HTTP API or the `prometheusQuery` table functions) instead of probing the server on every query. Without a choice the server is still probed, but the answer is now kept for 5 minutes. A source set to the HTTP API against a server that does not serve it fails with a clear error. +feat: Choose how a PromQL source on ClickHouse is evaluated (the Prometheus HTTP API or the `prometheusQuery` table functions) instead of probing the server on every query. Without a choice the server is still probed, but the answer is now kept for 5 minutes. A source set to the HTTP API against a server that does not serve it fails with a clear error. Onboarding pins a PromQL source it detects to the HTTP API when the server serves it. diff --git a/packages/api/src/__tests__/setupDefaults.test.ts b/packages/api/src/__tests__/setupDefaults.test.ts index f7b42bc64f..a94af81b38 100644 --- a/packages/api/src/__tests__/setupDefaults.test.ts +++ b/packages/api/src/__tests__/setupDefaults.test.ts @@ -19,6 +19,7 @@ jest.mock('@/config', () => ({ connection: 'Local ClickHouse', from: { databaseName: 'default', tableName: 'metrics_ts' }, timestampValueExpression: 'timestamp', + promqlBackend: 'clickhouse-http', }, ]), get IS_PROMQL_ENABLED() { @@ -73,6 +74,7 @@ describe('setupTeamDefaults', () => { kind: SourceKind.Promql, connection: 'conn-1', from: { databaseName: 'default', tableName: 'metrics_ts' }, + promqlBackend: 'clickhouse-http', }), ); }); diff --git a/packages/api/src/controllers/promql.ts b/packages/api/src/controllers/promql.ts index 68a023f145..3e687c0b20 100644 --- a/packages/api/src/controllers/promql.ts +++ b/packages/api/src/controllers/promql.ts @@ -571,6 +571,27 @@ async function clickhouseServesPrometheusHttpApi( } } +/** + * Whether a connection serves PromQL over ClickHouse's HTTP API right now, + * for callers that want to pin a source to it (see the onboarding flow). Reads + * the same kept answer the queries do. Only a definitive yes is true: a + * Prometheus endpoint, an old server, a missing handler and a server that + * could not be asked all answer false. + */ +export async function connectionServesPrometheusHttpApi(connection: { + id: string; + host: string; + username: string; + password?: string; + isPrometheusEndpoint?: boolean; +}): Promise { + if (connection.isPrometheusEndpoint) return false; + return clickhouseServesPrometheusHttpApi( + newPromqlClickhouseClient(connection), + connection, + ); +} + type ClickhouseQueryBackend = | { kind: 'clickhouse-http'; diff --git a/packages/api/src/routers/api/__tests__/connections.int.test.ts b/packages/api/src/routers/api/__tests__/connections.int.test.ts index c08c10d14a..d508e88a8c 100644 --- a/packages/api/src/routers/api/__tests__/connections.int.test.ts +++ b/packages/api/src/routers/api/__tests__/connections.int.test.ts @@ -1,9 +1,18 @@ import { ObjectId } from 'mongodb'; +import { Types } from 'mongoose'; import * as config from '@/config'; -import { getLoggedInAgent, getServer } from '@/fixtures'; +import { clearBackendProbeCache } from '@/controllers/promql'; +import { getAgent, getLoggedInAgent, getServer } from '@/fixtures'; import Connection from '@/models/connection'; +const mockFetch = jest.mocked(global.fetch); + +// What the router's `format_query` probe sees from a server that does, or does +// not, have the prometheus_api_v1 handler configured. +const probeAnswer = (status: number, body: string) => + new Response(body, { status }); + describe('connections router', () => { const server = getServer(); @@ -13,6 +22,8 @@ describe('connections router', () => { afterEach(async () => { await server.clearDBs(); + clearBackendProbeCache(); + mockFetch.mockReset(); }); afterAll(async () => { @@ -118,4 +129,118 @@ describe('connections router', () => { expect(stored?.host).toBe(config.CLICKHOUSE_HOST); expect(stored?.isPrometheusEndpoint).toBe(false); }); + + describe('GET /connections/:id/prometheus-http-api', () => { + const createClickHouseConnection = (teamId: Types.ObjectId) => + Connection.create({ + team: teamId, + name: 'CH', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + + it('rejects unauthenticated requests with 401', async () => { + await getAgent(server) + .get(`/connections/${new ObjectId().toString()}/prometheus-http-api`) + .expect(401); + }); + + it('rejects an id that is not an ObjectId with 400', async () => { + const { agent } = await getLoggedInAgent(server); + await agent.get('/connections/nope/prometheus-http-api').expect(400); + }); + + it("returns 404 for another team's connection", async () => { + const { agent } = await getLoggedInAgent(server); + const other = await Connection.create({ + team: new ObjectId(), + name: 'Other', + host: config.CLICKHOUSE_HOST, + username: 'default', + password: '', + }); + + await agent + .get(`/connections/${other._id.toString()}/prometheus-http-api`) + .expect(404); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports a Prometheus endpoint as not serving it, without probing', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connection = await Connection.create({ + team: team._id, + name: 'Thanos', + host: 'http://thanos:10902', + username: '', + password: '', + isPrometheusEndpoint: true, + }); + + const res = await agent + .get(`/connections/${connection._id.toString()}/prometheus-http-api`) + .expect(200); + + expect(res.body).toEqual({ supported: false }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('reports a server with the handler as serving it', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connection = await createClickHouseConnection(team._id); + mockFetch.mockResolvedValue( + probeAnswer(200, JSON.stringify({ status: 'success', data: 'up' })), + ); + + const res = await agent + .get(`/connections/${connection._id.toString()}/prometheus-http-api`) + .expect(200); + + expect(res.body).toEqual({ supported: true }); + expect(String(mockFetch.mock.calls[0][0])).toContain( + '/prometheus/api/v1/format_query', + ); + }); + + it('reports a server without the handler as not serving it', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connection = await createClickHouseConnection(team._id); + mockFetch.mockResolvedValue(probeAnswer(404, 'Not found.\n')); + + const res = await agent + .get(`/connections/${connection._id.toString()}/prometheus-http-api`) + .expect(200); + + expect(res.body).toEqual({ supported: false }); + }); + + it('keeps the answer between requests', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connection = await createClickHouseConnection(team._id); + mockFetch.mockResolvedValue( + probeAnswer(200, JSON.stringify({ status: 'success', data: 'up' })), + ); + + for (let i = 0; i < 2; i++) { + await agent + .get(`/connections/${connection._id.toString()}/prometheus-http-api`) + .expect(200); + } + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('answers false rather than failing when the probe is inconclusive', async () => { + const { agent, team } = await getLoggedInAgent(server); + const connection = await createClickHouseConnection(team._id); + mockFetch.mockResolvedValue(probeAnswer(502, 'Bad gateway')); + + const res = await agent + .get(`/connections/${connection._id.toString()}/prometheus-http-api`) + .expect(200); + + expect(res.body).toEqual({ supported: false }); + }); + }); }); diff --git a/packages/api/src/routers/api/connections.ts b/packages/api/src/routers/api/connections.ts index e202b1a99b..4f7d8e8120 100644 --- a/packages/api/src/routers/api/connections.ts +++ b/packages/api/src/routers/api/connections.ts @@ -1,5 +1,6 @@ import { ConnectionSchema } from '@hyperdx/common-utils/dist/types'; import express from 'express'; +import z from 'zod'; import { validateRequest } from 'zod-express-middleware'; import { @@ -9,7 +10,9 @@ import { getConnectionsByTeam, updateConnection, } from '@/controllers/connection'; +import { connectionServesPrometheusHttpApi } from '@/controllers/promql'; import { getNonNullUserWithTeam } from '@/middleware/auth'; +import { objectIdSchema } from '@/utils/zod'; const router = express.Router(); @@ -118,6 +121,35 @@ router.put( }, ); +// Whether the connection answers PromQL over ClickHouse's Prometheus HTTP API. +// Onboarding uses it to pin a new PromQL source to that API. +router.get( + '/:id/prometheus-http-api', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const { teamId } = getNonNullUserWithTeam(req); + + const connection = await getConnectionById( + teamId.toString(), + req.params.id, + true, + ); + + if (!connection) { + res.status(404).send(); + return; + } + + res.json({ + supported: await connectionServesPrometheusHttpApi(connection), + }); + } catch (e) { + next(e); + } + }, +); + router.delete('/:id', async (req, res, next) => { try { const { teamId } = getNonNullUserWithTeam(req); diff --git a/packages/app/src/__tests__/connection.test.ts b/packages/app/src/__tests__/connection.test.ts new file mode 100644 index 0000000000..5b4ebb8d45 --- /dev/null +++ b/packages/app/src/__tests__/connection.test.ts @@ -0,0 +1,54 @@ +const mockConfig = { IS_LOCAL_MODE: false }; +jest.mock('@/config', () => ({ + HDX_LOCAL_DEFAULT_CONNECTIONS: undefined, + get IS_LOCAL_MODE() { + return mockConfig.IS_LOCAL_MODE; + }, +})); + +const mockHdxServer = jest.fn(); +jest.mock('@/api', () => ({ + hdxServer: (...args: unknown[]) => mockHdxServer(...args), +})); + +import { fetchPrometheusHttpApiSupport } from '@/connection'; + +const answers = (body: unknown) => + mockHdxServer.mockReturnValue({ json: () => Promise.resolve(body) }); + +describe('fetchPrometheusHttpApiSupport', () => { + beforeEach(() => { + mockHdxServer.mockReset(); + mockConfig.IS_LOCAL_MODE = false; + }); + + it('asks the server about the connection', async () => { + answers({ supported: true }); + + await expect(fetchPrometheusHttpApiSupport('conn-1')).resolves.toBe(true); + expect(mockHdxServer).toHaveBeenCalledWith( + 'connections/conn-1/prometheus-http-api', + ); + }); + + it('reports a server that does not serve it', async () => { + answers({ supported: false }); + + await expect(fetchPrometheusHttpApiSupport('conn-1')).resolves.toBe(false); + }); + + it('reports false when the server cannot be asked', async () => { + mockHdxServer.mockReturnValue({ + json: () => Promise.reject(new Error('boom')), + }); + + await expect(fetchPrometheusHttpApiSupport('conn-1')).resolves.toBe(false); + }); + + it('does not ask the server about a local-mode connection', async () => { + mockConfig.IS_LOCAL_MODE = true; + + await expect(fetchPrometheusHttpApiSupport('local')).resolves.toBe(false); + expect(mockHdxServer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/OnboardingModal.tsx b/packages/app/src/components/OnboardingModal.tsx index 4d98c32d51..ab2e3cbf05 100644 --- a/packages/app/src/components/OnboardingModal.tsx +++ b/packages/app/src/components/OnboardingModal.tsx @@ -22,6 +22,7 @@ import { IS_PROMQL_ENABLED, } from '@/config'; import { + fetchPrometheusHttpApiSupport, useConnections, useCreateConnection, useUpdateConnection, @@ -536,6 +537,10 @@ function OnboardingModalComponent({ // Create PromQL Source if a TimeSeries table is available if (timeSeriesTable) { try { + // Pin the source to the HTTP API only when the server answers it + // now. Otherwise leave it on Auto, which keeps checking. + const servesHttpApi = + await fetchPrometheusHttpApiSupport(connectionId); const promqlSource = await createSourceMutation.mutateAsync({ source: { kind: SourceKind.Promql, @@ -543,6 +548,9 @@ function OnboardingModalComponent({ connection: connectionId, from: timeSeriesTable, timestampValueExpression: PROMQL_TIMESTAMP_EXPRESSION, + ...(servesHttpApi + ? { promqlBackend: 'clickhouse-http' as const } + : {}), }, }); createdSources.push(promqlSource); diff --git a/packages/app/src/components/__tests__/OnboardingModal.test.tsx b/packages/app/src/components/__tests__/OnboardingModal.test.tsx index b66ae46f87..8bb64b1995 100644 --- a/packages/app/src/components/__tests__/OnboardingModal.test.tsx +++ b/packages/app/src/components/__tests__/OnboardingModal.test.tsx @@ -39,7 +39,10 @@ const mockConnectionsResult = { data: [{ id: 'conn-1', name: 'Default', host: 'http://localhost:8123' }], }; const mockUpdateConnectionMutation = { mutateAsync: jest.fn() }; +const mockFetchHttpApiSupport = jest.fn(); jest.mock('@/connection', () => ({ + fetchPrometheusHttpApiSupport: (...args: unknown[]) => + mockFetchHttpApiSupport(...args), useConnections: () => mockConnectionsResult, useCreateConnection: () => ({ mutate: jest.fn() }), useUpdateConnection: () => mockUpdateConnectionMutation, @@ -93,6 +96,7 @@ describe('OnboardingModal auto-detection', () => { }), ); mockUpdateSource.mockResolvedValue(undefined); + mockFetchHttpApiSupport.mockResolvedValue(false); mockMetadata.getOtelTables.mockResolvedValue(OTEL_LOGS_ONLY); mockMetadata.getTimeSeriesTables.mockResolvedValue([ { databaseName: 'default', tableName: 'metrics_ts' }, @@ -118,6 +122,29 @@ describe('OnboardingModal auto-detection', () => { }); }); + it('pins the PromQL source to the HTTP API when the server serves it', async () => { + mockFetchHttpApiSupport.mockResolvedValue(true); + + renderWithMantine(); + + await screen.findByText('Automatically detected and created 2 sources.'); + expect(mockFetchHttpApiSupport).toHaveBeenCalledWith('conn-1'); + expect(mockCreateSource).toHaveBeenLastCalledWith({ + source: expect.objectContaining({ + kind: SourceKind.Promql, + promqlBackend: 'clickhouse-http', + }), + }); + }); + + it('leaves the PromQL backend unset when the server does not serve the HTTP API', async () => { + renderWithMantine(); + + await screen.findByText('Automatically detected and created 2 sources.'); + const [{ source }] = mockCreateSource.mock.lastCall; + expect(source).not.toHaveProperty('promqlBackend'); + }); + it('skips PromQL detection when PromQL is disabled', async () => { mockConfig.IS_PROMQL_ENABLED = false; @@ -125,6 +152,7 @@ describe('OnboardingModal auto-detection', () => { await screen.findByText('Automatically detected and created 1 source.'); expect(mockMetadata.getTimeSeriesTables).not.toHaveBeenCalled(); + expect(mockFetchHttpApiSupport).not.toHaveBeenCalled(); expect(createdKinds()).toEqual([SourceKind.Log]); }); diff --git a/packages/app/src/connection.ts b/packages/app/src/connection.ts index 3ed38168b7..97a933c14b 100644 --- a/packages/app/src/connection.ts +++ b/packages/app/src/connection.ts @@ -53,6 +53,26 @@ export function useConnections() { }); } +/** + * Whether the server behind a connection answers PromQL over ClickHouse's + * Prometheus HTTP API. False when it does not, and also when it could not be + * asked, so a caller pins to the HTTP API only on a definitive yes. + */ +export async function fetchPrometheusHttpApiSupport( + connectionId: string, +): Promise { + // A local-mode connection lives in the browser; the API has no record of it. + if (IS_LOCAL_MODE) return false; + try { + const { supported } = await hdxServer( + `connections/${connectionId}/prometheus-http-api`, + ).json<{ supported: boolean }>(); + return supported === true; + } catch { + return false; + } +} + export function useCreateConnection() { const queryClient = useQueryClient();