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
336 lines (335 loc) • 16.2 kB
JavaScript
/**
* grafana_query_logs tool
*
* Run LogQL queries against any Loki datasource via Grafana's datasource proxy.
* Handles both log queries (streams) and metric queries (matrix/vector).
* Mirrors grafana_query structure for consistency.
*/
import { jsonResult, readStringParam, readNumberParam } from "../sdk-compat.js";
import { instanceProperties } from "./instance-param.js";
import { resolvePanelQuery } from "./resolve-panel.js";
import { getLogQLGuidance } from "./query-guidance.js";
const MAX_LOG_ENTRIES = 100;
const DEFAULT_LOG_LINE_LENGTH = 500;
const MAX_LOG_LINE_LENGTH = 2000;
const MAX_RANGE_VALUES = 20;
/** Max number of top-level series returned from a matrix (metric-over-logs) query. */
export const MAX_MATRIX_SERIES = 50;
/** Max number of top-level results returned from a vector (instant metric-over-logs) query. */
export const MAX_VECTOR_RESULTS = 50;
/**
* Infrastructure noise labels that obscure meaningful OTel attributes.
* Removed when extractFields is true to surface signal over noise.
*/
const NOISE_LABELS = new Set([
"telemetry_sdk_language",
"telemetry_sdk_name",
"telemetry_sdk_version",
"service_name",
"service_namespace",
"service_version",
"service_instance_id",
"scope_name",
"flags",
"observed_timestamp",
"event_domain",
"severity_number", // redundant with severity_text
"detected_level", // redundant with severity_text
]);
/**
* Extract structured fields from a log entry's labels and body.
* - Promotes known OTel/lifecycle attributes from labels to a clean `fields` object
* - Strips openclaw_ prefix for readability (openclaw_session_id → session_id)
* - Attempts JSON parsing of log line body and merges parsed keys
* - Removes infrastructure noise labels from the entry's labels
*/
function extractStructuredFields(entry) {
const fields = {};
const cleanLabels = {};
for (const [key, value] of Object.entries(entry.labels)) {
if (NOISE_LABELS.has(key))
continue;
// Promote openclaw_ prefixed keys with cleaner names into fields
if (key.startsWith("openclaw_")) {
const cleanKey = key.slice("openclaw_".length);
// Strict numeric check — avoids Infinity, hex (0x1F), whitespace-padded strings
fields[cleanKey] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
}
else {
// Non-noise, non-prefixed labels: keep in both labels and fields
cleanLabels[key] = value;
fields[key] = value;
}
}
// Attempt JSON parsing of log line body
const trimmed = entry.line.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
try {
const parsed = JSON.parse(trimmed);
for (const [key, value] of Object.entries(parsed)) {
if (value !== null && value !== undefined && !NOISE_LABELS.has(key)) {
const v = typeof value === "object" ? JSON.stringify(value) : value;
fields[key] = v;
}
}
}
catch {
// Not valid JSON — pass through unchanged
}
}
return {
labels: cleanLabels,
timestamp: entry.timestamp,
line: entry.line,
fields,
};
}
/** Convert a Loki nanosecond timestamp string to ISO 8601. */
function nanoToISO(ns) {
const ms = Math.floor(Number(ns) / 1_000_000);
return new Date(ms).toISOString();
}
/** Flatten Loki stream results into a sorted array of log entries. */
function flattenStreams(streams, limit, lineLimit) {
const all = [];
for (const stream of streams) {
for (const [ts, line] of stream.values) {
const truncatedLine = line.length > lineLimit
? line.slice(0, lineLimit) + "... (truncated)"
: line;
all.push({
labels: stream.stream,
timestamp: nanoToISO(ts),
line: truncatedLine,
});
}
}
const totalEntries = all.length;
// Sort by timestamp descending (newest first) — Loki default
all.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
return { entries: all.slice(0, limit), totalEntries };
}
export function createQueryLogsToolFactory(registry) {
return (_ctx) => ({
name: "grafana_query_logs",
label: "Grafana Query Logs",
description: [
"Run a LogQL query against a Loki datasource in Grafana.",
"WORKFLOW: Use for log searches — find errors, investigate incidents, correlate with metrics.",
"Use queryType 'range' (default) for time-window log searches, 'instant' for point-in-time.",
"Requires a datasourceUid — use grafana_explore_datasources to find Loki datasources (type: 'loki').",
"PANEL RE-RUN: Set dashboardUid + panelId to re-run an existing panel's LogQL query with a different time range — no need to extract the query manually. Overrides expr and datasourceUid.",
"Returns structured log entries (timestamp, labels, line). For metric queries over logs, returns same shape as grafana_query.",
"Log lines are truncated to 500 chars by default. Set lineLimit up to 2000 when investigating stack traces or verbose error messages.",
"Set extractFields: true for OTel/structured logs — promotes meaningful attributes (component, event_name, session_id, trace_id, model, duration) to a clean 'fields' object and strips infrastructure noise from labels.",
].join(" "),
parameters: {
type: "object",
properties: {
...instanceProperties(registry),
datasourceUid: {
type: "string",
description: "UID of the Loki datasource (use grafana_explore_datasources to find it). Optional when using dashboardUid + panelId.",
},
expr: {
type: "string",
description: "LogQL expression (e.g., '{job=\"api\"} |= \"error\"', 'rate({job=\"api\"}[5m])'). Optional when using dashboardUid + panelId.",
},
dashboardUid: {
type: "string",
description: "Dashboard UID to resolve a panel's LogQL query from (use with panelId).",
},
panelId: {
type: "number",
description: "Panel ID within the dashboard to re-run (use with dashboardUid).",
},
queryType: {
type: "string",
enum: ["instant", "range"],
description: "Query type: 'range' for time-window search (default), 'instant' for point-in-time",
},
start: {
type: "string",
description: "Start time (default: 'now-1h'). Accepts: 'now-1h', 'now-30m', 'now-7d', Unix seconds (e.g., '1700000000'), or RFC3339 (e.g., '2026-01-15T00:00:00Z')",
},
end: {
type: "string",
description: "End time (default: 'now'). Accepts: 'now', Unix seconds, or RFC3339",
},
step: {
type: "string",
description: "Step interval for metric queries over ranges (e.g., '60', '5m')",
},
limit: {
type: "number",
description: "Max log entries to return (default 100)",
},
direction: {
type: "string",
enum: ["backward", "forward"],
description: "Sort order: 'backward' (newest first, default) or 'forward' (oldest first)",
},
lineLimit: {
type: "number",
description: "Max characters per log line (default 500, max 2000). Increase to 2000 for full stack traces or verbose error output.",
},
extractFields: {
type: "boolean",
description: "Extract structured fields from OTel log attributes and JSON bodies (default false). When true, each entry gains a 'fields' object with clean keys (session_id, trace_id, model, event_name, component, etc.) and labels are stripped of infrastructure noise. Use for session debugging and OTel log investigation.",
},
},
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") ?? "range";
const start = readStringParam(params, "start") ?? "now-1h";
const end = readStringParam(params, "end") ?? "now";
const step = readStringParam(params, "step");
const limit = readNumberParam(params, "limit") ?? MAX_LOG_ENTRIES;
const direction = readStringParam(params, "direction") ?? "backward";
const rawLineLimit = readNumberParam(params, "lineLimit") ?? DEFAULT_LOG_LINE_LENGTH;
const lineLimit = Math.min(Math.max(1, rawLineLimit), MAX_LOG_LINE_LENGTH);
const extractFields = params.extractFields === true;
// 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_logs") {
return jsonResult({
error: `Panel ${panelId} ('${resolved.panelTitle}') uses ${resolved.datasourceType} datasource. Use ${resolved.queryTool} with the same dashboardUid + panelId instead.`,
});
}
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 LogQL expression directly or use dashboardUid + panelId to resolve from a panel." });
}
let result;
if (queryType === "instant") {
result = await client.queryLoki(datasourceUid, expr, { limit, direction });
}
else {
result = await client.queryLokiRange(datasourceUid, expr, start, end, {
step: step ?? undefined,
limit,
direction,
});
}
return formatLokiResult(result, { expr, datasourceUid, queryType, limit, lineLimit, extractFields, panelMeta });
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
const guidance = getLogQLGuidance(reason, expr ?? "");
return jsonResult({
error: `Log query failed: ${reason}`,
...(guidance ? { guidance } : {}),
});
}
},
});
}
function formatLokiResult(result, opts) {
const { expr, datasourceUid, queryType, limit, lineLimit, extractFields = false, panelMeta } = opts;
const { resultType } = result.data;
if (resultType === "streams") {
const streams = result.data.result;
const { entries, totalEntries } = flattenStreams(streams, limit, lineLimit);
const finalEntries = extractFields ? entries.map(extractStructuredFields) : entries;
// Collect unique trace_ids from extracted fields for correlation hint
const traceIds = extractFields
? [...new Set(finalEntries
.map((e) => e.fields?.trace_id)
.filter((id) => typeof id === "string" && id.length > 0))].slice(0, 5)
: [];
return jsonResult({
status: "success",
queryType,
resultType: "streams",
expr,
datasourceUid,
totalStreams: streams.length,
totalEntries,
entries: finalEntries,
...(totalEntries > entries.length ? { truncated: true } : {}),
...(traceIds.length > 0 ? {
traceCorrelation: {
traceIds,
tool: "grafana_query_traces",
tip: `Found ${traceIds.length} trace ID(s) in log entries. Use grafana_query_traces with queryType "get" and any traceId to see the full distributed trace.`,
},
} : {}),
...panelMeta,
});
}
if (resultType === "matrix") {
const matrixResult = result.data.result;
const totalSeries = matrixResult.length;
const seriesTruncated = totalSeries > MAX_MATRIX_SERIES;
const slicedMatrix = seriesTruncated ? matrixResult.slice(0, MAX_MATRIX_SERIES) : matrixResult;
const series = slicedMatrix.map((s) => {
const values = s.values ?? [];
const valueTruncated = values.length > MAX_RANGE_VALUES;
return {
metric: s.metric,
values: values.slice(0, MAX_RANGE_VALUES).map(([ts, val]) => ({
time: new Date(ts * 1000).toISOString(),
value: val,
})),
...(valueTruncated ? { truncated: true, totalValues: values.length } : {}),
};
});
return jsonResult({
status: "success",
queryType,
resultType: "matrix",
expr,
datasourceUid,
resultCount: series.length,
...(seriesTruncated ? { totalSeries, truncated: true, truncationHint: `Showing ${MAX_MATRIX_SERIES} of ${totalSeries} series. Narrow your LogQL query to see specific series.` } : {}),
series,
...panelMeta,
});
}
// vector (instant metric query)
const vectorResult = result.data.result;
const totalResults = vectorResult.length;
const resultsTruncated = totalResults > MAX_VECTOR_RESULTS;
const slicedVector = resultsTruncated ? vectorResult.slice(0, MAX_VECTOR_RESULTS) : vectorResult;
const metrics = slicedVector.map((r) => ({
metric: r.metric,
value: r.value?.[1] ?? null,
timestamp: r.value ? new Date(r.value[0] * 1000).toISOString() : null,
}));
return jsonResult({
status: "success",
queryType,
resultType: "vector",
expr,
datasourceUid,
resultCount: metrics.length,
...(resultsTruncated ? { totalResults, truncated: true, truncationHint: `Showing ${MAX_VECTOR_RESULTS} of ${totalResults} results. Narrow your LogQL query to see specific results.` } : {}),
metrics,
...panelMeta,
});
}