agentdynamics 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/PKG-INFO +15 -4
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/README.md +12 -3
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/__init__.py +1 -1
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/__main__.py +67 -3
- agentdynamics-0.7.0/agentdynamics/alerts.py +251 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/analysis.py +79 -16
- agentdynamics-0.7.0/agentdynamics/api/__init__.py +1 -0
- agentdynamics-0.7.0/agentdynamics/api/assess.py +160 -0
- agentdynamics-0.7.0/agentdynamics/api/base.py +191 -0
- agentdynamics-0.7.0/agentdynamics/api/diagnose.py +87 -0
- agentdynamics-0.7.0/agentdynamics/api/governance.py +160 -0
- agentdynamics-0.7.0/agentdynamics/api/monitor.py +270 -0
- agentdynamics-0.7.0/agentdynamics/api/ops.py +142 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/config.py +31 -4
- agentdynamics-0.7.0/agentdynamics/engine.py +852 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/flows.py +3 -2
- agentdynamics-0.7.0/agentdynamics/pg.py +490 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/privacy.py +1 -0
- agentdynamics-0.7.0/agentdynamics/server.py +375 -0
- agentdynamics-0.7.0/agentdynamics/slo.py +185 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/store.py +179 -6
- agentdynamics-0.7.0/agentdynamics/web/app.js +224 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/web/index.html +5 -0
- agentdynamics-0.7.0/agentdynamics/web/pages/assess.js +117 -0
- agentdynamics-0.7.0/agentdynamics/web/pages/configure.js +224 -0
- agentdynamics-0.7.0/agentdynamics/web/pages/diagnose.js +70 -0
- agentdynamics-0.7.0/agentdynamics/web/pages/governance.js +87 -0
- agentdynamics-0.7.0/agentdynamics/web/pages/monitor.js +449 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/PKG-INFO +15 -4
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/SOURCES.txt +18 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/requires.txt +3 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/pyproject.toml +3 -2
- agentdynamics-0.7.0/tests/test_alerts.py +414 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_core.py +123 -3
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_incremental.py +132 -15
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_outcomes.py +2 -2
- agentdynamics-0.7.0/tests/test_postgres.py +373 -0
- agentdynamics-0.7.0/tests/test_rollups.py +179 -0
- agentdynamics-0.7.0/tests/test_scoped_keys.py +376 -0
- agentdynamics-0.6.0/agentdynamics/engine.py +0 -437
- agentdynamics-0.6.0/agentdynamics/server.py +0 -1041
- agentdynamics-0.6.0/agentdynamics/slo.py +0 -84
- agentdynamics-0.6.0/agentdynamics/web/app.js +0 -1116
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/LICENSE +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/autotrace.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/__init__.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/aegis_audit.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/claude_code.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/generic.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/inbox.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/langfuse.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/langsmith.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/otlp.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/collectors/spans.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/govern.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/integrations/__init__.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/integrations/aegis.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/phases.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/pricing.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/sdk.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/web/charts.js +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics/web/style.css +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/entry_points.txt +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/top_level.txt +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/setup.cfg +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_aegis_integration.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_autotrace.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_console_ui.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_ecosystem.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_integrations.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_live_anthropic.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_policy_coverage.py +0 -0
- {agentdynamics-0.6.0 → agentdynamics-0.7.0}/tests/test_token_accounting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdynamics
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Application performance monitoring for AI agents: flows, cost, reliability and process quality
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/Aditya31398/agentdynamics
|
|
@@ -14,6 +14,8 @@ Description-Content-Type: text/markdown
|
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Provides-Extra: toml
|
|
16
16
|
Requires-Dist: tomli; python_version < "3.11" and extra == "toml"
|
|
17
|
+
Provides-Extra: postgres
|
|
18
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
17
19
|
Provides-Extra: test
|
|
18
20
|
Requires-Dist: langchain-core==0.2.33; extra == "test"
|
|
19
21
|
Requires-Dist: langsmith==0.1.99; extra == "test"
|
|
@@ -146,7 +148,7 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
|
146
148
|
| **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
|
|
147
149
|
| **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
|
|
148
150
|
| **Baselines & Apdex** | "Normal" cost and latency per workflow, learned automatically, plus an agent Apdex score |
|
|
149
|
-
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
|
|
151
|
+
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack, PagerDuty or webhooks, routed by project, rule and severity |
|
|
150
152
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
151
153
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
152
154
|
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
|
|
@@ -203,12 +205,21 @@ Telemetry is sent from a background thread with a bounded queue. Your agent neve
|
|
|
203
205
|
agentdynamics keys create --role ingest # for apps / collectors
|
|
204
206
|
agentdynamics keys create --role read # for people and Grafana
|
|
205
207
|
agentdynamics keys create --role admin # for rules, SLOs and config
|
|
208
|
+
agentdynamics keys create --role read --project checkout # one team: sees and sends only its projects
|
|
206
209
|
docker compose -f deploy/docker-compose.yml up -d # AgentDynamics + OpenTelemetry Collector gateway
|
|
207
210
|
```
|
|
208
211
|
|
|
209
|
-
|
|
212
|
+
The store is a SQLite file by default. For several instances behind a load balancer, or a database you back up
|
|
213
|
+
and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
|
|
214
|
+
`AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
|
|
215
|
+
write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
|
|
216
|
+
|
|
217
|
+
- **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
|
|
218
|
+
runs, steps and events, on every endpoint, and can't write into or over another project's traces.
|
|
210
219
|
- **Privacy:** redaction of emails, API keys and card numbers, or `store_content = false` to keep metadata only.
|
|
211
|
-
- **Operations:** retention, Prometheus `/metrics`, `/healthz`,
|
|
220
|
+
- **Operations:** retention, Prometheus `/metrics`, `/healthz`, a status page for every source, and alerts to Slack,
|
|
221
|
+
PagerDuty or any webhook: health-rule events and SLO burn-rate pages, delivered in order with retries
|
|
222
|
+
(`agentdynamics alerts test` checks a destination end to end).
|
|
212
223
|
|
|
213
224
|
See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
214
225
|
|
|
@@ -123,7 +123,7 @@ AGENTDYNAMICS_URL=http://127.0.0.1:8790 python examples/governed_agent.py 40
|
|
|
123
123
|
| **Workflows** | The real execution graph mined from your traces: path variants and their success rates, loops, the critical node, handoffs |
|
|
124
124
|
| **Task snapshots** | Span-tree waterfall for each request: context growth, cost against the baseline, what went wrong, what the user said next |
|
|
125
125
|
| **Baselines & Apdex** | "Normal" cost and latency per workflow, learned automatically, plus an agent Apdex score |
|
|
126
|
-
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack or webhooks |
|
|
126
|
+
| **Health rules & alerts** | 28 rules (runaway cost, policy denials, boundary probing, revocations, retry loops, node loops, truncation, rate limits, ping-pong handoffs, unverified code changes, …) sent to Slack, PagerDuty or webhooks, routed by project, rule and severity |
|
|
127
127
|
| **Tools & Models** | Error rate and p95 per tool; per model TTFT, tokens/s, truncation rate, cache hit rate and spend |
|
|
128
128
|
| **SLOs** | Success rate, Apdex, latency and cost objectives with error budgets and burn rates |
|
|
129
129
|
| **Governance** | Aegis policy enforcement per task: denials by rule, budget stops, kill-switch revocations, unused grants, policy export, and what a policy change would refuse |
|
|
@@ -180,12 +180,21 @@ Telemetry is sent from a background thread with a bounded queue. Your agent neve
|
|
|
180
180
|
agentdynamics keys create --role ingest # for apps / collectors
|
|
181
181
|
agentdynamics keys create --role read # for people and Grafana
|
|
182
182
|
agentdynamics keys create --role admin # for rules, SLOs and config
|
|
183
|
+
agentdynamics keys create --role read --project checkout # one team: sees and sends only its projects
|
|
183
184
|
docker compose -f deploy/docker-compose.yml up -d # AgentDynamics + OpenTelemetry Collector gateway
|
|
184
185
|
```
|
|
185
186
|
|
|
186
|
-
|
|
187
|
+
The store is a SQLite file by default. For several instances behind a load balancer, or a database you back up
|
|
188
|
+
and operate like any other, use Postgres: `pip install "agentdynamics[postgres]"` and set
|
|
189
|
+
`AGENTDYNAMICS_DB_URL=postgresql://...` (or `deploy/docker-compose.postgres.yml`). One instance is elected to
|
|
190
|
+
write the analysis; the others serve the console and take ingest, and one takes over if the writer goes away.
|
|
191
|
+
|
|
192
|
+
- **Security:** role-based API keys, optionally scoped to projects. A scoped key reads only its projects' tasks,
|
|
193
|
+
runs, steps and events, on every endpoint, and can't write into or over another project's traces.
|
|
187
194
|
- **Privacy:** redaction of emails, API keys and card numbers, or `store_content = false` to keep metadata only.
|
|
188
|
-
- **Operations:** retention, Prometheus `/metrics`, `/healthz`,
|
|
195
|
+
- **Operations:** retention, Prometheus `/metrics`, `/healthz`, a status page for every source, and alerts to Slack,
|
|
196
|
+
PagerDuty or any webhook: health-rule events and SLO burn-rate pages, delivered in order with retries
|
|
197
|
+
(`agentdynamics alerts test` checks a destination end to end).
|
|
189
198
|
|
|
190
199
|
See [deploy/](deploy/) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
191
200
|
|
|
@@ -142,22 +142,76 @@ def cmd_doctor(a):
|
|
|
142
142
|
return 0 if ok else 1
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def cmd_alerts(a, data):
|
|
146
|
+
from . import alerts as alertmod, config as cfgmod, store
|
|
147
|
+
cfg = cfgmod.load(data)
|
|
148
|
+
dests, problems = alertmod.destinations(cfg["alerts"])
|
|
149
|
+
for p in problems:
|
|
150
|
+
print(f"ignored: {p}")
|
|
151
|
+
if not dests:
|
|
152
|
+
print("No alert destinations configured: add [[alerts.webhooks]] to agentdynamics.toml "
|
|
153
|
+
"(see `python -c \"import agentdynamics.alerts; help(agentdynamics.alerts)\"`).")
|
|
154
|
+
return 1
|
|
155
|
+
if a.action == "status":
|
|
156
|
+
con = store.connect(*store.target(cfg, data))
|
|
157
|
+
depth = store.outbox_depth(con)
|
|
158
|
+
oldest = {r["dest"]: dict(r) for r in con.execute(
|
|
159
|
+
"SELECT dest, created, attempts, last_error FROM alert_outbox WHERE id IN "
|
|
160
|
+
"(SELECT MIN(id) FROM alert_outbox GROUP BY dest)")}
|
|
161
|
+
for d in dests:
|
|
162
|
+
print(f"{d['id']:<28} {d['format']:<10} {'+'.join(d['kinds']):<12} min {d['min_severity']:<8} "
|
|
163
|
+
f"queued {depth.get(d['id'], 0)}")
|
|
164
|
+
o = oldest.get(d["id"])
|
|
165
|
+
if o and o["attempts"]:
|
|
166
|
+
print(f" retrying since {time.strftime('%Y-%m-%d %H:%M', time.localtime(o['created']))} "
|
|
167
|
+
f"({o['attempts']} attempts): {o['last_error']}")
|
|
168
|
+
firing = store.alert_state(con)
|
|
169
|
+
print(f"\nSLO alerts firing: {len(firing) or 'none'}")
|
|
170
|
+
for k, v in sorted(firing.items(), key=lambda kv: kv[1]["since"]):
|
|
171
|
+
print(f" {k} since {time.strftime('%Y-%m-%d %H:%M', time.localtime(v['since']))} {v.get('severity')}")
|
|
172
|
+
return 0
|
|
173
|
+
failed = 0
|
|
174
|
+
chosen = [d for d in dests if not a.to or d["id"] == a.to]
|
|
175
|
+
if not chosen:
|
|
176
|
+
print(f"no destination named {a.to!r}; names: {', '.join(d['id'] for d in dests)}")
|
|
177
|
+
return 2
|
|
178
|
+
for d in chosen:
|
|
179
|
+
alert = alertmod.test_alert()
|
|
180
|
+
bodies = alertmod.render(d, [alert], cfg["alerts"].get("console_url") or "")
|
|
181
|
+
if d["format"] == "pagerduty": # resolve it straight away, so a test leaves no open incident
|
|
182
|
+
bodies += alertmod.render(d, [dict(alert, action="resolve")])
|
|
183
|
+
results = [alertmod.send(d, b) for b in bodies]
|
|
184
|
+
ok = all(r[0] for r in results)
|
|
185
|
+
failed += not ok
|
|
186
|
+
print(f"{d['id']:<28} {'ok' if ok else 'FAILED'} {'; '.join(r[2] for r in results)}")
|
|
187
|
+
return 1 if failed else 0
|
|
188
|
+
|
|
189
|
+
|
|
145
190
|
def cmd_keys(a, data):
|
|
146
191
|
from .config import keys_path, load_keys, save_keys
|
|
147
192
|
keys = load_keys(data)
|
|
148
193
|
if a.action == "create":
|
|
194
|
+
if a.project and a.role == "admin":
|
|
195
|
+
print("admin keys cover the whole install and cannot be scoped to projects; "
|
|
196
|
+
"use --role read or --role ingest", file=sys.stderr)
|
|
197
|
+
return 2
|
|
149
198
|
k = {"name": a.name or f"{a.role}-{len(keys) + 1}", "role": a.role, "key": f"ad_{a.role[0]}_{secrets.token_urlsafe(24)}",
|
|
150
199
|
"created": int(time.time())}
|
|
200
|
+
if a.project:
|
|
201
|
+
k["projects"] = sorted(set(a.project))
|
|
151
202
|
keys.append(k)
|
|
152
203
|
save_keys(data, keys)
|
|
153
|
-
|
|
204
|
+
scope = f" for project(s) {', '.join(k['projects'])}" if a.project else ""
|
|
205
|
+
print(f"Created {k['role']} key '{k['name']}'{scope}:\n\n {k['key']}\n\nAuth is now ON for the server using {keys_path(data)} (restart it).")
|
|
154
206
|
if a.role == "ingest":
|
|
155
207
|
print(f"Use it in your app: export AGENTDYNAMICS_API_KEY={k['key']}")
|
|
156
208
|
elif a.action == "list":
|
|
157
209
|
if not keys:
|
|
158
210
|
print("No keys: auth is off (local mode).")
|
|
159
211
|
for k in keys:
|
|
160
|
-
|
|
212
|
+
scope = ", ".join(k["projects"]) or "no projects" if isinstance(k.get("projects"), list) else "all projects"
|
|
213
|
+
print(f" {k['name']:<20} {k['role']:<7} {k['key'][:8]}... created "
|
|
214
|
+
f"{time.strftime('%Y-%m-%d', time.localtime(k.get('created', 0)))} {scope}")
|
|
161
215
|
elif a.action == "revoke":
|
|
162
216
|
left = [k for k in keys if k["name"] != a.name and not k["key"].startswith(a.name or "\0")]
|
|
163
217
|
save_keys(data, left)
|
|
@@ -297,6 +351,8 @@ def main(argv=None):
|
|
|
297
351
|
k.add_argument("action", choices=["create", "list", "revoke"])
|
|
298
352
|
k.add_argument("--role", choices=["ingest", "read", "admin"], default="ingest")
|
|
299
353
|
k.add_argument("--name", default=None)
|
|
354
|
+
k.add_argument("--project", action="append", default=None, metavar="NAME",
|
|
355
|
+
help="scope a read or ingest key to this project (repeat for several); omit for all projects")
|
|
300
356
|
po = sub.add_parser("policy", help="Aegis policy from observed behaviour (observe -> govern)")
|
|
301
357
|
po.add_argument("action", choices=["export", "report", "check"],
|
|
302
358
|
help="export: generate a tightened policy; report: governance summary; "
|
|
@@ -313,6 +369,10 @@ def main(argv=None):
|
|
|
313
369
|
po.add_argument("--max-denied-fraction", type=float, default=0.0,
|
|
314
370
|
help="fail if the policy would refuse more than this share of observed, allowed calls "
|
|
315
371
|
"(export, check; default 0: any refusal fails)")
|
|
372
|
+
al = sub.add_parser("alerts", help="alert destinations: what is queued and firing, or send a test alert")
|
|
373
|
+
al.add_argument("action", choices=["status", "test"],
|
|
374
|
+
help="status: destinations, queue and firing SLO alerts; test: send a test alert to each")
|
|
375
|
+
al.add_argument("--to", metavar="NAME", help="test only this destination (its name, as status shows it)")
|
|
316
376
|
sub.add_parser("ingest", help="scan sources once and rebuild the database")
|
|
317
377
|
rp = sub.add_parser("report", help="print a text summary")
|
|
318
378
|
rp.add_argument("--project")
|
|
@@ -335,6 +395,8 @@ def main(argv=None):
|
|
|
335
395
|
os.makedirs(a.data, exist_ok=True)
|
|
336
396
|
if cmd == "keys":
|
|
337
397
|
return cmd_keys(a, a.data)
|
|
398
|
+
if cmd == "alerts":
|
|
399
|
+
return cmd_alerts(a, a.data)
|
|
338
400
|
|
|
339
401
|
from .engine import Engine
|
|
340
402
|
eng = Engine(a.data, a.claude_root or None)
|
|
@@ -347,7 +409,9 @@ def main(argv=None):
|
|
|
347
409
|
eng.refresh()
|
|
348
410
|
return 0
|
|
349
411
|
eng.refresh(force=True)
|
|
350
|
-
|
|
412
|
+
from .pg import redact_url
|
|
413
|
+
where = f"{redact_url(eng.db_path)} (schema {eng.db_schema})" if eng.db_schema else eng.db_path
|
|
414
|
+
print(f"indexed in {eng.last_duration}s -> {where}", file=sys.stderr)
|
|
351
415
|
if cmd == "ingest":
|
|
352
416
|
return 0
|
|
353
417
|
if cmd == "policy":
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Alert routing: health-rule events and SLO burn rates, to Slack, PagerDuty and JSON webhooks.
|
|
2
|
+
|
|
3
|
+
Destinations are `[[alerts.webhooks]]` in agentdynamics.toml:
|
|
4
|
+
|
|
5
|
+
[alerts]
|
|
6
|
+
console_url = "https://agentdynamics.internal" # optional: alerts link to the task / SLO page
|
|
7
|
+
|
|
8
|
+
[[alerts.webhooks]]
|
|
9
|
+
name = "sre-pager" # optional; names it in `agentdynamics alerts status/test`
|
|
10
|
+
format = "pagerduty" # json (default) | slack | pagerduty
|
|
11
|
+
routing_key_env = "PD_ROUTING_KEY" # PagerDuty Events API v2 integration key (or routing_key = "...")
|
|
12
|
+
min_severity = "critical" # info | warning (default) | critical
|
|
13
|
+
kinds = ["events", "slos"] # default ["events"]; "slos" adds SLO burn-rate alerts
|
|
14
|
+
projects = ["checkout"] # optional: only these projects
|
|
15
|
+
rules = ["run_failed"] # optional: only these health rules
|
|
16
|
+
|
|
17
|
+
What is sent:
|
|
18
|
+
- A health-rule event is a point in time. Slack and JSON get each one (JSON in the original
|
|
19
|
+
`{"source": "agentdynamics", "events": [...]}` shape, a contract). PagerDuty gets one alert per rule,
|
|
20
|
+
project and task type (the dedup key), so a burst of runaway-cost tasks is one incident, not fifty.
|
|
21
|
+
- An SLO alert has a start and an end: a trigger when a burn-rate policy starts firing and a resolve when
|
|
22
|
+
it stops (slo.alert_conditions). What is firing is kept in the durable `alert_state` table, so a restart
|
|
23
|
+
neither repeats a trigger nor forgets to resolve.
|
|
24
|
+
|
|
25
|
+
Delivery is at least once, in order per destination, through the durable `alert_outbox` table: a failed
|
|
26
|
+
send is retried with backoff (429, 5xx, network errors) for up to a day; any other 4xx is a configuration
|
|
27
|
+
error and is dropped and reported. No secret is stored: bodies are queued without the PagerDuty routing key,
|
|
28
|
+
and destinations are identified by name or a hash, never by URL.
|
|
29
|
+
"""
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from collections import OrderedDict
|
|
37
|
+
from urllib.parse import quote
|
|
38
|
+
|
|
39
|
+
FORMATS = ("json", "slack", "pagerduty")
|
|
40
|
+
KINDS = ("events", "slos")
|
|
41
|
+
SEV = {"info": 1, "warning": 2, "critical": 3}
|
|
42
|
+
# https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/events-v2/openapiv3.json
|
|
43
|
+
PAGERDUTY_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
44
|
+
PAGERDUTY_SEVERITY = {"critical": "critical", "warning": "warning", "info": "info"}
|
|
45
|
+
SLACK_LINES = 10 # a Slack message lists this many alerts, then "and N more"
|
|
46
|
+
GIVE_UP_S = 86400 # a message still failing after a day is dropped
|
|
47
|
+
MAX_BACKOFF_S = 3600
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def backoff(attempts):
|
|
51
|
+
return min(MAX_BACKOFF_S, 15 * 2 ** attempts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------- destinations
|
|
55
|
+
|
|
56
|
+
def destinations(alerts_cfg, env=None):
|
|
57
|
+
"""([destination], [problem]) from the [alerts] config. A misconfigured destination is reported, not
|
|
58
|
+
raised: one bad entry must not stop the others, or the server."""
|
|
59
|
+
env = os.environ if env is None else env
|
|
60
|
+
out, problems, seen = [], [], set()
|
|
61
|
+
for i, h in enumerate((alerts_cfg or {}).get("webhooks") or []):
|
|
62
|
+
h = dict(h)
|
|
63
|
+
label = h.get("name") or f"webhook #{i + 1}"
|
|
64
|
+
fmt = h.get("format") or "json"
|
|
65
|
+
if fmt not in FORMATS:
|
|
66
|
+
problems.append(f"{label}: unknown format {fmt!r}; use one of {', '.join(FORMATS)}")
|
|
67
|
+
continue
|
|
68
|
+
kinds = h.get("kinds") or ["events"]
|
|
69
|
+
bad = [k for k in kinds if k not in KINDS]
|
|
70
|
+
if bad:
|
|
71
|
+
problems.append(f"{label}: unknown kinds {bad}; use {list(KINDS)}")
|
|
72
|
+
continue
|
|
73
|
+
if h.get("min_severity", "warning") not in SEV:
|
|
74
|
+
problems.append(f"{label}: min_severity must be one of {', '.join(SEV)}")
|
|
75
|
+
continue
|
|
76
|
+
secret = ""
|
|
77
|
+
if fmt == "pagerduty":
|
|
78
|
+
h.setdefault("url", PAGERDUTY_URL)
|
|
79
|
+
secret = h.get("routing_key") or env.get(h.get("routing_key_env") or "", "")
|
|
80
|
+
if not secret:
|
|
81
|
+
problems.append(f"{label}: a pagerduty destination needs routing_key or routing_key_env"
|
|
82
|
+
+ (f" ({h['routing_key_env']} is not set)" if h.get("routing_key_env") else ""))
|
|
83
|
+
continue
|
|
84
|
+
if not h.get("url"):
|
|
85
|
+
problems.append(f"{label}: no url")
|
|
86
|
+
continue
|
|
87
|
+
did = h.get("name") or f"{fmt}-{hashlib.sha256((h['url'] + secret).encode()).hexdigest()[:10]}"
|
|
88
|
+
if did in seen:
|
|
89
|
+
problems.append(f"{label}: duplicate destination name {did!r}")
|
|
90
|
+
continue
|
|
91
|
+
seen.add(did)
|
|
92
|
+
out.append({"id": did, "format": fmt, "url": h["url"], "secret": secret, "kinds": list(kinds),
|
|
93
|
+
"min_severity": h.get("min_severity", "warning"),
|
|
94
|
+
"projects": h.get("projects"), "rules": h.get("rules")})
|
|
95
|
+
return out, problems
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def routes(dest, alert):
|
|
99
|
+
"""Whether this destination takes this alert."""
|
|
100
|
+
if alert["kind"] + "s" not in dest["kinds"]:
|
|
101
|
+
return False
|
|
102
|
+
if SEV.get(alert["severity"], 1) < SEV[dest["min_severity"]]:
|
|
103
|
+
return False
|
|
104
|
+
if dest["projects"] is not None and alert.get("project") not in dest["projects"]:
|
|
105
|
+
return False # an install-wide SLO has no project, so it goes only to unfiltered destinations
|
|
106
|
+
if dest["rules"] is not None and alert["kind"] == "event" and alert.get("rule_id") not in dest["rules"]:
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- alerts
|
|
112
|
+
|
|
113
|
+
def from_event(e):
|
|
114
|
+
return {"kind": "event", "action": "trigger", "key": f"event/{e['rule_id']}/{e['project']}/{e['task_type']}",
|
|
115
|
+
"severity": e["severity"], "summary": f"{e['rule']}: {e['message']}", "project": e["project"],
|
|
116
|
+
"rule_id": e["rule_id"], "task_type": e["task_type"], "ts": e.get("ts"), "event": e}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def slo_summary(c, action="trigger"):
|
|
120
|
+
scope = " ".join(f"{k}={v}" for k, v in (c.get("scope") or {}).items() if v)
|
|
121
|
+
where = f" [{scope}]" if scope else ""
|
|
122
|
+
if action == "resolve":
|
|
123
|
+
what = "is back within its objective" if c["alert"] == "breach" else \
|
|
124
|
+
f"is no longer burning its error budget fast enough to {c['alert']}"
|
|
125
|
+
return f"Resolved: SLO '{c['name']}'{where} {what}"
|
|
126
|
+
if c["policy"] == "breach":
|
|
127
|
+
return f"SLO '{c['name']}'{where} breached: {c['value']:.4g} against a target of {c['op']} {c['target']}"
|
|
128
|
+
return (f"SLO '{c['name']}'{where} burning its error budget at {c['burn_rate']}x "
|
|
129
|
+
f"(threshold {c['threshold']}x: {c['budget']:.0%} of the budget in {c['long_h']}h)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def from_slo(key, c, action, now):
|
|
133
|
+
return {"kind": "slo", "action": action, "key": key, "severity": c["severity"],
|
|
134
|
+
"summary": slo_summary(c, action),
|
|
135
|
+
"project": (c.get("scope") or {}).get("project") or None, "slo": c["slo"], "ts": now, "condition": c}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- formats
|
|
139
|
+
|
|
140
|
+
def _dedup_key(key):
|
|
141
|
+
key = "agentdynamics/" + key
|
|
142
|
+
return key if len(key) <= 255 else key[:200] + "#" + hashlib.sha256(key.encode()).hexdigest()[:32]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def link(console_url, alert):
|
|
146
|
+
if not console_url:
|
|
147
|
+
return None
|
|
148
|
+
base = console_url.rstrip("/")
|
|
149
|
+
if alert["kind"] == "event":
|
|
150
|
+
return f"{base}/#/task/{quote(alert['event']['task_id'], safe='')}"
|
|
151
|
+
return f"{base}/#/slos"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _slack_escape(s):
|
|
155
|
+
# Slack mrkdwn: these three are control characters in message text
|
|
156
|
+
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _iso(ts):
|
|
160
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts or time.time()))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render(dest, alerts, console_url=""):
|
|
164
|
+
"""The request bodies that deliver `alerts` to `dest`, in order. Secrets are added at send time."""
|
|
165
|
+
fmt = dest["format"]
|
|
166
|
+
if fmt == "json":
|
|
167
|
+
bodies = []
|
|
168
|
+
events = [a["event"] for a in alerts if a["kind"] == "event"]
|
|
169
|
+
if events:
|
|
170
|
+
bodies.append({"source": "agentdynamics", "events": events})
|
|
171
|
+
slos = [{"action": a["action"], "key": a["key"], "severity": a["severity"], "summary": a["summary"],
|
|
172
|
+
"link": link(console_url, a), **a["condition"]} for a in alerts if a["kind"] == "slo"]
|
|
173
|
+
if slos:
|
|
174
|
+
bodies.append({"source": "agentdynamics", "slo_alerts": slos})
|
|
175
|
+
return bodies
|
|
176
|
+
# one line (Slack) or one alert (PagerDuty) per dedup key, so a burst of the same violation reads as one
|
|
177
|
+
groups = OrderedDict()
|
|
178
|
+
for a in alerts:
|
|
179
|
+
groups.setdefault(a["key"], []).append(a)
|
|
180
|
+
if fmt == "slack":
|
|
181
|
+
lines = []
|
|
182
|
+
for g in list(groups.values())[:SLACK_LINES]:
|
|
183
|
+
a = g[-1]
|
|
184
|
+
head = "RESOLVED" if a["action"] == "resolve" else a["severity"].upper()
|
|
185
|
+
where = " / ".join(_slack_escape(x) for x in (a.get("project"), a.get("task_type")) if x)
|
|
186
|
+
url = link(console_url, a)
|
|
187
|
+
ref = f" · <{url}|{'latest' if len(g) > 1 else 'open'}>" if url else ""
|
|
188
|
+
if a["kind"] == "event":
|
|
189
|
+
e = a["event"]
|
|
190
|
+
times = f" ×{len(g)}" if len(g) > 1 else ""
|
|
191
|
+
lines.append(f"*{head}* · {_slack_escape(e['rule'])}{times} · {where}{ref}\n{_slack_escape(e['message'])}")
|
|
192
|
+
else:
|
|
193
|
+
lines.append(f"*{head}* · {_slack_escape(a['summary'])}{ref}")
|
|
194
|
+
if len(groups) > SLACK_LINES:
|
|
195
|
+
lines.append(f"…and {len(groups) - SLACK_LINES} more")
|
|
196
|
+
return [{"text": "AgentDynamics alerts\n" + "\n\n".join(lines)}] if lines else []
|
|
197
|
+
bodies = []
|
|
198
|
+
for key, g in groups.items():
|
|
199
|
+
a = g[-1]
|
|
200
|
+
if a["action"] == "resolve":
|
|
201
|
+
bodies.append({"event_action": "resolve", "dedup_key": _dedup_key(key)})
|
|
202
|
+
continue
|
|
203
|
+
if a["kind"] == "event":
|
|
204
|
+
e = a["event"]
|
|
205
|
+
details = {"rule": e["rule"], "rule_id": e["rule_id"], "message": e["message"], "value": e.get("value"),
|
|
206
|
+
"task_id": e["task_id"], "run_id": e.get("run_id"), "project": e["project"],
|
|
207
|
+
"task_type": e["task_type"], "occurrences": len(g)}
|
|
208
|
+
group, klass = e["task_type"], e["rule_id"]
|
|
209
|
+
else:
|
|
210
|
+
details, group, klass = dict(a["condition"]), a["slo"], "slo_" + a["condition"]["alert"]
|
|
211
|
+
body = {"event_action": "trigger", "dedup_key": _dedup_key(key),
|
|
212
|
+
"payload": {"summary": a["summary"][:1024], "source": "agentdynamics",
|
|
213
|
+
"severity": PAGERDUTY_SEVERITY.get(a["severity"], "warning"),
|
|
214
|
+
"timestamp": _iso(a.get("ts")), "component": a.get("project") or "all projects",
|
|
215
|
+
"group": group, "class": klass, "custom_details": details},
|
|
216
|
+
"client": "AgentDynamics"}
|
|
217
|
+
url = link(console_url, a)
|
|
218
|
+
if url:
|
|
219
|
+
body["client_url"] = console_url
|
|
220
|
+
body["links"] = [{"href": url, "text": "Open in AgentDynamics"}]
|
|
221
|
+
bodies.append(body)
|
|
222
|
+
return bodies
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------- sending
|
|
226
|
+
|
|
227
|
+
def send(dest, body, timeout=10):
|
|
228
|
+
"""POST one body. Returns (ok, retryable, detail)."""
|
|
229
|
+
if dest["format"] == "pagerduty":
|
|
230
|
+
body = dict(body, routing_key=dest["secret"])
|
|
231
|
+
req = urllib.request.Request(dest["url"], data=json.dumps(body, default=str).encode(), method="POST",
|
|
232
|
+
headers={"Content-Type": "application/json", "User-Agent": "agentdynamics"})
|
|
233
|
+
try:
|
|
234
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
235
|
+
return True, False, f"HTTP {r.status}"
|
|
236
|
+
except urllib.error.HTTPError as ex:
|
|
237
|
+
text = ex.read(300).decode("utf-8", "replace").strip()
|
|
238
|
+
return False, ex.code == 429 or ex.code >= 500, f"HTTP {ex.code}: {text}"[:300]
|
|
239
|
+
except (urllib.error.URLError, OSError) as ex:
|
|
240
|
+
return False, True, f"{type(ex).__name__}: {getattr(ex, 'reason', ex)}"[:300]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_alert(now=None):
|
|
244
|
+
"""A harmless alert for `agentdynamics alerts test`: a trigger, and for PagerDuty the resolve after it."""
|
|
245
|
+
now = now or time.time()
|
|
246
|
+
c = {"slo": "agentdynamics-test", "name": "AgentDynamics test alert", "alert": "breach", "policy": "breach",
|
|
247
|
+
"severity": "info",
|
|
248
|
+
"metric": "success_rate", "op": ">=", "target": 1, "value": 0, "window_days": 1, "scope": {}, "tasks": 0}
|
|
249
|
+
a = from_slo("test/agentdynamics", c, "trigger", now)
|
|
250
|
+
a["summary"] = "AgentDynamics test alert: this destination is configured correctly"
|
|
251
|
+
return a
|