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

228 lines (227 loc) 9.18 kB
/** * 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, }; }