Skip to content

Commit 40555c3

Browse files
committed
feat(grafana): add deployment-annotations tool for change correlation (#2689)
Add a read-only GrafanaAnnotationsTool plus a query_annotations() method on GrafanaClientBase so the agent can answer "did a deploy/config change precede this alert?" for deploys from any source (ArgoCD/Flux, Helm, Terraform, manual), not just GitHub pushes. Complements GitDeployTimelineTool and reuses the existing Grafana auth. - service: query_annotations() mirrors query_alert_rules() (direct requests.get -> list[dict]); _map_annotation/_epoch_ms_to_iso map /api/annotations to ISO-8601 UTC - tool: mirrors GrafanaAlertRulesTool, reuses GrafanaLogsTool helpers, supports the grafana_backend fixture path, forwards basic-auth credentials - backends: add query_annotations() to the GrafanaBackend Protocol and all implementers - docs: docs/grafana_annotations.mdx registered in docs.json - tests: tests/tools/test_grafana_annotations_tool.py (schema, availability, extraction, backend path, UTC parsing, basic-auth, time-window override)
1 parent 1829ca1 commit 40555c3

9 files changed

Lines changed: 500 additions & 1 deletion

File tree

app/integrations/opensre/csv_grafana_backend.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ def query_traces(self, service_name: str = "", **_: Any) -> dict[str, Any]:
260260
traces = [{"traceID": tid, "spans": spans} for tid, spans in span_by_trace.items()]
261261
return {"traces": traces, "metrics": {}}
262262

263+
def query_annotations(self, **_: Any) -> list[dict[str, Any]]:
264+
# OpenSRE CSV telemetry carries no deploy/config-change annotations.
265+
return []
266+
263267
def _default_alert(self) -> dict[str, Any]:
264268
return {
265269
"title": "OpenSRE local telemetry",

app/services/grafana/base.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import base64
66
import json
77
import logging
8+
from datetime import UTC, datetime
89
from typing import Any
910
from urllib.parse import quote
1011

@@ -45,6 +46,29 @@ def _extract_rule_queries(rule: dict) -> list[dict]:
4546
return queries
4647

4748

49+
def _epoch_ms_to_iso(ms: Any) -> str | None:
50+
"""Convert a Grafana epoch-millisecond timestamp to an ISO 8601 UTC string."""
51+
if ms is None:
52+
return None
53+
try:
54+
return datetime.fromtimestamp(int(ms) / 1000, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
55+
except (TypeError, ValueError, OSError):
56+
return None
57+
58+
59+
def _map_annotation(item: dict[str, Any]) -> dict[str, Any]:
60+
"""Map a raw /api/annotations item to the tool-facing annotation shape."""
61+
tags = item.get("tags")
62+
return {
63+
"time": _epoch_ms_to_iso(item.get("time")),
64+
"time_end": _epoch_ms_to_iso(item.get("timeEnd")),
65+
"text": item.get("text", ""),
66+
"tags": tags if isinstance(tags, list) else [],
67+
# Modern UID only; ignore the legacy numeric dashboardId (0 == not attached).
68+
"dashboard_uid": item.get("dashboardUID") or None,
69+
}
70+
71+
4872
class GrafanaClientBase:
4973
"""Base HTTP client with common request methods for Grafana Cloud."""
5074

@@ -295,6 +319,42 @@ def query_alert_rules(self, folder: str | None = None) -> list[dict[str, Any]]:
295319
logger.warning("[grafana] Failed to query alert rules: %s", e)
296320
return []
297321

322+
def query_annotations(
323+
self,
324+
from_ts: int,
325+
to_ts: int,
326+
tags: list[str] | None = None,
327+
limit: int = 100,
328+
) -> list[dict[str, Any]]:
329+
"""Query Grafana annotations in a time window (epoch ms), optional tag filter.
330+
331+
Mirrors ``query_alert_rules``: a direct ``requests.get`` returning a list.
332+
``/api/annotations`` responds with a JSON array, so ``_make_request`` (which
333+
returns a dict) is unsuitable here.
334+
"""
335+
url = f"{self.instance_url}/api/annotations"
336+
params: dict[str, Any] = {
337+
"from": from_ts,
338+
"to": to_ts,
339+
"type": "annotation",
340+
"limit": limit,
341+
}
342+
if tags:
343+
params["tags"] = tags # requests repeats the param once per tag
344+
try:
345+
response = requests.get(
346+
url,
347+
headers=self._get_auth_headers(),
348+
params=params,
349+
timeout=10,
350+
)
351+
response.raise_for_status()
352+
data = response.json()
353+
return [_map_annotation(item) for item in data if isinstance(item, dict)]
354+
except Exception as e:
355+
logger.warning("[grafana] Failed to query annotations: %s", e)
356+
return []
357+
298358
def _get_auth_headers(self) -> dict[str, str]:
299359
if self.username and self.password:
300360
credentials = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Grafana deployment-annotations query tool for change correlation."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from datetime import UTC, datetime
7+
from typing import Any
8+
9+
from app.services.grafana.base import _epoch_ms_to_iso, _map_annotation
10+
from app.tools.GrafanaLogsTool import (
11+
_grafana_available,
12+
_grafana_creds,
13+
_grafana_source,
14+
_resolve_grafana_client,
15+
)
16+
from app.tools.tool_decorator import tool
17+
18+
19+
def _query_grafana_annotations_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
20+
grafana = _grafana_source(sources)
21+
return {
22+
"time_range_minutes": grafana.get("time_range_minutes", 60),
23+
"grafana_backend": grafana.get("_backend"),
24+
**_grafana_creds(grafana),
25+
}
26+
27+
28+
def _query_grafana_annotations_available(sources: dict[str, dict]) -> bool:
29+
return _grafana_available(sources)
30+
31+
32+
def _normalize_backend_annotations(raw: Any) -> list[dict[str, Any]]:
33+
"""Normalize fixture/backend ``/api/annotations`` arrays to the client shape."""
34+
if not isinstance(raw, list):
35+
return []
36+
return [_map_annotation(item) for item in raw if isinstance(item, dict)]
37+
38+
39+
def _iso_to_epoch_ms(value: str) -> int:
40+
"""Parse an ISO 8601 timestamp to epoch milliseconds (UTC). Raises ValueError if invalid.
41+
42+
A timezone-naive value (no ``Z`` / offset) is interpreted as UTC, not host-local time.
43+
"""
44+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
45+
if dt.tzinfo is None:
46+
dt = dt.replace(tzinfo=UTC)
47+
return int(dt.timestamp() * 1000)
48+
49+
50+
@tool(
51+
name="query_grafana_annotations",
52+
display_name="Grafana annotations",
53+
source="grafana",
54+
description=(
55+
"Query Grafana deployment/config-change annotations to correlate changes with "
56+
"an incident — the source-agnostic 'what changed and when' marker."
57+
),
58+
use_cases=[
59+
"Checking whether a deploy or config change preceded an alert",
60+
"Correlating incidents with ArgoCD/Flux/Helm/Terraform/manual changes emitted as annotations",
61+
"Building a source-agnostic change timeline alongside the GitHub deploy timeline",
62+
],
63+
requires=[],
64+
input_schema={
65+
"type": "object",
66+
"properties": {
67+
"from": {
68+
"type": "string",
69+
"description": "ISO 8601 window start (overrides time_range_minutes)",
70+
},
71+
"to": {
72+
"type": "string",
73+
"description": "ISO 8601 window end (overrides time_range_minutes)",
74+
},
75+
"tags": {"type": "array", "items": {"type": "string"}},
76+
"time_range_minutes": {"type": "integer", "default": 60},
77+
"limit": {"type": "integer", "default": 100},
78+
"grafana_endpoint": {"type": "string"},
79+
"grafana_api_key": {"type": "string"},
80+
},
81+
"required": [],
82+
},
83+
is_available=_query_grafana_annotations_available,
84+
extract_params=_query_grafana_annotations_extract_params,
85+
)
86+
def query_grafana_annotations(
87+
tags: list[str] | None = None,
88+
time_range_minutes: int = 60,
89+
limit: int = 100,
90+
grafana_endpoint: str | None = None,
91+
grafana_api_key: str | None = None,
92+
grafana_username: str = "",
93+
grafana_password: str = "",
94+
grafana_backend: Any = None,
95+
**_kwargs: Any,
96+
) -> dict:
97+
"""Query Grafana annotations to correlate deploys/config changes with an incident.
98+
99+
``from``/``to`` are accepted via the schema (ISO 8601); they are read from
100+
``_kwargs`` because ``from`` is a Python keyword and cannot be a parameter name.
101+
When absent, the window defaults to the last ``time_range_minutes``.
102+
"""
103+
if grafana_backend is not None:
104+
raw = grafana_backend.query_annotations(tags=tags, limit=limit)
105+
annotations = _normalize_backend_annotations(raw)
106+
return {
107+
"source": "grafana_annotations",
108+
"available": True,
109+
"annotations": annotations,
110+
"total": len(annotations),
111+
"raw": raw,
112+
}
113+
114+
client = _resolve_grafana_client(
115+
grafana_endpoint, grafana_api_key, grafana_username, grafana_password
116+
)
117+
if not client or not client.is_configured:
118+
return {
119+
"source": "grafana_annotations",
120+
"available": False,
121+
"error": "Grafana integration not configured",
122+
"annotations": [],
123+
}
124+
125+
now_ms = int(time.time() * 1000)
126+
try:
127+
from_iso, to_iso = _kwargs.get("from"), _kwargs.get("to")
128+
to_ts = _iso_to_epoch_ms(to_iso) if to_iso else now_ms
129+
# Default the window to end at `to` (now if unset), so a `to`-only call still
130+
# yields a valid [to - window, to] range rather than from_ts > to_ts.
131+
from_ts = _iso_to_epoch_ms(from_iso) if from_iso else to_ts - time_range_minutes * 60 * 1000
132+
except (ValueError, TypeError, AttributeError) as e:
133+
return {
134+
"source": "grafana_annotations",
135+
"available": False,
136+
"error": f"Invalid timestamp: {e}",
137+
"annotations": [],
138+
}
139+
140+
annotations = client.query_annotations(from_ts=from_ts, to_ts=to_ts, tags=tags, limit=limit)
141+
return {
142+
"source": "grafana_annotations",
143+
"available": True,
144+
"annotations": annotations,
145+
"total": len(annotations),
146+
"tags_filter": tags,
147+
"from": _epoch_ms_to_iso(from_ts),
148+
"to": _epoch_ms_to_iso(to_ts),
149+
}

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"coralogix",
109109
"datadog",
110110
"grafana",
111+
"grafana_annotations",
111112
"hermes",
112113
"honeycomb",
113114
"incident_io",

docs/grafana_annotations.mdx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
title: "Grafana Annotations"
3+
---
4+
5+
Correlate incidents with **deployments and config changes** from any source. OpenSRE reads
6+
[Grafana annotations](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/)
7+
the standard, source-agnostic "what changed and when" marker — so the agent can answer
8+
_"did a deploy or config change precede this alert?"_ even when the change did **not** come
9+
from a GitHub push (ArgoCD/Flux syncs, `helm upgrade`, Jenkins/CircleCI jobs, Terraform
10+
applies, manual hotfixes).
11+
12+
This complements the GitHub deploy timeline, which only sees GitHub-originated deploys.
13+
14+
## Requirements
15+
16+
No new setup or credentials — it reuses your existing **Grafana** integration. If Grafana is
17+
connected (see [Grafana](/grafana)), the annotations tool is available automatically during
18+
investigations.
19+
20+
## Parameters
21+
22+
| Parameter | Description |
23+
| -------------------- | --------------------------------------------------------------------------- |
24+
| `from` | ISO 8601 window start (e.g. `2026-05-30T14:00:00Z`). Overrides the default. |
25+
| `to` | ISO 8601 window end. Overrides the default. |
26+
| `tags` | Optional list of annotation tags to filter by (e.g. `["deployment"]`). |
27+
| `time_range_minutes` | Window size when `from`/`to` are omitted (default `60`, ending now). |
28+
| `limit` | Maximum annotations to return (default `100`). |
29+
30+
## Example
31+
32+
```text
33+
query_grafana_annotations(from="2026-05-30T14:00:00Z", to="2026-05-30T15:00:00Z", tags=["deployment"])
34+
```
35+
36+
```json
37+
{
38+
"source": "grafana_annotations",
39+
"total": 1,
40+
"annotations": [
41+
{
42+
"time": "2026-05-30T14:41:09Z",
43+
"time_end": null,
44+
"text": "deploy checkout-api v2.8.1",
45+
"tags": ["deployment", "checkout-api"],
46+
"dashboard_uid": null
47+
}
48+
]
49+
}
50+
```
51+
52+
Each annotation also carries `time_end` (set only for region annotations) and `dashboard_uid`
53+
(set only when the annotation is attached to a dashboard); both are `null` otherwise.
54+
55+
The agent uses results like this to flag a likely change-induced regression and tie the
56+
suspected root cause to a specific deploy.
57+
58+
## Emitting annotations
59+
60+
Make your deploys visible by writing a Grafana annotation when you ship. Most CD tools can
61+
post to Grafana's annotations API, for example:
62+
63+
```bash
64+
curl -s -X POST "$GRAFANA_URL/api/annotations" \
65+
-H "Authorization: Bearer $GRAFANA_TOKEN" \
66+
-H "Content-Type: application/json" \
67+
-d '{"time": 1717079669000, "text": "deploy checkout-api v2.8.1", "tags": ["deployment", "checkout-api"]}'
68+
```
69+
70+
Tagging deploy annotations consistently (e.g. `deployment`) lets the agent filter precisely
71+
during an investigation.

tests/synthetic/mock_grafana_backend/backend.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,12 @@
3434
class GrafanaBackend(Protocol):
3535
"""Minimal observability interface used by the RDS investigation agent.
3636
37-
Four methods — one per evidence pillar:
37+
Four evidence pillars plus change correlation:
3838
query_timeseries → Mimir/Prometheus matrix response
3939
query_logs → Loki streams response
4040
query_alert_rules → Grafana Ruler rules response
4141
query_traces → Tempo search response
42+
query_annotations → /api/annotations array (deploy/config-change markers)
4243
"""
4344

4445
def query_timeseries(self, query: str = "", **kwargs: Any) -> dict[str, Any]:
@@ -57,6 +58,10 @@ def query_traces(self, **kwargs: Any) -> dict[str, Any]:
5758
"""Return a Tempo-compatible search response."""
5859
pass
5960

61+
def query_annotations(self, **kwargs: Any) -> list[dict[str, Any]]:
62+
"""Return a Grafana /api/annotations array (a JSON list, not a dict)."""
63+
pass
64+
6065

6166
class FixtureGrafanaBackend:
6267
"""GrafanaBackend implementation backed by a ScenarioFixture.
@@ -181,3 +186,7 @@ def query_alert_rules(self, **_: Any) -> dict[str, Any]:
181186

182187
def query_traces(self, **_: Any) -> dict[str, Any]:
183188
return format_tempo_search()
189+
190+
def query_annotations(self, **_: Any) -> list[dict[str, Any]]:
191+
# RDS scenario fixtures declare no deploy/config-change annotations.
192+
return []

tests/synthetic/mock_grafana_backend/selective_backend.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ def query_alert_rules(self, **_: Any) -> dict[str, Any]:
9292
def query_traces(self, **_: Any) -> dict[str, Any]:
9393
return format_tempo_search()
9494

95+
def query_annotations(self, **_: Any) -> list[dict[str, Any]]:
96+
return []
97+
9598
def reset(self) -> None:
9699
"""Clear the queried_metrics audit log (useful for re-running a scenario)."""
97100
self.queried_metrics = []

0 commit comments

Comments
 (0)