Skip to content

Commit bf4443d

Browse files
authored
feat: Support autocomplete for PromQL label filters (#3072)
## Summary This PR adds support for querying available labels from a PromQL source (either by querying a timeseries table or by proxying to a remote prometheus endpoint), and uses the new functionality to provide label autocomplete suggestions when configuring a PromQL Labels dashboard filter: <img width="691" height="677" alt="Screenshot 2026-09-03 at 7 10 05 AM" src="https://github.com/user-attachments/assets/278db93d-6059-4486-bd10-9bac4f0b6eda" /> ### How to test locally 1. Setup a promql source based on the metrics_ts table 2. Create a dashboard and add a promql labels filter, note the autocomplete is populated ### References - Linear Issue: Closes HDX-5256 - Related PRs:
1 parent fbb1e20 commit bf4443d

10 files changed

Lines changed: 610 additions & 83 deletions

File tree

‎.changeset/early-hotels-lay.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/api": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: Support autocomplete for PromQL label filters

‎packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { ClickhouseClient } from '@/clickhouse';
22
import * as config from '@/config';
3-
import { queryLabelValues } from '@/controllers/timeseriesEngine';
3+
import {
4+
queryLabelNames,
5+
queryLabelValues,
6+
} from '@/controllers/timeseriesEngine';
47
import {
58
closeTestFixtureClickHouseClient,
69
DEFAULT_DATABASE,
710
dropTimeSeriesTable,
811
executeSqlCommand,
912
seedTimeSeriesTagsTable,
13+
TimeSeriesFixtureSeries,
1014
} from '@/fixtures';
1115

1216
const CONNECTION_ID = 'timeseries-engine-int-test';
@@ -19,11 +23,12 @@ const RECENT_START_SEC = 1700000000;
1923
const sec = (s: number) => s * 1000;
2024

2125
// Three series: one only in the distant past, two only in the recent window
22-
// sharing a metric name (so DISTINCT has something to collapse).
23-
const SERIES = [
26+
// sharing a metric name (so DISTINCT has something to collapse). Only the old
27+
// series carries `region`, so a window can exclude a label name too.
28+
const SERIES: TimeSeriesFixtureSeries[] = [
2429
{
2530
metricName: 'old_metric',
26-
tags: { job: 'batch' },
31+
tags: { job: 'batch', region: 'eu' },
2732
startSec: OLD_START_SEC,
2833
endSec: OLD_START_SEC + HOUR_SEC,
2934
},
@@ -241,4 +246,62 @@ describe('timeseriesEngine controller', () => {
241246
}
242247
});
243248
});
249+
250+
describe('queryLabelNames', () => {
251+
const labelNames = (
252+
args: Partial<Parameters<typeof queryLabelNames>[0]> = {},
253+
) =>
254+
queryLabelNames({
255+
client,
256+
connectionId: CONNECTION_ID,
257+
databaseName: DEFAULT_DATABASE,
258+
tableName: BOUNDED_TABLE,
259+
...args,
260+
});
261+
262+
// `__name__` is not a key of `tags`, so it only appears if folded back in.
263+
it('returns every label name including __name__, deduplicated and sorted', async () => {
264+
expect(await labelNames()).toEqual(['__name__', 'job', 'region']);
265+
});
266+
267+
it('keeps only labels carried by series overlapping the window', async () => {
268+
expect(
269+
await labelNames({
270+
startMs: sec(RECENT_START_SEC),
271+
endMs: sec(RECENT_START_SEC + HOUR_SEC),
272+
}),
273+
).toEqual(['__name__', 'job']);
274+
});
275+
276+
it('returns nothing for a window no series covers', async () => {
277+
expect(
278+
await labelNames({
279+
startMs: sec(OLD_START_SEC + 2 * HOUR_SEC),
280+
endMs: sec(OLD_START_SEC + 3 * HOUR_SEC),
281+
}),
282+
).toEqual([]);
283+
});
284+
285+
it('caps the number of names returned, keeping the ordering', async () => {
286+
expect(await labelNames({ limit: 2 })).toEqual(['__name__', 'job']);
287+
});
288+
289+
it('treats a zero limit as unlimited', async () => {
290+
expect(await labelNames({ limit: 0 })).toEqual([
291+
'__name__',
292+
'job',
293+
'region',
294+
]);
295+
});
296+
297+
it('ignores the bounds on a table without min_time/max_time', async () => {
298+
expect(
299+
await labelNames({
300+
tableName: UNBOUNDED_TABLE,
301+
startMs: sec(RECENT_START_SEC),
302+
endMs: sec(RECENT_START_SEC + HOUR_SEC),
303+
}),
304+
).toEqual(['__name__', 'job', 'region']);
305+
});
306+
});
244307
});

‎packages/api/src/controllers/timeseriesEngine.ts‎

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import logger from '@/utils/logger';
1414
export const PROMETHEUS_MAX_EXECUTION_SEC = 30;
1515
export const PROMETHEUS_MAX_RESULT_ROWS = 100000;
1616

