Skip to content

Commit 0c125fa

Browse files
committed
feat(tools): add Prometheus discovery tools (list_metrics, metadata, targets)
Three new read-only tools adapted from pab1it0/prometheus-mcp-server, all backed by the standard Prometheus HTTP API so they work against self-hosted Prom and Google Managed Prometheus alike (with one caveat, noted below). list_prom_metrics — list metric names known to the server with an optional case-insensitive substring filter. Resolves the 'no series returned' diagnostic loop where the agent can't tell whether the metric name is wrong, the labels are wrong, or the workload is silent. Backed by /api/v1/label/__name__/values. get_prom_metric_metadata — return type/help/unit for a metric or all metrics. Resolves rate/gauge/histogram confusion before writing PromQL (e.g. counter needs rate(), gauge does not, histogram needs histogram_quantile()). Backed by /api/v1/metadata. get_prom_targets — scrape target health (up/down) with lastError on failures. Detects the 'Prom isn't scraping the workload' failure mode before blaming the workload. Backed by /api/v1/targets. NOTE: Google Managed Prometheus gateways do not expose /api/v1/targets; the tool returns an empty list there and the summary surfaces that explicitly rather than treating it as an error. PromClient interface gains listMetrics/metricMetadata/targets methods (implemented in RealPromClient with native fetch, plus matching fixture support). Three new evidence keys: prom_metrics, prom_metric_metadata, prom_targets. Planner adds a PROM_DISCOVERY_TOOLS group selected when the alert has metric/rate/latency signals (matches the existing hasMetricSignal heuristic), keeping tool budget tight for non-metric alerts.
1 parent bcbb0ef commit 0c125fa

11 files changed

