agentdynamics 0.5.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.
Files changed (75) hide show
  1. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/PKG-INFO +15 -4
  2. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/README.md +12 -3
  3. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/__init__.py +1 -1
  4. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/__main__.py +67 -3
  5. agentdynamics-0.7.0/agentdynamics/alerts.py +251 -0
  6. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/analysis.py +174 -17
  7. agentdynamics-0.7.0/agentdynamics/api/__init__.py +1 -0
  8. agentdynamics-0.7.0/agentdynamics/api/assess.py +160 -0
  9. agentdynamics-0.7.0/agentdynamics/api/base.py +191 -0
  10. agentdynamics-0.7.0/agentdynamics/api/diagnose.py +87 -0
  11. agentdynamics-0.7.0/agentdynamics/api/governance.py +160 -0
  12. agentdynamics-0.7.0/agentdynamics/api/monitor.py +270 -0
  13. agentdynamics-0.7.0/agentdynamics/api/ops.py +142 -0
  14. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/config.py +31 -4
  15. agentdynamics-0.7.0/agentdynamics/engine.py +852 -0
  16. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/flows.py +3 -2
  17. agentdynamics-0.7.0/agentdynamics/pg.py +490 -0
  18. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/privacy.py +1 -0
  19. agentdynamics-0.7.0/agentdynamics/server.py +375 -0
  20. agentdynamics-0.7.0/agentdynamics/slo.py +185 -0
  21. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/store.py +225 -26
  22. agentdynamics-0.7.0/agentdynamics/web/app.js +224 -0
  23. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/web/index.html +5 -0
  24. agentdynamics-0.7.0/agentdynamics/web/pages/assess.js +117 -0
  25. agentdynamics-0.7.0/agentdynamics/web/pages/configure.js +224 -0
  26. agentdynamics-0.7.0/agentdynamics/web/pages/diagnose.js +70 -0
  27. agentdynamics-0.7.0/agentdynamics/web/pages/governance.js +87 -0
  28. agentdynamics-0.7.0/agentdynamics/web/pages/monitor.js +449 -0
  29. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/PKG-INFO +15 -4
  30. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/SOURCES.txt +19 -0
  31. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/requires.txt +3 -0
  32. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/pyproject.toml +3 -2
  33. agentdynamics-0.7.0/tests/test_alerts.py +414 -0
  34. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_core.py +123 -3
  35. agentdynamics-0.7.0/tests/test_incremental.py +357 -0
  36. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_outcomes.py +2 -2
  37. agentdynamics-0.7.0/tests/test_postgres.py +373 -0
  38. agentdynamics-0.7.0/tests/test_rollups.py +179 -0
  39. agentdynamics-0.7.0/tests/test_scoped_keys.py +376 -0
  40. agentdynamics-0.5.0/agentdynamics/engine.py +0 -418
  41. agentdynamics-0.5.0/agentdynamics/server.py +0 -1041
  42. agentdynamics-0.5.0/agentdynamics/slo.py +0 -84
  43. agentdynamics-0.5.0/agentdynamics/web/app.js +0 -1116
  44. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/LICENSE +0 -0
  45. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/autotrace.py +0 -0
  46. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/bootstrap/sitecustomize.py +0 -0
  47. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/__init__.py +0 -0
  48. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/aegis_audit.py +0 -0
  49. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/claude_code.py +0 -0
  50. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/generic.py +0 -0
  51. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/inbox.py +0 -0
  52. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/langfuse.py +0 -0
  53. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/langsmith.py +0 -0
  54. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/otlp.py +0 -0
  55. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/collectors/spans.py +0 -0
  56. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/govern.py +0 -0
  57. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/integrations/__init__.py +0 -0
  58. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/integrations/aegis.py +0 -0
  59. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/phases.py +0 -0
  60. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/pricing.py +0 -0
  61. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/sdk.py +0 -0
  62. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/web/charts.js +0 -0
  63. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics/web/style.css +0 -0
  64. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/dependency_links.txt +0 -0
  65. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/entry_points.txt +0 -0
  66. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/agentdynamics.egg-info/top_level.txt +0 -0
  67. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/setup.cfg +0 -0
  68. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_aegis_integration.py +0 -0
  69. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_autotrace.py +0 -0
  70. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_console_ui.py +0 -0
  71. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_ecosystem.py +0 -0
  72. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_integrations.py +0 -0
  73. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_live_anthropic.py +0 -0
  74. {agentdynamics-0.5.0 → agentdynamics-0.7.0}/tests/test_policy_coverage.py +0 -0
  75. {agentdynamics-0.5.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.5.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
- - **Security:** role-based API keys.
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`, Slack and webhook alerts, and a status page for every source.
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
- - **Security:** role-based API keys.
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`, Slack and webhook alerts, and a status page for every source.
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
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  See agentdynamics.autotrace for @trace / @tool / span.
7
7
  """
8
- __version__ = "0.5.0"
8
+ __version__ = "0.7.0"
9
9
 
10
10
  from .autotrace import flush, init, llm_call, outcome, record_llm, span, tool, trace # noqa: E402,F401
11
11
 
@@ -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
- print(f"Created {k['role']} key '{k['name']}':\n\n {k['key']}\n\nAuth is now ON for the server using {keys_path(data)} (restart it).")
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
- print(f" {k['name']:<20} {k['role']:<7} {k['key'][:8]}... created {time.strftime('%Y-%m-%d', time.localtime(k.get('created', 0)))}")
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
- print(f"indexed in {eng.last_duration}s -> {os.path.join(a.data, 'agentdynamics.db')}", file=sys.stderr)
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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