17+
export type TimeSeriesTagsQueryArgs = {
18+
client: ClickhouseClient;
19+
connectionId: string;
20+
databaseName: string;
21+
tableName: string;
22+
startMs?: number;
23+
endMs?: number;
24+
limit?: number;
25+
};
26+
1727
/**
1828
* Indicates whether the tags inner table associated with the given TimeSeries table
1929
* includes the optional `min_time` and `max_time` columns.
@@ -49,64 +59,61 @@ async function timeSeriesTagsTableHasTimeBounds({
4959
}
5060

5161
/**
52-
* Queries distinct values for the given label name from the given TimeSeries table,
53-
* optionally filtering by a time range (if the table has the optional time range columns).
62+
* Returns SQL predicates restricting tags rows to series that overlap [startMs, endMs].
63+
* Empty when no bound is given, or when the table stores no time bounds.
5464
*/
55-
export async function queryLabelValues({
65+
async function getTimeFilterCondition({
5666
client,
57-
databaseName,
5867
connectionId,
68+
databaseName,
5969
tableName,
60-
labelName,
6170
startMs,
6271
endMs,
63-
limit,
64-
}: {
65-
client: ClickhouseClient;
66-
connectionId: string;
67-
databaseName: string;
68-
tableName: string;
69-
labelName: string;
70-
startMs?: number;
71-
endMs?: number;
72-
limit?: number;
73-
}): Promise<string[]> {
74-
const isMetricName = labelName === '__name__';
75-
const value = isMetricName
76-
? chSql`${{ Identifier: 'metric_name' }} `
77-
: chSql`${{ Identifier: 'tags' }}[${{ String: labelName }}]`;
78-
79-
const conditions: ChSql[] = [];
80-
if (!isMetricName)
81-
conditions.push(
82-
chSql`mapContains(${{ Identifier: 'tags' }}, ${{ String: labelName }})`,
83-
);
84-
85-
const metadata = new Metadata(client, new MetadataCache());
86-
const hasStartTime = startMs != null;
87-
const hasEndTime = endMs != null;
72+
}: Omit<TimeSeriesTagsQueryArgs, 'limit'>): Promise<ChSql[]> {
73+
if (startMs == null && endMs == null) return [];
8874

8975
// min and max time columns are optional in the tags inner table,
9076
// so we check if they exist before adding time conditions
91-
const tableHasTimeBounds =
92-
(hasStartTime || hasEndTime) && // Short-circuit if no time conditions are needed
93-
(await timeSeriesTagsTableHasTimeBounds({
94-
metadata,
95-
connectionId,
96-
database: databaseName,
97-
table: tableName,
98-
}));
77+
const metadata = new Metadata(client, new MetadataCache());
78+
const tableHasTimeBounds = await timeSeriesTagsTableHasTimeBounds({
79+
metadata,
80+
connectionId,
81+
database: databaseName,
82+
table: tableName,
83+
});
84+
if (!tableHasTimeBounds) return [];
9985

100-
if (tableHasTimeBounds && endMs != null)
86+
const conditions: ChSql[] = [];
87+
if (endMs != null)
10188
conditions.push(
10289
chSql`(min_time IS NULL OR min_time <= fromUnixTimestamp64Milli(${{ Int64: endMs }}))`,
10390
);
104-
105-
if (tableHasTimeBounds && startMs != null)
91+
if (startMs != null)
10692
conditions.push(
10793
chSql`(max_time IS NULL OR max_time >= fromUnixTimestamp64Milli(${{ Int64: startMs }}))`,
10894
);
95+
return conditions;
96+
}
10997

98+
/**
99+
* Runs `SELECT DISTINCT <value> AS val FROM timeSeriesTags(...)` with the given
100+
* conditions, sorted, and returns the distinct values.
101+
*/
102+
async function queryDistinctTagsValues({
103+
client,
104+
databaseName,
105+
tableName,
106+
value,
107+
conditions,
108+
limit,
109+
}: {
110+
client: ClickhouseClient;
111+
databaseName: string;
112+
tableName: string;
113+
value: ChSql;
114+
conditions: ChSql[];
115+
limit?: number;
116+
}): Promise<string[]> {
110117
const where = conditions.length
111118
? chSql`WHERE ${concatChSql(' AND ', ...conditions)}`
112119
: chSql``;
@@ -135,3 +142,44 @@ export async function queryLabelValues({
135142
const json = await resp.json<{ val: string }>();
136143
return json.data.map(r => r.val);
137144
}
145+
146+
/**
147+
* Queries distinct values for the given label name from the given TimeSeries table,
148+
* optionally filtering by a time range (if the table has the optional time range columns).
149+
*/
150+
export async function queryLabelValues({
151+
labelName,
152+
limit,
153+
...args
154+
}: TimeSeriesTagsQueryArgs & { labelName: string }): Promise<string[]> {
155+
const isMetricName = labelName === '__name__';
156+
const value = isMetricName
157+
? chSql`${{ Identifier: 'metric_name' }} `
158+
: chSql`${{ Identifier: 'tags' }}[${{ String: labelName }}]`;
159+
160+
const conditions: ChSql[] = [];
161+
if (!isMetricName)
162+
conditions.push(
163+
chSql`mapContains(${{ Identifier: 'tags' }}, ${{ String: labelName }})`,
164+
);
165+
conditions.push(...(await getTimeFilterCondition(args)));
166+
167+
return queryDistinctTagsValues({ ...args, value, conditions, limit });
168+
}
169+
170+
/**
171+
* Queries the distinct label names carried by any series in the given TimeSeries
172+
* table, optionally filtering by a time range (if the table has the optional time
173+
* range columns).
174+
*/
175+
export async function queryLabelNames({
176+
limit,
177+
...args
178+
}: TimeSeriesTagsQueryArgs): Promise<string[]> {
179+
// `all_tags` is EPHEMERAL by default, so the label names come from `tags`,
180+
// which the engine strips `__name__` out of. It is folded back in explicitly.
181+
const value = chSql`arrayJoin(arrayConcat([${{ String: '__name__' }}], mapKeys(${{ Identifier: 'tags' }})))`;
182+
const conditions = await getTimeFilterCondition(args);
183+
184+
return queryDistinctTagsValues({ ...args, value, conditions, limit });
185+
}

0 commit comments

Comments
 (0)