Files
tracer-cloud--opensre/integrations/grafana/base.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

381 lines
14 KiB
Python

"""Base HTTP client for Grafana Cloud API."""
from __future__ import annotations
import base64
import json
import logging
from datetime import UTC, datetime
from typing import Any
from urllib.parse import quote
import requests
from integrations.grafana.config import GrafanaAccountConfig
logger = logging.getLogger(__name__)
def _extract_datasource_uid(rule: dict) -> str:
"""Extract the primary datasource UID from an alert rule."""
alert = rule.get("grafana_alert", {})
for datum in alert.get("data", []):
model = datum.get("model", {})
ds = model.get("datasource", {})
uid = ds.get("uid")
if isinstance(uid, str) and uid:
return uid
return ""
def _extract_rule_queries(rule: dict) -> list[dict]:
"""Extract query expressions from an alert rule."""
alert = rule.get("grafana_alert", {})
queries = []
for datum in alert.get("data", []):
model = datum.get("model", {})
expr = model.get("expr", "")
if expr:
queries.append(
{
"ref_id": datum.get("refId", ""),
"expr": expr,
"datasource_uid": model.get("datasource", {}).get("uid", ""),
}
)
return queries
def _epoch_ms_to_iso(ms: Any) -> str | None:
"""Convert a Grafana epoch-millisecond timestamp to an ISO 8601 UTC string."""
if ms is None:
return None
try:
return datetime.fromtimestamp(int(ms) / 1000, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
except (TypeError, ValueError, OSError):
return None
def _map_annotation(item: dict[str, Any]) -> dict[str, Any]:
"""Map a raw /api/annotations item to the tool-facing annotation shape."""
tags = item.get("tags")
return {
"time": _epoch_ms_to_iso(item.get("time")),
"time_end": _epoch_ms_to_iso(item.get("timeEnd")),
"text": item.get("text", ""),
"tags": tags if isinstance(tags, list) else [],
# Modern UID only; ignore the legacy numeric dashboardId (0 == not attached).
"dashboard_uid": item.get("dashboardUID") or None,
}
class GrafanaClientBase:
"""Base HTTP client with common request methods for Grafana Cloud."""
def __init__(self, config: GrafanaAccountConfig):
self._config = config
self.account_id = config.account_id
self.instance_url = config.instance_url
self.read_token = config.read_token
self.username = config.username
self.password = config.password
self.loki_datasource_uid = config.loki_datasource_uid
self.tempo_datasource_uid = config.tempo_datasource_uid
self.mimir_datasource_uid = config.mimir_datasource_uid
self.uses_local_anonymous_auth = config.uses_local_anonymous_auth
@property
def is_configured(self) -> bool:
return self._config.is_configured
def _build_datasource_url(self, datasource_uid: str, path: str) -> str:
return f"{self.instance_url}/api/datasources/proxy/uid/{datasource_uid}{path}"
def build_logql_query(
self,
service_name: str,
*,
correlation_id: str | None = None,
execution_run_id: str | None = None,
) -> str:
base = f'{{service_name="{service_name}"}}'
filters: list[str] = []
if execution_run_id:
filters.append(execution_run_id)
if correlation_id and correlation_id != execution_run_id:
filters.append(correlation_id)
for value in filters:
base += f' |= "{value}"'
return base
def build_explore_url(
self,
*,
query: str,
datasource_uid: str,
from_time: str = "now-1h",
to_time: str = "now",
) -> str:
left = [from_time, to_time, datasource_uid, {"expr": query, "refId": "A"}]
left_param = quote(json.dumps(left, separators=(",", ":")))
return f"{self.instance_url.rstrip('/')}/explore?orgId=1&left={left_param}"
def build_loki_explore_url(
self,
service_name: str,
*,
correlation_id: str | None = None,
execution_run_id: str | None = None,
from_time: str = "now-1h",
to_time: str = "now",
) -> str:
if not self.instance_url:
return ""
query = self.build_logql_query(
service_name,
correlation_id=correlation_id,
execution_run_id=execution_run_id,
)
return self.build_explore_url(
query=query,
datasource_uid=self.loki_datasource_uid,
from_time=from_time,
to_time=to_time,
)
# Datasource type keywords used to classify each datasource
_TYPE_MAP = {
"loki": "loki_uid",
"tempo": "tempo_uid",
"prometheus": "mimir_uid",
}
# UIDs/names containing these substrings are internal/secondary datasources
# that should be deprioritized in favour of the primary ones.
_DEPRIORITIZE_KEYWORDS = ("alert", "state-history", "ml-", "usage-insights")
# UIDs/names containing these substrings are strong signals for primary datasources.
_PRIMARY_HINTS: dict[str, list[str]] = {
"loki_uid": ["logs", "-log"],
"tempo_uid": ["traces", "-trace"],
"mimir_uid": ["prom", "metrics", "-metric"],
}
def discover_datasource_uids(self) -> dict[str, str]:
"""Discover datasource UIDs by querying GET /api/datasources.
Iterates all datasources returned by the user's Grafana instance and
picks the best one matching each type (loki, tempo, prometheus).
Selection priority:
1. Datasource marked ``isDefault``
2. Datasource whose uid/name contains a primary hint (e.g. "logs" for loki)
3. Datasource whose uid/name does NOT contain deprioritized keywords
4. First datasource of that type (fallback)
Returns:
Dict with keys loki_uid, tempo_uid, mimir_uid (only present if found).
"""
if not self.instance_url or not self.is_configured:
return {}
url = f"{self.instance_url}/api/datasources"
try:
response = requests.get(
url,
headers=self._get_auth_headers(),
timeout=10,
)
response.raise_for_status()
datasources = response.json()
# Collect all candidates per type, then pick the best one.
candidates: dict[str, list[dict]] = {key: [] for key in self._TYPE_MAP.values()}
for ds in datasources:
ds_type = ds.get("type", "").lower()
uid = ds.get("uid", "")
name = ds.get("name", "")
is_default = bool(ds.get("isDefault"))
if not uid:
continue
for type_keyword, result_key in self._TYPE_MAP.items():
if type_keyword in ds_type:
candidates[result_key].append(
{
"uid": uid,
"name": name,
"is_default": is_default,
}
)
break
result: dict[str, str] = {}
for result_key, ds_list in candidates.items():
if not ds_list:
continue
logger.info(
"[grafana] Candidates for %s: %s",
result_key,
[(d["uid"], d["name"]) for d in ds_list],
)
def _is_deprioritized(d: dict) -> bool:
return any(
kw in d["uid"].lower() or kw in d["name"].lower()
for kw in self._DEPRIORITIZE_KEYWORDS
)
# 1. Prefer the default datasource for this type
defaults = [d for d in ds_list if d["is_default"]]
if defaults:
result[result_key] = defaults[0]["uid"]
continue
# Filter out deprioritized datasources for hint matching
non_deprioritized = [d for d in ds_list if not _is_deprioritized(d)]
# 2. Prefer non-deprioritized datasources matching primary hints
hints = self._PRIMARY_HINTS.get(result_key, [])
if hints and non_deprioritized:
hinted = [
d
for d in non_deprioritized
if any(h in d["uid"].lower() or h in d["name"].lower() for h in hints)
]
if hinted:
result[result_key] = hinted[0]["uid"]
continue
# 3. Use any non-deprioritized datasource
if non_deprioritized:
result[result_key] = non_deprioritized[0]["uid"]
continue
# 4. Fallback to first (even if deprioritized)
result[result_key] = ds_list[0]["uid"]
logger.info("[grafana] Discovered datasource UIDs: %s", result)
return result
except Exception as e:
logger.warning("[grafana] Failed to discover datasource UIDs: %s", e)
return {}
def query_loki_label_values(self, label: str = "service_name") -> list[str]:
"""Query Loki for available values of a label."""
if not self.loki_datasource_uid:
return []
url = self._build_datasource_url(
self.loki_datasource_uid,
f"/loki/api/v1/label/{label}/values",
)
try:
data = self._make_request(url)
values: list[str] = data.get("data", [])
return values
except Exception:
logger.debug("Failed to fetch Loki label values for %s", label, exc_info=True)
return []
def query_alert_rules(self, folder: str | None = None) -> list[dict[str, Any]]:
"""Query Grafana alert rules, optionally filtered by folder title."""
url = f"{self.instance_url}/api/ruler/grafana/api/v1/rules"
try:
response = requests.get(
url,
headers=self._get_auth_headers(),
timeout=10,
)
response.raise_for_status()
data = response.json()
rules: list[dict[str, Any]] = []
for folder_name, groups in data.items():
if folder and folder.lower() not in folder_name.lower():
continue
for group in groups:
for rule in group.get("rules", []):
rules.append(
{
"folder": folder_name,
"group": group.get("name", ""),
"rule_name": rule.get("grafana_alert", {}).get("title", ""),
"condition": rule.get("grafana_alert", {}).get("condition", ""),
"datasource_uid": _extract_datasource_uid(rule),
"queries": _extract_rule_queries(rule),
"state": rule.get("grafana_alert", {}).get("current_state", ""),
"no_data_state": rule.get("grafana_alert", {}).get(
"no_data_state", ""
),
}
)
return rules
except Exception as e:
logger.warning("[grafana] Failed to query alert rules: %s", e)
return []
def query_annotations(
self,
from_ts: int,
to_ts: int,
tags: list[str] | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Query Grafana annotations in a time window (epoch ms), optional tag filter.
Mirrors ``query_alert_rules``: a direct ``requests.get`` returning a list.
``/api/annotations`` responds with a JSON array, so ``_make_request`` (which
returns a dict) is unsuitable here.
"""
url = f"{self.instance_url}/api/annotations"
params: dict[str, Any] = {
"from": from_ts,
"to": to_ts,
"type": "annotation",
"limit": limit,
}
if tags:
params["tags"] = tags # requests repeats the param once per tag
try:
response = requests.get(
url,
headers=self._get_auth_headers(),
params=params,
timeout=10,
)
response.raise_for_status()
data = response.json()
return [_map_annotation(item) for item in data if isinstance(item, dict)]
except Exception as e:
logger.warning("[grafana] Failed to query annotations: %s", e)
return []
def _get_auth_headers(self) -> dict[str, str]:
if self.username and self.password:
credentials = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
return {"Authorization": f"Basic {credentials}"}
if not self.read_token:
return {}
return {"Authorization": f"Bearer {self.read_token}"}
def _make_request(
self,
url: str,
params: dict[str, str] | None = None,
timeout: int = 10,
) -> dict[str, Any]:
response = requests.get(
url,
headers=self._get_auth_headers(),
params=params,
timeout=timeout,
)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result