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
294 lines (293 loc) • 13.2 kB
JavaScript
/**
* grafana_push_metrics tool
*
* Four actions in one tool:
* - push (default): Push data points into Prometheus via custom metrics
* - register: Pre-register a metric with explicit schema
* - list: List all custom metrics
* - delete: Remove a custom metric
*
* This is the bridge that turns Grafana into a general-purpose analytics
* platform. Once data is pushed, all existing tools (query, dashboard,
* alert, share, annotate) work on it automatically.
*/
import { jsonResult, readStringParam, readNumberParam } from "../sdk-compat.js";
import { normalizeMetricName, getPromQLName } from "../services/custom-metrics-store.js";
/**
* Build concrete next-step suggestions from the actual pushed/registered metric names.
* Uses the first queryName as the representative metric for examples.
*/
function buildPushWorkflow(queryNames) {
const names = Object.values(queryNames);
if (names.length === 0)
return [];
const representative = names[0];
const steps = [
{
tool: "grafana_query",
action: "Verify data landed",
example: { expr: representative },
},
{
tool: "grafana_create_dashboard",
action: "Visualize with a dashboard",
example: { template: "metric-explorer", variables: { metric: representative } },
},
];
// Only suggest alerts for single-metric pushes (multi-metric threshold is ambiguous)
if (names.length === 1) {
steps.push({
tool: "grafana_create_alert",
action: "Monitor with an alert",
example: { name: `${representative} alert`, expr: representative, condition: "gt", threshold: "<set_threshold>" },
});
}
return steps;
}
function buildRegisterWorkflow(normalizedName, queryName, type, labelNames) {
const metricsEntry = { name: normalizedName, value: 0 };
if (labelNames.length > 0) {
const labelsExample = {};
for (const l of labelNames)
labelsExample[l] = `<${l}>`;
metricsEntry.labels = labelsExample;
}
const steps = [
{
tool: "grafana_push_metrics",
action: "Push data points",
example: { metrics: [metricsEntry] },
},
{
tool: "grafana_query",
action: "Query the metric",
example: { expr: type === "counter" ? `rate(${queryName}[5m])` : queryName },
},
];
return steps;
}
export function createPushMetricsToolFactory(_registry, getCustomMetricsStore) {
return (_ctx) => ({
name: "grafana_push_metrics",
label: "Grafana Push Metrics",
description: [
"Push custom data (calendar, git, fitness, finance, IoT) via OTLP for visualization in Grafana.",
"WORKFLOW: Use action 'push' (default) to write data points — auto-registers metrics if needed.",
"Use action 'register' to pre-register a metric with explicit labels/type/TTL.",
"Use action 'list' to see all custom metrics. Use action 'delete' to remove a metric.",
"All metric names get the 'openclaw_ext_' prefix (auto-prepended if missing).",
"Gauge (default): last value wins — use for snapshots like 'steps today = 8000', 'weight = 72.5'. Counter: value accumulates (each push adds to total) and gets '_total' PromQL suffix — use only for incremental event counts like 'api_calls += 1'. Most life-dashboard metrics are gauges.",
"Supports historical backfill: add 'timestamp' (ISO 8601, e.g. '2025-01-15') to any gauge data point to record it at that time instead of now. Batch multiple timestamps in one call for multi-day backfill.",
"Response includes 'queryNames' with exact PromQL names and 'suggestedWorkflow' with concrete next-step examples (verify, visualize, alert). Data available immediately — all tools (query, dashboard, alert, share) work on pushed data.",
].join(" "),
parameters: {
type: "object",
properties: {
action: {
type: "string",
enum: ["push", "register", "list", "delete"],
description: "Action to perform. Default: 'push'",
},
metrics: {
type: "array",
description: "Array of data points to push (action 'push'). Each: { name, value, labels?, type?, help? }",
items: {
type: "object",
properties: {
name: { type: "string", description: "Metric name (openclaw_ext_ prefix auto-added if missing)" },
value: { type: "number", description: "Numeric value" },
labels: { type: "object", description: "Optional key-value labels (e.g., { region: 'us' })" },
type: { type: "string", enum: ["gauge", "counter"], description: "Metric type. Default: 'gauge'" },
help: { type: "string", description: "Description. Default: 'Custom metric'" },
timestamp: {
type: "string",
description: "ISO 8601 timestamp for historical data (e.g., '2025-01-15'). Omit for real-time (current time). Gauge only — counters with timestamps are rejected.",
},
},
required: ["name", "value"],
},
},
name: {
type: "string",
description: "Metric name for 'register' or 'delete' actions",
},
type: {
type: "string",
enum: ["gauge", "counter"],
description: "Metric type for 'register'. Default: 'gauge'",
},
help: {
type: "string",
description: "Description for 'register'. Default: 'Custom metric'",
},
labelNames: {
type: "array",
items: { type: "string" },
description: "Label keys for 'register' (e.g., ['region', 'env'])",
},
ttlDays: {
type: "number",
description: "Auto-expire metric after N days for 'register'",
},
},
},
async execute(_toolCallId, params) {
const store = getCustomMetricsStore();
if (!store) {
return jsonResult({
error: "Custom metrics service not started yet — ensure metrics.enabled is true in config",
});
}
const action = readStringParam(params, "action") ?? "push";
switch (action) {
case "push":
return handlePush(store, params);
case "register":
return handleRegister(store, params);
case "list":
return handleList(store);
case "delete":
return handleDelete(store, params);
default:
return jsonResult({ error: `Unknown action '${action}'. Use: push, register, list, delete` });
}
},
});
}
async function handlePush(store, params) {
const metrics = params.metrics;
if (!metrics || !Array.isArray(metrics) || metrics.length === 0) {
return jsonResult({
error: "No metrics provided. Pass a 'metrics' array with at least one data point. Example: { metrics: [{ name: 'steps_today', value: 8000 }] }",
});
}
try {
// Partition into real-time (no timestamp) and timestamped points
const realTime = [];
const timestamped = [];
for (const point of metrics) {
if (point.timestamp) {
timestamped.push(point);
}
else {
realTime.push(point);
}
}
// Process both paths and merge results
let totalAccepted = 0;
const allRejected = [];
const allQueryNames = {};
// Real-time path (existing)
if (realTime.length > 0) {
const rtResult = store.pushValues(realTime);
totalAccepted += rtResult.accepted;
allRejected.push(...rtResult.rejected);
Object.assign(allQueryNames, rtResult.queryNames);
// Force OTLP flush so real-time data is immediately queryable
await store.forceFlush();
}
// Timestamped path (new)
if (timestamped.length > 0) {
const tsResult = await store.pushTimestampedValues(timestamped);
totalAccepted += tsResult.accepted;
allRejected.push(...tsResult.rejected);
Object.assign(allQueryNames, tsResult.queryNames);
}
// Track push stats for the openclaw_lens_custom_metrics_pushed_total counter
store.trackPush(totalAccepted, allRejected.length);
const response = {
status: "ok",
accepted: totalAccepted,
queryNames: allQueryNames,
message: `${totalAccepted} of ${metrics.length} data points accepted. Use queryNames for PromQL queries.`,
};
if (allRejected.length > 0) {
response.rejected = allRejected;
response.message = `${totalAccepted} of ${metrics.length} accepted, ${allRejected.length} rejected. Use queryNames for PromQL queries on accepted metrics.`;
}
// Suggest next steps using the actual pushed metric names
if (totalAccepted > 0) {
response.suggestedWorkflow = buildPushWorkflow(allQueryNames);
}
// Warn when timestamped data points are >10 minutes old — Prometheus/Mimir
// may silently drop out-of-order samples outside its out_of_order_time_window
if (timestamped.length > 0) {
const tenMinAgo = Date.now() - 10 * 60 * 1000;
const hasOldTimestamp = timestamped.some((p) => {
const ts = new Date(p.timestamp).getTime();
return !isNaN(ts) && ts < tenMinAgo;
});
if (hasOldTimestamp) {
response.note =
"Some timestamps are >10m old — verify data landed with grafana_query. If missing, the backend's out_of_order_time_window may need increasing.";
}
}
return jsonResult(response);
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return jsonResult({ error: `Failed to push metrics: ${reason}` });
}
}
function handleRegister(store, params) {
const rawName = readStringParam(params, "name", { required: true, label: "Metric name" });
const type = (readStringParam(params, "type") ?? "gauge");
const help = readStringParam(params, "help") ?? "Custom metric";
const labelNames = params.labelNames ?? [];
const ttlDays = readNumberParam(params, "ttlDays");
const { normalized, wasAutoPrepended } = normalizeMetricName(rawName);
try {
const def = store.registerMetric({
name: normalized,
type,
help,
labelNames,
ttlMs: ttlDays ? ttlDays * 86_400_000 : undefined,
});
const queryName = getPromQLName(normalized, type);
const response = {
status: "registered",
metric: def,
queryName,
suggestedWorkflow: buildRegisterWorkflow(normalized, queryName, type, labelNames),
};
if (wasAutoPrepended) {
response.note = `Name auto-prefixed to '${normalized}' (openclaw_ext_ prefix required)`;
}
return jsonResult(response);
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return jsonResult({ error: `Failed to register metric: ${reason}` });
}
}
function handleList(store) {
const metrics = store.listMetrics();
return jsonResult({
status: "success",
count: metrics.length,
metrics: metrics.map((m) => ({
name: m.name,
type: m.type,
queryName: getPromQLName(m.name, m.type),
help: m.help,
labelNames: m.labelNames,
createdAt: new Date(m.createdAt).toISOString(),
updatedAt: new Date(m.updatedAt).toISOString(),
ttlMs: m.ttlMs,
})),
});
}
function handleDelete(store, params) {
const name = readStringParam(params, "name", { required: true, label: "Metric name" });
const deleted = store.deleteMetric(name);
if (!deleted) {
const { normalized } = normalizeMetricName(name);
return jsonResult({ error: `Metric '${normalized}' not found` });
}
return jsonResult({
status: "deleted",
name,
note: "Metric unregistered — new data points will not be recorded. Historical data already in Grafana remains queryable until retention expires.",
});
}