Repository navigation
feat: Accept optional time bounds on Prometheus label values endpoint #3039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@hyperdx/api': patch | ||
| '@hyperdx/app': patch | ||
| --- | ||
|
|
||
| feat: Accept optional time bounds on Prometheus label values endpoint | ||
244 changes: 244 additions & 0 deletions
244
packages/api/src/controllers/__tests__/timeseriesEngine.int.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| import { ClickhouseClient } from '@/clickhouse'; | ||
| import * as config from '@/config'; | ||
| import { queryLabelValues } from '@/controllers/timeseriesEngine'; | ||
| import { | ||
| closeTestFixtureClickHouseClient, | ||
| DEFAULT_DATABASE, | ||
| dropTimeSeriesTable, | ||
| executeSqlCommand, | ||
| seedTimeSeriesTagsTable, | ||
| } from '@/fixtures'; | ||
|
|
||
| const CONNECTION_ID = 'timeseries-engine-int-test'; | ||
| const BOUNDED_TABLE = 'test_ts_label_values'; | ||
| const UNBOUNDED_TABLE = 'test_ts_label_values_no_bounds'; | ||
|
|
||
| const HOUR_SEC = 3600; | ||
| const OLD_START_SEC = 1600000000; | ||
| const RECENT_START_SEC = 1700000000; | ||
| const sec = (s: number) => s * 1000; | ||
|
|
||
| // Three series: one only in the distant past, two only in the recent window | ||
| // sharing a metric name (so DISTINCT has something to collapse). | ||
| const SERIES = [ | ||
| { | ||
| metricName: 'old_metric', | ||
| tags: { job: 'batch' }, | ||
| startSec: OLD_START_SEC, | ||
| endSec: OLD_START_SEC + HOUR_SEC, | ||
| }, | ||
| { | ||
| metricName: 'recent_metric', | ||
| tags: { job: 'api' }, | ||
| startSec: RECENT_START_SEC, | ||
| endSec: RECENT_START_SEC + HOUR_SEC, | ||
| }, | ||
| { | ||
| metricName: 'recent_metric', | ||
| tags: { job: 'web' }, | ||
| startSec: RECENT_START_SEC, | ||
| endSec: RECENT_START_SEC + HOUR_SEC, | ||
| }, | ||
| ]; | ||
|
|
||
| describe('timeseriesEngine controller', () => { | ||
| let client: ClickhouseClient; | ||
|
|
||
| const labelValues = ( | ||
| args: Partial<Parameters<typeof queryLabelValues>[0]> & { | ||
| labelName: string; | ||
| }, | ||
| ) => | ||
| queryLabelValues({ | ||
| client, | ||
| connectionId: CONNECTION_ID, | ||
| databaseName: DEFAULT_DATABASE, | ||
| tableName: BOUNDED_TABLE, | ||
| ...args, | ||
| }); | ||
|
|
||
| beforeAll(async () => { | ||
| client = new ClickhouseClient({ | ||
| host: config.CLICKHOUSE_HOST, | ||
| username: config.CLICKHOUSE_USER, | ||
| password: config.CLICKHOUSE_PASSWORD, | ||
| }); | ||
| await seedTimeSeriesTagsTable({ table: BOUNDED_TABLE, series: SERIES }); | ||
| await seedTimeSeriesTagsTable({ | ||
| table: UNBOUNDED_TABLE, | ||
| series: SERIES, | ||
| storeTimeBounds: false, | ||
| }); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| for (const table of [BOUNDED_TABLE, UNBOUNDED_TABLE]) { | ||
| await dropTimeSeriesTable({ table }); | ||
| } | ||
| await client.close(); | ||
| await closeTestFixtureClickHouseClient(); | ||
| }); | ||
|
|
||
| describe('queryLabelValues', () => { | ||
| describe('__name__', () => { | ||
| it('returns every metric name, deduplicated and sorted', async () => { | ||
| expect(await labelValues({ labelName: '__name__' })).toEqual([ | ||
| 'old_metric', | ||
| 'recent_metric', | ||
| ]); | ||
| }); | ||
|
|
||
| it('keeps only metrics whose series overlap the window', async () => { | ||
| expect( | ||
| await labelValues({ | ||
| labelName: '__name__', | ||
| startMs: sec(RECENT_START_SEC), | ||
| endMs: sec(RECENT_START_SEC + HOUR_SEC), | ||
| }), | ||
| ).toEqual(['recent_metric']); | ||
|
|
||
| expect( | ||
| await labelValues({ | ||
| labelName: '__name__', | ||
| startMs: sec(OLD_START_SEC), | ||
| endMs: sec(OLD_START_SEC + HOUR_SEC), | ||
| }), | ||
| ).toEqual(['old_metric']); | ||
| }); | ||
|
|
||
| // A lone `end` bounds min_time (nothing may start after the window) and a | ||
| // lone `start` bounds max_time (nothing may end before it). Getting the | ||
| // pairing backwards still filters, just wrongly, so both directions are | ||
| // pinned. | ||
| it('applies a lone end bound against min_time', async () => { | ||
| expect( | ||
| await labelValues({ | ||
| labelName: '__name__', | ||
| endMs: sec(OLD_START_SEC + HOUR_SEC), | ||
| }), | ||
| ).toEqual(['old_metric']); | ||
| }); | ||
|
|
||
| it('applies a lone start bound against max_time', async () => { | ||
| expect( | ||
| await labelValues({ | ||
| labelName: '__name__', | ||
| startMs: sec(RECENT_START_SEC), | ||
| }), | ||
| ).toEqual(['recent_metric']); | ||
| }); | ||
|
|
||
| it('returns nothing for a window no series covers', async () => { | ||
| expect( | ||
| await labelValues({ | ||
| labelName: '__name__', | ||
| startMs: sec(OLD_START_SEC + 2 * HOUR_SEC), | ||
| endMs: sec(OLD_START_SEC + 3 * HOUR_SEC), | ||
| }), | ||
| ).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('tag labels', () => { | ||
| it('returns the label values, deduplicated and sorted', async () => { | ||
| expect(await labelValues({ labelName: 'job' })).toEqual([ | ||
| 'api', | ||
| 'batch', | ||
| 'web', | ||
| ]); | ||
| }); | ||
|
|
||
| it('scopes label values to the window', async () => { | ||
| expect( | ||
| await labelValues({ | ||
| labelName: 'job', | ||
| startMs: sec(RECENT_START_SEC), | ||
| endMs: sec(RECENT_START_SEC + HOUR_SEC), | ||
| }), | ||
| ).toEqual(['api', 'web']); | ||
| }); | ||
|
|
||
| it('returns nothing for a label no series carries', async () => { | ||
| expect(await labelValues({ labelName: 'instance' })).toEqual([]); | ||
| }); | ||
|
|
||
| // The label name reaches both the WHERE clause and the value expression. | ||
| // Parameterized, this is an ordinary miss; interpolated, it is a syntax | ||
| // error — so the assertion is "empty", not "throws". | ||
| it('parameterizes the label name rather than interpolating it', async () => { | ||
| expect(await labelValues({ labelName: `job') OR 1=1 --` })).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('limit', () => { | ||
| it('caps the number of values returned, keeping the ordering', async () => { | ||
| expect(await labelValues({ labelName: '__name__', limit: 1 })).toEqual([ | ||
| 'old_metric', | ||
| ]); | ||
| }); | ||
|
|
||
| // Prometheus reads limit=0 as unlimited, so it must not become `LIMIT 0`. | ||
| it('treats a zero limit as unlimited', async () => { | ||
| expect(await labelValues({ labelName: '__name__', limit: 0 })).toEqual([ | ||
| 'old_metric', | ||
| 'recent_metric', | ||
| ]); | ||
| }); | ||
|
|
||
| it('returns everything when the limit exceeds the result set', async () => { | ||
| expect( | ||
| await labelValues({ labelName: '__name__', limit: 100 }), | ||
| ).toEqual(['old_metric', 'recent_metric']); | ||
| }); | ||
| }); | ||
|
|
||
| // store_min_time_and_max_time = 0 leaves the tags table without the columns | ||
| // the predicate reads, and referencing a missing column is a hard error. | ||
| // Time bounds are best-effort narrowing, so the request widens instead. | ||
| describe('table without min_time/max_time', () => { | ||
| it('ignores the bounds instead of failing', async () => { | ||
| expect( | ||
| await queryLabelValues({ | ||
| client, | ||
| connectionId: CONNECTION_ID, | ||
| databaseName: DEFAULT_DATABASE, | ||
| tableName: UNBOUNDED_TABLE, | ||
| labelName: '__name__', | ||
| startMs: sec(RECENT_START_SEC), | ||
| endMs: sec(RECENT_START_SEC + HOUR_SEC), | ||
| }), | ||
| ).toEqual(['old_metric', 'recent_metric']); | ||
| }); | ||
|
|
||
| it('still filters on the label itself', async () => { | ||
| expect( | ||
| await queryLabelValues({ | ||
| client, | ||
| connectionId: CONNECTION_ID, | ||
| databaseName: DEFAULT_DATABASE, | ||
| tableName: UNBOUNDED_TABLE, | ||
| labelName: 'job', | ||
| }), | ||
| ).toEqual(['api', 'batch', 'web']); | ||
| }); | ||
| }); | ||
|
|
||
| it('rejects when the table is not a TimeSeries table', async () => { | ||
| await executeSqlCommand( | ||
| `CREATE OR REPLACE TABLE ${DEFAULT_DATABASE}.test_ts_plain (ts DateTime) ENGINE = MergeTree ORDER BY ts`, | ||
| ); | ||
| try { | ||
| await expect( | ||
| labelValues({ | ||
| labelName: '__name__', | ||
| tableName: 'test_ts_plain', | ||
| }), | ||
| ).rejects.toThrow(/TimeSeries table only/); | ||
| } finally { | ||
| await executeSqlCommand( | ||
| `DROP TABLE IF EXISTS ${DEFAULT_DATABASE}.test_ts_plain`, | ||
| ); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { | ||
| ChSql, | ||
| chSql, | ||
| concatChSql, | ||
| } from '@hyperdx/common-utils/dist/clickhouse'; | ||
| import { | ||
| Metadata, | ||
| MetadataCache, | ||
| } from '@hyperdx/common-utils/dist/core/metadata'; | ||
|
|
||
| import { ClickhouseClient } from '@/clickhouse'; | ||
| import logger from '@/utils/logger'; | ||
|
|
||
| export const PROMETHEUS_MAX_EXECUTION_SEC = 30; | ||
| export const PROMETHEUS_MAX_RESULT_ROWS = 100000; | ||
|
|
||
| /** | ||
| * Indicates whether the tags inner table associated with the given TimeSeries table | ||
| * includes the optional `min_time` and `max_time` columns. | ||
| */ | ||
| async function timeSeriesTagsTableHasTimeBounds({ | ||
| metadata, | ||
| connectionId, | ||
| database, | ||
| table, | ||
| }: { | ||
| metadata: Metadata; | ||
| connectionId: string; | ||
| database: string; | ||
| table: string; | ||
| }): Promise<boolean> { | ||
| try { | ||
| const columns = await metadata.getTimeSeriesTableColumns({ | ||
| connectionId, | ||
| databaseName: database, | ||
| tableName: table, | ||
| innerTableType: 'Tags', | ||
| }); | ||
|
|
||
| const columnNames = new Set(columns.map(column => column.name)); | ||
| return columnNames.has('min_time') && columnNames.has('max_time'); | ||
| } catch (e) { | ||
| logger.warn( | ||
| { err: e, database, table }, | ||
| 'Failed to check if TimeSeries tags table has time bounds columns', | ||
| ); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Queries distinct values for the given label name from the given TimeSeries table, | ||
| * optionally filtering by a time range (if the table has the optional time range columns). | ||
| */ | ||
| export async function queryLabelValues({ | ||
| client, | ||
| databaseName, | ||
| connectionId, | ||
| tableName, | ||
| labelName, | ||
| startMs, | ||
| endMs, | ||
| limit, | ||
| }: { | ||
| client: ClickhouseClient; | ||
| connectionId: string; | ||
| databaseName: string; | ||
| tableName: string; | ||
| labelName: string; | ||
| startMs?: number; | ||
| endMs?: number; | ||
| limit?: number; | ||
| }): Promise<string[]> { | ||
| const isMetricName = labelName === '__name__'; | ||
| const value = isMetricName | ||
| ? chSql`${{ Identifier: 'metric_name' }} ` | ||
| : chSql`${{ Identifier: 'tags' }}[${{ String: labelName }}]`; | ||
|
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
|
||
|
|
||
| const conditions: ChSql[] = []; | ||
| if (!isMetricName) | ||
| conditions.push( | ||
| chSql`mapContains(${{ Identifier: 'tags' }}, ${{ String: labelName }})`, | ||
| ); | ||
|
|
||
| const metadata = new Metadata(client, new MetadataCache()); | ||
|
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
|
||
| const hasStartTime = startMs != null; | ||
| const hasEndTime = endMs != null; | ||
|
|
||
| // min and max time columns are optional in the tags inner table, | ||
| // so we check if they exist before adding time conditions | ||
| const tableHasTimeBounds = | ||
| (hasStartTime || hasEndTime) && // Short-circuit if no time conditions are needed | ||
| (await timeSeriesTagsTableHasTimeBounds({ | ||
| metadata, | ||
| connectionId, | ||
| database: databaseName, | ||
| table: tableName, | ||
| })); | ||
|
|
||
| if (tableHasTimeBounds && endMs != null) | ||
| conditions.push( | ||
| chSql`(min_time IS NULL OR min_time <= fromUnixTimestamp64Milli(${{ Int64: endMs }}))`, | ||
| ); | ||
|
|
||
| if (tableHasTimeBounds && startMs != null) | ||
| conditions.push( | ||
| chSql`(max_time IS NULL OR max_time >= fromUnixTimestamp64Milli(${{ Int64: startMs }}))`, | ||
| ); | ||
|
|
||
| const where = conditions.length | ||
| ? chSql`WHERE ${concatChSql(' AND ', ...conditions)}` | ||
| : chSql``; | ||
| const limitSql = limit | ||
| ? chSql`LIMIT ${{ Int32: Math.min(limit, PROMETHEUS_MAX_RESULT_ROWS) }}` | ||
| : chSql``; | ||
| const query = chSql` | ||
| SELECT DISTINCT ${value} AS val | ||
| FROM timeSeriesTags(${{ Identifier: databaseName }}, ${{ Identifier: tableName }}) | ||
| ${where} | ||
| ORDER BY val | ||
| ${limitSql} | ||
| `; | ||
|
|
||
| const resp = await client.query({ | ||
| query: query.sql, | ||
| query_params: query.params, | ||
| 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<{ val: string }>(); | ||
| return json.data.map(r => r.val); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.