Lines changed: 544 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,4 @@ See [`AGENTS.md`](./AGENTS.md) for development conventions.
116116
- [`googleapis/gcloud-mcp`](https://github.com/googleapis/gcloud-mcp) — tool-design reference.
117117
- [`JuliusBrussee/caveman`](https://github.com/JuliusBrussee/caveman) — prompt-compression reference.
118118
- [`k8sgpt-ai/k8sgpt`](https://github.com/k8sgpt-ai/k8sgpt) — deterministic k8s analyzer + failure-catalog reference.
119+
- [`pab1it0/prometheus-mcp-server`](https://github.com/pab1it0/prometheus-mcp-server) — Prometheus discovery-tool reference.

packages/clients/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ const THROWING: ClientRegistry = {
3535
async alertRules() {
3636
throw new Error("Prom client not configured");
3737
},
38+
async listMetrics() {
39+
throw new Error("Prom client not configured");
40+
},
41+
async metricMetadata() {
42+
throw new Error("Prom client not configured");
43+
},
44+
async targets() {
45+
throw new Error("Prom client not configured");
46+
},
3847
uiUrl() {
3948
return undefined;
4049
},

packages/clients/src/prom/fixture.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1-
import type { PromInstantResult, PromRuleState, PromSeriesResult } from "@opsremedy/core/types";
2-
import type { PromClient, PromInstantQuery, PromRangeQuery } from "../types.ts";
1+
import type {
2+
PromInstantResult,
3+
PromMetricMetadata,
4+
PromRuleState,
5+
PromSeriesResult,
6+
PromTarget,
7+
} from "@opsremedy/core/types";
8+
import type {
9+
PromClient,
10+
PromInstantQuery,
11+
PromMetadataQuery,
12+
PromMetricsListQuery,
13+
PromRangeQuery,
14+
PromTargetsQuery,
15+
} from "../types.ts";
316

417
export interface FixturePromPayload {
518
/** Keyed by exact PromQL query string. */
619
instant?: Record<string, PromInstantResult>;
720
range?: Record<string, PromSeriesResult>;
821
alertRules?: PromRuleState[];
22+
metrics?: string[];
23+
metadata?: PromMetricMetadata[];
24+
targets?: PromTarget[];
925
}
1026

1127
const EMPTY_INSTANT: PromInstantResult = { resultType: "vector", series: [] };
@@ -31,6 +47,25 @@ export class FixturePromClient implements PromClient {
3147
return this.data.alertRules ?? [];
3248
}
3349

50+
async listMetrics(q: PromMetricsListQuery): Promise<string[]> {
51+
let names = this.data.metrics ?? [];
52+
if (q.contains) {
53+
const needle = q.contains.toLowerCase();
54+
names = names.filter((n) => n.toLowerCase().includes(needle));
55+
}
56+
return names.slice(0, q.limit ?? 200);
57+
}
58+
59+
async metricMetadata(q: PromMetadataQuery): Promise<PromMetricMetadata[]> {
60+
const all = this.data.metadata ?? [];
61+
return q.metric ? all.filter((m) => m.metric === q.metric) : all;
62+
}
63+
64+
async targets(q: PromTargetsQuery): Promise<PromTarget[]> {
65+
const all = this.data.targets ?? [];
66+
return q.job ? all.filter((t) => t.job === q.job) : all;
67+
}
68+
3469
uiUrl(): undefined {
3570
return undefined;
3671
}

packages/clients/src/prom/real.ts

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
import type { PromInstantResult, PromRuleState, PromSeriesResult } from "@opsremedy/core/types";
2-
import type { PromClient, PromInstantQuery, PromRangeQuery } from "../types.ts";
1+
import type {
2+
PromInstantResult,
3+
PromMetricMetadata,
4+
PromRuleState,
5+
PromSeriesResult,
6+
PromTarget,
7+
} from "@opsremedy/core/types";
8+
import type {
9+
PromClient,
10+
PromInstantQuery,
11+
PromMetadataQuery,
12+
PromMetricsListQuery,
13+
PromRangeQuery,
14+
PromTargetsQuery,
15+
} from "../types.ts";
316

417
export interface RealPromClientOptions {
518
baseUrl: string;
@@ -42,6 +55,30 @@ interface PromRulesData {
4255
}>;
4356
}
4457

58+
/**
59+
* `/api/v1/metadata` returns either Record<metric, MetadataItem[]> (vanilla
60+
* Prom) or a flat array (some downstreams). We normalise to the array form.
61+
*/
62+
type PromMetadataRaw =
63+
| Record<string, Array<{ type?: string; help?: string; unit?: string }>>
64+
| Array<{ metric?: string; type?: string; help?: string; unit?: string }>;
65+
66+
interface PromTargetRaw {
67+
discoveredLabels?: Record<string, string>;
68+
labels?: Record<string, string>;
69+
scrapePool?: string;
70+
scrapeUrl?: string;
71+
globalUrl?: string;
72+
lastError?: string;
73+
lastScrape?: string;
74+
health?: "up" | "down" | "unknown";
75+
}
76+
77+
interface PromTargetsData {
78+
activeTargets?: PromTargetRaw[];
79+
droppedTargets?: PromTargetRaw[];
80+
}
81+
4582
/**
4683
* Prometheus HTTP API client. Uses native fetch.
4784
* Supports bearer token or HTTP basic auth.
@@ -106,6 +143,51 @@ export class RealPromClient implements PromClient {
106143
return out;
107144
}
108145

146+
async listMetrics(q: PromMetricsListQuery): Promise<string[]> {
147+
// Prom + GMP both expose label values via /api/v1/label/<label>/values.
148+
const data = await this.get<string[]>("/api/v1/label/__name__/values", new URLSearchParams(), q.signal);
149+
let names = Array.isArray(data) ? data : [];
150+
if (q.contains) {
151+
const needle = q.contains.toLowerCase();
152+
names = names.filter((n) => n.toLowerCase().includes(needle));
153+
}
154+
const limit = q.limit ?? 200;
155+
return names.slice(0, limit);
156+
}
157+
158+
async metricMetadata(q: PromMetadataQuery): Promise<PromMetricMetadata[]> {
159+
const params = new URLSearchParams();
160+
if (q.metric) params.set("metric", q.metric);
161+
if (q.perMetricLimit !== undefined) params.set("limit_per_metric", String(q.perMetricLimit));
162+
const data = await this.get<PromMetadataRaw>("/api/v1/metadata", params, q.signal);
163+
return normaliseMetadata(data);
164+
}
165+
166+
async targets(q: PromTargetsQuery): Promise<PromTarget[]> {
167+
const params = new URLSearchParams();
168+
params.set("state", q.state ?? "active");
169+
const data = await this.get<PromTargetsData>("/api/v1/targets", params, q.signal);
170+
const sources: PromTargetRaw[] = [];
171+
if (data.activeTargets) sources.push(...data.activeTargets);
172+
if (q.state === "dropped" || q.state === "any") {
173+
if (data.droppedTargets) sources.push(...data.droppedTargets);
174+
}
175+
const out: PromTarget[] = sources.map((t) => {
176+
const labels = t.labels ?? {};
177+
const target: PromTarget = {
178+
job: labels.job ?? t.scrapePool ?? "",
179+
instance: labels.instance ?? "",
180+
health: t.health ?? "unknown",
181+
labels,
182+
};
183+
if (t.scrapeUrl !== undefined) target.scrapeUrl = t.scrapeUrl;
184+
if (t.lastError !== undefined && t.lastError.length > 0) target.lastError = t.lastError;
185+
if (t.lastScrape !== undefined) target.lastScrape = t.lastScrape;
186+
return target;
187+
});
188+
return q.job ? out.filter((t) => t.job === q.job) : out;
189+
}
190+
109191
uiUrl(kind: "graph" | "alerts", query?: string): string {
110192
if (kind === "alerts") return `${this.baseUrl}/alerts`;
111193
if (!query) return `${this.baseUrl}/graph`;
@@ -141,6 +223,33 @@ export class RealPromClient implements PromClient {
141223
}
142224
}
143225

226+
function normaliseMetadata(raw: PromMetadataRaw): PromMetricMetadata[] {
227+
const out: PromMetricMetadata[] = [];
228+
if (Array.isArray(raw)) {
229+
for (const item of raw) {
230+
if (!item.metric) continue;
231+
out.push({
232+
metric: item.metric,
233+
type: (item.type ?? "unknown") as PromMetricMetadata["type"],
234+
help: item.help ?? "",
235+
...(item.unit !== undefined && item.unit.length > 0 && { unit: item.unit }),
236+
});
237+
}
238+
return out;
239+
}
240+
for (const [metric, items] of Object.entries(raw)) {
241+
const first = items?.[0];
242+
if (!first) continue;
243+
out.push({
244+
metric,
245+
type: (first.type ?? "unknown") as PromMetricMetadata["type"],
246+
help: first.help ?? "",
247+
...(first.unit !== undefined && first.unit.length > 0 && { unit: first.unit }),
248+
});
249+
}
250+
return out;
251+
}
252+
144253
function pickInstantSample(s: { value?: [number, string]; values?: Array<[number, string]> }): {
145254
timestamp: number;
146255
value: number;

packages/clients/src/types.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import type {
33
LogEntry,
44
PodSummary,
55
PromInstantResult,
6+
PromMetricMetadata,
67
PromRuleState,
78
PromSeriesResult,
9+
PromTarget,
810
ServiceDep,
911
TraceSummary,
1012
} from "@opsremedy/core/types";
@@ -50,10 +52,40 @@ export interface PromRangeQuery {
5052
signal?: AbortSignal;
5153
}
5254

55+
export interface PromMetricsListQuery {
56+
/** Optional case-insensitive substring filter applied to metric names. */
57+
contains?: string;
58+
/** Cap returned names; defaults to 200. */
59+
limit?: number;
60+
signal?: AbortSignal;
61+
}
62+
63+
export interface PromMetadataQuery {
64+
/** When set, return only metadata for this metric name. */
65+
metric?: string;
66+
/** Per-metric metadata limit returned by Prom (defaults to 1). */
67+
perMetricLimit?: number;
68+
signal?: AbortSignal;
69+
}
70+
71+
export interface PromTargetsQuery {
72+
/** When set, return only targets whose `job` label matches. */
73+
job?: string;
74+
/** When "active"|"dropped"|"any" — defaults to "active". */
75+
state?: "active" | "dropped" | "any";
76+
signal?: AbortSignal;
77+
}
78+
5379
export interface PromClient {
5480
instant(q: PromInstantQuery): Promise<PromInstantResult>;
5581
range(q: PromRangeQuery): Promise<PromSeriesResult>;
5682
alertRules(signal?: AbortSignal): Promise<PromRuleState[]>;
83+
/** List metric names known to the server (`/api/v1/label/__name__/values`). */
84+
listMetrics(q: PromMetricsListQuery): Promise<string[]>;
85+
/** Per-metric metadata: type/help/unit (`/api/v1/metadata`). */
86+
metricMetadata(q: PromMetadataQuery): Promise<PromMetricMetadata[]>;
87+
/** Scrape target health (`/api/v1/targets`). */
88+
targets(q: PromTargetsQuery): Promise<PromTarget[]>;
5789
/** UI URL for a query in /graph; falls back to the bare /graph or /alerts. */
5890
uiUrl(kind: "graph" | "alerts", query?: string): string | undefined;
5991
}

packages/core/src/planner.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export const ALL_TOOL_NAMES = [
66
"query_prom_instant",
77
"query_prom_range",
88
"get_prom_alert_rules",
9+
"list_prom_metrics",
10+
"get_prom_metric_metadata",
11+
"get_prom_targets",
912
"query_jaeger_traces",
1013
"get_jaeger_service_deps",
1114
"k8s_cluster_info",
@@ -34,6 +37,11 @@ const K8S_TOOLS: ToolName[] = [
3437
"k8s_triage_pod",
3538
];
3639
const TRACE_TOOLS: ToolName[] = ["query_jaeger_traces", "get_jaeger_service_deps"];
40+
const PROM_DISCOVERY_TOOLS: ToolName[] = [
41+
"list_prom_metrics",
42+
"get_prom_metric_metadata",
43+
"get_prom_targets",
44+
];
3745

3846
export function planGatherTools(alert: Alert, loop: number, rerouteHint?: string): GatherPlanAudit {
3947
if (loop > 0 || rerouteHint) return selectAll("reroute can need any source", loop);
@@ -50,6 +58,11 @@ export function planGatherTools(alert: Alert, loop: number, rerouteHint?: string
5058
for (const tool of TRACE_TOOLS) selected.set(tool, "alert contains service/latency/dependency signal");
5159
}
5260

61+
if (hasMetricSignal(text)) {
62+
for (const tool of PROM_DISCOVERY_TOOLS)
63+
selected.set(tool, "metric/rate alert benefits from Prom discovery (names, types, scrape health)");
64+
}
65+
5366
if (hasInfraSignal(text)) selected.set("query_prom_instant", "alert asks for current infra state");
5467

5568
return buildPlan(selected, loop);

packages/core/src/types.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,32 @@ export interface PromRuleState {
5454
lastTransition?: number;
5555
}
5656

57+
/**
58+
* Per-metric metadata from Prometheus `/api/v1/metadata`. Same shape across
59+
* vanilla Prom + Google Managed Prometheus. Lets the LLM tell counter from
60+
* gauge from histogram before writing rate() over a gauge.
61+
*/
62+
export interface PromMetricMetadata {
63+
metric: string;
64+
type: "counter" | "gauge" | "histogram" | "summary" | "untyped" | "info" | "stateset" | "unknown";
65+
help: string;
66+
unit?: string;
67+
}
68+
69+
/**
70+
* Scrape target health from Prometheus `/api/v1/targets`. Single entry per
71+
* (job, instance). `lastError` is empty when the target is healthy.
72+
*/
73+
export interface PromTarget {
74+
job: string;
75+
instance: string;
76+
health: "up" | "down" | "unknown";
77+
scrapeUrl?: string;
78+
lastError?: string;
79+
lastScrape?: string;
80+
labels: Record<string, string>;
81+
}
82+
5783
export interface TraceSummary {
5884
traceId: string;
5985
rootService: string;
@@ -189,6 +215,9 @@ export interface Evidence {
189215
prom_instant?: Record<string, PromInstantResult>;
190216
prom_series?: Record<string, PromSeriesResult>;
191217
prom_alert_rules?: PromRuleState[];
218+
prom_metrics?: string[];
219+
prom_metric_metadata?: PromMetricMetadata[];
220+
prom_targets?: PromTarget[];
192221

193222
jaeger_traces?: TraceSummary[];
194223
jaeger_service_deps?: ServiceDep[];
@@ -312,6 +341,9 @@ export const ALL_EVIDENCE_KEYS = [
312341
"prom_instant",
313342
"prom_series",
314343
"prom_alert_rules",
344+
"prom_metrics",
345+
"prom_metric_metadata",
346+
"prom_targets",
315347
"jaeger_traces",
316348
"jaeger_service_deps",
317349
"k8s_pods",

packages/tools/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @opsremedy/tools
22

3-
The 14 read-only tools the gather agent calls. Each is a pi-mono `AgentTool`
3+
The 17 read-only tools the gather agent calls. Each is a pi-mono `AgentTool`
44
factory that takes the `InvestigationContext` and returns a tool with a
55
TypeBox parameter schema.
66

@@ -13,6 +13,9 @@ TypeBox parameter schema.
1313
| `query_prom_instant` | PromQL at a single timestamp |
1414
| `query_prom_range` | PromQL over a window (range query) |
1515
| `get_prom_alert_rules` | List alerting rules + state |
16+
| `list_prom_metrics` | Discover metric names by substring (resolves "no series returned" before guessing labels) |
17+
| `get_prom_metric_metadata` | Metric type/help/unit (counter vs gauge vs histogram) |
18+
| `get_prom_targets` | Scrape target health (up/down + lastError); empty under Google Managed Prometheus |
1619
| `query_jaeger_traces` | Find traces for a service |
1720
| `get_jaeger_service_deps` | Upstream/downstream edges |
1821
| `k8s_cluster_info` | Cluster-level facts: node ready count + namespace list |

0 commit comments

Comments
 (0)