agentops-accelerator 0.3.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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Production telemetry queries for the local AgentOps cockpit.
|
|
2
|
+
|
|
3
|
+
Pulls live signals (invocation count, error rate, p95 latency, token
|
|
4
|
+
spend) from the Application Insights resource that the Foundry project
|
|
5
|
+
endpoint resolves to, and reshapes them as cockpit cards.
|
|
6
|
+
|
|
7
|
+
All work is best-effort:
|
|
8
|
+
|
|
9
|
+
* If the App Insights connection string is not discoverable, the
|
|
10
|
+
cockpit skips this section silently.
|
|
11
|
+
* If the API call fails (auth, network, resource not found, etc.), the
|
|
12
|
+
module returns an empty payload - the rest of the cockpit keeps
|
|
13
|
+
rendering.
|
|
14
|
+
|
|
15
|
+
The KQL hits ``https://api.applicationinsights.io/v1/apps/<appId>/query``
|
|
16
|
+
directly with a ``DefaultAzureCredential`` bearer token, which means no
|
|
17
|
+
extra Azure SDK dependency beyond what the ``[agent]`` extra already
|
|
18
|
+
installs (``azure-identity``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# In-process cache so /api/production and the HTML render don't hammer the
|
|
31
|
+
# App Insights API on every cockpit refresh (default refresh: 15s).
|
|
32
|
+
_CACHE_TTL_SECONDS = 60.0
|
|
33
|
+
_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _humanize_token_error(exc: Exception) -> str:
|
|
37
|
+
"""Convert a verbose ``DefaultAzureCredential`` failure into a short,
|
|
38
|
+
actionable message suitable for the cockpit's error tile.
|
|
39
|
+
|
|
40
|
+
The Azure SDK concatenates the failure reason of every credential
|
|
41
|
+
in the chain into a single multi-line string ("EnvironmentCredential:
|
|
42
|
+
... WorkloadIdentityCredential: ... AzureCliCredential: ..."). The
|
|
43
|
+
full text is technically accurate but useless for a user staring at
|
|
44
|
+
a cockpit. Detect the common failure shapes and surface a
|
|
45
|
+
one-sentence remediation instead.
|
|
46
|
+
"""
|
|
47
|
+
text = str(exc)
|
|
48
|
+
lower = text.lower()
|
|
49
|
+
# The Azure CLI sign-in is the path users on dev machines actually
|
|
50
|
+
# take, so prioritize that hint when its sub-credential failed.
|
|
51
|
+
cli_failed = (
|
|
52
|
+
"azureclicredential: failed to invoke the azure cli" in lower
|
|
53
|
+
or "no accounts were found in the cache" in lower
|
|
54
|
+
)
|
|
55
|
+
if cli_failed:
|
|
56
|
+
return (
|
|
57
|
+
"Not signed in to Azure. Run `az login` in the same shell "
|
|
58
|
+
"you launched `agentops cockpit` from, then refresh."
|
|
59
|
+
)
|
|
60
|
+
if "defaultazurecredential failed to retrieve a token" in lower:
|
|
61
|
+
return (
|
|
62
|
+
"Azure authentication failed: DefaultAzureCredential could "
|
|
63
|
+
"not acquire a token. On a dev machine the usual fix is "
|
|
64
|
+
"`az login`. See cockpit logs for the full credential "
|
|
65
|
+
"chain."
|
|
66
|
+
)
|
|
67
|
+
# Truncate generic exceptions so the tile stays readable.
|
|
68
|
+
snippet = text.splitlines()[0].strip()
|
|
69
|
+
if len(snippet) > 240:
|
|
70
|
+
snippet = snippet[:237] + "..."
|
|
71
|
+
return f"Token acquisition failed: {snippet}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_application_id(connection_string: Optional[str]) -> Optional[str]:
|
|
75
|
+
"""Pull the ``ApplicationId=<guid>`` segment out of an App Insights
|
|
76
|
+
connection string. Returns ``None`` when absent (older format)."""
|
|
77
|
+
if not connection_string:
|
|
78
|
+
return None
|
|
79
|
+
m = re.search(r"ApplicationId=([0-9a-fA-F-]+)", connection_string)
|
|
80
|
+
return m.group(1) if m else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_production_metrics(
|
|
84
|
+
application_id: Optional[str],
|
|
85
|
+
*,
|
|
86
|
+
lookback_hours: int = 24,
|
|
87
|
+
) -> Dict[str, Any]:
|
|
88
|
+
"""Return a cockpit-ready payload of live telemetry cards.
|
|
89
|
+
|
|
90
|
+
Always returns a dict with ``has_data`` and ``cards`` keys; values
|
|
91
|
+
populate when the App Insights query succeeds. The ``lookback_hours``
|
|
92
|
+
parameter is substituted into the KQL templates so the cockpit
|
|
93
|
+
time-range picker can drive how far back each card looks.
|
|
94
|
+
"""
|
|
95
|
+
empty = {"has_data": False, "cards": [], "diagnostics": {}}
|
|
96
|
+
if not application_id:
|
|
97
|
+
empty["diagnostics"] = {"reason": "no ApplicationId in connection string"}
|
|
98
|
+
return empty
|
|
99
|
+
|
|
100
|
+
cache_key = f"{application_id}:{lookback_hours}"
|
|
101
|
+
cached = _cache.get(cache_key)
|
|
102
|
+
now = time.time()
|
|
103
|
+
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
|
104
|
+
return cached[1]
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
bearer = _acquire_token()
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
log.debug("token acquisition failed: %s", exc)
|
|
110
|
+
empty["diagnostics"] = {"reason": _humanize_token_error(exc)}
|
|
111
|
+
return empty
|
|
112
|
+
|
|
113
|
+
# Pick a sensible bucket size: 1h for short windows, 6h for ~30d.
|
|
114
|
+
bucket = "1h" if lookback_hours <= 48 else "6h"
|
|
115
|
+
|
|
116
|
+
# Fire all four queries in parallel - sequential round-trips to App
|
|
117
|
+
# Insights were the single biggest source of cockpit latency
|
|
118
|
+
# (~4s vs ~1s after this change).
|
|
119
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
120
|
+
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
121
|
+
fut_summary = ex.submit(
|
|
122
|
+
_run_query, application_id, bearer, _KQL_SUMMARY.format(hours=lookback_hours),
|
|
123
|
+
)
|
|
124
|
+
fut_invocations = ex.submit(
|
|
125
|
+
_run_query, application_id, bearer,
|
|
126
|
+
_KQL_HOURLY_INVOCATIONS.format(hours=lookback_hours, bucket=bucket),
|
|
127
|
+
)
|
|
128
|
+
fut_latency = ex.submit(
|
|
129
|
+
_run_query, application_id, bearer,
|
|
130
|
+
_KQL_HOURLY_LATENCY.format(hours=lookback_hours, bucket=bucket),
|
|
131
|
+
)
|
|
132
|
+
fut_tokens = ex.submit(
|
|
133
|
+
_run_query, application_id, bearer,
|
|
134
|
+
_KQL_TOKENS.format(hours=lookback_hours),
|
|
135
|
+
)
|
|
136
|
+
summary = fut_summary.result()
|
|
137
|
+
invocations_buckets = fut_invocations.result() or {}
|
|
138
|
+
latency_buckets = fut_latency.result() or {}
|
|
139
|
+
tokens = fut_tokens.result() or {}
|
|
140
|
+
|
|
141
|
+
if summary is None:
|
|
142
|
+
empty["diagnostics"] = {
|
|
143
|
+
"reason": "Application Insights query failed (auth, network, "
|
|
144
|
+
"or KQL error). See `agentops cockpit` console logs for the "
|
|
145
|
+
"exact message."
|
|
146
|
+
}
|
|
147
|
+
return empty
|
|
148
|
+
|
|
149
|
+
cards = _build_cards(summary, invocations_buckets, latency_buckets, tokens, lookback_hours)
|
|
150
|
+
payload = {"has_data": bool(cards), "cards": cards, "diagnostics": {}}
|
|
151
|
+
# Only cache populated payloads. Caching empty results masks
|
|
152
|
+
# transient failures (token expiry, App Insights 5xx, etc.) for up
|
|
153
|
+
# to a minute â exactly the case the user notices as "the
|
|
154
|
+
# cockpit suddenly stopped showing telemetry". A subsequent
|
|
155
|
+
# refresh will retry the query immediately.
|
|
156
|
+
if cards:
|
|
157
|
+
_cache[cache_key] = (now, payload)
|
|
158
|
+
else:
|
|
159
|
+
payload["diagnostics"] = {
|
|
160
|
+
"reason": "App Insights returned 0 invocations for the "
|
|
161
|
+
"selected window. If you expect data here, widen the time "
|
|
162
|
+
"range or verify that traces are being emitted."
|
|
163
|
+
}
|
|
164
|
+
return payload
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# KQL queries
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_KQL_SUMMARY = """
|
|
173
|
+
union dependencies, requests
|
|
174
|
+
| where timestamp > ago({hours}h)
|
|
175
|
+
| where name has "invoke_agent" or name has "chat " or name has "RUN "
|
|
176
|
+
| summarize
|
|
177
|
+
invocations = count(),
|
|
178
|
+
errors = countif(success == false),
|
|
179
|
+
avg_ms = avg(duration),
|
|
180
|
+
p95_ms = percentile(duration, 95)
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
_KQL_HOURLY_INVOCATIONS = """
|
|
184
|
+
union dependencies, requests
|
|
185
|
+
| where timestamp > ago({hours}h)
|
|
186
|
+
| where name has "invoke_agent" or name has "chat "
|
|
187
|
+
| summarize count = count() by bin(timestamp, {bucket})
|
|
188
|
+
| order by timestamp asc
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
_KQL_HOURLY_LATENCY = """
|
|
192
|
+
dependencies
|
|
193
|
+
| where timestamp > ago({hours}h)
|
|
194
|
+
| where name has "invoke_agent" or name has "chat "
|
|
195
|
+
| summarize p95_ms = percentile(duration, 95) by bin(timestamp, {bucket})
|
|
196
|
+
| order by timestamp asc
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
_KQL_TOKENS = """
|
|
200
|
+
dependencies
|
|
201
|
+
| where timestamp > ago({hours}h)
|
|
202
|
+
| extend input_t = toint(coalesce(
|
|
203
|
+
customDimensions["gen_ai.usage.input_tokens"],
|
|
204
|
+
customDimensions["llm.usage.input_tokens"]
|
|
205
|
+
))
|
|
206
|
+
| extend output_t = toint(coalesce(
|
|
207
|
+
customDimensions["gen_ai.usage.output_tokens"],
|
|
208
|
+
customDimensions["llm.usage.output_tokens"]
|
|
209
|
+
))
|
|
210
|
+
| extend model_name = tostring(coalesce(
|
|
211
|
+
customDimensions["gen_ai.request.model"],
|
|
212
|
+
customDimensions["gen_ai.response.model"],
|
|
213
|
+
customDimensions["llm.request.model"],
|
|
214
|
+
customDimensions["llm.response.model"],
|
|
215
|
+
"unknown"
|
|
216
|
+
))
|
|
217
|
+
| where isnotnull(input_t) or isnotnull(output_t)
|
|
218
|
+
| summarize
|
|
219
|
+
input_tokens = sum(input_t),
|
|
220
|
+
output_tokens = sum(output_t)
|
|
221
|
+
by model_name
|
|
222
|
+
| order by (input_tokens + output_tokens) desc
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# HTTP helpers
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _acquire_token() -> str:
|
|
232
|
+
"""Acquire an OAuth bearer token for the App Insights API.
|
|
233
|
+
|
|
234
|
+
Cached in-process for 5 minutes to avoid re-running the expensive
|
|
235
|
+
DefaultAzureCredential chain (IMDS timeouts on non-Azure boxes are
|
|
236
|
+
the single biggest source of cockpit latency).
|
|
237
|
+
"""
|
|
238
|
+
cached = _token_cache.get("bearer")
|
|
239
|
+
now = time.time()
|
|
240
|
+
if cached and now - cached[0] < _TOKEN_CACHE_TTL_SECONDS:
|
|
241
|
+
return cached[1]
|
|
242
|
+
|
|
243
|
+
from azure.identity import DefaultAzureCredential
|
|
244
|
+
credential = DefaultAzureCredential(exclude_developer_cli_credential=True, process_timeout=30)
|
|
245
|
+
token = credential.get_token("https://api.applicationinsights.io/.default")
|
|
246
|
+
_token_cache["bearer"] = (now, token.token)
|
|
247
|
+
return token.token
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_TOKEN_CACHE_TTL_SECONDS = 5 * 60
|
|
251
|
+
_token_cache: Dict[str, Tuple[float, str]] = {}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _run_query(app_id: str, bearer: str, kql: str) -> Optional[Dict[str, Any]]:
|
|
255
|
+
"""POST a KQL query to the App Insights REST endpoint.
|
|
256
|
+
|
|
257
|
+
Returns ``None`` on any failure (HTTP error, network issue, or an
|
|
258
|
+
Application Insights query error returned with HTTP 200). The
|
|
259
|
+
caller treats ``None`` as a recoverable failure and surfaces the
|
|
260
|
+
reason via diagnostics rather than caching a misleading empty
|
|
261
|
+
result.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
# urllib stays in stdlib - avoids dragging requests as a dep.
|
|
265
|
+
import json as _json
|
|
266
|
+
from urllib import error, request
|
|
267
|
+
|
|
268
|
+
body = _json.dumps({"query": kql}).encode("utf-8")
|
|
269
|
+
req = request.Request(
|
|
270
|
+
url=f"https://api.applicationinsights.io/v1/apps/{app_id}/query",
|
|
271
|
+
data=body,
|
|
272
|
+
headers={
|
|
273
|
+
"Authorization": f"Bearer {bearer}",
|
|
274
|
+
"Content-Type": "application/json",
|
|
275
|
+
},
|
|
276
|
+
method="POST",
|
|
277
|
+
)
|
|
278
|
+
with request.urlopen(req, timeout=10) as resp: # noqa: S310
|
|
279
|
+
data = resp.read()
|
|
280
|
+
parsed = _json.loads(data)
|
|
281
|
+
# App Insights surfaces query failures with HTTP 200 and an
|
|
282
|
+
# ``error`` object â surface those as failures so the caller
|
|
283
|
+
# does not mistake them for "no data".
|
|
284
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
285
|
+
err = parsed["error"]
|
|
286
|
+
msg = err.get("message") if isinstance(err, dict) else str(err)
|
|
287
|
+
log.debug("app insights query reported error: %s", msg)
|
|
288
|
+
return None
|
|
289
|
+
return _flatten_first_table(parsed)
|
|
290
|
+
except (error.URLError, ValueError, KeyError) as exc:
|
|
291
|
+
log.debug("app insights query failed: %s", exc)
|
|
292
|
+
return None
|
|
293
|
+
except Exception as exc: # noqa: BLE001
|
|
294
|
+
log.debug("app insights query failed unexpectedly: %s", exc)
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _flatten_first_table(parsed: Dict[str, Any]) -> Dict[str, Any]:
|
|
299
|
+
"""Reduce the Kusto REST response into a list of column->value rows."""
|
|
300
|
+
tables = parsed.get("tables") or []
|
|
301
|
+
if not tables:
|
|
302
|
+
return {"rows": []}
|
|
303
|
+
table = tables[0]
|
|
304
|
+
columns = [c.get("name") for c in (table.get("columns") or [])]
|
|
305
|
+
rows = []
|
|
306
|
+
for raw in table.get("rows") or []:
|
|
307
|
+
rows.append(dict(zip(columns, raw)))
|
|
308
|
+
return {"columns": columns, "rows": rows}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Card builders
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_cards(
|
|
317
|
+
summary: Dict[str, Any],
|
|
318
|
+
invocations_buckets: Dict[str, Any],
|
|
319
|
+
latency_buckets: Dict[str, Any],
|
|
320
|
+
tokens: Dict[str, Any],
|
|
321
|
+
lookback_hours: int = 24,
|
|
322
|
+
) -> List[Dict[str, Any]]:
|
|
323
|
+
cards: List[Dict[str, Any]] = []
|
|
324
|
+
rows = summary.get("rows") or []
|
|
325
|
+
if not rows:
|
|
326
|
+
return cards
|
|
327
|
+
row = rows[0]
|
|
328
|
+
|
|
329
|
+
invocations = int(row.get("invocations") or 0)
|
|
330
|
+
errors = int(row.get("errors") or 0)
|
|
331
|
+
error_rate = (errors / invocations) if invocations else 0.0
|
|
332
|
+
p95_ms = row.get("p95_ms")
|
|
333
|
+
p95_seconds = (float(p95_ms) / 1000.0) if p95_ms is not None else None
|
|
334
|
+
|
|
335
|
+
inv_series, inv_labels = _hourly_series(invocations_buckets, "count")
|
|
336
|
+
lat_series, lat_labels = _hourly_series(latency_buckets, "p95_ms", scale=1 / 1000.0)
|
|
337
|
+
|
|
338
|
+
window_label = _window_label(lookback_hours)
|
|
339
|
+
|
|
340
|
+
cards.append({
|
|
341
|
+
"key": "prod_errors",
|
|
342
|
+
"label": f"Error rate ({window_label})",
|
|
343
|
+
"value": f"{int(error_rate * 100)}%",
|
|
344
|
+
"unit": f"{errors} errors",
|
|
345
|
+
"series": inv_series or [0.0],
|
|
346
|
+
"labels": inv_labels,
|
|
347
|
+
"badge": _error_rate_badge(error_rate),
|
|
348
|
+
"help": (
|
|
349
|
+
"Share of invocations whose dependency telemetry reported "
|
|
350
|
+
"success = false."
|
|
351
|
+
"\n\nBadge tiers:"
|
|
352
|
+
"\nâĸ 0% - healthy"
|
|
353
|
+
"\nâĸ under 5% - watch"
|
|
354
|
+
"\nâĸ 5% or more - unhealthy"
|
|
355
|
+
),
|
|
356
|
+
"source": "Share of invocations that reported a failure status.",
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
cards.append({
|
|
360
|
+
"key": "prod_p95",
|
|
361
|
+
"label": f"P95 latency ({window_label})",
|
|
362
|
+
"value": f"{p95_seconds:.2f}" if p95_seconds is not None else " - ",
|
|
363
|
+
"unit": "s",
|
|
364
|
+
"series": lat_series or ([p95_seconds] if p95_seconds is not None else [0.0]),
|
|
365
|
+
"labels": lat_labels,
|
|
366
|
+
"badge": _latency_badge(p95_seconds),
|
|
367
|
+
"help": (
|
|
368
|
+
"95th percentile end-to-end agent latency over the window. "
|
|
369
|
+
"Includes invoke_agent spans (full agent turn with tool "
|
|
370
|
+
"calls) and chat spans (direct model calls)."
|
|
371
|
+
"\n\nBadge tiers:"
|
|
372
|
+
"\nâĸ under 2s - snappy"
|
|
373
|
+
"\nâĸ 2 to 5s - acceptable"
|
|
374
|
+
"\nâĸ over 5s - sluggish"
|
|
375
|
+
),
|
|
376
|
+
"source": "95th-percentile end-to-end duration of agent and chat spans.",
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
return cards
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _window_label(hours: int) -> str:
|
|
383
|
+
if hours <= 24:
|
|
384
|
+
return "24h"
|
|
385
|
+
if hours == 24 * 7:
|
|
386
|
+
return "7d"
|
|
387
|
+
if hours == 24 * 30:
|
|
388
|
+
return "30d"
|
|
389
|
+
days = hours // 24
|
|
390
|
+
return f"{days}d" if days else f"{hours}h"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _hourly_series(buckets: Dict[str, Any], value_key: str, *, scale: float = 1.0) -> Tuple[List[float], List[str]]:
|
|
394
|
+
rows = (buckets or {}).get("rows") or []
|
|
395
|
+
series: List[float] = []
|
|
396
|
+
labels: List[str] = []
|
|
397
|
+
for r in rows:
|
|
398
|
+
v = r.get(value_key)
|
|
399
|
+
if v is None:
|
|
400
|
+
continue
|
|
401
|
+
series.append(float(v) * scale)
|
|
402
|
+
ts = r.get("timestamp") or ""
|
|
403
|
+
labels.append(str(ts)[:16].replace("T", " "))
|
|
404
|
+
return series, labels
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _error_rate_badge(rate: float) -> Dict[str, str]:
|
|
408
|
+
if rate <= 0.01:
|
|
409
|
+
return {"label": "healthy", "tone": "ok"}
|
|
410
|
+
if rate <= 0.05:
|
|
411
|
+
return {"label": "watch", "tone": "warn"}
|
|
412
|
+
return {"label": "elevated", "tone": "crit"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _latency_badge(seconds: Optional[float]) -> Dict[str, str]:
|
|
416
|
+
if seconds is None:
|
|
417
|
+
return {"label": "no data", "tone": "muted"}
|
|
418
|
+
if seconds <= 5:
|
|
419
|
+
return {"label": "snappy", "tone": "ok"}
|
|
420
|
+
if seconds <= 15:
|
|
421
|
+
return {"label": "ok", "tone": "info"}
|
|
422
|
+
return {"label": "slow", "tone": "warn"}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _format_tokens(n: int) -> str:
|
|
426
|
+
if n >= 1_000_000:
|
|
427
|
+
return f"{n / 1_000_000:.1f}M"
|
|
428
|
+
if n >= 1_000:
|
|
429
|
+
return f"{n / 1_000:.1f}k"
|
|
430
|
+
return str(n)
|
agentops/agent/report.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Markdown renderer for watchdog agent findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
from agentops.agent.analyzer import AnalysisResult
|
|
10
|
+
from agentops.agent.findings import Category, Finding, Severity, severity_emoji
|
|
11
|
+
from agentops.agent.knowledge import find_waf_item
|
|
12
|
+
|
|
13
|
+
_CATEGORY_ORDER: List[Category] = [
|
|
14
|
+
Category.QUALITY,
|
|
15
|
+
Category.PERFORMANCE,
|
|
16
|
+
Category.RELIABILITY,
|
|
17
|
+
Category.OPERATIONAL_EXCELLENCE,
|
|
18
|
+
Category.SECURITY,
|
|
19
|
+
Category.RESPONSIBLE_AI,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_CATEGORY_LABEL: Dict[Category, str] = {
|
|
23
|
+
Category.QUALITY: "Quality",
|
|
24
|
+
Category.PERFORMANCE: "Performance Efficiency",
|
|
25
|
+
Category.RELIABILITY: "Reliability",
|
|
26
|
+
Category.OPERATIONAL_EXCELLENCE: "Operational Excellence",
|
|
27
|
+
Category.SECURITY: "Security",
|
|
28
|
+
Category.RESPONSIBLE_AI: "Responsible AI",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_CATEGORY_FOOTER: Dict[Category, str] = {
|
|
32
|
+
Category.SECURITY: (
|
|
33
|
+
"_Audit reference: Microsoft Well-Architected Framework for AI "
|
|
34
|
+
"workloads - Security pillar - "
|
|
35
|
+
"https://learn.microsoft.com/azure/well-architected/ai/security_"
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_diagnostics_row(name: str, diagnostics: dict) -> str:
|
|
41
|
+
status = diagnostics.get("status", "unknown")
|
|
42
|
+
detail = diagnostics.get("reason") or diagnostics.get("runs_loaded") or ""
|
|
43
|
+
return f"| `{name}` | `{status}` | {detail} |"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_finding_row(finding: Finding) -> str:
|
|
47
|
+
return (
|
|
48
|
+
f"| {severity_emoji(finding.severity)} `{finding.severity.value}` "
|
|
49
|
+
f"| `{finding.id}` | {finding.title} | `{finding.source}` |"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _verdict_banner(result: AnalysisResult) -> str:
|
|
54
|
+
if not result.findings:
|
|
55
|
+
return "## Verdict: â
No issues detected"
|
|
56
|
+
max_sev = result.max_severity
|
|
57
|
+
if max_sev == Severity.CRITICAL:
|
|
58
|
+
return "## Verdict: đ¨ CRITICAL issues found"
|
|
59
|
+
if max_sev == Severity.WARNING:
|
|
60
|
+
return "## Verdict: â ī¸ Warnings found"
|
|
61
|
+
return "## Verdict: âšī¸ Informational findings"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _group_by_category(findings: List[Finding]) -> Dict[Category, List[Finding]]:
|
|
65
|
+
grouped: Dict[Category, List[Finding]] = {}
|
|
66
|
+
for f in findings:
|
|
67
|
+
grouped.setdefault(f.category, []).append(f)
|
|
68
|
+
return grouped
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_finding_detail(lines: List[str], finding: Finding, workspace=None) -> None:
|
|
72
|
+
lines.append(
|
|
73
|
+
f"#### {severity_emoji(finding.severity)} `{finding.id}` - {finding.title}"
|
|
74
|
+
)
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.append(f"- **Severity:** `{finding.severity.value}`")
|
|
77
|
+
lines.append(f"- **Category:** `{finding.category.value}`")
|
|
78
|
+
lines.append(f"- **Source:** `{finding.source}`")
|
|
79
|
+
waf = find_waf_item(finding.id, workspace=workspace)
|
|
80
|
+
if waf is not None:
|
|
81
|
+
lines.append(
|
|
82
|
+
f"- **WAF:** `{waf.pillar}` / `{waf.area}` - [{waf.item_id}]({waf.reference_url})"
|
|
83
|
+
)
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append(finding.summary)
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(f"**Recommendation:** {finding.recommendation}")
|
|
88
|
+
lines.append("")
|
|
89
|
+
if finding.evidence:
|
|
90
|
+
lines.append("**Evidence:**")
|
|
91
|
+
lines.append("")
|
|
92
|
+
lines.append("```json")
|
|
93
|
+
lines.append(json.dumps(finding.evidence, indent=2, default=str))
|
|
94
|
+
lines.append("```")
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_report(result: AnalysisResult) -> str:
|
|
99
|
+
lines: List[str] = []
|
|
100
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
101
|
+
lines.append("# AgentOps Doctor Report")
|
|
102
|
+
lines.append("")
|
|
103
|
+
lines.append(f"_Generated: {now}_")
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append(_verdict_banner(result))
|
|
106
|
+
lines.append("")
|
|
107
|
+
|
|
108
|
+
# Summary counts
|
|
109
|
+
sev_counts = {Severity.CRITICAL: 0, Severity.WARNING: 0, Severity.INFO: 0}
|
|
110
|
+
cat_counts: Dict[Category, int] = {c: 0 for c in _CATEGORY_ORDER}
|
|
111
|
+
for f in result.findings:
|
|
112
|
+
sev_counts[f.severity] += 1
|
|
113
|
+
cat_counts[f.category] = cat_counts.get(f.category, 0) + 1
|
|
114
|
+
|
|
115
|
+
lines.append("## Summary")
|
|
116
|
+
lines.append("")
|
|
117
|
+
lines.append("| Severity | Count |")
|
|
118
|
+
lines.append("|---|---|")
|
|
119
|
+
lines.append(f"| đ¨ Critical | {sev_counts[Severity.CRITICAL]} |")
|
|
120
|
+
lines.append(f"| â ī¸ Warning | {sev_counts[Severity.WARNING]} |")
|
|
121
|
+
lines.append(f"| âšī¸ Info | {sev_counts[Severity.INFO]} |")
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append("| Category | Count |")
|
|
124
|
+
lines.append("|---|---|")
|
|
125
|
+
for cat in _CATEGORY_ORDER:
|
|
126
|
+
lines.append(f"| {_CATEGORY_LABEL[cat]} | {cat_counts.get(cat, 0)} |")
|
|
127
|
+
lines.append("")
|
|
128
|
+
|
|
129
|
+
# Sources
|
|
130
|
+
lines.append("## Sources")
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append("| Source | Status | Detail |")
|
|
133
|
+
lines.append("|---|---|---|")
|
|
134
|
+
for name, diag in result.diagnostics.items():
|
|
135
|
+
lines.append(_format_diagnostics_row(name, diag))
|
|
136
|
+
lines.append("")
|
|
137
|
+
|
|
138
|
+
# Findings grouped by category
|
|
139
|
+
if result.findings:
|
|
140
|
+
grouped = _group_by_category(result.findings)
|
|
141
|
+
lines.append("## Findings")
|
|
142
|
+
lines.append("")
|
|
143
|
+
for cat in _CATEGORY_ORDER:
|
|
144
|
+
bucket = grouped.get(cat)
|
|
145
|
+
if not bucket:
|
|
146
|
+
continue
|
|
147
|
+
lines.append(f"### {_CATEGORY_LABEL[cat]}")
|
|
148
|
+
lines.append("")
|
|
149
|
+
lines.append("| Severity | ID | Title | Source |")
|
|
150
|
+
lines.append("|---|---|---|---|")
|
|
151
|
+
for f in bucket:
|
|
152
|
+
lines.append(_format_finding_row(f))
|
|
153
|
+
lines.append("")
|
|
154
|
+
for f in bucket:
|
|
155
|
+
_render_finding_detail(lines, f, workspace=result.workspace)
|
|
156
|
+
footer = _CATEGORY_FOOTER.get(cat)
|
|
157
|
+
if footer:
|
|
158
|
+
lines.append(footer)
|
|
159
|
+
lines.append("")
|
|
160
|
+
else:
|
|
161
|
+
lines.append("## Findings")
|
|
162
|
+
lines.append("")
|
|
163
|
+
lines.append("_No findings - all configured checks passed._")
|
|
164
|
+
lines.append("")
|
|
165
|
+
|
|
166
|
+
# History appendix
|
|
167
|
+
if result.history and result.history.runs:
|
|
168
|
+
lines.append("## Recent runs")
|
|
169
|
+
lines.append("")
|
|
170
|
+
lines.append("| Run ID | Timestamp | Items pass | Run pass |")
|
|
171
|
+
lines.append("|---|---|---|---|")
|
|
172
|
+
for run in result.history.runs[-10:]:
|
|
173
|
+
ts = run.timestamp.strftime("%Y-%m-%d %H:%M") if run.timestamp else "-"
|
|
174
|
+
items = (
|
|
175
|
+
f"{run.items_passed_all}/{run.items_total}"
|
|
176
|
+
if run.items_total
|
|
177
|
+
else "-"
|
|
178
|
+
)
|
|
179
|
+
run_pass = (
|
|
180
|
+
"â
" if run.run_pass else "â" if run.run_pass is False else "-"
|
|
181
|
+
)
|
|
182
|
+
lines.append(f"| `{run.run_id}` | {ts} | {items} | {run_pass} |")
|
|
183
|
+
lines.append("")
|
|
184
|
+
|
|
185
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def short_chat_summary(result: AnalysisResult) -> str:
|
|
189
|
+
"""Compact one-screen summary used by the Copilot Extension server."""
|
|
190
|
+
if not result.findings:
|
|
191
|
+
return "â
No issues detected by the AgentOps doctor."
|
|
192
|
+
counts = {Severity.CRITICAL: 0, Severity.WARNING: 0, Severity.INFO: 0}
|
|
193
|
+
for f in result.findings:
|
|
194
|
+
counts[f.severity] += 1
|
|
195
|
+
parts = [
|
|
196
|
+
f"AgentOps doctor found {len(result.findings)} finding(s): "
|
|
197
|
+
f"đ¨ {counts[Severity.CRITICAL]} critical, "
|
|
198
|
+
f"â ī¸ {counts[Severity.WARNING]} warning, "
|
|
199
|
+
f"âšī¸ {counts[Severity.INFO]} info."
|
|
200
|
+
]
|
|
201
|
+
parts.append("")
|
|
202
|
+
parts.append("Top items:")
|
|
203
|
+
for f in result.findings[:5]:
|
|
204
|
+
parts.append(
|
|
205
|
+
f"- {severity_emoji(f.severity)} **{f.id}** - `{f.category.value}` - {f.title}"
|
|
206
|
+
)
|
|
207
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI Copilot Extension server for the watchdog agent."""
|