openclaw-grafana-lens
Version:
OpenClaw plugin that gives AI agents full Grafana access — 18 composable tools for PromQL/LogQL/TraceQL queries, dashboard creation, alerting, SRE investigation, security monitoring, data collection pipeline management via Grafana Alloy (29 recipes), and
395 lines (394 loc) • 20 kB
JavaScript
/**
* grafana_list_metrics tool
*
* Discovers available metrics (or label values) from a Prometheus datasource.
* Helps the agent understand what data exists before querying or building
* dashboards — "what can I track?" gets answered here.
*/
import { jsonResult, readStringParam } from "../sdk-compat.js";
import { escapeRegex } from "../grafana-client.js";
import { instanceProperties } from "./instance-param.js";
import { KNOWN_METRICS_MAP } from "../metric-definitions.js";
const MAX_RESULTS = 200;
/**
* Known metrics map — derived from the shared metric-definitions registry.
* Re-exported so existing consumers (tests, deduplicateAndEnrich) continue to work.
*/
export const KNOWN_LENS_METRICS = KNOWN_METRICS_MAP;
/**
* Deduplicate histogram sub-metrics and synthesize metadata entries from
* metric names + the known-metrics registry. Histogram _bucket/_count/_sum
* variants are coalesced into a single base-name entry with type "histogram".
*/
export function deduplicateAndEnrich(names) {
// Detect histogram bases from _bucket names
const histogramBases = new Set();
const skipNames = new Set();
for (const name of names) {
if (name.endsWith("_bucket")) {
const base = name.slice(0, -"_bucket".length);
histogramBases.add(base);
skipNames.add(`${base}_bucket`);
skipNames.add(`${base}_count`);
skipNames.add(`${base}_sum`);
}
}
const entries = [];
const seen = new Set();
// Add histogram base entries first
for (const base of histogramBases) {
if (seen.has(base))
continue;
seen.add(base);
const known = KNOWN_LENS_METRICS.get(base);
const category = categorizeMetric(base);
entries.push({
name: base,
type: known?.type ?? "histogram",
help: known?.help ?? "",
...(category ? { category } : {}),
source: "synthetic",
});
}
// Add remaining non-histogram names
for (const name of names) {
if (skipNames.has(name) || seen.has(name))
continue;
seen.add(name);
const known = KNOWN_LENS_METRICS.get(name);
const category = categorizeMetric(name);
entries.push({
name,
type: known?.type ?? (name.endsWith("_total") ? "counter" : "gauge"),
help: known?.help ?? "",
...(category ? { category } : {}),
source: "synthetic",
});
}
return entries;
}
/**
* Maps a high-level purpose to the metric categories that serve it.
* Agents say "show me performance metrics" → we filter to session + tools categories.
*/
export const PURPOSE_CATEGORIES = {
performance: new Set(["session", "tools"]),
cost: new Set(["cost", "usage"]),
reliability: new Set(["webhook", "messaging", "agent"]),
capacity: new Set(["queue", "session"]),
};
/**
* Rules are evaluated top-to-bottom, first match wins.
* Each rule: [substring to match after stripping the namespace prefix, category].
* The namespace prefix (`openclaw_lens_`, `openclaw_`) is stripped before matching.
*/
const CATEGORY_RULES = [
// cost & billing
["cost_", "cost"],
["daily_cost_", "cost"],
["cache_savings_", "cost"],
// usage (tokens, context, cache ratios)
["tokens_", "usage"],
["token_", "usage"],
["context_tokens", "usage"],
["cache_read_ratio", "usage"],
["cache_token_ratio", "usage"],
// session lifecycle
["sessions_", "session"],
["session_", "session"],
["stuck_session_", "session"],
// queue infrastructure
["queue_", "queue"],
// messaging
["message_", "messaging"],
["messages_", "messaging"],
// webhook handling
["webhook_", "webhook"],
["alert_webhooks_", "webhook"],
// tools
["tool_", "tools"],
// agent / subagent / run
["subagent", "agent"],
["run_", "agent"],
// custom metrics push bookkeeping
["custom_metrics_", "custom"],
];
/**
* Categorize a metric name by functional area.
* Returns the category for `openclaw_*` metrics, or `undefined` for non-openclaw metrics.
* `openclaw_ext_*` metrics are always categorized as `"custom"`.
*/
export function categorizeMetric(name) {
if (!name.startsWith("openclaw_"))
return undefined;
// All user-pushed custom metrics
if (name.startsWith("openclaw_ext_"))
return "custom";
// Strip namespace prefix for rule matching
const suffix = name.startsWith("openclaw_lens_")
? name.slice("openclaw_lens_".length)
: name.slice("openclaw_".length);
for (const [pattern, category] of CATEGORY_RULES) {
if (suffix.startsWith(pattern))
return category;
}
return undefined;
}
/** Build a categorySummary from categorized entries. */
function buildCategorySummary(entries) {
const summary = {};
let hasCats = false;
for (const e of entries) {
if (e.category) {
summary[e.category] = (summary[e.category] ?? 0) + 1;
hasCats = true;
}
}
return hasCats ? summary : undefined;
}
export function createListMetricsToolFactory(registry, getCustomMetricsStore) {
return (_ctx) => ({
name: "grafana_list_metrics",
label: "List Metrics",
description: [
"Discover available metrics or label values from a Prometheus datasource.",
"WORKFLOW: Use after grafana_explore_datasources to see what metrics exist.",
"By default lists metric names. Set 'label' to list values for a specific label instead.",
"Set 'metadata' to true for enriched results with type (counter/gauge/histogram), help text, and functional category for openclaw_* metrics (cost, usage, session, queue, messaging, webhook, tools, agent, custom) — useful before composing dashboards. Includes categorySummary for quick overview. Works on OTLP-only stacks via synthetic metadata from the metric registry.",
"Use 'prefix' to filter by prefix. Use 'search' for targeted metric discovery (e.g., 'steps' finds 'openclaw_ext_steps_today'). Server-side regex — only matching metrics returned. Also searches help text in metadata mode.",
"Use 'purpose' to filter by intent: performance (session + tools), cost (cost + usage), reliability (webhook + messaging + agent), capacity (queue + session). Auto-narrows to openclaw_* metrics.",
"Set compact=true with metadata=true for minimal fields (name, type, category only) — ~60% smaller, ideal for multi-tool chains.",
].join(" "),
parameters: {
type: "object",
properties: {
...instanceProperties(registry),
datasourceUid: {
type: "string",
description: "UID of the Prometheus datasource (from grafana_explore_datasources)",
},
prefix: {
type: "string",
description: "Filter metric names by prefix (e.g., 'openclaw_lens_', 'node_')",
},
search: {
type: "string",
description: "Search for metrics by substring (server-side regex). E.g., 'steps' finds 'openclaw_ext_steps_today'. Combinable with prefix.",
},
label: {
type: "string",
description: "List values for this label instead of metric names (e.g., 'job', 'instance')",
},
metadata: {
type: "boolean",
description: "Return enriched results with type (counter/gauge/histogram), help text, and functional category for openclaw_* metrics. Includes categorySummary counts. Ignored when 'label' is set.",
},
purpose: {
type: "string",
enum: ["performance", "cost", "reliability", "capacity"],
description: "Filter metrics by purpose: performance (session + tools), cost (cost + usage), reliability (webhook + messaging + agent), capacity (queue + session). Auto-narrows to openclaw_* metrics. Composes with prefix, search, and metadata.",
},
compact: {
type: "boolean",
description: "Return minimal fields only — {name, type, category} per metric. Drops help, source, labelNames. Use in multi-tool chains to reduce context. Requires metadata=true. Default: false",
},
},
required: ["datasourceUid"],
},
async execute(_toolCallId, params) {
const client = registry.get(readStringParam(params, "instance"));
const datasourceUid = readStringParam(params, "datasourceUid", { required: true, label: "Datasource UID" });
let prefix = readStringParam(params, "prefix");
const search = readStringParam(params, "search");
const label = readStringParam(params, "label");
const metadata = typeof params.metadata === "boolean" ? params.metadata : false;
const compact = typeof params.compact === "boolean" ? params.compact : false;
const purpose = readStringParam(params, "purpose");
// Validate purpose
if (purpose && !PURPOSE_CATEGORIES[purpose]) {
return jsonResult({
error: `Invalid purpose '${purpose}'. Valid values: ${Object.keys(PURPOSE_CATEGORIES).join(", ")}`,
});
}
// Purpose auto-injects openclaw_ prefix for server-side narrowing
const purposeCategories = purpose ? PURPOSE_CATEGORIES[purpose] : null;
const prefixAutoInjected = !!(purposeCategories && !prefix);
if (prefixAutoInjected) {
prefix = "openclaw_";
}
try {
if (label) {
// List label values mode
const values = await client.listLabelValues(datasourceUid, label);
const truncated = values.length > MAX_RESULTS;
return jsonResult({
status: "success",
label,
count: Math.min(values.length, MAX_RESULTS),
totalCount: values.length,
values: values.slice(0, MAX_RESULTS),
...(truncated ? { truncated: true } : {}),
});
}
if (metadata) {
// Metadata mode — enriched results with type and help
const meta = await client.getMetricMetadata(datasourceUid);
const metadataIsEmpty = Object.keys(meta).length === 0;
let metadataSource = "prometheus";
let entries;
if (!metadataIsEmpty) {
// ── Prometheus metadata available ──────────────────────
// Use server-side filtered names to intersect with metadata when search/prefix provided.
// Skip when prefix was auto-injected by purpose (no explicit search) — the purpose
// filter handles narrowing client-side, avoiding a redundant HTTP round-trip.
let allowedNames = null;
const match = (prefixAutoInjected && !search) ? undefined : buildMatchSelector(prefix, search);
if (match) {
const serverNames = await client.listMetricNames(datasourceUid, { match });
allowedNames = new Set(serverNames);
}
entries = Object.entries(meta).map(([name, items]) => {
const category = categorizeMetric(name);
return {
name,
type: items[0]?.type ?? "unknown",
help: items[0]?.help ?? "",
...(category ? { category } : {}),
};
});
if (allowedNames) {
entries = entries.filter((e) => allowedNames.has(e.name));
}
// Also search help text client-side for search term
if (search && !allowedNames) {
const lowerSearch = search.toLowerCase();
entries = entries.filter((e) => e.name.toLowerCase().includes(lowerSearch) || e.help.toLowerCase().includes(lowerSearch));
}
else if (search && allowedNames) {
// Already filtered by name via server-side; also include entries matching help text
const lowerSearch = search.toLowerCase();
const helpMatches = Object.entries(meta)
.filter(([name, items]) => !allowedNames.has(name) && (items[0]?.help ?? "").toLowerCase().includes(lowerSearch))
.map(([name, items]) => {
const category = categorizeMetric(name);
return {
name,
type: items[0]?.type ?? "unknown",
help: items[0]?.help ?? "",
...(category ? { category } : {}),
};
});
entries = [...entries, ...helpMatches];
}
}
else {
// ── OTLP fallback: synthesize from names + registry ───
// Prometheus /api/v1/metadata returns nothing for OTLP-pushed metrics.
// Fall back to listing metric names (which works for OTLP) and enriching
// with type/help from the known-metrics registry.
metadataSource = "synthetic";
const match = buildMatchSelector(prefix, search);
const names = await client.listMetricNames(datasourceUid, match ? { match } : undefined);
entries = deduplicateAndEnrich(names);
}
// Merge custom metric definitions from CustomMetricsStore (OTLP-pushed
// metrics don't appear in Prometheus /api/v1/metadata which is scrape-based)
const store = getCustomMetricsStore?.();
if (store) {
const promNames = new Set(entries.map((e) => e.name));
for (const def of store.listMetrics()) {
// Skip if Prometheus already has this name (or its _total variant)
if (promNames.has(def.name) || promNames.has(`${def.name}_total`))
continue;
// Apply same prefix/search filters
const lowerName = def.name.toLowerCase();
const lowerHelp = def.help.toLowerCase();
if (prefix && !def.name.startsWith(prefix))
continue;
if (search) {
const lowerSearch = search.toLowerCase();
if (!lowerName.includes(lowerSearch) && !lowerHelp.includes(lowerSearch))
continue;
}
const category = categorizeMetric(def.name);
entries.push({
name: def.name,
type: def.type,
help: def.help,
...(category ? { category } : {}),
source: "custom",
labelNames: def.labelNames,
});
}
}
// Purpose filter — keep only metrics whose category matches the purpose
if (purposeCategories) {
entries = entries.filter((e) => e.category != null && purposeCategories.has(e.category));
}
const truncated = entries.length > MAX_RESULTS;
const sliced = entries.slice(0, MAX_RESULTS);
const categorySummary = buildCategorySummary(sliced);
return jsonResult({
status: "success",
metadataSource,
count: sliced.length,
totalCount: entries.length,
...(categorySummary ? { categorySummary } : {}),
metrics: compact
? sliced.map((e) => ({
name: e.name,
type: e.type,
...(e.category ? { category: e.category } : {}),
}))
: sliced,
...(truncated ? { truncated: true } : {}),
...(prefix ? { prefix } : {}),
...(search ? { search } : {}),
...(purpose ? { purpose } : {}),
...(metadataSource === "synthetic" ? {
hint: "Metadata synthesized from known metric definitions. Prometheus /api/v1/metadata returned no entries (common with OTLP-only stacks). Type and help text are from the Grafana Lens metric registry.",
} : {}),
});
}
// List metric names mode (default) — server-side filtering via match[]
const match = buildMatchSelector(prefix, search);
let names = await client.listMetricNames(datasourceUid, match ? { match } : undefined);
// Purpose filter — client-side category check on names
if (purposeCategories) {
names = names.filter((n) => {
const cat = categorizeMetric(n);
return cat != null && purposeCategories.has(cat);
});
}
const truncated = names.length > MAX_RESULTS;
return jsonResult({
status: "success",
count: Math.min(names.length, MAX_RESULTS),
totalCount: names.length,
metrics: names.slice(0, MAX_RESULTS),
...(truncated ? { truncated: true } : {}),
...(prefix ? { prefix } : {}),
...(search ? { search } : {}),
...(purpose ? { purpose } : {}),
});
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return jsonResult({ error: `Failed to list metrics: ${reason}` });
}
},
});
}
/** Build a Prometheus match[] selector from prefix and/or search terms. */
function buildMatchSelector(prefix, search) {
if (prefix && search) {
return `{__name__=~"${escapeRegex(prefix)}.*${escapeRegex(search)}.*"}`;
}
if (prefix) {
return `{__name__=~"${escapeRegex(prefix)}.*"}`;
}
if (search) {
return `{__name__=~".*${escapeRegex(search)}.*"}`;
}
return undefined;
}