Skip to content

Commit 5cd7090

Browse files
authored
feat: add ability to connect to external prometheus datastore (#2518)
1 parent d789e53 commit 5cd7090

13 files changed

Lines changed: 550 additions & 269 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@hyperdx/app': minor
3+
'@hyperdx/api': minor
4+
'@hyperdx/common-utils': minor
5+
---
6+
7+
Add UI support for configuring an external Prometheus-compatible endpoint on a
8+
connection. Modify Connections model to now have a boolean
9+
`isPrometheusEndpoint` field and use host for storing the host.

‎packages/api/openapi.json‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,10 @@
620620
"nullable": true,
621621
"example": "hyperdx_"
622622
},
623-
"prometheusEndpoint": {
624-
"type": "string",
625-
"description": "Optional Prometheus-compatible API endpoint. When set, PromQL queries are proxied to this endpoint.",
626-
"nullable": true,
627-
"example": "http://prometheus:9090"
623+
"isPrometheusEndpoint": {
624+
"type": "boolean",
625+
"description": "Optional. When true, `host` is treated as a Prometheus-compatible API endpoint (e.g. Prometheus or Thanos) and PromQL queries are proxied to it. When false or omitted, `host` is a ClickHouse HTTP endpoint.",
626+
"example": false
628627
},
629628
"createdAt": {
630629
"type": "string",
@@ -675,10 +674,10 @@
675674
"nullable": true,
676675
"example": "hyperdx_"
677676
},
678-
"prometheusEndpoint": {
679-
"type": "string",
680-
"description": "Optional Prometheus-compatible API endpoint. When set, PromQL queries are proxied to this endpoint.",
681-
"example": "http://prometheus:9090"
677+
"isPrometheusEndpoint": {
678+
"type": "boolean",
679+
"description": "Optional. When true, `host` is treated as a Prometheus-compatible API endpoint (e.g. Prometheus or Thanos) and PromQL queries are proxied to it. When false or omitted, `host` is a ClickHouse HTTP endpoint.",
680+
"example": false
682681
}
683682
}
684683
},
@@ -717,11 +716,10 @@
717716
"nullable": true,
718717
"example": "hyperdx_"
719718
},
720-
"prometheusEndpoint": {
721-
"type": "string",
722-
"description": "Optional Prometheus-compatible API endpoint. Set to null to clear the existing value. If omitted, the existing value is kept.",
723-
"nullable": true,
724-
"example": "http://prometheus:9090"
719+
"isPrometheusEndpoint": {
720+
"type": "boolean",
721+
"description": "Optional. When true, `host` is treated as a Prometheus-compatible API endpoint. When false or omitted, `host` is a ClickHouse HTTP endpoint. Omit to keep the existing value unchanged.",
722+
"example": false
725723
}
726724
}
727725
},

‎packages/api/src/models/connection.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ export interface IConnection {
1212
username: string;
1313
team: ObjectId;
1414
hyperdxSettingPrefix?: string;
15-
/** Optional Prometheus-compatible API endpoint (e.g. http://prometheus:9090).
16-
* When set, PromQL queries are proxied to this endpoint instead of using
17-
* ClickHouse's prometheusQuery() function. */
18-
prometheusEndpoint?: string;
15+
/** When true, `host` is treated as a Prometheus-compatible API endpoint
16+
* (e.g. Prometheus or Thanos) and PromQL queries are proxied directly to
17+
* it. When false/unset, `host` is a ClickHouse HTTP endpoint and PromQL
18+
* queries use ClickHouse's prometheusQuery() function. */
19+
isPrometheusEndpoint?: boolean;
1920
}
2021

