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.
@@ -0,0 +1,202 @@
1
+ """Canonical span model + trace assembler.
2
+
3
+ Every trace-based integration (OTLP, LangSmith, Langfuse, inbox files) converts its
4
+ native records into *canonical spans* and hands them to `build_run`, which assembles
5
+ one run (= one task) per trace. Analysis only ever sees runs.
6
+
7
+ Canonical span (dict):
8
+ trace_id, span_id, parent_id, name, kind, start, end (epoch seconds), status ("ok"|"error"|"unset"),
9
+ error, model, provider, input_tokens, output_tokens, cache_read, cache_write, cost, stop_reason,
10
+ ttft_ms, input, output, node, agent, workflow, project, environment, session_id, user_id,
11
+ framework, docs (retriever result count), feedback ([{key, score}]), tags, attrs, source
12
+
13
+ kind: llm | tool | retriever | embedding | agent | chain | node | guardrail | evaluator | human | span
14
+ """
15
+ import hashlib
16
+ import json
17
+
18
+ from .. import pricing
19
+ from ..phases import classify
20
+
21
+ STRUCTURAL = {"agent", "chain", "node", "guardrail", "evaluator", "human", "span", "handoff"}
22
+ RATE_LIMIT_HINTS = ("429", "rate limit", "rate_limit", "ratelimit", "overloaded", "529", "too many requests", "quota")
23
+
24
+
25
+ def preview(v, n=600):
26
+ if v is None:
27
+ return ""
28
+ if isinstance(v, str):
29
+ return v[:n]
30
+ try:
31
+ return json.dumps(v, default=str)[:n]
32
+ except (TypeError, ValueError):
33
+ return str(v)[:n]
34
+
35
+
36
+ def extract_prompt(v):
37
+ """Best-effort human-readable request text from a root span input."""
38
+ if v is None:
39
+ return ""
40
+ if isinstance(v, str):
41
+ s = v.strip()
42
+ if s.startswith("{") or s.startswith("["):
43
+ try:
44
+ return extract_prompt(json.loads(s))
45
+ except ValueError:
46
+ return s
47
+ return s
48
+ if isinstance(v, dict):
49
+ for k in ("input", "question", "query", "prompt", "task", "text", "content", "message", "user_input"):
50
+ if k in v and v[k]:
51
+ return extract_prompt(v[k])
52
+ if "messages" in v and v["messages"]:
53
+ return extract_prompt(v["messages"])
54
+ # graph state such as {"q": "...", "steps": 0}: use the first substantial string field
55
+ for val in v.values():
56
+ if isinstance(val, str) and len(val.strip()) > 3:
57
+ return val.strip()
58
+ return preview(v, 2000)
59
+ if isinstance(v, list):
60
+ # chat messages: take the last human/user message
61
+ for m in reversed(v):
62
+ if isinstance(m, list):
63
+ r = extract_prompt(m)
64
+ if r:
65
+ return r
66
+ if isinstance(m, dict):
67
+ role = m.get("role") or m.get("type") or (m.get("kwargs") or {}).get("type") or (m.get("id") or [""])[-1]
68
+ if str(role).lower() in ("user", "human", "humanmessage"):
69
+ c = m.get("content") if "content" in m else (m.get("kwargs") or {}).get("content")
70
+ return extract_prompt(c)
71
+ if isinstance(m, str):
72
+ return m
73
+ return preview(v, 2000)
74
+ return str(v)
75
+
76
+
77
+ def _hash(name, inp):
78
+ return hashlib.sha1((str(name) + preview(inp, 4000)).encode()).hexdigest()[:16]
79
+
80
+
81
+ def build_run(trace_id, spans, source):
82
+ """Assemble canonical spans of one trace into a normalized run."""
83
+ if not spans:
84
+ return None
85
+ by_id = {s["span_id"]: s for s in spans}
86
+ roots = [s for s in spans if not s.get("parent_id") or s["parent_id"] not in by_id]
87
+ roots.sort(key=lambda s: s.get("start") or 0)
88
+ root = roots[0]
89
+
90
+ depth_cache = {}
91
+
92
+ def depth(s):
93
+ sid = s["span_id"]
94
+ if sid in depth_cache:
95
+ return depth_cache[sid]
96
+ d, cur, seen = 0, s, set()
97
+ while cur.get("parent_id") in by_id and cur["span_id"] not in seen:
98
+ seen.add(cur["span_id"])
99
+ cur = by_id[cur["parent_id"]]
100
+ d += 1
101
+ depth_cache[sid] = d
102
+ return d
103
+
104
+ def inherited(s, field):
105
+ cur, seen = s, set()
106
+ while cur is not None and cur["span_id"] not in seen:
107
+ if cur.get(field):
108
+ return cur[field]
109
+ seen.add(cur["span_id"])
110
+ cur = by_id.get(cur.get("parent_id"))
111
+ return None
112
+
113
+ first = lambda f: next((s.get(f) for s in [root] + spans if s.get(f)), None) # noqa: E731
114
+ # the entry point names the workflow; GenAI-semconv agent roots are named "invoke_agent <agent>", so prefer the agent name
115
+ workflow = root.get("workflow") or (root.get("agent") if root.get("kind") == "agent" else None) or root.get("name") or "trace"
116
+ run = {
117
+ "id": f"{source}:{trace_id}",
118
+ "source": source,
119
+ "file": None,
120
+ "project": first("project") or "default",
121
+ "environment": first("environment") or "default",
122
+ "framework": "langgraph" if any(s.get("framework") == "langgraph" for s in spans) else (first("framework") or source),
123
+ "workflow": workflow,
124
+ "cwd": None,
125
+ "title": extract_prompt(root.get("input"))[:200] or workflow,
126
+ "agent_name": first("agent"),
127
+ "thread_id": first("session_id"),
128
+ "user_id": first("user_id"),
129
+ "parent_id": None,
130
+ "is_subagent": False,
131
+ "version": None,
132
+ "git_branch": None,
133
+ "entrypoint": None,
134
+ "tags": sorted({t for s in spans for t in (s.get("tags") or [])})[:20],
135
+ "feedback": [f for s in spans for f in (s.get("feedback") or [])],
136
+ "steps": [],
137
+ }
138
+ steps = run["steps"]
139
+ prompt_text = extract_prompt(root.get("input"))
140
+ steps.append({"kind": "prompt", "name": "trace", "ts": root.get("start"), "text": prompt_text[:2000] or workflow})
141
+
142
+ ordered = sorted(spans, key=lambda s: ((s.get("start") or 0), depth(s)))
143
+ last_llm = None
144
+ for s in ordered:
145
+ kind = s.get("kind") or "span"
146
+ err = s.get("error") or ""
147
+ st = {
148
+ "span_id": s["span_id"], "parent_span_id": s.get("parent_id"), "depth": depth(s),
149
+ "name": s.get("name") or kind, "ts": s.get("start"), "start_ts": s.get("start"), "end_ts": s.get("end"),
150
+ "is_error": s.get("status") == "error", "error": preview(err, 400) if err else None,
151
+ "node": inherited(s, "node"), "agent": inherited(s, "agent"), "span_kind": kind,
152
+ }
153
+ if st["start_ts"] and st["end_ts"]:
154
+ st["duration_ms"] = max(0, int((st["end_ts"] - st["start_ts"]) * 1000))
155
+ if kind in ("llm", "embedding"):
156
+ model = s.get("model") or s.get("name")
157
+ it, ot = int(s.get("input_tokens") or 0), int(s.get("output_tokens") or 0)
158
+ cr, cw = int(s.get("cache_read") or 0), int(s.get("cache_write") or 0)
159
+ # OTel/LangSmith report input_tokens inclusive of cache reads for most providers; keep uncached part separate
160
+ if cr and it >= cr:
161
+ it -= cr
162
+ cost = s.get("cost")
163
+ if cost is None:
164
+ cost = pricing.cost(model, it, ot, cr, cw, 0)
165
+ st.update({
166
+ "kind": "llm", "model": model, "name": model, "input_tokens": it, "output_tokens": ot,
167
+ "cache_read": cr, "cache_write": cw, "cost": float(cost or 0), "priced": bool(cost) or pricing.rates(model) is not None,
168
+ "context_tokens": it + cr + cw, "stop_reason": s.get("stop_reason"), "ttft_ms": s.get("ttft_ms"),
169
+ "text": preview(s.get("output"), 600), "tool_calls": 0, "thinking_tokens": int(s.get("thinking_tokens") or 0),
170
+ "rate_limited": bool(err) and any(h in err.lower() for h in RATE_LIMIT_HINTS),
171
+ })
172
+ last_llm = st
173
+ elif kind in ("tool", "retriever"):
174
+ inp = s.get("input")
175
+ phase, target = classify(s.get("name"), inp if isinstance(inp, dict) else {})
176
+ if kind == "retriever":
177
+ phase = "retrieve"
178
+ elif phase == "other":
179
+ phase = "execute"
180
+ st.update({
181
+ "kind": "tool", "phase": phase, "target": target or preview(inp, 200),
182
+ "input_hash": _hash(s.get("name"), inp), "input_preview": preview(inp, 400),
183
+ "output_chars": len(preview(s.get("output"), 10**7)), "docs": s.get("docs"),
184
+ "text": preview(s.get("output"), 300),
185
+ })
186
+ if last_llm is not None:
187
+ st["llm_msg"] = last_llm["span_id"]
188
+ last_llm["tool_calls"] += 1
189
+ else:
190
+ st.update({"kind": "span", "input_preview": preview(s.get("input"), 400), "text": preview(s.get("output"), 300)})
191
+ if kind == "human" or "interrupt" in (s.get("name") or "").lower() or "GraphInterrupt" in err:
192
+ st["hitl"] = True
193
+ steps.append(st)
194
+ for i, st in enumerate(steps):
195
+ st["seq"] = i
196
+ tss = [s["ts"] for s in steps if s.get("ts")] + [s["end_ts"] for s in steps if s.get("end_ts")]
197
+ run["started"] = min(tss) if tss else None
198
+ run["ended"] = max(tss) if tss else None
199
+ run["root_status"] = root.get("status")
200
+ run["root_error"] = preview(root.get("error"), 400) if root.get("error") else None
201
+ run["complete"] = root.get("end") is not None
202
+ return run
@@ -0,0 +1,139 @@
1
+ """Configuration: <data_dir>/agentdynamics.toml (optional) + environment overrides.
2
+
3
+ Example agentdynamics.toml:
4
+
5
+ [server]
6
+ host = "0.0.0.0"
7
+ port = 8787
8
+
9
+ [auth]
10
+ enabled = true
11
+ # role: ingest (write telemetry), read (view console/API), admin (rules, SLOs, config)
12
+ keys = [
13
+ { name = "otel-collector", key = "ad_ingest_xxx", role = "ingest" },
14
+ { name = "sre-team", key = "ad_read_xxx", role = "read" },
15
+ { name = "platform-admin", key = "ad_admin_xxx", role = "admin" },
16
+ ]
17
+
18
+ [privacy]
19
+ store_content = true # false keeps only sizes/metadata, never prompt or tool text
20
+ redact = ["email", "api_key", "credit_card", "bearer", "aws_key"]
21
+ extra_patterns = [] # additional regexes to mask
22
+
23
+ [retention]
24
+ days = 90 # spans/runs older than this are purged
25
+
26
+ [[alerts.webhooks]]
27
+ url = "https://hooks.slack.com/services/..."
28
+ min_severity = "warning"
29
+ format = "slack" # slack | json
30
+
31
+ [[sources]]
32
+ type = "claude_code" # built-in, on by default
33
+ [[sources]]
34
+ type = "langsmith_api" # pull runs from LangSmith
35
+ project = "my-agent-prod"
36
+ api_key_env = "LANGSMITH_API_KEY"
37
+ interval = 60
38
+ [[sources]]
39
+ type = "langfuse_api"
40
+ host = "https://cloud.langfuse.com"
41
+ public_key_env = "LANGFUSE_PUBLIC_KEY"
42
+ secret_key_env = "LANGFUSE_SECRET_KEY"
43
+ [[sources]]
44
+ type = "inbox" # tail *.jsonl / *.json dropped by Fluent Bit, Vector, S3 sync...
45
+ path = "/var/log/agent-traces"
46
+ """
47
+ import copy
48
+ import os
49
+
50
+ try:
51
+ import tomllib # Python 3.11+
52
+ except ImportError: # pragma: no cover - Python 3.10
53
+ try:
54
+ import tomli as tomllib
55
+ except ImportError:
56
+ tomllib = None
57
+
58
+ DEFAULTS = {
59
+ "server": {"host": "127.0.0.1", "port": 8787},
60
+ "auth": {"enabled": False, "keys": []},
61
+ "privacy": {"store_content": True, "redact": ["email", "api_key", "credit_card", "bearer", "aws_key"], "extra_patterns": []},
62
+ "retention": {"days": 0},
63
+ "alerts": {"webhooks": []},
64
+ "analysis": {"interval": 15, "idle_cap_seconds": 300},
65
+ "sources": [],
66
+ }
67
+
68
+ ROLES = {"ingest": 1, "read": 2, "admin": 3}
69
+
70
+
71
+ def _merge(base, over):
72
+ out = copy.deepcopy(base)
73
+ for k, v in (over or {}).items():
74
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
75
+ out[k] = _merge(out[k], v)
76
+ else:
77
+ out[k] = v
78
+ return out
79
+
80
+
81
+ def keys_path(data_dir):
82
+ return os.path.join(data_dir, "keys.json")
83
+
84
+
85
+ def load_keys(data_dir):
86
+ p = keys_path(data_dir)
87
+ if os.path.exists(p):
88
+ import json
89
+ with open(p, encoding="utf-8") as f:
90
+ return json.load(f)
91
+ return []
92
+
93
+
94
+ def save_keys(data_dir, keys):
95
+ import json
96
+ p = keys_path(data_dir)
97
+ with open(p, "w", encoding="utf-8") as f:
98
+ json.dump(keys, f, indent=2)
99
+ try:
100
+ os.chmod(p, 0o600)
101
+ except OSError:
102
+ pass
103
+
104
+
105
+ def load(data_dir):
106
+ cfg = copy.deepcopy(DEFAULTS)
107
+ path = os.environ.get("AGENTDYNAMICS_CONFIG") or os.path.join(data_dir, "agentdynamics.toml")
108
+ if os.path.exists(path):
109
+ if tomllib is None:
110
+ raise RuntimeError("reading agentdynamics.toml on Python 3.10 needs `pip install tomli`")
111
+ with open(path, "rb") as f:
112
+ cfg = _merge(cfg, tomllib.load(f))
113
+ cfg["_path"] = path
114
+ # Env overrides for container deployments: AGENTDYNAMICS_API_KEYS="name:key:role,name2:key2:role2"
115
+ env_keys = os.environ.get("AGENTDYNAMICS_API_KEYS")
116
+ if env_keys:
117
+ cfg["auth"]["enabled"] = True
118
+ for item in env_keys.split(","):
119
+ parts = item.strip().split(":")
120
+ if len(parts) == 3:
121
+ cfg["auth"]["keys"].append({"name": parts[0], "key": parts[1], "role": parts[2]})
122
+ file_keys = load_keys(data_dir) # created with `agentdynamics keys create`
123
+ if file_keys:
124
+ cfg["auth"]["enabled"] = True
125
+ cfg["auth"]["keys"] = list(cfg["auth"]["keys"]) + file_keys
126
+ if os.environ.get("AGENTDYNAMICS_STORE_CONTENT") in ("0", "false"):
127
+ cfg["privacy"]["store_content"] = False
128
+ if os.environ.get("AGENTDYNAMICS_RETENTION_DAYS"):
129
+ cfg["retention"]["days"] = int(os.environ["AGENTDYNAMICS_RETENTION_DAYS"])
130
+ return cfg
131
+
132
+
133
+ def public_view(cfg):
134
+ """Config safe to show in the UI (no secrets)."""
135
+ v = copy.deepcopy(cfg)
136
+ v["auth"]["keys"] = [{"name": k.get("name"), "role": k.get("role"), "key": (k.get("key") or "")[:6] + "…"} for k in v["auth"]["keys"]]
137
+ for w in v["alerts"]["webhooks"]:
138
+ w["url"] = (w.get("url") or "")[:28] + "…"
139
+ return v