Skip to content

Commit 5f2dff4

Browse files
[FEATURE] Prometheus: support query params (#485)
* MVP Signed-off-by: Antoine THEBAUD <[email protected]> * use an intermediary array for local state Signed-off-by: Antoine THEBAUD <[email protected]> * misc Signed-off-by: Antoine THEBAUD <[email protected]> * refactor in client + add query params to healthcheck call too Signed-off-by: Antoine THEBAUD <[email protected]> * Implem review comments - dont save empty keys - avoid using index as key for loop-generated components - remove useless check Signed-off-by: Antoine THEBAUD <[email protected]> --------- Signed-off-by: Antoine THEBAUD <[email protected]>
1 parent 06ccdcc commit 5f2dff4

File tree

11 files changed

+327
-25
lines changed

11 files changed

+327
-25
lines changed

docs/prometheus/go-sdk/datasource.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,28 @@ datasource.HTTPProxy("https://current-domain-name.io", httpProxyOptions...)
3737

3838
Configure the access to the Prometheus datasource with a proxy URL. More info at [HTTP Proxy](https://perses.dev/perses/docs/dac/go/helper/http-proxy).
3939

40-
## Example
40+
#### Query Parameters
41+
42+
```golang
43+
import "github.com/perses/plugins/prometheus/sdk/go/datasource"
44+
45+
// Add multiple query parameters at once
46+
datasource.QueryParams(map[string]string{
47+
"dedup": "false",
48+
"max_source_resolution": "0s",
49+
})
50+
51+
// Or add individual query parameters
52+
datasource.QueryParam("dedup", "false")
53+
datasource.QueryParam("max_source_resolution", "0s")
54+
```
55+
56+
Configure query parameters to be appended to all Prometheus API requests. This is useful for:
57+
- Thanos deduplication control (`dedup=false`)
58+
- Resolution control (`max_source_resolution=0s`)
59+
- Any custom query parameters required by your Prometheus setup
60+
61+
## Examples
4162

4263
```golang
4364
package main
@@ -50,7 +71,30 @@ import (
5071

5172
func main() {
5273
dashboard.New("Example Dashboard",
53-
dashboard.AddDatasource("prometheusDemo", promDs.Prometheus(promDs.DirectURL("https://prometheus.demo.do.prometheus.io/"))),
74+
dashboard.AddDatasource("prometheusDemo",
75+
promDs.Prometheus(
76+
promDs.DirectURL("https://prometheus.demo.do.prometheus.io/")
77+
),
78+
),
79+
)
80+
}
81+
```
82+
83+
Another example that makes use of http query params for a Thanos setup:
84+
85+
```golang
86+
func main() {
87+
dashboard.New("Example Dashboard",
88+
dashboard.AddDatasource("thanosQuery",
89+
promDs.Prometheus(
90+
promDs.DirectURL("https://thanos-query.example.com/"),
91+
promDs.QueryParams(map[string]string{
92+
"dedup": "false",
93+
"max_source_resolution": "0s",
94+
"partial_response": "true",
95+
}),
96+
),
97+
),
5498
)
5599
}
56100
```

prometheus/schemas/datasource/prometheus.cue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ kind: #kind
2222
spec: {
2323
#directUrl | #proxy
2424
scrapeInterval?: =~#durationRegex
25+
queryParams?: {[string]: string}
2526
}
2627

2728
#kind: "PrometheusDatasource"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"kind": "PrometheusDatasource",
3+
"spec": {
4+
"proxy": {
5+
"kind": "HTTPProxy",
6+
"spec": {
7+
"url": "http://localhost:9090"
8+
}
9+
},
10+
"scrapeInterval": "30s",
11+
"queryParams": {
12+
"engine": "prometheus",
13+
"tenant": "default"
14+
}
15+
}
16+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"kind": "PrometheusDatasource",
3+
"spec": {
4+
"directUrl": "http://localhost:9090",
5+
"scrapeInterval": "15s",
6+
"queryParams": {
7+
"dedup": "false",
8+
"timeout": "30s"
9+
}
10+
}
11+
}

prometheus/sdk/go/datasource/datasource.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ const (
2727
)
2828

2929
type PluginSpec struct {
30-
DirectURL string `json:"directUrl,omitempty" yaml:"directUrl,omitempty"`
31-
Proxy *http.Proxy `json:"proxy,omitempty" yaml:"proxy,omitempty"`
32-
ScrapeInterval common.Duration `json:"scrapeInterval,omitempty" yaml:"scrapeInterval,omitempty"`
30+
DirectURL string `json:"directUrl,omitempty" yaml:"directUrl,omitempty"`
31+
Proxy *http.Proxy `json:"proxy,omitempty" yaml:"proxy,omitempty"`
32+
ScrapeInterval common.Duration `json:"scrapeInterval,omitempty" yaml:"scrapeInterval,omitempty"`
33+
QueryParams map[string]string `json:"queryParams,omitempty" yaml:"queryParams,omitempty"`
3334
}
3435

3536
func (s *PluginSpec) UnmarshalJSON(data []byte) error {

prometheus/sdk/go/datasource/options.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,20 @@ func HTTPProxy(url string, options ...http.Option) Option {
3434
return nil
3535
}
3636
}
37+
38+
func QueryParams(params map[string]string) Option {
39+
return func(builder *Builder) error {
40+
builder.QueryParams = params
41+
return nil
42+
}
43+
}
44+
45+
func QueryParam(key, value string) Option {
46+
return func(builder *Builder) error {
47+
if builder.QueryParams == nil {
48+
builder.QueryParams = make(map[string]string)
49+
}
50+
builder.QueryParams[key] = value
51+
return nil
52+
}
53+
}

prometheus/src/model/prometheus-client.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,33 @@ export interface QueryOptions {
7575
datasourceUrl: string;
7676
headers?: RequestHeaders;
7777
abortSignal?: AbortSignal;
78+
queryParams?: Record<string, string>;
79+
}
80+
81+
/**
82+
* Builds a query string from datasource-level query parameters.
83+
* Optionally merges with existing URLSearchParams.
84+
* Returns empty string if no parameters, otherwise returns query string with leading '?'.
85+
*/
86+
function buildQueryString(queryParams?: Record<string, string>, initialParams?: URLSearchParams): string {
87+
const urlParams = initialParams || new URLSearchParams();
88+
89+
if (queryParams) {
90+
Object.entries(queryParams).forEach(([key, value]) => {
91+
urlParams.set(key, value);
92+
});
93+
}
94+
95+
const queryString = urlParams.toString();
96+
return queryString !== '' ? `?${queryString}` : '';
7897
}
7998

8099
/**
81100
* Calls the `/-/healthy` endpoint to check if the datasource is healthy.
82101
*/
83102
export function healthCheck(queryOptions: QueryOptions) {
84103
return async (): Promise<boolean> => {
85-
const url = `${queryOptions.datasourceUrl}/-/healthy`;
104+
const url = `${queryOptions.datasourceUrl}/-/healthy${buildQueryString(queryOptions.queryParams)}`;
86105

87106
try {
88107
const resp = await fetch(url, { headers: queryOptions.headers, signal: queryOptions.abortSignal });
@@ -179,12 +198,8 @@ function fetchWithGet<T extends RequestParams<T>, TResponse>(
179198
params: T,
180199
queryOptions: QueryOptions
181200
): Promise<TResponse> {
182-
const { datasourceUrl, headers } = queryOptions;
183-
let url = `${datasourceUrl}${apiURI}`;
184-
const urlParams = createSearchParams(params).toString();
185-
if (urlParams !== '') {
186-
url += `?${urlParams}`;
187-
}
201+
const { datasourceUrl, headers, queryParams } = queryOptions;
202+
const url = `${datasourceUrl}${apiURI}${buildQueryString(queryParams, createSearchParams(params))}`;
188203
return fetchJson<TResponse>(url, { method: 'GET', headers });
189204
}
190205

@@ -193,8 +208,9 @@ function fetchWithPost<T extends RequestParams<T>, TResponse>(
193208
params: T,
194209
queryOptions: QueryOptions
195210
): Promise<TResponse> {
196-
const { datasourceUrl, headers, abortSignal: signal } = queryOptions;
197-
const url = `${datasourceUrl}${apiURI}`;
211+
const { datasourceUrl, headers, abortSignal: signal, queryParams } = queryOptions;
212+
const url = `${datasourceUrl}${apiURI}${buildQueryString(queryParams)}`;
213+
198214
const init = {
199215
method: 'POST',
200216
headers: {

prometheus/src/plugins/PrometheusDatasourceEditor.tsx

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,19 @@
1313

1414
import { DurationString } from '@perses-dev/core';
1515
import { HTTPSettingsEditor } from '@perses-dev/plugin-system';
16-
import { TextField, Typography } from '@mui/material';
17-
import React, { ReactElement } from 'react';
16+
import { Box, TextField, Typography, IconButton } from '@mui/material';
17+
import PlusIcon from 'mdi-material-ui/Plus';
18+
import MinusIcon from 'mdi-material-ui/Minus';
19+
import React, { ReactElement, useState, useRef } from 'react';
1820
import { DEFAULT_SCRAPE_INTERVAL, PrometheusDatasourceSpec } from './types';
1921

22+
interface QueryParamEntry {
23+
// Unique identifier for the entry, added to avoid using array index as key
24+
id: string;
25+
key: string;
26+
value: string;
27+
}
28+
2029
export interface PrometheusDatasourceEditorProps {
2130
value: PrometheusDatasourceSpec;
2231
onChange: (next: PrometheusDatasourceSpec) => void;
@@ -26,6 +35,70 @@ export interface PrometheusDatasourceEditorProps {
2635
export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProps): ReactElement {
2736
const { value, onChange, isReadonly } = props;
2837

38+
// Counter for generating unique IDs
39+
const nextIdRef = useRef(0);
40+
41+
// Use local state to maintain an array of entries during editing, instead of
42+
// manipulating a map directly which causes weird UX.
43+
const [entries, setEntries] = useState<QueryParamEntry[]>(() => {
44+
const queryParams = value.queryParams ?? {};
45+
return Object.entries(queryParams).map(([key, value]) => ({
46+
id: String(nextIdRef.current++),
47+
key,
48+
value,
49+
}));
50+
});
51+
52+
// Check for duplicate keys
53+
const keyMap = new Map<string, number>();
54+
const duplicateKeys = new Set<string>();
55+
entries.forEach(({ key }) => {
56+
if (key !== '') {
57+
const count = (keyMap.get(key) || 0) + 1;
58+
keyMap.set(key, count);
59+
if (count > 1) {
60+
duplicateKeys.add(key);
61+
}
62+
}
63+
});
64+
const hasDuplicates = duplicateKeys.size > 0;
65+
66+
// Convert entries array to object and trigger onChange
67+
const syncToParent = (newEntries: QueryParamEntry[]) => {
68+
const newParams: Record<string, string> = {};
69+
newEntries.forEach(({ key, value }) => {
70+
if (key !== '') {
71+
newParams[key] = value;
72+
}
73+
});
74+
75+
onChange({
76+
...value,
77+
queryParams: Object.keys(newParams).length > 0 ? newParams : undefined,
78+
});
79+
};
80+
81+
const handleQueryParamChange = (id: string, field: 'key' | 'value', newValue: string) => {
82+
const newEntries = entries.map((entry) => {
83+
if (entry.id !== id) return entry;
84+
return field === 'key' ? { ...entry, key: newValue } : { ...entry, value: newValue };
85+
});
86+
setEntries(newEntries);
87+
syncToParent(newEntries);
88+
};
89+
90+
const addQueryParam = () => {
91+
const newEntries = [...entries, { id: String(nextIdRef.current++), key: '', value: '' }];
92+
setEntries(newEntries);
93+
syncToParent(newEntries);
94+
};
95+
96+
const removeQueryParam = (id: string) => {
97+
const newEntries = entries.filter((entry) => entry.id !== id);
98+
setEntries(newEntries);
99+
syncToParent(newEntries);
100+
};
101+
29102
const initialSpecDirect: PrometheusDatasourceSpec = {
30103
directUrl: '',
31104
};
@@ -76,6 +149,7 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp
76149
General Settings
77150
</Typography>
78151
<TextField
152+
size="small"
79153
fullWidth
80154
label="Scrape Interval"
81155
value={value.scrapeInterval || ''}
@@ -85,6 +159,7 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp
85159
}}
86160
InputLabelProps={{ shrink: isReadonly ? true : undefined }}
87161
onChange={(e) => onChange({ ...value, scrapeInterval: e.target.value as DurationString })}
162+
helperText="Set it to match the typical scrape interval used in your Prometheus instance."
88163
/>
89164
<HTTPSettingsEditor
90165
value={value}
@@ -93,6 +168,57 @@ export function PrometheusDatasourceEditor(props: PrometheusDatasourceEditorProp
93168
initialSpecDirect={initialSpecDirect}
94169
initialSpecProxy={initialSpecProxy}
95170
/>
171+
<Typography variant="h5" mt={2} mb={1}>
172+
Query Parameters
173+
</Typography>
174+
{entries.length > 0 && (
175+
<>
176+
{entries.map((entry) => (
177+
<Box key={entry.id} display="flex" alignItems="center" gap={2} mb={1}>
178+
<TextField
179+
size="small"
180+
label="Key"
181+
value={entry.key}
182+
placeholder="Parameter name"
183+
disabled={isReadonly}
184+
onChange={(e) => handleQueryParamChange(entry.id, 'key', e.target.value)}
185+
error={duplicateKeys.has(entry.key)}
186+
sx={{ minWidth: 150 }}
187+
/>
188+
<TextField
189+
size="small"
190+
label="Value"
191+
value={entry.value}
192+
placeholder="Parameter value"
193+
disabled={isReadonly}
194+
onChange={(e) => handleQueryParamChange(entry.id, 'value', e.target.value)}
195+
sx={{ minWidth: 150, flexGrow: 1 }}
196+
/>
197+
{!isReadonly && (
198+
<IconButton onClick={() => removeQueryParam(entry.id)}>
199+
<MinusIcon />
200+
</IconButton>
201+
)}
202+
</Box>
203+
))}
204+
</>
205+
)}
206+
{hasDuplicates && (
207+
<Typography variant="body2" color="error" mb={1}>
208+
Duplicate parameter keys detected. Each key must be unique.
209+
</Typography>
210+
)}
211+
{entries.length === 0 && (
212+
<Typography variant="body2" color="textSecondary">
213+
No query parameters configured. Use query parameters to pass additional options to Prometheus (e.g.,
214+
dedup=false for Thanos).
215+
</Typography>
216+
)}
217+
{!isReadonly && (
218+
<IconButton onClick={addQueryParam}>
219+
<PlusIcon />
220+
</IconButton>
221+
)}
96222
</>
97223
);
98224
}

0 commit comments

Comments
 (0)