agentdynamics 0.4.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.
- agentdynamics/__init__.py +12 -0
- agentdynamics/__main__.py +322 -0
- agentdynamics/analysis.py +755 -0
- agentdynamics/autotrace.py +697 -0
- agentdynamics/bootstrap/sitecustomize.py +27 -0
- agentdynamics/collectors/__init__.py +0 -0
- agentdynamics/collectors/aegis_audit.py +72 -0
- agentdynamics/collectors/claude_code.py +257 -0
- agentdynamics/collectors/generic.py +103 -0
- agentdynamics/collectors/inbox.py +95 -0
- agentdynamics/collectors/langfuse.py +98 -0
- agentdynamics/collectors/langsmith.py +258 -0
- agentdynamics/collectors/otlp.py +349 -0
- agentdynamics/collectors/spans.py +202 -0
- agentdynamics/config.py +139 -0
- agentdynamics/engine.py +376 -0
- agentdynamics/flows.py +142 -0
- agentdynamics/govern.py +231 -0
- agentdynamics/integrations/__init__.py +1 -0
- agentdynamics/integrations/aegis.py +352 -0
- agentdynamics/phases.py +82 -0
- agentdynamics/pricing.py +69 -0
- agentdynamics/privacy.py +41 -0
- agentdynamics/sdk.py +105 -0
- agentdynamics/server.py +949 -0
- agentdynamics/slo.py +84 -0
- agentdynamics/store.py +228 -0
- agentdynamics/web/app.js +1069 -0
- agentdynamics/web/charts.js +185 -0
- agentdynamics/web/index.html +40 -0
- agentdynamics/web/style.css +244 -0
- agentdynamics-0.4.0.dist-info/METADATA +195 -0
- agentdynamics-0.4.0.dist-info/RECORD +37 -0
- agentdynamics-0.4.0.dist-info/WHEEL +5 -0
- agentdynamics-0.4.0.dist-info/entry_points.txt +2 -0
- agentdynamics-0.4.0.dist-info/licenses/LICENSE +202 -0
- agentdynamics-0.4.0.dist-info/top_level.txt +1 -0
agentdynamics/engine.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Ingestion + analysis engine.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
push (OTLP, LangSmith-compatible API, SDK, webhook) ─┐
|
|
5
|
+
pull (LangSmith API, Langfuse API, inbox file tailer) ─┼─> spans_raw (durable, idempotent upserts by span id)
|
|
6
|
+
files (Claude Code transcripts, SDK run files) ───────┘ │
|
|
7
|
+
▼
|
|
8
|
+
trace assembly (late spans simply re-assemble their trace) -> normalized runs
|
|
9
|
+
▼
|
|
10
|
+
per-run analysis (cached; only dirty runs recomputed) -> cross-run finalize
|
|
11
|
+
(baselines, scores, events) -> SQLite -> API / console / alerts / Prometheus
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from . import analysis, config as cfgmod, pricing, store
|
|
21
|
+
from .collectors import aegis_audit, claude_code, generic, inbox, langfuse, langsmith, otlp, spans as spanmod
|
|
22
|
+
from .privacy import Redactor
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SourceStatus:
|
|
26
|
+
def __init__(self, name, kind, detail=""):
|
|
27
|
+
self.d = {"name": name, "type": kind, "detail": detail, "status": "idle", "last_ok": None, "last_error": None,
|
|
28
|
+
"last_error_at": None, "items": 0, "last_batch": 0}
|
|
29
|
+
|
|
30
|
+
def ok(self, n=0):
|
|
31
|
+
self.d.update(status="ok", last_ok=time.time(), last_batch=n)
|
|
32
|
+
self.d["items"] += n
|
|
33
|
+
|
|
34
|
+
def fail(self, err):
|
|
35
|
+
self.d.update(status="error", last_error=str(err)[:400], last_error_at=time.time())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Engine:
|
|
39
|
+
def __init__(self, data_dir, claude_root=claude_code.DEFAULT_ROOT, cfg=None):
|
|
40
|
+
self.data_dir = data_dir
|
|
41
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
42
|
+
self.cfg = cfg or cfgmod.load(data_dir)
|
|
43
|
+
self.claude_root = claude_root
|
|
44
|
+
self.runs_dir = os.path.join(data_dir, "runs")
|
|
45
|
+
os.makedirs(self.runs_dir, exist_ok=True)
|
|
46
|
+
pricing.load_overrides(data_dir)
|
|
47
|
+
self.db_path = os.path.join(data_dir, "agentdynamics.db")
|
|
48
|
+
self.con = store.connect(self.db_path)
|
|
49
|
+
self.lock = threading.RLock()
|
|
50
|
+
self.redactor = Redactor(self.cfg["privacy"])
|
|
51
|
+
self._files = {} # path -> (mtime, size, run)
|
|
52
|
+
self._runs = {} # run id -> run (trace/SDK/transcript)
|
|
53
|
+
self._tasks = {} # run id -> [task]
|
|
54
|
+
self._span_since = 0.0
|
|
55
|
+
self._first = True
|
|
56
|
+
self._wake = threading.Event()
|
|
57
|
+
self.last_refresh = None
|
|
58
|
+
self.last_duration = None
|
|
59
|
+
self.stats = {"spans_ingested": 0, "refreshes": 0, "alerts_sent": 0}
|
|
60
|
+
self.sources = {}
|
|
61
|
+
if claude_root:
|
|
62
|
+
self.sources["claude_code"] = SourceStatus("claude_code", "file", claude_root)
|
|
63
|
+
self.sources["sdk"] = SourceStatus("sdk", "push", "/api/ingest")
|
|
64
|
+
self.sources["otlp"] = SourceStatus("otlp", "push", "/v1/traces (OTLP/HTTP json+protobuf)")
|
|
65
|
+
self.sources["langsmith"] = SourceStatus("langsmith", "push", "/langsmith (LangSmith-compatible API)")
|
|
66
|
+
self.sources["aegis"] = SourceStatus("aegis", "push", "Aegis audit records (/api/ingest/records, inbox)")
|
|
67
|
+
self.pullers = []
|
|
68
|
+
for i, sc in enumerate(self.cfg.get("sources") or []):
|
|
69
|
+
self._add_source(i, sc)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------ config-driven sources
|
|
72
|
+
def _add_source(self, i, sc):
|
|
73
|
+
t = sc.get("type")
|
|
74
|
+
name = sc.get("name") or f"{t}-{i}"
|
|
75
|
+
if t == "claude_code":
|
|
76
|
+
if sc.get("path"):
|
|
77
|
+
self.claude_root = sc["path"]
|
|
78
|
+
self.sources["claude_code"] = SourceStatus("claude_code", "file", sc["path"])
|
|
79
|
+
return
|
|
80
|
+
st = SourceStatus(name, t, sc.get("project") or sc.get("host") or sc.get("path") or "")
|
|
81
|
+
self.sources[name] = st
|
|
82
|
+
self.pullers.append((name, t, sc, st))
|
|
83
|
+
|
|
84
|
+
def _run_puller(self, name, t, sc, st):
|
|
85
|
+
state = store.get_state(self.con, name)
|
|
86
|
+
try:
|
|
87
|
+
if t == "langsmith_api":
|
|
88
|
+
runs = langsmith.LangSmithPuller(sc, state).pull()
|
|
89
|
+
self.ingest_langsmith(runs, [], count_source=False)
|
|
90
|
+
st.ok(len(runs))
|
|
91
|
+
elif t == "langfuse_api":
|
|
92
|
+
traces = langfuse.LangfusePuller(sc, state).pull()
|
|
93
|
+
items = [s for tr in traces for s in langfuse.trace_to_spans(tr)]
|
|
94
|
+
self.ingest_spans(items, "langfuse", count_source=False)
|
|
95
|
+
st.ok(len(traces))
|
|
96
|
+
elif t == "inbox":
|
|
97
|
+
recs = inbox.Inbox(sc["path"], state).poll()
|
|
98
|
+
self.ingest_records(recs)
|
|
99
|
+
st.ok(len(recs))
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(f"unknown source type '{t}'")
|
|
102
|
+
except Exception as ex: # a failing connector must never stop the others
|
|
103
|
+
st.fail(ex)
|
|
104
|
+
finally:
|
|
105
|
+
with self.lock:
|
|
106
|
+
store.set_state(self.con, name, state)
|
|
107
|
+
|
|
108
|
+
def start_pullers(self):
|
|
109
|
+
for name, t, sc, st in self.pullers:
|
|
110
|
+
interval = float(sc.get("interval", 60 if t != "inbox" else 5))
|
|
111
|
+
|
|
112
|
+
def loop(name=name, t=t, sc=sc, st=st, interval=interval):
|
|
113
|
+
while True:
|
|
114
|
+
self._run_puller(name, t, sc, st)
|
|
115
|
+
self._wake.set()
|
|
116
|
+
time.sleep(interval)
|
|
117
|
+
threading.Thread(target=loop, daemon=True, name=f"src-{name}").start()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------ push ingestion
|
|
120
|
+
def ingest_spans(self, spans_, source, count_source=True):
|
|
121
|
+
"""Canonical spans (OTLP, Langfuse, inbox) -> durable store."""
|
|
122
|
+
items = [(s["trace_id"], s["span_id"], "canonical", s) for s in spans_ if s.get("trace_id") and s.get("span_id")]
|
|
123
|
+
with self.lock:
|
|
124
|
+
store.upsert_spans(self.con, source, items)
|
|
125
|
+
self.stats["spans_ingested"] += len(items)
|
|
126
|
+
if count_source and source in self.sources:
|
|
127
|
+
self.sources[source].ok(len(items))
|
|
128
|
+
self._wake.set()
|
|
129
|
+
return len(items)
|
|
130
|
+
|
|
131
|
+
def ingest_otlp(self, body, content_type):
|
|
132
|
+
return self.ingest_spans(otlp.parse_request(body, content_type), "otlp")
|
|
133
|
+
|
|
134
|
+
def ingest_langsmith(self, posts, patches, feedback=(), count_source=True):
|
|
135
|
+
"""LangSmith runs: posts create, patches update. Merged per run id, idempotently."""
|
|
136
|
+
with self.lock:
|
|
137
|
+
ids = [str(r["id"]) for r in list(posts) + list(patches) if r.get("id")]
|
|
138
|
+
ids += [str(f.get("run_id")) for f in feedback if f.get("run_id")]
|
|
139
|
+
existing = store.get_span_docs(self.con, "langsmith", ids)
|
|
140
|
+
merged = {}
|
|
141
|
+
for r in list(posts) + list(patches):
|
|
142
|
+
rid = str(r.get("id"))
|
|
143
|
+
base = merged.get(rid) or existing.get(rid) or {}
|
|
144
|
+
merged[rid] = langsmith.merge(base, r)
|
|
145
|
+
for f in feedback:
|
|
146
|
+
rid = str(f.get("run_id"))
|
|
147
|
+
# feedback can arrive before the (asynchronously batched) run: keep a stub that the run merges into
|
|
148
|
+
base = merged.get(rid) or existing.get(rid) or {"id": rid, "trace_id": rid, "_stub": True}
|
|
149
|
+
merged[rid] = langsmith.merge(base, {"feedback": [{"key": f.get("key"), "score": f.get("score")}]})
|
|
150
|
+
items = [(str(d.get("trace_id") or rid), rid, "langsmith", d) for rid, d in merged.items()]
|
|
151
|
+
store.upsert_spans(self.con, "langsmith", items)
|
|
152
|
+
self.stats["spans_ingested"] += len(items)
|
|
153
|
+
if count_source:
|
|
154
|
+
self.sources["langsmith"].ok(len(items))
|
|
155
|
+
self._wake.set()
|
|
156
|
+
return len(items)
|
|
157
|
+
|
|
158
|
+
def ingest(self, payload):
|
|
159
|
+
"""Generic run JSON (SDK / webhook)."""
|
|
160
|
+
run = generic.normalize(payload) # validate before writing
|
|
161
|
+
path = os.path.join(self.runs_dir, f"{run['id'].replace(':', '_').replace('/', '_')}.json")
|
|
162
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
163
|
+
json.dump(payload, f)
|
|
164
|
+
self.sources["sdk"].ok(1)
|
|
165
|
+
self._wake.set()
|
|
166
|
+
return run["id"]
|
|
167
|
+
|
|
168
|
+
def ingest_records(self, recs):
|
|
169
|
+
"""Auto-detected records from files/log pipelines."""
|
|
170
|
+
by = {"otlp": [], "langsmith": [], "langfuse": [], "span": [], "aegis": []}
|
|
171
|
+
for fmt, r in recs:
|
|
172
|
+
if fmt == "generic":
|
|
173
|
+
self.ingest(r)
|
|
174
|
+
elif fmt in by:
|
|
175
|
+
by[fmt].append(r)
|
|
176
|
+
n = 0
|
|
177
|
+
for doc in by["otlp"]:
|
|
178
|
+
n += self.ingest_spans([otlp.map_span(a, b, c) for a, b, c in otlp.decode_json(doc)], "otlp", count_source=False)
|
|
179
|
+
if by["langsmith"]:
|
|
180
|
+
n += self.ingest_langsmith(by["langsmith"], [], count_source=False)
|
|
181
|
+
for tr in by["langfuse"]:
|
|
182
|
+
n += self.ingest_spans(langfuse.trace_to_spans(tr), "langfuse", count_source=False)
|
|
183
|
+
for s in by["span"]:
|
|
184
|
+
n += self.ingest_spans([s], s.get("source") or "inbox", count_source=False)
|
|
185
|
+
if by["aegis"]:
|
|
186
|
+
n += self.ingest_aegis(by["aegis"])
|
|
187
|
+
return n
|
|
188
|
+
|
|
189
|
+
def ingest_aegis(self, records):
|
|
190
|
+
"""Aegis audit records (JSONL audit log lines), grouped into runs by correlation id."""
|
|
191
|
+
items = [(aegis_audit.group_key(r), aegis_audit.span_id(r), "aegis", r) for r in records if aegis_audit.is_record(r)]
|
|
192
|
+
with self.lock:
|
|
193
|
+
store.upsert_spans(self.con, "aegis", items)
|
|
194
|
+
self.stats["spans_ingested"] += len(items)
|
|
195
|
+
if "aegis" in self.sources:
|
|
196
|
+
self.sources["aegis"].ok(len(items))
|
|
197
|
+
self._wake.set()
|
|
198
|
+
return len(items)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ health rules config
|
|
201
|
+
@property
|
|
202
|
+
def rules_path(self):
|
|
203
|
+
return os.path.join(self.data_dir, "rules.json")
|
|
204
|
+
|
|
205
|
+
def rules(self):
|
|
206
|
+
if os.path.exists(self.rules_path):
|
|
207
|
+
with open(self.rules_path, encoding="utf-8") as f:
|
|
208
|
+
saved = json.load(f)
|
|
209
|
+
known = {r["id"] for r in saved}
|
|
210
|
+
return saved + [r for r in analysis.DEFAULT_RULES if r["id"] not in known] # new built-in rules appear automatically
|
|
211
|
+
return analysis.DEFAULT_RULES
|
|
212
|
+
|
|
213
|
+
def save_rules(self, rules):
|
|
214
|
+
with open(self.rules_path, "w", encoding="utf-8") as f:
|
|
215
|
+
json.dump(rules, f, indent=2)
|
|
216
|
+
self.refresh(force=True)
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ refresh
|
|
219
|
+
def _scan_files(self):
|
|
220
|
+
"""Returns (changed runs, removed run ids) for file-based sources."""
|
|
221
|
+
found = []
|
|
222
|
+
if self.claude_root and os.path.isdir(self.claude_root):
|
|
223
|
+
found += [("cc", p) for p in claude_code.discover(self.claude_root)]
|
|
224
|
+
found += [("sdk", os.path.join(self.runs_dir, fn)) for fn in os.listdir(self.runs_dir) if fn.endswith(".json")]
|
|
225
|
+
changed, seen = [], set()
|
|
226
|
+
for kind, p in found:
|
|
227
|
+
try:
|
|
228
|
+
st = os.stat(p)
|
|
229
|
+
except OSError:
|
|
230
|
+
continue
|
|
231
|
+
seen.add(p)
|
|
232
|
+
c = self._files.get(p)
|
|
233
|
+
if c and c[0] == st.st_mtime and c[1] == st.st_size:
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
if kind == "cc":
|
|
237
|
+
run = claude_code.parse_file(p, self.claude_root)
|
|
238
|
+
else:
|
|
239
|
+
with open(p, encoding="utf-8") as f:
|
|
240
|
+
run = generic.normalize(json.load(f))
|
|
241
|
+
run["file"] = p
|
|
242
|
+
except Exception as ex: # a malformed file must not take down monitoring
|
|
243
|
+
(self.sources.get("claude_code") if kind == "cc" else self.sources["sdk"]).fail(f"{os.path.basename(p)}: {ex}")
|
|
244
|
+
continue
|
|
245
|
+
old = self._files.get(p)
|
|
246
|
+
self._files[p] = (st.st_mtime, st.st_size, run)
|
|
247
|
+
if old and old[2]["id"] != run["id"]:
|
|
248
|
+
changed.append(("remove", old[2]["id"]))
|
|
249
|
+
changed.append(("upsert", run))
|
|
250
|
+
removed = [self._files.pop(p)[2]["id"] for p in list(self._files) if p not in seen]
|
|
251
|
+
if "claude_code" in self.sources and any(k == "cc" for k, _ in found):
|
|
252
|
+
self.sources["claude_code"].d.update(status="ok", last_ok=time.time(), items=sum(1 for k, _ in found if k == "cc"))
|
|
253
|
+
return changed, removed
|
|
254
|
+
|
|
255
|
+
def _assemble_traces(self):
|
|
256
|
+
since = self._span_since
|
|
257
|
+
now = time.time()
|
|
258
|
+
touched = store.all_traces(self.con) if since == 0 else store.traces_updated_since(self.con, since - 2)
|
|
259
|
+
runs = []
|
|
260
|
+
sdk_ids = {f[2]["id"] for f in self._files.values()}
|
|
261
|
+
for source, trace_id in touched:
|
|
262
|
+
docs = store.trace_spans(self.con, source, trace_id)
|
|
263
|
+
if source == "aegis":
|
|
264
|
+
if trace_id in sdk_ids:
|
|
265
|
+
continue # already recorded in-process by agentdynamics.integrations.aegis
|
|
266
|
+
run = generic.normalize(aegis_audit.build_payload(trace_id, [d for _, d in docs]))
|
|
267
|
+
run["source"] = "aegis"
|
|
268
|
+
runs.append(run)
|
|
269
|
+
continue
|
|
270
|
+
canon = [c for c in (langsmith.to_span(d) if fmt == "langsmith" else d for fmt, d in docs) if c]
|
|
271
|
+
run = spanmod.build_run(trace_id, canon, source)
|
|
272
|
+
if run:
|
|
273
|
+
runs.append(run)
|
|
274
|
+
self._span_since = now
|
|
275
|
+
return runs
|
|
276
|
+
|
|
277
|
+
def refresh(self, force=False):
|
|
278
|
+
with self.lock:
|
|
279
|
+
t0 = time.time()
|
|
280
|
+
if force:
|
|
281
|
+
self._files.clear()
|
|
282
|
+
self._span_since = 0
|
|
283
|
+
self._runs.clear()
|
|
284
|
+
self._tasks.clear()
|
|
285
|
+
changed, removed = self._scan_files()
|
|
286
|
+
dirty = {}
|
|
287
|
+
for op, x in changed:
|
|
288
|
+
if op == "remove":
|
|
289
|
+
removed.append(x)
|
|
290
|
+
else:
|
|
291
|
+
dirty[x["id"]] = x
|
|
292
|
+
for run in self._assemble_traces():
|
|
293
|
+
dirty[run["id"]] = run
|
|
294
|
+
# retention
|
|
295
|
+
days = float(self.cfg["retention"].get("days") or 0)
|
|
296
|
+
if days > 0:
|
|
297
|
+
cutoff = time.time() - days * 86400
|
|
298
|
+
store.purge_spans_before(self.con, cutoff)
|
|
299
|
+
for rid, r in list(self._runs.items()) + list(dirty.items()):
|
|
300
|
+
if (r.get("ended") or r.get("started") or time.time()) < cutoff:
|
|
301
|
+
removed.append(rid)
|
|
302
|
+
dirty.pop(rid, None)
|
|
303
|
+
dirty = {k: v for k, v in dirty.items() if v["steps"]}
|
|
304
|
+
if not dirty and not removed and not force and not self._first:
|
|
305
|
+
return False
|
|
306
|
+
for rid in removed:
|
|
307
|
+
self._runs.pop(rid, None)
|
|
308
|
+
self._tasks.pop(rid, None)
|
|
309
|
+
for rid, run in dirty.items():
|
|
310
|
+
for s in run["steps"]:
|
|
311
|
+
s.pop("flags", None)
|
|
312
|
+
s.pop("attributed_cost", None)
|
|
313
|
+
self._runs[rid] = run
|
|
314
|
+
self._tasks[rid] = analysis.run_tasks(run)
|
|
315
|
+
runs = list(self._runs.values())
|
|
316
|
+
tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules())
|
|
317
|
+
insights = analysis.process_insights(tasks)
|
|
318
|
+
meta = {"refreshed": time.time(), "runs": len(runs), "tasks": len(tasks), "insights": insights}
|
|
319
|
+
store.write_runs(self.con, list(dirty.values()), removed, self.redactor)
|
|
320
|
+
store.write_analysis(self.con, tasks, baselines, events, meta, self.redactor)
|
|
321
|
+
self._dispatch_alerts(events)
|
|
322
|
+
self._first = False
|
|
323
|
+
self.last_refresh = time.time()
|
|
324
|
+
self.last_duration = round(self.last_refresh - t0, 2)
|
|
325
|
+
self.stats["refreshes"] += 1
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
def watch(self, interval=None):
|
|
329
|
+
interval = interval or float(self.cfg["analysis"].get("interval", 15))
|
|
330
|
+
|
|
331
|
+
def loop():
|
|
332
|
+
while True:
|
|
333
|
+
self._wake.wait(interval)
|
|
334
|
+
if self._wake.is_set():
|
|
335
|
+
time.sleep(1.0) # debounce bursts of pushed spans
|
|
336
|
+
self._wake.clear()
|
|
337
|
+
try:
|
|
338
|
+
self.refresh()
|
|
339
|
+
except Exception:
|
|
340
|
+
traceback.print_exc()
|
|
341
|
+
threading.Thread(target=loop, daemon=True, name="analyzer").start()
|
|
342
|
+
self.start_pullers()
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------ alerting
|
|
345
|
+
SEV = {"info": 1, "warning": 2, "critical": 3}
|
|
346
|
+
|
|
347
|
+
def _dispatch_alerts(self, events):
|
|
348
|
+
hooks = self.cfg["alerts"].get("webhooks") or []
|
|
349
|
+
sent = {r[0] for r in self.con.execute("SELECT event_id FROM alerts_sent")}
|
|
350
|
+
new = [e for e in events if e["id"] not in sent]
|
|
351
|
+
if not new:
|
|
352
|
+
return
|
|
353
|
+
with self.con:
|
|
354
|
+
self.con.executemany("INSERT OR IGNORE INTO alerts_sent (event_id, ts) VALUES (?, ?)", [(e["id"], time.time()) for e in new])
|
|
355
|
+
if self._first or not hooks: # never flood a channel with history on first start
|
|
356
|
+
return
|
|
357
|
+
recent = [e for e in new if (e.get("ts") or 0) > time.time() - 3600]
|
|
358
|
+
for h in hooks:
|
|
359
|
+
lvl = self.SEV.get(h.get("min_severity", "warning"), 2)
|
|
360
|
+
batch = [e for e in recent if self.SEV.get(e["severity"], 1) >= lvl]
|
|
361
|
+
if batch:
|
|
362
|
+
threading.Thread(target=self._post_hook, args=(h, batch), daemon=True).start()
|
|
363
|
+
|
|
364
|
+
def _post_hook(self, hook, events):
|
|
365
|
+
if hook.get("format", "json") == "slack":
|
|
366
|
+
lines = [f"*{e['severity'].upper()}* · {e['rule']} · {e['project']} / {e['task_type']}\n{e['message']}" for e in events[:10]]
|
|
367
|
+
body = {"text": "AgentDynamics alerts\n" + "\n\n".join(lines)}
|
|
368
|
+
else:
|
|
369
|
+
body = {"source": "agentdynamics", "events": events}
|
|
370
|
+
try:
|
|
371
|
+
req = urllib.request.Request(hook["url"], data=json.dumps(body, default=str).encode(), method="POST",
|
|
372
|
+
headers={"Content-Type": "application/json"})
|
|
373
|
+
urllib.request.urlopen(req, timeout=10).read()
|
|
374
|
+
self.stats["alerts_sent"] += len(events)
|
|
375
|
+
except Exception as ex:
|
|
376
|
+
print(f"[agentdynamics] alert webhook failed: {ex}")
|
agentdynamics/flows.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Workflow (agent-graph) analytics: process mining over node paths.
|
|
2
|
+
|
|
3
|
+
Works for LangGraph / multi-agent traces (nodes = graph nodes or agents) and for coding agents
|
|
4
|
+
without an explicit graph (nodes = process phases: explore -> edit -> verify ...).
|
|
5
|
+
"""
|
|
6
|
+
import statistics
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
|
|
9
|
+
from .analysis import apdex_score, pct
|
|
10
|
+
from .store import rows
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _stats(tasks):
|
|
14
|
+
n = len(tasks)
|
|
15
|
+
cost = [t["cost"] + (t["subagent_cost"] or 0) for t in tasks]
|
|
16
|
+
wall = [t["wall_s"] or 0 for t in tasks]
|
|
17
|
+
ok = sum(1 for t in tasks if t["outcome"] == "completed")
|
|
18
|
+
return {
|
|
19
|
+
"runs": n, "success_rate": round(ok / n, 3) if n else None,
|
|
20
|
+
"failed": sum(1 for t in tasks if t["outcome"] == "failed"),
|
|
21
|
+
"cost": round(sum(cost), 4), "avg_cost": round(sum(cost) / n, 5) if n else 0,
|
|
22
|
+
"p50_s": round(pct(wall, 0.5) or 0, 2), "p95_s": round(pct(wall, 0.95) or 0, 2),
|
|
23
|
+
"avg_steps": round(statistics.mean([t["steps_total"] or 0 for t in tasks]), 1) if n else 0,
|
|
24
|
+
"loop_rate": round(sum(1 for t in tasks if (t["max_node_visits"] or 0) >= 5) / n, 3) if n else 0,
|
|
25
|
+
"apdex": apdex_score([t for t in tasks if t["apdex"]]),
|
|
26
|
+
"tokens": sum(t["total_tokens"] for t in tasks),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def workflows(api, q):
|
|
31
|
+
ts = api.tasks(q)
|
|
32
|
+
by = defaultdict(list)
|
|
33
|
+
for t in ts:
|
|
34
|
+
by[t["workflow"] or t["task_type"]].append(t)
|
|
35
|
+
out = []
|
|
36
|
+
for wf, g in by.items():
|
|
37
|
+
paths = Counter(tuple(t["path"] or []) for t in g)
|
|
38
|
+
s = _stats(g)
|
|
39
|
+
s.update({"workflow": wf, "framework": Counter(t["framework"] for t in g).most_common(1)[0][0],
|
|
40
|
+
"variants": len(paths), "top_path": list(paths.most_common(1)[0][0]) if paths else [],
|
|
41
|
+
"nodes": max((t["nodes"] or 0) for t in g), "graph": any((t["nodes"] or 0) > 0 for t in g),
|
|
42
|
+
"projects": sorted({t["project"] for t in g})[:5]})
|
|
43
|
+
out.append(s)
|
|
44
|
+
out.sort(key=lambda x: -x["runs"])
|
|
45
|
+
return {"workflows": out}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def workflow_detail(api, name, q):
|
|
49
|
+
ts = [t for t in api.tasks(q) if (t["workflow"] or t["task_type"]) == name]
|
|
50
|
+
if not ts:
|
|
51
|
+
return None
|
|
52
|
+
graph = any((t["nodes"] or 0) > 0 for t in ts)
|
|
53
|
+
# --- transitions (directly-follows graph) with failure overlay
|
|
54
|
+
edges = Counter()
|
|
55
|
+
edge_fail = Counter()
|
|
56
|
+
node_runs = Counter()
|
|
57
|
+
for t in ts:
|
|
58
|
+
p = ["__start__"] + list(t["path"] or []) + ["__end__"]
|
|
59
|
+
failed = t["outcome"] in ("failed", "interrupted", "rework")
|
|
60
|
+
for a, b in zip(p, p[1:]):
|
|
61
|
+
edges[(a, b)] += 1
|
|
62
|
+
if failed:
|
|
63
|
+
edge_fail[(a, b)] += 1
|
|
64
|
+
for n in set(t["path"] or []):
|
|
65
|
+
node_runs[n] += 1
|
|
66
|
+
# --- node / phase statistics from steps
|
|
67
|
+
ids = [t["id"] for t in ts]
|
|
68
|
+
steps = []
|
|
69
|
+
for i in range(0, len(ids), 400):
|
|
70
|
+
chunk = ids[i:i + 400]
|
|
71
|
+
steps += rows(api.con, f"""SELECT task_id, kind, name, node, phase, span_kind, duration_ms, cost, attributed_cost, is_error,
|
|
72
|
+
input_tokens, output_tokens, cache_read, cache_write, agent FROM steps WHERE task_id IN ({','.join('?' * len(chunk))})""", chunk)
|
|
73
|
+
nodes = defaultdict(lambda: {"executions": 0, "errors": 0, "ms": [], "llm_calls": 0, "tool_calls": 0, "cost": 0.0, "tokens": 0})
|
|
74
|
+
for s in steps:
|
|
75
|
+
if graph:
|
|
76
|
+
key = s["node"] or (s["name"] if s["span_kind"] == "agent" else None)
|
|
77
|
+
if not key:
|
|
78
|
+
continue
|
|
79
|
+
n = nodes[key]
|
|
80
|
+
if s["kind"] == "span" and (s["name"] == s["node"] or s["span_kind"] in ("node", "agent")):
|
|
81
|
+
n["executions"] += 1
|
|
82
|
+
n["errors"] += s["is_error"] or 0
|
|
83
|
+
if s["duration_ms"] is not None:
|
|
84
|
+
n["ms"].append(s["duration_ms"])
|
|
85
|
+
elif s["kind"] == "llm":
|
|
86
|
+
n["llm_calls"] += 1
|
|
87
|
+
n["cost"] += s["cost"] or 0
|
|
88
|
+
n["tokens"] += sum((s[k] or 0) for k in ("input_tokens", "output_tokens", "cache_read", "cache_write"))
|
|
89
|
+
n["errors"] += s["is_error"] or 0
|
|
90
|
+
elif s["kind"] == "tool":
|
|
91
|
+
n["tool_calls"] += 1
|
|
92
|
+
n["errors"] += s["is_error"] or 0
|
|
93
|
+
else:
|
|
94
|
+
if s["kind"] != "tool":
|
|
95
|
+
continue
|
|
96
|
+
n = nodes[s["phase"] or "other"]
|
|
97
|
+
n["executions"] += 1
|
|
98
|
+
n["tool_calls"] += 1
|
|
99
|
+
n["errors"] += s["is_error"] or 0
|
|
100
|
+
n["cost"] += s["attributed_cost"] or 0
|
|
101
|
+
if s["duration_ms"] is not None:
|
|
102
|
+
n["ms"].append(s["duration_ms"])
|
|
103
|
+
total_cost = sum(n["cost"] for n in nodes.values()) or 1
|
|
104
|
+
node_list = []
|
|
105
|
+
for k, n in nodes.items():
|
|
106
|
+
ms = n.pop("ms")
|
|
107
|
+
node_list.append({"node": k, **n, "runs": node_runs.get(k, 0), "per_run": round(n["executions"] / len(ts), 2),
|
|
108
|
+
"p50_ms": round(pct(ms, 0.5)) if ms else None, "p95_ms": round(pct(ms, 0.95)) if ms else None,
|
|
109
|
+
"error_rate": round(n["errors"] / max(1, n["executions"] + n["llm_calls"] + (0 if graph else 0)), 3),
|
|
110
|
+
"cost_share": round(n["cost"] / total_cost, 3)})
|
|
111
|
+
node_list.sort(key=lambda x: -x["executions"])
|
|
112
|
+
# --- path variants
|
|
113
|
+
by_path = defaultdict(list)
|
|
114
|
+
for t in ts:
|
|
115
|
+
by_path[tuple(t["path"] or [])].append(t)
|
|
116
|
+
variants = []
|
|
117
|
+
for p, g in sorted(by_path.items(), key=lambda kv: -len(kv[1]))[:15]:
|
|
118
|
+
st = _stats(g)
|
|
119
|
+
variants.append({"path": list(p), "runs": len(g), "share": round(len(g) / len(ts), 3), "success_rate": st["success_rate"],
|
|
120
|
+
"avg_cost": st["avg_cost"], "p50_s": st["p50_s"], "example": g[0]["id"]})
|
|
121
|
+
# --- handoffs between agents
|
|
122
|
+
handoffs = Counter()
|
|
123
|
+
by_task = defaultdict(list)
|
|
124
|
+
for s in steps:
|
|
125
|
+
if s["kind"] in ("llm", "tool") and s["agent"]:
|
|
126
|
+
by_task[s["task_id"]].append(s["agent"])
|
|
127
|
+
for seq in by_task.values():
|
|
128
|
+
c = [a for i, a in enumerate(seq) if i == 0 or a != seq[i - 1]]
|
|
129
|
+
for a, b in zip(c, c[1:]):
|
|
130
|
+
handoffs[(a, b)] += 1
|
|
131
|
+
loops = Counter(min(t["max_node_visits"] or 0, 10) for t in ts)
|
|
132
|
+
crit = Counter(t["critical_node"] for t in ts if t["critical_node"])
|
|
133
|
+
return {
|
|
134
|
+
"workflow": name, "graph": graph, "summary": _stats(ts), "nodes": node_list,
|
|
135
|
+
"edges": [{"from": a, "to": b, "count": c, "fail": edge_fail.get((a, b), 0)} for (a, b), c in edges.items()],
|
|
136
|
+
"variants": variants, "variant_count": len(by_path),
|
|
137
|
+
"handoffs": [{"from": a, "to": b, "count": c} for (a, b), c in handoffs.most_common(20)],
|
|
138
|
+
"loops": {str(k): v for k, v in sorted(loops.items())},
|
|
139
|
+
"critical": [{"node": k, "runs": v} for k, v in crit.most_common(8)],
|
|
140
|
+
"failures": [api._task_brief(t) | {"root_error": None} for t in sorted(ts, key=lambda x: -(x["started"] or 0))
|
|
141
|
+
if t["outcome"] in ("failed", "interrupted")][:10],
|
|
142
|
+
}
|