UNPKG

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

231 lines (230 loc) 13.1 kB
/** * grafana_query tool * * Run PromQL instant or range queries against any Prometheus datasource. * The agent uses this to answer data questions directly without creating * a dashboard — "what's my cost today?" gets a number, not a URL. */ import { jsonResult, readStringParam, readNumberParam } from "../sdk-compat.js"; import { parseDateMathToSeconds } from "../grafana-client.js"; import { instanceProperties } from "./instance-param.js"; import { getHealthRule, evaluateHealthContext } from "./health-context.js"; import { resolvePanelQuery } from "./resolve-panel.js"; import { getPromQLGuidance, parsePrometheusWarnings, getEmptyResultHint } from "./query-guidance.js"; const MAX_RANGE_VALUES = 20; /** Max number of top-level series returned from a range query. */ export const MAX_RANGE_SERIES = 50; /** Max number of top-level results returned from an instant query. */ export const MAX_INSTANT_RESULTS = 50; /** Target number of datapoints for auto-step calculation. */ const TARGET_DATAPOINTS = 300; /** Minimum auto-step in seconds (avoid sub-15s resolution). */ const MIN_STEP_SECONDS = 15; /** * Auto-calculate a step interval from a time range. * Targets ~300 datapoints — enough for trend visibility without response bloat. * Returns step in seconds as a string (Prometheus-compatible). */ export function calculateAutoStep(startStr, endStr) { const startEpoch = Number(parseDateMathToSeconds(startStr)); const endEpoch = Number(parseDateMathToSeconds(endStr)); const rangeSec = Math.max(endEpoch - startEpoch, 1); const stepSeconds = Math.max(Math.ceil(rangeSec / TARGET_DATAPOINTS), MIN_STEP_SECONDS); // Format for human readability in response metadata let stepDisplay; if (stepSeconds >= 86400) stepDisplay = `${Math.round(stepSeconds / 86400)}d`; else if (stepSeconds >= 3600) stepDisplay = `${Math.round(stepSeconds / 3600)}h`; else if (stepSeconds >= 60) stepDisplay = `${Math.round(stepSeconds / 60)}m`; else stepDisplay = `${stepSeconds}s`; return { stepSeconds, stepDisplay }; } export function createQueryToolFactory(registry) { return (_ctx) => ({ name: "grafana_query", label: "Grafana Query", description: [ "Run a PromQL query against a Prometheus datasource in Grafana.", "WORKFLOW: Use for instant answers ('what is the current value of X?').", "Use queryType 'instant' for current values, 'range' for time series.", "For range queries: 'start' is required, 'end' defaults to 'now', 'step' is auto-calculated from the time range (~300 datapoints) if omitted.", "Requires a datasourceUid — use grafana_explore_datasources to find it.", "PANEL RE-RUN: Set dashboardUid + panelId to re-run an existing panel's query with a different time range — no need to extract PromQL manually from get_dashboard output. Overrides expr and datasourceUid.", "Returns metric values directly. For visualization, use grafana_create_dashboard instead.", "For understanding what a metric means, its trend, or investigating spikes, prefer grafana_explain_metric — it returns current value, trend, and stats in one call.", ].join(" "), parameters: { type: "object", properties: { ...instanceProperties(registry), datasourceUid: { type: "string", description: "UID of the Prometheus datasource (use grafana_explore_datasources to find it). Optional when using dashboardUid + panelId — resolved from the panel's datasource config.", }, expr: { type: "string", description: "PromQL expression (e.g., 'up', 'rate(http_requests_total[5m])'). Optional when using dashboardUid + panelId — extracted from the panel's query.", }, dashboardUid: { type: "string", description: "Dashboard UID to resolve a panel's query from (use with panelId). Get UIDs from grafana_search or grafana_create_dashboard results.", }, panelId: { type: "number", description: "Panel ID within the dashboard to re-run (use with dashboardUid). Get panel IDs from grafana_get_dashboard results.", }, queryType: { type: "string", enum: ["instant", "range"], description: "Query type: 'instant' for current value (default), 'range' for time series", }, time: { type: "string", description: "Evaluation time for instant queries (default: now). Accepts: 'now', 'now-1h', Unix seconds (e.g., '1700000000'), or RFC3339 (e.g., '2026-01-15T00:00:00Z')", }, start: { type: "string", description: "Start time for range queries. Accepts: 'now-1h', 'now-30m', 'now-7d', Unix seconds, or RFC3339", }, end: { type: "string", description: "End time for range queries (default: 'now'). Accepts: 'now', 'now-1h', Unix seconds, or RFC3339", }, step: { type: "string", description: "Step interval for range queries (e.g., '60', '5m', '1h'). Optional — auto-calculated from time range (~300 datapoints) if omitted", }, }, required: [], }, async execute(_toolCallId, params) { const client = registry.get(readStringParam(params, "instance")); const dashboardUid = readStringParam(params, "dashboardUid"); const panelId = readNumberParam(params, "panelId"); let datasourceUid = readStringParam(params, "datasourceUid"); let expr = readStringParam(params, "expr"); const queryType = readStringParam(params, "queryType") ?? "instant"; const time = readStringParam(params, "time"); const start = readStringParam(params, "start"); const end = readStringParam(params, "end"); const step = readStringParam(params, "step"); // Panel resolution metadata (included in response when panel is used) let panelMeta; try { // ── Panel resolution ────────────────────────────────────────── if (dashboardUid && panelId != null) { const resolved = await resolvePanelQuery(client, dashboardUid, panelId); if ("error" in resolved) { return jsonResult({ error: resolved.error }); } if (resolved.queryTool !== "grafana_query") { return jsonResult({ error: `Panel ${panelId} ('${resolved.panelTitle}') uses ${resolved.datasourceType} datasource. Use ${resolved.queryTool} with the same dashboardUid + panelId instead.`, }); } // Panel values are defaults — explicit params override expr = expr ?? resolved.expr; datasourceUid = datasourceUid ?? resolved.datasourceUid; panelMeta = { resolvedFrom: "panel", panelTitle: resolved.panelTitle, panelType: resolved.panelType, templateVarsReplaced: resolved.templateVarsReplaced, }; } // Validate required params (after panel resolution) if (!datasourceUid) { return jsonResult({ error: "Missing 'datasourceUid'. Provide it directly or use dashboardUid + panelId to resolve from a panel." }); } if (!expr) { return jsonResult({ error: "Missing 'expr'. Provide a PromQL expression directly or use dashboardUid + panelId to resolve from a panel." }); } if (queryType === "range") { if (!start) { return jsonResult({ error: "Range queries require a 'start' parameter (e.g., 'now-1h', 'now-30d')" }); } const resolvedEnd = end ?? "now"; let resolvedStep = step; let autoStep; if (!resolvedStep) { autoStep = calculateAutoStep(start, resolvedEnd); resolvedStep = String(autoStep.stepSeconds); } const result = await client.queryPrometheusRange(datasourceUid, expr, start, resolvedEnd, resolvedStep); const warnings = parsePrometheusWarnings(result.infos); const totalSeries = result.data.result.length; const seriesTruncated = totalSeries > MAX_RANGE_SERIES; const slicedResult = seriesTruncated ? result.data.result.slice(0, MAX_RANGE_SERIES) : result.data.result; const series = slicedResult.map((s) => { const valueTruncated = s.values.length > MAX_RANGE_VALUES; return { metric: s.metric, values: s.values.slice(0, MAX_RANGE_VALUES).map(([ts, val]) => ({ time: new Date(ts * 1000).toISOString(), value: val, })), ...(valueTruncated ? { truncated: true, totalValues: s.values.length } : {}), }; }); return jsonResult({ status: "success", queryType: "range", expr, datasourceUid, resultCount: series.length, ...(seriesTruncated ? { totalSeries, truncated: true, truncationHint: `Showing ${MAX_RANGE_SERIES} of ${totalSeries} series. Narrow your query to see specific series.` } : {}), ...(totalSeries === 0 ? { hint: getEmptyResultHint(expr) } : {}), ...(warnings ? { warnings } : {}), series, ...(autoStep ? { step: { value: `${autoStep.stepSeconds}s`, display: autoStep.stepDisplay, auto: true } } : {}), ...panelMeta, }); } // Instant query (default) const result = await client.queryPrometheus(datasourceUid, expr, time); const warnings = parsePrometheusWarnings(result.infos); const totalResults = result.data.result.length; const resultsTruncated = totalResults > MAX_INSTANT_RESULTS; const slicedResults = resultsTruncated ? result.data.result.slice(0, MAX_INSTANT_RESULTS) : result.data.result; // Resolve health rule once for the expression, then evaluate per-result const healthRule = getHealthRule(expr); const metrics = slicedResults.map((r) => { const entry = { metric: r.metric, value: r.value[1], timestamp: new Date(r.value[0] * 1000).toISOString(), }; if (healthRule) { const health = evaluateHealthContext(healthRule, r.value[1]); if (health) entry.healthContext = health; } return entry; }); return jsonResult({ status: "success", queryType: "instant", expr, datasourceUid, resultCount: metrics.length, ...(resultsTruncated ? { totalResults, truncated: true, truncationHint: `Showing ${MAX_INSTANT_RESULTS} of ${totalResults} results. Narrow your query to see specific metrics.` } : {}), ...(totalResults === 0 ? { hint: getEmptyResultHint(expr) } : {}), ...(warnings ? { warnings } : {}), metrics, ...panelMeta, }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); const guidance = getPromQLGuidance(reason, expr ?? ""); return jsonResult({ error: `Query failed: ${reason}`, ...(guidance ? { guidance } : {}), }); } }, }); }