sre-agent-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sre_agent/__init__.py +3 -0
- sre_agent/adapters.py +833 -0
- sre_agent/approvals.py +243 -0
- sre_agent/audit.py +225 -0
- sre_agent/backend_scores.py +184 -0
- sre_agent/backend_url.py +146 -0
- sre_agent/cli.py +732 -0
- sre_agent/config.py +22 -0
- sre_agent/evaluation.py +391 -0
- sre_agent/evaluation_page.py +209 -0
- sre_agent/hosted_demo.py +153 -0
- sre_agent/ingestion.py +423 -0
- sre_agent/injection.py +580 -0
- sre_agent/logging_config.py +83 -0
- sre_agent/memory.py +411 -0
- sre_agent/model_backend.py +551 -0
- sre_agent/model_proposals.py +287 -0
- sre_agent/models.py +278 -0
- sre_agent/policy.py +208 -0
- sre_agent/postmortems.py +296 -0
- sre_agent/reasoning.py +547 -0
- sre_agent/regression.py +287 -0
- sre_agent/runbooks.py +290 -0
- sre_agent/security_review.py +826 -0
- sre_agent/server.py +684 -0
- sre_agent/storage.py +266 -0
- sre_agent/tier3.py +440 -0
- sre_agent/tools.py +712 -0
- sre_agent_cli-0.2.0.dist-info/METADATA +364 -0
- sre_agent_cli-0.2.0.dist-info/RECORD +34 -0
- sre_agent_cli-0.2.0.dist-info/WHEEL +5 -0
- sre_agent_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sre_agent_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- sre_agent_cli-0.2.0.dist-info/top_level.txt +1 -0
sre_agent/reasoning.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Reasoning loop v1: turn gathered evidence into a diagnosis.
|
|
2
|
+
|
|
3
|
+
In plain words, this is the part that looks at an incident and
|
|
4
|
+
writes down what it thinks is wrong — and *why* it thinks so.
|
|
5
|
+
|
|
6
|
+
How v1 works, honestly:
|
|
7
|
+
|
|
8
|
+
- **A backend reads the evidence, and the default is plain
|
|
9
|
+
rules.** The loop gathers evidence the same way no matter
|
|
10
|
+
what, then hands it to a diagnosis backend (see
|
|
11
|
+
``model_backend.py``). The default backend is deterministic:
|
|
12
|
+
a small set of plain rules, no model call, no API key, so
|
|
13
|
+
the tests can check every diagnosis exactly, word for word.
|
|
14
|
+
An operator may instead hand the loop a language-model
|
|
15
|
+
backend built around their own model client — off by
|
|
16
|
+
default, enabled only by building it in code and passing it
|
|
17
|
+
in, with no client shipped in this repository. Whichever
|
|
18
|
+
backend runs, the report has the same shape and names the
|
|
19
|
+
backend, and the loop's own checks below apply to both: a
|
|
20
|
+
claim or cause citing evidence that is not on the incident
|
|
21
|
+
is refused, and a backend that returns no causes gets the
|
|
22
|
+
same "I don't know" escalation. The deterministic backend
|
|
23
|
+
reads counts, statuses and measured values only; the model
|
|
24
|
+
backend's reply is checked as untrusted input before any of
|
|
25
|
+
it can become a claim (``model_backend.py`` lists the rules).
|
|
26
|
+
- **A fixed, bounded plan.** The loop runs at most four read-only
|
|
27
|
+
queries, in a set order: service status, error logs, the error
|
|
28
|
+
metric, then the request metric. Each query counts as one
|
|
29
|
+
step against a step budget. The same query is never run
|
|
30
|
+
twice in one diagnosis. If the budget runs out before the
|
|
31
|
+
plan finishes, the loop stops gathering and escalates.
|
|
32
|
+
- **Selected adapters are extra steps, never a new plan.**
|
|
33
|
+
An operator may select read adapters (Kubernetes, Datadog,
|
|
34
|
+
Azure Monitor — see ``adapters.py``) as extra evidence
|
|
35
|
+
sources. Each selected adapter is asked exactly one fixed
|
|
36
|
+
question, after the four core questions, and counts as one
|
|
37
|
+
more step. Adapter questions are built only from the
|
|
38
|
+
incident's target names, and only when a name is a plain
|
|
39
|
+
service or namespace name — a name carrying query syntax is
|
|
40
|
+
never built into a Datadog or KQL question, and that source
|
|
41
|
+
is skipped for that incident. Adapters are extras, so the
|
|
42
|
+
rules differ from the core plan in one way: if the step
|
|
43
|
+
budget runs out before an adapter step, that step is simply
|
|
44
|
+
skipped and the core diagnosis stands, and an adapter that
|
|
45
|
+
fails is skipped with the failure logged. Adapter answers
|
|
46
|
+
are read as counts and measured values only, exactly like
|
|
47
|
+
the core tools — raw adapter text never becomes a claim.
|
|
48
|
+
- **Memory advice is attached last, and changes nothing.**
|
|
49
|
+
An operator may hand the loop a memory of past incidents
|
|
50
|
+
(``memory.py``). After the diagnosis above is decided — never
|
|
51
|
+
before, never as an input to it — the loop asks that memory
|
|
52
|
+
for its report on this incident and attaches it to the
|
|
53
|
+
incident and to this report: similar past incidents, each
|
|
54
|
+
runbook's record, and suggested runbooks, as advice only.
|
|
55
|
+
The advice proposes nothing and runs nothing; any suggested
|
|
56
|
+
fix still goes through the policy engine and a human
|
|
57
|
+
approval like any other. A memory that fails is skipped
|
|
58
|
+
with the failure logged, exactly like a failing adapter —
|
|
59
|
+
advice can never discard a diagnosis.
|
|
60
|
+
- **Every claim cites evidence.** A claim or a hypothesis with
|
|
61
|
+
no evidence behind it is refused by its own data model, and
|
|
62
|
+
the loop checks again that every cited id is a real evidence
|
|
63
|
+
record on the incident before it writes anything down.
|
|
64
|
+
- **"I don't know" is a normal answer.** If the gathered
|
|
65
|
+
evidence shows no clear problem — the service looks healthy,
|
|
66
|
+
there are no error logs, and the error metric is quiet — the
|
|
67
|
+
loop says so, sets no diagnosis, and moves the incident to
|
|
68
|
+
"escalated" for a human. Guessing confidently is a bug here.
|
|
69
|
+
- **Telemetry is data, never orders.** Log lines can say
|
|
70
|
+
anything, including attacks. The default backend's claims
|
|
71
|
+
are built from counts, statuses and measured values only;
|
|
72
|
+
raw log text is never copied into a claim, and nothing in a
|
|
73
|
+
report can propose or run an action. Fixes come in a later
|
|
74
|
+
phase, through the policy engine and a human approval.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
import re
|
|
80
|
+
from enum import Enum
|
|
81
|
+
|
|
82
|
+
from pydantic import BaseModel, Field
|
|
83
|
+
|
|
84
|
+
from .adapters import (
|
|
85
|
+
AdapterSources,
|
|
86
|
+
AzureQueryResult,
|
|
87
|
+
KqlQueryRequest,
|
|
88
|
+
KubernetesPodsResult,
|
|
89
|
+
ListPodsRequest,
|
|
90
|
+
)
|
|
91
|
+
from .logging_config import get_logger
|
|
92
|
+
from .memory import IncidentMemory
|
|
93
|
+
from .model_backend import (
|
|
94
|
+
BackendContext,
|
|
95
|
+
DeterministicBackend,
|
|
96
|
+
DiagnosisBackend,
|
|
97
|
+
)
|
|
98
|
+
from .models import (
|
|
99
|
+
DiagnosisClaim,
|
|
100
|
+
Evidence,
|
|
101
|
+
Hypothesis,
|
|
102
|
+
Incident,
|
|
103
|
+
IncidentStatus,
|
|
104
|
+
MemoryReport,
|
|
105
|
+
Target,
|
|
106
|
+
)
|
|
107
|
+
from .tools import (
|
|
108
|
+
QueryMetricsRequest,
|
|
109
|
+
QueryMetricsResult,
|
|
110
|
+
SearchLogsRequest,
|
|
111
|
+
ToolLayer,
|
|
112
|
+
demo_tool_layer,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
log = get_logger(__name__)
|
|
116
|
+
|
|
117
|
+
# The fixed evidence plan: these two metrics, in this order.
|
|
118
|
+
ERROR_METRIC = "http_request_errors_total"
|
|
119
|
+
REQUEST_METRIC = "http_requests_total"
|
|
120
|
+
DEFAULT_MAX_STEPS = 25
|
|
121
|
+
|
|
122
|
+
# A name that may be built into a fixed adapter question: a plain
|
|
123
|
+
# service name — letters, digits, dots, underscores, dashes — and
|
|
124
|
+
# nothing else. A target name carrying spaces, quotes or query
|
|
125
|
+
# syntax fails this check, and the Datadog and Azure steps are
|
|
126
|
+
# skipped for that incident rather than built from it.
|
|
127
|
+
_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class DiagnosisOutcome(str, Enum):
|
|
131
|
+
DIAGNOSED = "diagnosed"
|
|
132
|
+
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DiagnosisReport(BaseModel):
|
|
136
|
+
"""The full result of one run of the reasoning loop."""
|
|
137
|
+
|
|
138
|
+
incident_id: str
|
|
139
|
+
outcome: DiagnosisOutcome
|
|
140
|
+
summary: str
|
|
141
|
+
claims: list[DiagnosisClaim] = Field(default_factory=list)
|
|
142
|
+
hypotheses: list[Hypothesis] = Field(default_factory=list)
|
|
143
|
+
gathered_evidence: list[Evidence] = Field(default_factory=list)
|
|
144
|
+
steps_used: int = 0
|
|
145
|
+
queries_run: list[str] = Field(default_factory=list)
|
|
146
|
+
# Which backend produced the claims and causes above:
|
|
147
|
+
# "deterministic" (the default) or "language-model". The
|
|
148
|
+
# shape is the same either way; the name is recorded so a
|
|
149
|
+
# score can always be tied to the backend that earned it.
|
|
150
|
+
backend_name: str = "deterministic"
|
|
151
|
+
# Past-incident advice, attached after the diagnosis was
|
|
152
|
+
# decided when the loop was built with a memory. Advice
|
|
153
|
+
# only: it influenced no claim, cause or action above.
|
|
154
|
+
memory_report: MemoryReport | None = None
|
|
155
|
+
|
|
156
|
+
def render_text(self) -> str:
|
|
157
|
+
"""Write the report as plain text a human can check.
|
|
158
|
+
|
|
159
|
+
Every claim is printed with the exact query behind the
|
|
160
|
+
evidence it cites, so a reader can re-run that query and
|
|
161
|
+
see the same thing the agent saw.
|
|
162
|
+
"""
|
|
163
|
+
by_id = {ev.id: ev for ev in self.gathered_evidence}
|
|
164
|
+
lines = [
|
|
165
|
+
f"Diagnosis for incident {self.incident_id}",
|
|
166
|
+
f"Outcome: {self.outcome.value}",
|
|
167
|
+
f"Backend: {self.backend_name}",
|
|
168
|
+
f"Summary: {self.summary}",
|
|
169
|
+
f"Steps used: {self.steps_used}",
|
|
170
|
+
"Claims (each one backed by the evidence named under it):",
|
|
171
|
+
]
|
|
172
|
+
if not self.claims:
|
|
173
|
+
lines.append("- No claims: the evidence did not support a diagnosis.")
|
|
174
|
+
for claim in self.claims:
|
|
175
|
+
lines.append(f"- {claim.statement} (confidence {claim.confidence:.2f})")
|
|
176
|
+
for ev_id in claim.evidence_ids:
|
|
177
|
+
ev = by_id.get(ev_id)
|
|
178
|
+
if ev is not None:
|
|
179
|
+
lines.append(f" evidence: {ev.query}")
|
|
180
|
+
if self.hypotheses:
|
|
181
|
+
lines.append("Possible causes, most likely first:")
|
|
182
|
+
for hyp in self.hypotheses:
|
|
183
|
+
lines.append(
|
|
184
|
+
f"- {hyp.statement} (confidence {hyp.confidence:.2f}, "
|
|
185
|
+
f"supported by {len(hyp.supporting_evidence)} evidence record(s))"
|
|
186
|
+
)
|
|
187
|
+
if self.memory_report is not None:
|
|
188
|
+
report = self.memory_report
|
|
189
|
+
lines.append(
|
|
190
|
+
"Memory advice (advice only — it changed no claim, "
|
|
191
|
+
"cause or action):"
|
|
192
|
+
)
|
|
193
|
+
if not report.matches:
|
|
194
|
+
lines.append("- No similar past incident was found.")
|
|
195
|
+
for match in report.matches:
|
|
196
|
+
lines.append(
|
|
197
|
+
f"- Past incident {match.incident_id} "
|
|
198
|
+
f"[{match.status.value}] score {match.score}: "
|
|
199
|
+
f"{match.title}"
|
|
200
|
+
)
|
|
201
|
+
if not report.suggestions:
|
|
202
|
+
lines.append(
|
|
203
|
+
"- No runbook is suggested: no similar past "
|
|
204
|
+
"incident ran one."
|
|
205
|
+
)
|
|
206
|
+
for suggestion in report.suggestions:
|
|
207
|
+
lines.append(
|
|
208
|
+
f"- Suggested runbook {suggestion.runbook_id}: "
|
|
209
|
+
f"{suggestion.reason}"
|
|
210
|
+
)
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ReasoningLoop:
|
|
215
|
+
"""The bounded reasoning loop, over a pluggable backend.
|
|
216
|
+
|
|
217
|
+
Gathering is the loop's; reading the gathered evidence is
|
|
218
|
+
the backend's. With no backend given, the deterministic
|
|
219
|
+
backend runs — the default, everywhere this loop is used.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
tools: ToolLayer,
|
|
225
|
+
max_steps: int = DEFAULT_MAX_STEPS,
|
|
226
|
+
adapters: AdapterSources | None = None,
|
|
227
|
+
memory: IncidentMemory | None = None,
|
|
228
|
+
backend: DiagnosisBackend | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
if max_steps < 1:
|
|
231
|
+
raise ValueError("max_steps must be at least 1")
|
|
232
|
+
self._tools = tools
|
|
233
|
+
self._max_steps = max_steps
|
|
234
|
+
self._adapters = adapters
|
|
235
|
+
self._memory = memory
|
|
236
|
+
self._backend: DiagnosisBackend = (
|
|
237
|
+
backend if backend is not None else DeterministicBackend()
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def backend_name(self) -> str:
|
|
242
|
+
"""The name of the backend this loop diagnoses with."""
|
|
243
|
+
return self._backend.name
|
|
244
|
+
|
|
245
|
+
def diagnose(self, incident: Incident) -> DiagnosisReport:
|
|
246
|
+
"""Gather evidence for *incident* and write a diagnosis.
|
|
247
|
+
|
|
248
|
+
Moves the incident to "diagnosed" when the evidence
|
|
249
|
+
supports one, or to "escalated" when it does not (or the
|
|
250
|
+
step budget ran out). Any other starting state is a
|
|
251
|
+
programming error and raises.
|
|
252
|
+
"""
|
|
253
|
+
if incident.status is IncidentStatus.OPEN:
|
|
254
|
+
incident.transition(IncidentStatus.TRIAGING)
|
|
255
|
+
if incident.status is not IncidentStatus.TRIAGING:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"cannot diagnose an incident in status {incident.status.value!r}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
gathered: list[Evidence] = []
|
|
261
|
+
queries_run: list[str] = []
|
|
262
|
+
seen_queries: set[str] = set()
|
|
263
|
+
steps = 0
|
|
264
|
+
budget_exhausted = False
|
|
265
|
+
|
|
266
|
+
def run_once(key: str) -> bool:
|
|
267
|
+
"""Count one step. False means: stop, budget is gone.
|
|
268
|
+
|
|
269
|
+
The same query key is never counted or run twice.
|
|
270
|
+
"""
|
|
271
|
+
nonlocal steps, budget_exhausted
|
|
272
|
+
if key in seen_queries:
|
|
273
|
+
return True
|
|
274
|
+
if steps >= self._max_steps:
|
|
275
|
+
budget_exhausted = True
|
|
276
|
+
return False
|
|
277
|
+
seen_queries.add(key)
|
|
278
|
+
queries_run.append(key)
|
|
279
|
+
steps += 1
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def run_adapter_once(key: str) -> bool:
|
|
283
|
+
"""Count one adapter step. False means: skip this
|
|
284
|
+
source — the budget is gone, but adapters are extras,
|
|
285
|
+
so skipping one never discards the core diagnosis the
|
|
286
|
+
way an unfinished core plan does."""
|
|
287
|
+
nonlocal steps
|
|
288
|
+
if key in seen_queries:
|
|
289
|
+
return True
|
|
290
|
+
if steps >= self._max_steps:
|
|
291
|
+
return False
|
|
292
|
+
seen_queries.add(key)
|
|
293
|
+
queries_run.append(key)
|
|
294
|
+
steps += 1
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
target = incident.target
|
|
298
|
+
|
|
299
|
+
# Step 1: is the service itself up, degraded, or down?
|
|
300
|
+
status_result = None
|
|
301
|
+
status_ev: Evidence | None = None
|
|
302
|
+
if run_once(f"service status: {target.service}"):
|
|
303
|
+
status_result = self._tools.get_service_status(target)
|
|
304
|
+
status_ev = status_result.to_evidence()
|
|
305
|
+
gathered.append(status_ev)
|
|
306
|
+
incident.add_evidence(status_ev)
|
|
307
|
+
|
|
308
|
+
# Step 2: what do its error logs say (in counts, not words)?
|
|
309
|
+
logs_result = None
|
|
310
|
+
logs_ev: Evidence | None = None
|
|
311
|
+
if not budget_exhausted and run_once(f"error logs: {target.service}"):
|
|
312
|
+
logs_result = self._tools.search_logs(
|
|
313
|
+
SearchLogsRequest(
|
|
314
|
+
service=target.service,
|
|
315
|
+
namespace=target.namespace,
|
|
316
|
+
level="ERROR",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
logs_ev = logs_result.to_evidence()
|
|
320
|
+
gathered.append(logs_ev)
|
|
321
|
+
incident.add_evidence(logs_ev)
|
|
322
|
+
|
|
323
|
+
# Step 3: are errors showing up in the metrics?
|
|
324
|
+
errors_result = None
|
|
325
|
+
errors_ev: Evidence | None = None
|
|
326
|
+
if not budget_exhausted and run_once(f"promql: {ERROR_METRIC}"):
|
|
327
|
+
errors_result = self._tools.query_metrics(
|
|
328
|
+
QueryMetricsRequest(query=ERROR_METRIC)
|
|
329
|
+
)
|
|
330
|
+
errors_ev = errors_result.to_evidence()
|
|
331
|
+
gathered.append(errors_ev)
|
|
332
|
+
incident.add_evidence(errors_ev)
|
|
333
|
+
|
|
334
|
+
# Step 4: how much traffic is there, for context? It is
|
|
335
|
+
# gathered and stored, but it never creates a claim or a
|
|
336
|
+
# hypothesis on its own: traffic alone diagnoses nothing.
|
|
337
|
+
if not budget_exhausted and run_once(f"promql: {REQUEST_METRIC}"):
|
|
338
|
+
requests_ev = self._tools.query_metrics(
|
|
339
|
+
QueryMetricsRequest(query=REQUEST_METRIC)
|
|
340
|
+
).to_evidence()
|
|
341
|
+
gathered.append(requests_ev)
|
|
342
|
+
incident.add_evidence(requests_ev)
|
|
343
|
+
|
|
344
|
+
# Extra steps, only for adapters the operator selected.
|
|
345
|
+
# Each is one fixed question. A source that fails, or a
|
|
346
|
+
# target name that may not be built into its question,
|
|
347
|
+
# skips that source only — the diagnosis proceeds with
|
|
348
|
+
# whatever the other sources said.
|
|
349
|
+
adapters = self._adapters
|
|
350
|
+
pods_result: KubernetesPodsResult | None = None
|
|
351
|
+
pods_ev: Evidence | None = None
|
|
352
|
+
datadog_result: QueryMetricsResult | None = None
|
|
353
|
+
datadog_ev: Evidence | None = None
|
|
354
|
+
azure_result: AzureQueryResult | None = None
|
|
355
|
+
azure_ev: Evidence | None = None
|
|
356
|
+
if adapters is not None and not budget_exhausted:
|
|
357
|
+
if adapters.kubernetes is not None and run_adapter_once(
|
|
358
|
+
f"kubernetes pods: {target.namespace}/{target.service}"
|
|
359
|
+
):
|
|
360
|
+
try:
|
|
361
|
+
pods_result = adapters.kubernetes.list_pods(
|
|
362
|
+
ListPodsRequest(
|
|
363
|
+
namespace=target.namespace, service=target.service
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
pods_ev = pods_result.to_evidence()
|
|
367
|
+
gathered.append(pods_ev)
|
|
368
|
+
incident.add_evidence(pods_ev)
|
|
369
|
+
except Exception:
|
|
370
|
+
log.exception(
|
|
371
|
+
"kubernetes adapter failed for incident %s; "
|
|
372
|
+
"that source is skipped",
|
|
373
|
+
incident.id,
|
|
374
|
+
)
|
|
375
|
+
pods_result = None
|
|
376
|
+
pods_ev = None
|
|
377
|
+
if adapters.datadog is not None:
|
|
378
|
+
if not _SAFE_NAME_RE.match(target.service):
|
|
379
|
+
log.warning(
|
|
380
|
+
"datadog adapter skipped for incident %s: the "
|
|
381
|
+
"service name is not a plain name",
|
|
382
|
+
incident.id,
|
|
383
|
+
)
|
|
384
|
+
elif run_adapter_once(f"datadog: {ERROR_METRIC}"):
|
|
385
|
+
try:
|
|
386
|
+
datadog_result = adapters.datadog.query_metrics(
|
|
387
|
+
QueryMetricsRequest(
|
|
388
|
+
query=(
|
|
389
|
+
f"sum:{ERROR_METRIC}"
|
|
390
|
+
f"{{service:{target.service}}}"
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
datadog_ev = datadog_result.to_evidence()
|
|
395
|
+
gathered.append(datadog_ev)
|
|
396
|
+
incident.add_evidence(datadog_ev)
|
|
397
|
+
except Exception:
|
|
398
|
+
log.exception(
|
|
399
|
+
"datadog adapter failed for incident %s; "
|
|
400
|
+
"that source is skipped",
|
|
401
|
+
incident.id,
|
|
402
|
+
)
|
|
403
|
+
datadog_result = None
|
|
404
|
+
datadog_ev = None
|
|
405
|
+
if adapters.azure is not None:
|
|
406
|
+
if not _SAFE_NAME_RE.match(target.service):
|
|
407
|
+
log.warning(
|
|
408
|
+
"azure adapter skipped for incident %s: the "
|
|
409
|
+
"service name is not a plain name",
|
|
410
|
+
incident.id,
|
|
411
|
+
)
|
|
412
|
+
elif run_adapter_once("azure kql: error rows"):
|
|
413
|
+
try:
|
|
414
|
+
azure_result = adapters.azure.query(
|
|
415
|
+
KqlQueryRequest(
|
|
416
|
+
query=(
|
|
417
|
+
"AppTraces | where "
|
|
418
|
+
f"Service == '{target.service}' "
|
|
419
|
+
"and Level == 'Error'"
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
azure_ev = azure_result.to_evidence()
|
|
424
|
+
gathered.append(azure_ev)
|
|
425
|
+
incident.add_evidence(azure_ev)
|
|
426
|
+
except Exception:
|
|
427
|
+
log.exception(
|
|
428
|
+
"azure adapter failed for incident %s; "
|
|
429
|
+
"that source is skipped",
|
|
430
|
+
incident.id,
|
|
431
|
+
)
|
|
432
|
+
azure_result = None
|
|
433
|
+
azure_ev = None
|
|
434
|
+
|
|
435
|
+
claims, hypotheses = self._backend.reason(
|
|
436
|
+
BackendContext(
|
|
437
|
+
incident=incident,
|
|
438
|
+
evidence=list(gathered),
|
|
439
|
+
status_result=status_result,
|
|
440
|
+
status_ev=status_ev,
|
|
441
|
+
logs_result=logs_result,
|
|
442
|
+
logs_ev=logs_ev,
|
|
443
|
+
errors_result=errors_result,
|
|
444
|
+
errors_ev=errors_ev,
|
|
445
|
+
pods_result=pods_result,
|
|
446
|
+
pods_ev=pods_ev,
|
|
447
|
+
datadog_result=datadog_result,
|
|
448
|
+
datadog_ev=datadog_ev,
|
|
449
|
+
azure_result=azure_result,
|
|
450
|
+
azure_ev=azure_ev,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
# The same shaping for every backend: causes most
|
|
454
|
+
# likely first, at most three of them.
|
|
455
|
+
hypotheses = sorted(
|
|
456
|
+
hypotheses, key=lambda h: h.confidence, reverse=True
|
|
457
|
+
)[:3]
|
|
458
|
+
|
|
459
|
+
# Final honesty check: nothing may cite evidence that is
|
|
460
|
+
# not really on this incident.
|
|
461
|
+
known_ids = {ev.id for ev in incident.evidence}
|
|
462
|
+
for claim in claims:
|
|
463
|
+
missing = [i for i in claim.evidence_ids if i not in known_ids]
|
|
464
|
+
if missing:
|
|
465
|
+
raise ValueError(f"claim cites evidence not on the incident: {missing}")
|
|
466
|
+
for hyp in hypotheses:
|
|
467
|
+
missing = [i for i in hyp.supporting_evidence if i not in known_ids]
|
|
468
|
+
if missing:
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"hypothesis cites evidence not on the incident: {missing}"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if budget_exhausted:
|
|
474
|
+
summary = (
|
|
475
|
+
"I don't know: the step budget ran out before the evidence "
|
|
476
|
+
"plan finished, so this needs a human."
|
|
477
|
+
)
|
|
478
|
+
incident.transition(IncidentStatus.ESCALATED)
|
|
479
|
+
outcome = DiagnosisOutcome.INSUFFICIENT_EVIDENCE
|
|
480
|
+
# A half-finished plan supports no claims at all, even
|
|
481
|
+
# ones the gathered part suggested on its own.
|
|
482
|
+
claims = []
|
|
483
|
+
hypotheses = []
|
|
484
|
+
elif hypotheses:
|
|
485
|
+
summary = f"Most likely: {hypotheses[0].statement}"
|
|
486
|
+
incident.diagnosis = summary
|
|
487
|
+
incident.transition(IncidentStatus.DIAGNOSED)
|
|
488
|
+
outcome = DiagnosisOutcome.DIAGNOSED
|
|
489
|
+
else:
|
|
490
|
+
summary = (
|
|
491
|
+
"I don't know: the evidence gathered shows no clear problem, "
|
|
492
|
+
"so this needs a human rather than a guess."
|
|
493
|
+
)
|
|
494
|
+
incident.transition(IncidentStatus.ESCALATED)
|
|
495
|
+
outcome = DiagnosisOutcome.INSUFFICIENT_EVIDENCE
|
|
496
|
+
|
|
497
|
+
incident.hypotheses.extend(hypotheses)
|
|
498
|
+
|
|
499
|
+
# Memory advice comes last, on purpose: the outcome,
|
|
500
|
+
# claims and causes above are already decided, so the
|
|
501
|
+
# advice cannot have influenced them, and a memory that
|
|
502
|
+
# fails is skipped rather than discarding the diagnosis.
|
|
503
|
+
memory_report: MemoryReport | None = None
|
|
504
|
+
if self._memory is not None:
|
|
505
|
+
try:
|
|
506
|
+
memory_report = self._memory.report(incident)
|
|
507
|
+
incident.memory_report = memory_report
|
|
508
|
+
except Exception:
|
|
509
|
+
log.exception(
|
|
510
|
+
"memory failed for incident %s; its advice is "
|
|
511
|
+
"skipped and the diagnosis stands",
|
|
512
|
+
incident.id,
|
|
513
|
+
)
|
|
514
|
+
memory_report = None
|
|
515
|
+
|
|
516
|
+
log.info(
|
|
517
|
+
"diagnosed incident %s -> %s in %d step(s)",
|
|
518
|
+
incident.id,
|
|
519
|
+
outcome.value,
|
|
520
|
+
steps,
|
|
521
|
+
)
|
|
522
|
+
return DiagnosisReport(
|
|
523
|
+
incident_id=incident.id,
|
|
524
|
+
outcome=outcome,
|
|
525
|
+
summary=summary,
|
|
526
|
+
claims=claims,
|
|
527
|
+
hypotheses=hypotheses,
|
|
528
|
+
gathered_evidence=gathered,
|
|
529
|
+
steps_used=steps,
|
|
530
|
+
queries_run=queries_run,
|
|
531
|
+
backend_name=self._backend.name,
|
|
532
|
+
memory_report=memory_report,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
def demo_diagnosis() -> DiagnosisReport:
|
|
536
|
+
"""Run the loop once on the seeded demo data, with no setup.
|
|
537
|
+
|
|
538
|
+
The demo incident is the checkout service from
|
|
539
|
+
``demo_tool_layer``: degraded, timing out, with errors in its
|
|
540
|
+
metrics. The report claims nothing about any real system.
|
|
541
|
+
"""
|
|
542
|
+
incident = Incident(
|
|
543
|
+
title="HighErrorRate on checkout",
|
|
544
|
+
target=Target(environment="demo", namespace="shop", service="checkout"),
|
|
545
|
+
)
|
|
546
|
+
incident.transition(IncidentStatus.TRIAGING)
|
|
547
|
+
return ReasoningLoop(demo_tool_layer()).diagnose(incident)
|