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
228 lines (227 loc) • 9.18 kB
JavaScript
/**
* Grafana Lens plugin configuration types.
*
* These mirror the JSON Schema in openclaw.plugin.json but provide
* TypeScript types for use within the extension code.
*
* Config precedence: explicit plugin config > environment variables > defaults.
* Env vars follow Grafana community conventions (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN)
* and OpenTelemetry conventions (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS).
*
* Supports two grafana config formats:
* 1. Legacy single instance: { url, apiKey, orgId }
* 2. Named instances: { instances: [{ name, url, apiKey, orgId }], default? }
* Both normalize to the same internal shape: { instances: Record<name, config>, defaultInstance }.
*/
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
/**
* Validate that the default instance has credentials and filter out incomplete instances.
*/
export function validateConfig(config) {
const errors = [];
const { instances, defaultInstance } = config.grafana;
const defaultInst = instances[defaultInstance];
if (!defaultInst) {
errors.push(`Default Grafana instance "${defaultInstance}" not found in configured instances.`);
return { valid: false, errors };
}
if (!defaultInst.url) {
errors.push("grafana.url is required. Set it in plugin config or via GRAFANA_URL environment variable.");
}
if (!defaultInst.apiKey) {
errors.push("grafana.apiKey is required. Set it in plugin config or via GRAFANA_SERVICE_ACCOUNT_TOKEN environment variable.");
}
if (errors.length > 0) {
return { valid: false, errors };
}
// Keep only instances that have both url + apiKey. Default is guaranteed valid above.
const validInstances = {};
for (const [name, inst] of Object.entries(instances)) {
if (inst.url && inst.apiKey) {
validInstances[name] = { url: inst.url, apiKey: inst.apiKey, orgId: inst.orgId };
}
}
return {
valid: true,
config: {
...config,
grafana: { instances: validInstances, defaultInstance },
},
};
}
/**
* Derive per-signal OTLP endpoints from a base URL or metrics endpoint.
*
* Accepts either a base URL (http://localhost:4318) or a full metrics
* endpoint (http://localhost:4318/v1/metrics) and returns all three signal paths.
*/
export function deriveOtlpEndpoints(endpoint) {
const raw = endpoint ?? "http://localhost:4318/v1/metrics";
// Strip any /v1/* suffix to get the base collector URL
const base = raw.replace(/\/v1\/(metrics|logs|traces)\/?$/, "").replace(/\/+$/, "");
return {
metrics: `${base}/v1/metrics`,
logs: `${base}/v1/logs`,
traces: `${base}/v1/traces`,
};
}
/**
* Parse OTEL_EXPORTER_OTLP_HEADERS env var format: "key=value,key2=value2"
* Returns parsed headers and count of skipped (malformed) pairs.
*/
export function parseOtlpHeadersEnv(raw) {
const headers = {};
let skipped = 0;
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed)
continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
headers[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
}
else {
skipped++;
}
}
return { headers, skipped };
}
// ── Format detection + normalization ──────────────────────────────────
/**
* Detect whether the raw grafana config is legacy single-instance or multi-instance.
*
* Legacy: { url: "...", apiKey: "...", orgId: 1 }
* Multi: { instances: [{ name, url, apiKey, orgId }], default?: "name" }
*/
function isMultiInstanceFormat(grafana) {
return Array.isArray(grafana.instances);
}
/** Parse a single Grafana instance from raw config, applying env var fallback for the default. */
function parseSingleInstance(raw, applyEnvFallback) {
let url = raw.url;
let apiKey = raw.apiKey;
if (applyEnvFallback) {
url = url ?? process.env.GRAFANA_URL;
apiKey = apiKey ?? process.env.GRAFANA_SERVICE_ACCOUNT_TOKEN;
}
return {
url: url?.replace(/\/+$/, ""),
apiKey,
orgId: raw.orgId ?? 1,
};
}
/** Normalize multi-instance array format to internal record. */
function parseMultiInstances(grafana) {
const rawInstances = grafana.instances;
const instances = {};
let firstName;
for (const entry of rawInstances) {
const name = entry.name;
if (!name)
continue;
if (!firstName)
firstName = name;
// Env var fallback applies only to the explicit default or the first entry
const isDefault = grafana.default === name || (!grafana.default && name === firstName);
instances[name] = parseSingleInstance(entry, isDefault);
}
const defaultInstance = grafana.default ?? firstName ?? "default";
return { instances, defaultInstance };
}
export function parseConfig(raw) {
const grafana = raw?.grafana ?? {};
// ── Normalize grafana config to { instances, defaultInstance } ───────
let grafanaNormalized;
if (isMultiInstanceFormat(grafana)) {
grafanaNormalized = parseMultiInstances(grafana);
}
else {
// Legacy single-instance format: normalize to a single "default" entry
const inst = parseSingleInstance(grafana, true);
grafanaNormalized = {
instances: { default: inst },
defaultInstance: "default",
};
}
// ── Resolve OTLP config: explicit config > OTEL_EXPORTER_OTLP_* env vars > defaults ──
const otlpRaw = raw?.otlp ?? {};
const otlpEndpointEnv = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const otlpHeadersEnv = process.env.OTEL_EXPORTER_OTLP_HEADERS;
let otlpEndpoint = otlpRaw.endpoint;
if (!otlpEndpoint && otlpEndpointEnv) {
// OTEL_EXPORTER_OTLP_ENDPOINT is the base URL; append /v1/metrics for HTTP
otlpEndpoint = otlpEndpointEnv.replace(/\/+$/, "") + "/v1/metrics";
}
let otlpHeaders = otlpRaw.headers;
let otlpHeadersSkipped = 0;
if (!otlpHeaders && otlpHeadersEnv) {
const parsed = parseOtlpHeadersEnv(otlpHeadersEnv);
otlpHeaders = parsed.headers;
otlpHeadersSkipped = parsed.skipped;
}
const warnings = [];
if (otlpHeadersSkipped > 0) {
warnings.push(`OTEL_EXPORTER_OTLP_HEADERS contained ${otlpHeadersSkipped} malformed pair(s) without '=' separator — these were skipped`);
}
return {
grafana: grafanaNormalized,
metrics: {
enabled: raw?.metrics?.enabled !== false,
},
otlp: {
endpoint: otlpEndpoint,
headers: otlpHeaders,
exportIntervalMs: otlpRaw.exportIntervalMs,
instanceId: otlpRaw.instanceId
?? process.env.OTEL_SERVICE_INSTANCE_ID
?? (hostname() || randomUUID()),
logs: otlpRaw.logs !== false,
traces: otlpRaw.traces !== false,
captureContent: otlpRaw.captureContent !== false,
contentMaxLength: otlpRaw.contentMaxLength ?? 2000,
forwardAppLogs: otlpRaw.forwardAppLogs !== false,
appLogMinSeverity: otlpRaw.appLogMinSeverity ?? "debug",
redactSecrets: otlpRaw.redactSecrets !== false,
},
proactive: {
enabled: raw?.proactive?.enabled === true,
webhookPath: raw?.proactive?.webhookPath ??
"/grafana-lens/alerts",
costAlertThreshold: raw?.proactive?.costAlertThreshold ?? 5.0,
},
customMetrics: {
enabled: raw?.customMetrics?.enabled !== false,
maxMetrics: raw?.customMetrics?.maxMetrics ?? 100,
maxLabelsPerMetric: raw?.customMetrics?.maxLabelsPerMetric ?? 5,
maxLabelValues: raw?.customMetrics?.maxLabelValues ?? 50,
defaultTtlDays: raw?.customMetrics?.defaultTtlDays,
},
alloy: parseAlloyConfig(raw?.alloy),
...(warnings.length > 0 ? { _warnings: warnings } : {}),
};
}
// ── Alloy config parsing ────────────────────────────────────────────
function parseAlloyConfig(raw) {
if (!raw)
return undefined;
const enabled = raw.enabled === true;
if (!enabled)
return { enabled: false };
const url = raw.url ?? process.env.ALLOY_URL ?? "http://localhost:12345";
const configDir = raw.configDir ?? process.env.ALLOY_CONFIG_DIR;
const lgtmRaw = raw.lgtm;
return {
enabled: true,
url: url.replace(/\/+$/, ""),
configDir,
filePrefix: raw.filePrefix ?? "lens-",
maxPipelines: raw.maxPipelines ?? 20,
lgtm: lgtmRaw ? {
prometheusRemoteWriteUrl: lgtmRaw.prometheusRemoteWriteUrl,
lokiUrl: lgtmRaw.lokiUrl,
otlpEndpoint: lgtmRaw.otlpEndpoint,
pyroscopeUrl: lgtmRaw.pyroscopeUrl,
} : undefined,
};
}