2122
export type ConnectionDocument = mongoose.HydratedDocument<IConnection>;
@@ -37,7 +38,7 @@ export default mongoose.model<IConnection>(
3738
select: false,
3839
},
3940
hyperdxSettingPrefix: String,
40-
prometheusEndpoint: String,
41+
isPrometheusEndpoint: Boolean,
4142
},
4243
{
4344
timestamps: true,

‎packages/api/src/routers/api/__tests__/connections.test.ts‎

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,67 +43,79 @@ describe('connections router', () => {
4343
expect(res.body[0].name).toBe('My Team Connection');
4444
});
4545

46-
it('persists prometheusEndpoint through POST /connections', async () => {
46+
it('persists isPrometheusEndpoint through POST /connections', async () => {
4747
const { agent } = await getLoggedInAgent(server);
4848

4949
const res = await agent
5050
.post('/connections')
5151
.send({
5252
name: 'Prom-enabled',
53-
host: config.CLICKHOUSE_HOST,
54-
username: 'default',
53+
host: 'http://thanos:10902',
54+
username: '',
5555
password: '',
56-
prometheusEndpoint: 'http://prom.example.com',
56+
isPrometheusEndpoint: true,
5757
})
5858
.expect(200);
5959

60-
const stored = await Connection.findById(res.body.id).select(
61-
'+prometheusEndpoint',
62-
);
63-
expect(stored?.prometheusEndpoint).toBe('http://prom.example.com');
60+
const stored = await Connection.findById(res.body.id);
61+
expect(stored?.host).toBe('http://thanos:10902');
62+
expect(stored?.isPrometheusEndpoint).toBe(true);
6463
});
6564

66-
it('rejects invalid prometheusEndpoint URLs with 400', async () => {
67-
const { agent } = await getLoggedInAgent(server);
65+
it('persists isPrometheusEndpoint through PUT /connections/:id', async () => {
66+
const { agent, team } = await getLoggedInAgent(server);
67+
68+
const created = await Connection.create({
69+
team: team._id,
70+
name: 'No-prom',
71+
host: config.CLICKHOUSE_HOST,
72+
username: 'default',
73+
password: '',
74+
});
6875

6976
await agent
70-
.post('/connections')
77+
.put(`/connections/${created._id.toString()}`)
7178
.send({
72-
name: 'Bad-URL',
73-
host: config.CLICKHOUSE_HOST,
74-
username: 'default',
79+
id: created._id.toString(),
80+
name: 'No-prom',
81+
host: 'http://thanos:10902',
82+
username: '',
7583
password: '',
76-
prometheusEndpoint: 'not-a-url',
84+
isPrometheusEndpoint: true,
7785
})
78-
.expect(400);
86+
.expect(200);
87+
88+
const stored = await Connection.findById(created._id);
89+
expect(stored?.host).toBe('http://thanos:10902');
90+
expect(stored?.isPrometheusEndpoint).toBe(true);
7991
});
8092

81-
it('persists prometheusEndpoint through PUT /connections/:id', async () => {
93+
it('toggles isPrometheusEndpoint back to false through PUT', async () => {
8294
const { agent, team } = await getLoggedInAgent(server);
8395

8496
const created = await Connection.create({
8597
team: team._id,
86-
name: 'No-prom',
87-
host: config.CLICKHOUSE_HOST,
88-
username: 'default',
98+
name: 'Prom',
99+
host: 'http://thanos:10902',
100+
username: '',
89101
password: '',
102+
isPrometheusEndpoint: true,
90103
});
91104

92105
await agent
93106
.put(`/connections/${created._id.toString()}`)
94107
.send({
95108
id: created._id.toString(),
96-
name: 'No-prom',
109+
name: 'Prom',
97110
host: config.CLICKHOUSE_HOST,
98111
username: 'default',
99112
password: '',
100-
prometheusEndpoint: 'http://prom-new.example.com',
113+
isPrometheusEndpoint: false,
101114
})
102115
.expect(200);
103116

104-
const stored = await Connection.findById(created._id).select(
105-
'+prometheusEndpoint',
106-
);
107-
expect(stored?.prometheusEndpoint).toBe('http://prom-new.example.com');
117+
const stored = await Connection.findById(created._id);
118+
expect(stored?.host).toBe(config.CLICKHOUSE_HOST);
119+
expect(stored?.isPrometheusEndpoint).toBe(false);
108120
});
109121
});

‎packages/api/src/routers/api/__tests__/prometheus.integration.test.ts‎

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ import Connection from '@/models/connection';
66

77
const mockFetch = global.fetch as jest.Mock;
88

9+
// The proxy now streams the upstream response straight through (no
10+
// `await resp.json()`), so test mocks must expose the fields the pipeline
11+
// actually reads: `status`, `headers.get()`, and a web `ReadableStream` body.
12+
function fakeUpstreamResponse(payload: unknown, status = 200) {
13+
const body = new ReadableStream<Uint8Array>({
14+
start(controller) {
15+
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
16+
controller.close();
17+
},
18+
});
19+
return {
20+
ok: status >= 200 && status < 300,
21+
status,
22+
headers: new Headers({ 'content-type': 'application/json' }),
23+
body,
24+
text: jest.fn().mockResolvedValue(JSON.stringify(payload)),
25+
json: jest.fn().mockResolvedValue(payload),
26+
};
27+
}
28+
929
describe('prometheus router', () => {
1030
const server = getServer();
1131

@@ -16,11 +36,7 @@ describe('prometheus router', () => {
1636
afterEach(async () => {
1737
await server.clearDBs();
1838
mockFetch.mockReset();
19-
mockFetch.mockResolvedValue({
20-
ok: true,
21-
text: jest.fn().mockResolvedValue(''),
22-
json: jest.fn().mockResolvedValue({}),
23-
} as any);
39+
mockFetch.mockResolvedValue(fakeUpstreamResponse({}) as any);
2440
});
2541

2642
afterAll(async () => {
@@ -31,10 +47,10 @@ describe('prometheus router', () => {
3147
return Connection.create({
3248
team: teamId,
3349
name: 'Prom',
34-
host: 'http://ch-server:8123',
35-
username: 'default',
50+
host: 'http://prom.example.com',
51+
username: '',
3652
password: '',
37-
prometheusEndpoint: 'http://prom.example.com',
53+
isPrometheusEndpoint: true,
3854
});
3955
};
4056

@@ -95,19 +111,17 @@ describe('prometheus router', () => {
95111
});
96112
});
97113

98-
it('proxies to upstream Prometheus when connection has prometheusEndpoint', async () => {
114+
it('proxies to upstream Prometheus when connection isPrometheusEndpoint', async () => {
99115
const { agent, team } = await getLoggedInAgent(server);
100116
const conn = await seedPrometheusConnection(team._id);
101117

102118
const promResponse = {
103119
status: 'success',
104120
data: { resultType: 'matrix', result: [] },
105121
};
106-
mockFetch.mockResolvedValueOnce({
107-
ok: true,
108-
text: jest.fn().mockResolvedValue(''),
109-
json: jest.fn().mockResolvedValue(promResponse),
110-
} as any);
122+
mockFetch.mockResolvedValueOnce(
123+
fakeUpstreamResponse(promResponse) as any,
124+
);
111125

112126
const res = await agent
113127
.get('/v1/prometheus/query_range')
@@ -129,7 +143,7 @@ describe('prometheus router', () => {
129143
expect(calledUrl).not.toContain('connectionId');
130144
});
131145

132-
it('does NOT proxy to Prometheus when connection has no prometheusEndpoint', async () => {
146+
it('does NOT proxy to Prometheus when connection is not isPrometheusEndpoint', async () => {
133147
const { agent, team } = await getLoggedInAgent(server);
134148
const conn = await seedClickHouseConnection(team._id);
135149

@@ -197,19 +211,17 @@ describe('prometheus router', () => {
197211
.expect(404);
198212
});
199213

200-
it('proxies to upstream Prometheus when connection has prometheusEndpoint', async () => {
214+
it('proxies to upstream Prometheus when connection isPrometheusEndpoint', async () => {
201215
const { agent, team } = await getLoggedInAgent(server);
202216
const conn = await seedPrometheusConnection(team._id);
203217

204218
const promResponse = {
205219
status: 'success',
206220
data: { resultType: 'vector', result: [] },
207221
};
208-
mockFetch.mockResolvedValueOnce({
209-
ok: true,
210-
text: jest.fn().mockResolvedValue(''),
211-
json: jest.fn().mockResolvedValue(promResponse),
212-
} as any);
222+
mockFetch.mockResolvedValueOnce(
223+
fakeUpstreamResponse(promResponse) as any,
224+
);
213225

214226
const res = await agent
215227
.get('/v1/prometheus/query')
@@ -238,16 +250,14 @@ describe('prometheus router', () => {
238250
.expect(404);
239251
});
240252

241-
it('proxies to upstream Prometheus when connection has prometheusEndpoint', async () => {
253+
it('proxies to upstream Prometheus when connection isPrometheusEndpoint', async () => {
242254
const { agent, team } = await getLoggedInAgent(server);
243255
const conn = await seedPrometheusConnection(team._id);
244256

245257
const promResponse = { status: 'success', data: ['up', 'requests'] };
246-
mockFetch.mockResolvedValueOnce({
247-
ok: true,
248-
text: jest.fn().mockResolvedValue(''),
249-
json: jest.fn().mockResolvedValue(promResponse),
250-
} as any);
258+
mockFetch.mockResolvedValueOnce(
259+
fakeUpstreamResponse(promResponse) as any,
260+
);
251261

252262
const res = await agent
253263
.get('/v1/prometheus/label/__name__/values')

‎packages/api/src/routers/api/connections.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ router.put(
6767
return;
6868
}
6969

70-
// Build the base connection update
7170
const shouldUnsetPrefix =
7271
req.body.hyperdxSettingPrefix === null ||
7372
req.body.hyperdxSettingPrefix === '';
@@ -82,7 +81,6 @@ router.put(
8281
: {
8382
password: connection.password,
8483
}),
85-
// Only include hyperdxSettingPrefix if it's a valid string
8684
...(!shouldUnsetPrefix && hyperdxSettingPrefix
8785
? { hyperdxSettingPrefix }
8886
: {}),

0 commit comments

Comments
 (0)