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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""LangSmith / LangChain / LangGraph integration.
|
|
2
|
+
|
|
3
|
+
Two ways in:
|
|
4
|
+
1. Drop-in receiver: point any LangChain/LangGraph app at AgentDynamics with
|
|
5
|
+
LANGSMITH_TRACING=true
|
|
6
|
+
LANGSMITH_ENDPOINT=http://<host>:8787/langsmith
|
|
7
|
+
LANGSMITH_API_KEY=<an AgentDynamics ingest key>
|
|
8
|
+
The LangSmith SDK then sends runs here (/runs/batch, /runs, PATCH /runs/{id}, /runs/multipart, /feedback).
|
|
9
|
+
2. Pull connector: read runs from an existing LangSmith project via its API (POST /runs/query).
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from email.parser import BytesParser
|
|
16
|
+
from email.policy import default as email_policy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_time(v):
|
|
20
|
+
if v is None or v == "":
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(v, (int, float)):
|
|
23
|
+
return v / 1000 if v > 1e12 else float(v)
|
|
24
|
+
s = str(v).replace("Z", "+00:00")
|
|
25
|
+
try:
|
|
26
|
+
d = datetime.fromisoformat(s)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
if d.tzinfo is None:
|
|
30
|
+
d = d.replace(tzinfo=timezone.utc)
|
|
31
|
+
return d.timestamp()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def merge(existing, update):
|
|
35
|
+
"""Apply a LangSmith PATCH onto a stored run."""
|
|
36
|
+
out = dict(existing or {})
|
|
37
|
+
for k, v in (update or {}).items():
|
|
38
|
+
if v is None:
|
|
39
|
+
continue
|
|
40
|
+
if k == "_stub":
|
|
41
|
+
continue
|
|
42
|
+
if k == "extra" and isinstance(v, dict):
|
|
43
|
+
ex = dict(out.get("extra") or {})
|
|
44
|
+
for k2, v2 in v.items():
|
|
45
|
+
if k2 == "metadata" and isinstance(v2, dict):
|
|
46
|
+
ex["metadata"] = {**(ex.get("metadata") or {}), **v2}
|
|
47
|
+
else:
|
|
48
|
+
ex[k2] = v2
|
|
49
|
+
out["extra"] = ex
|
|
50
|
+
elif k == "feedback":
|
|
51
|
+
out["feedback"] = (out.get("feedback") or []) + list(v)
|
|
52
|
+
else:
|
|
53
|
+
out[k] = v
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _usage(outputs, run):
|
|
58
|
+
"""Token usage from the many places LangChain puts it."""
|
|
59
|
+
it = ot = cr = cw = 0
|
|
60
|
+
stop = model = None
|
|
61
|
+
if run.get("prompt_tokens") or run.get("completion_tokens"):
|
|
62
|
+
it, ot = run.get("prompt_tokens") or 0, run.get("completion_tokens") or 0
|
|
63
|
+
o = outputs or {}
|
|
64
|
+
um = o.get("usage_metadata")
|
|
65
|
+
gens = o.get("generations") or []
|
|
66
|
+
flat = []
|
|
67
|
+
for g in gens:
|
|
68
|
+
flat.extend(g if isinstance(g, list) else [g])
|
|
69
|
+
for g in flat:
|
|
70
|
+
if not isinstance(g, dict):
|
|
71
|
+
continue
|
|
72
|
+
msg = g.get("message") or {}
|
|
73
|
+
kw = msg.get("kwargs") if isinstance(msg, dict) and "kwargs" in msg else msg
|
|
74
|
+
kw = kw or {}
|
|
75
|
+
um = um or kw.get("usage_metadata")
|
|
76
|
+
rm = kw.get("response_metadata") or {}
|
|
77
|
+
gi = g.get("generation_info") or {}
|
|
78
|
+
stop = stop or rm.get("stop_reason") or rm.get("finish_reason") or gi.get("finish_reason")
|
|
79
|
+
model = model or rm.get("model") or rm.get("model_name")
|
|
80
|
+
if um:
|
|
81
|
+
it = um.get("input_tokens") or it
|
|
82
|
+
ot = um.get("output_tokens") or ot
|
|
83
|
+
det = um.get("input_token_details") or {}
|
|
84
|
+
cr = det.get("cache_read") or 0
|
|
85
|
+
cw = det.get("cache_creation") or 0
|
|
86
|
+
tu = (o.get("llm_output") or {}).get("token_usage") or (o.get("llm_output") or {}).get("usage") or {}
|
|
87
|
+
if tu and not (it or ot):
|
|
88
|
+
it = tu.get("prompt_tokens") or tu.get("input_tokens") or 0
|
|
89
|
+
ot = tu.get("completion_tokens") or tu.get("output_tokens") or 0
|
|
90
|
+
return it, ot, cr, cw, stop, model
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
KIND = {"llm": "llm", "tool": "tool", "retriever": "retriever", "embedding": "embedding", "chain": "chain", "prompt": "chain", "parser": "chain"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def to_span(r):
|
|
97
|
+
"""LangSmith run dict -> canonical span (None for feedback-only stubs whose run hasn't arrived yet)."""
|
|
98
|
+
if r.get("_stub") and not r.get("run_type"):
|
|
99
|
+
return None
|
|
100
|
+
extra = r.get("extra") or {}
|
|
101
|
+
md = {**(extra.get("metadata") or {}), **(r.get("metadata") or {})}
|
|
102
|
+
inv = extra.get("invocation_params") or {}
|
|
103
|
+
run_type = r.get("run_type") or "chain"
|
|
104
|
+
kind = KIND.get(run_type, "chain")
|
|
105
|
+
node = md.get("langgraph_node")
|
|
106
|
+
if kind == "chain" and node and r.get("name") == node:
|
|
107
|
+
kind = "node"
|
|
108
|
+
if r.get("name") in ("__interrupt__",) or "GraphInterrupt" in str(r.get("error") or ""):
|
|
109
|
+
kind = "human"
|
|
110
|
+
it = ot = cr = cw = 0
|
|
111
|
+
stop = model_out = None
|
|
112
|
+
if kind in ("llm", "embedding"):
|
|
113
|
+
it, ot, cr, cw, stop, model_out = _usage(r.get("outputs"), r)
|
|
114
|
+
start = parse_time(r.get("start_time"))
|
|
115
|
+
ttft = None
|
|
116
|
+
for ev in r.get("events") or []:
|
|
117
|
+
if ev.get("name") == "new_token" and start:
|
|
118
|
+
t = parse_time(ev.get("time"))
|
|
119
|
+
if t:
|
|
120
|
+
ttft = max(0.0, (t - start) * 1000)
|
|
121
|
+
break
|
|
122
|
+
docs = None
|
|
123
|
+
if kind == "retriever":
|
|
124
|
+
d = (r.get("outputs") or {}).get("documents")
|
|
125
|
+
docs = len(d) if isinstance(d, list) else None
|
|
126
|
+
fb = []
|
|
127
|
+
for k, v in (r.get("feedback_stats") or {}).items():
|
|
128
|
+
if isinstance(v, dict) and v.get("avg") is not None:
|
|
129
|
+
fb.append({"key": k, "score": v["avg"]})
|
|
130
|
+
fb += [f for f in (r.get("feedback") or []) if f.get("score") is not None]
|
|
131
|
+
err = r.get("error")
|
|
132
|
+
return {
|
|
133
|
+
"trace_id": str(r.get("trace_id") or r.get("id")), "span_id": str(r.get("id")),
|
|
134
|
+
"parent_id": str(r["parent_run_id"]) if r.get("parent_run_id") else None,
|
|
135
|
+
"name": r.get("name"), "kind": kind, "start": start, "end": parse_time(r.get("end_time")),
|
|
136
|
+
"status": "error" if err else ("ok" if r.get("end_time") else "unset"), "error": err,
|
|
137
|
+
"model": md.get("ls_model_name") or inv.get("model") or inv.get("model_name") or model_out,
|
|
138
|
+
"provider": md.get("ls_provider"), "input_tokens": it, "output_tokens": ot, "cache_read": cr, "cache_write": cw,
|
|
139
|
+
"cost": r.get("total_cost") if kind == "llm" and r.get("total_cost") is not None else None,
|
|
140
|
+
"stop_reason": stop, "ttft_ms": ttft, "input": r.get("inputs"), "output": r.get("outputs"),
|
|
141
|
+
"node": node, "agent": md.get("agent_name") or md.get("lc_agent_name"),
|
|
142
|
+
"workflow": None,
|
|
143
|
+
"project": r.get("session_name") or md.get("project"), "environment": md.get("environment") or md.get("env"),
|
|
144
|
+
"session_id": md.get("thread_id") or md.get("session_id") or md.get("conversation_id"),
|
|
145
|
+
"user_id": md.get("user_id"), "framework": "langgraph" if (node or md.get("langgraph_step") is not None) else "langchain",
|
|
146
|
+
"docs": docs, "feedback": fb, "tags": r.get("tags") or [], "source": "langsmith",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------- receiver payloads
|
|
151
|
+
|
|
152
|
+
def parse_batch(body):
|
|
153
|
+
d = json.loads(body or b"{}")
|
|
154
|
+
return d.get("post") or [], d.get("patch") or []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_multipart(body, content_type):
|
|
158
|
+
"""/runs/multipart: parts named post.<id>, post.<id>.inputs, patch.<id>.outputs, feedback.<id> ..."""
|
|
159
|
+
msg = BytesParser(policy=email_policy).parsebytes(b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + body)
|
|
160
|
+
posts, patches, feedback = {}, {}, []
|
|
161
|
+
for part in msg.iter_parts():
|
|
162
|
+
name = part.get_param("name", header="content-disposition") or ""
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(part.get_content() if part.get_content_type().startswith("text") else part.get_payload(decode=True))
|
|
165
|
+
except (ValueError, TypeError):
|
|
166
|
+
continue
|
|
167
|
+
bits = name.split(".")
|
|
168
|
+
if len(bits) < 2:
|
|
169
|
+
continue
|
|
170
|
+
op, rid = bits[0], bits[1]
|
|
171
|
+
target = posts if op == "post" else patches if op == "patch" else None
|
|
172
|
+
if op == "feedback":
|
|
173
|
+
feedback.append(data)
|
|
174
|
+
continue
|
|
175
|
+
if target is None:
|
|
176
|
+
continue
|
|
177
|
+
if len(bits) == 2:
|
|
178
|
+
target.setdefault(rid, {}).update(data)
|
|
179
|
+
else:
|
|
180
|
+
target.setdefault(rid, {})[bits[2]] = data
|
|
181
|
+
for rid, r in list(posts.items()) + list(patches.items()):
|
|
182
|
+
r.setdefault("id", rid)
|
|
183
|
+
return list(posts.values()), list(patches.values()), feedback
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def zstd_available():
|
|
187
|
+
try:
|
|
188
|
+
import zstandard # noqa: F401 (installed alongside recent langsmith SDKs)
|
|
189
|
+
return True
|
|
190
|
+
except ImportError:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def zstd_decompress(body):
|
|
195
|
+
import io
|
|
196
|
+
|
|
197
|
+
import zstandard
|
|
198
|
+
# the SDK streams frames without a content size, so read through a stream reader
|
|
199
|
+
return zstandard.ZstdDecompressor().stream_reader(io.BytesIO(body)).read()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def info():
|
|
203
|
+
"""What we tell the LangSmith SDK (GET /info). With zstandard available we accept compressed multipart
|
|
204
|
+
(about 10x smaller uploads); otherwise the SDK falls back to plain JSON /runs/batch."""
|
|
205
|
+
z = zstd_available()
|
|
206
|
+
return {
|
|
207
|
+
"version": "agentdynamics-0.4",
|
|
208
|
+
"batch_ingest_config": {"scale_up_qsize_trigger": 1000, "scale_up_nthreads_limit": 16, "scale_down_nempty_trigger": 4,
|
|
209
|
+
"size_limit": 100, "size_limit_bytes": 20_971_520, "use_multipart_endpoint": z},
|
|
210
|
+
"instance_flags": {"zstd_compression_enabled": z},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
INFO = info()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------- pull connector
|
|
218
|
+
|
|
219
|
+
class LangSmithPuller:
|
|
220
|
+
"""Incrementally pull runs from a LangSmith project."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, cfg, state):
|
|
223
|
+
import os
|
|
224
|
+
self.api = (cfg.get("api_url") or os.environ.get("LANGSMITH_ENDPOINT") or "https://api.smith.langchain.com").rstrip("/")
|
|
225
|
+
self.key = os.environ.get(cfg.get("api_key_env", "LANGSMITH_API_KEY"), "")
|
|
226
|
+
self.project = cfg["project"]
|
|
227
|
+
self.state = state # dict persisted by the engine
|
|
228
|
+
self.lookback = float(cfg.get("lookback_hours", 24)) * 3600
|
|
229
|
+
|
|
230
|
+
def _req(self, method, path, body=None, params=""):
|
|
231
|
+
req = urllib.request.Request(f"{self.api}{path}{params}", method=method, data=json.dumps(body).encode() if body is not None else None,
|
|
232
|
+
headers={"x-api-key": self.key, "Content-Type": "application/json", "Accept": "application/json"})
|
|
233
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
234
|
+
return json.loads(r.read() or b"null")
|
|
235
|
+
|
|
236
|
+
def pull(self, max_runs=5000):
|
|
237
|
+
from urllib.parse import quote
|
|
238
|
+
if not self.state.get("session_id"):
|
|
239
|
+
ses = self._req("GET", "/sessions", params=f"?name={quote(self.project)}&limit=1")
|
|
240
|
+
if not ses:
|
|
241
|
+
raise RuntimeError(f"LangSmith project '{self.project}' not found")
|
|
242
|
+
self.state["session_id"] = ses[0]["id"]
|
|
243
|
+
since = self.state.get("since") or (time.time() - self.lookback)
|
|
244
|
+
body = {"session": [self.state["session_id"]], "start_time": datetime.fromtimestamp(since, timezone.utc).isoformat(), "limit": 100}
|
|
245
|
+
runs, newest = [], since
|
|
246
|
+
while len(runs) < max_runs:
|
|
247
|
+
res = self._req("POST", "/runs/query", body) or {}
|
|
248
|
+
batch = res.get("runs") or []
|
|
249
|
+
runs.extend(batch)
|
|
250
|
+
for r in batch:
|
|
251
|
+
newest = max(newest, parse_time(r.get("start_time")) or 0)
|
|
252
|
+
nxt = (res.get("cursors") or {}).get("next")
|
|
253
|
+
if not batch or not nxt:
|
|
254
|
+
break
|
|
255
|
+
body["cursor"] = nxt
|
|
256
|
+
# re-read a small window next time so runs that were still open get their end state
|
|
257
|
+
self.state["since"] = max(since, newest - 600)
|
|
258
|
+
return runs
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""OpenTelemetry (OTLP/HTTP) trace receiver: JSON and protobuf encodings, zero dependencies.
|
|
2
|
+
|
|
3
|
+
Maps the three agent semantic conventions in use today onto canonical spans:
|
|
4
|
+
* OpenTelemetry GenAI semconv (gen_ai.*) - OpenAI Agents SDK, Strands, Semantic Kernel, PydanticAI, ...
|
|
5
|
+
* OpenInference (openinference.span.kind, llm.*) - Arize Phoenix instrumentors: LangChain/LangGraph, LlamaIndex,
|
|
6
|
+
CrewAI, DSPy, AutoGen, OpenAI, Anthropic, Bedrock, ...
|
|
7
|
+
* OpenLLMetry / Traceloop (traceloop.*) - Traceloop SDK instrumentors
|
|
8
|
+
plus LangSmith's OTel attributes (langsmith.*) and LangGraph metadata (langgraph_node, langgraph_step).
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------- protobuf wire decoding
|
|
14
|
+
|
|
15
|
+
def _varint(buf, i):
|
|
16
|
+
shift = res = 0
|
|
17
|
+
while True:
|
|
18
|
+
b = buf[i]
|
|
19
|
+
i += 1
|
|
20
|
+
res |= (b & 0x7F) << shift
|
|
21
|
+
if not b & 0x80:
|
|
22
|
+
return res, i
|
|
23
|
+
shift += 7
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fields(buf):
|
|
27
|
+
"""Yield (field_number, wire_type, value) for a protobuf message."""
|
|
28
|
+
i, n = 0, len(buf)
|
|
29
|
+
while i < n:
|
|
30
|
+
key, i = _varint(buf, i)
|
|
31
|
+
fn, wt = key >> 3, key & 7
|
|
32
|
+
if wt == 0:
|
|
33
|
+
v, i = _varint(buf, i)
|
|
34
|
+
elif wt == 1:
|
|
35
|
+
v = buf[i:i + 8]
|
|
36
|
+
i += 8
|
|
37
|
+
elif wt == 2:
|
|
38
|
+
ln, i = _varint(buf, i)
|
|
39
|
+
v = buf[i:i + ln]
|
|
40
|
+
i += ln
|
|
41
|
+
elif wt == 5:
|
|
42
|
+
v = buf[i:i + 4]
|
|
43
|
+
i += 4
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError(f"unsupported wire type {wt}")
|
|
46
|
+
yield fn, wt, v
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _any_value(buf):
|
|
50
|
+
for fn, wt, v in _fields(buf):
|
|
51
|
+
if fn == 1:
|
|
52
|
+
return v.decode("utf-8", "replace")
|
|
53
|
+
if fn == 2:
|
|
54
|
+
return bool(v)
|
|
55
|
+
if fn == 3:
|
|
56
|
+
return v - (1 << 64) if v >= 1 << 63 else v
|
|
57
|
+
if fn == 4:
|
|
58
|
+
return struct.unpack("<d", v)[0]
|
|
59
|
+
if fn == 5:
|
|
60
|
+
return [_any_value(x) for f2, _, x in _fields(v) if f2 == 1]
|
|
61
|
+
if fn == 6:
|
|
62
|
+
return dict(_kv(x) for f2, _, x in _fields(v) if f2 == 1)
|
|
63
|
+
if fn == 7:
|
|
64
|
+
return v.hex()
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _kv(buf):
|
|
69
|
+
k, val = "", None
|
|
70
|
+
for fn, _, v in _fields(buf):
|
|
71
|
+
if fn == 1:
|
|
72
|
+
k = v.decode("utf-8", "replace")
|
|
73
|
+
elif fn == 2:
|
|
74
|
+
val = _any_value(v)
|
|
75
|
+
return k, val
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _span_pb(buf):
|
|
79
|
+
s = {"attributes": {}, "events": [], "status": {}}
|
|
80
|
+
for fn, wt, v in _fields(buf):
|
|
81
|
+
if fn == 1:
|
|
82
|
+
s["traceId"] = v.hex()
|
|
83
|
+
elif fn == 2:
|
|
84
|
+
s["spanId"] = v.hex()
|
|
85
|
+
elif fn == 4:
|
|
86
|
+
s["parentSpanId"] = v.hex()
|
|
87
|
+
elif fn == 5:
|
|
88
|
+
s["name"] = v.decode("utf-8", "replace")
|
|
89
|
+
elif fn == 6:
|
|
90
|
+
s["kind"] = v
|
|
91
|
+
elif fn == 7:
|
|
92
|
+
s["startTimeUnixNano"] = struct.unpack("<Q", v)[0]
|
|
93
|
+
elif fn == 8:
|
|
94
|
+
s["endTimeUnixNano"] = struct.unpack("<Q", v)[0]
|
|
95
|
+
elif fn == 9:
|
|
96
|
+
k, val = _kv(v)
|
|
97
|
+
s["attributes"][k] = val
|
|
98
|
+
elif fn == 11:
|
|
99
|
+
ev = {"attributes": {}}
|
|
100
|
+
for f2, _, x in _fields(v):
|
|
101
|
+
if f2 == 1:
|
|
102
|
+
ev["timeUnixNano"] = struct.unpack("<Q", x)[0]
|
|
103
|
+
elif f2 == 2:
|
|
104
|
+
ev["name"] = x.decode("utf-8", "replace")
|
|
105
|
+
elif f2 == 3:
|
|
106
|
+
k, val = _kv(x)
|
|
107
|
+
ev["attributes"][k] = val
|
|
108
|
+
s["events"].append(ev)
|
|
109
|
+
elif fn == 15:
|
|
110
|
+
for f2, _, x in _fields(v):
|
|
111
|
+
if f2 == 2:
|
|
112
|
+
s["status"]["message"] = x.decode("utf-8", "replace")
|
|
113
|
+
elif f2 == 3:
|
|
114
|
+
s["status"]["code"] = x
|
|
115
|
+
return s
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def decode_protobuf(body):
|
|
119
|
+
"""ExportTraceServiceRequest bytes -> list of (resource_attrs, scope_name, span_dict)."""
|
|
120
|
+
out = []
|
|
121
|
+
for fn, _, rs in _fields(body):
|
|
122
|
+
if fn != 1:
|
|
123
|
+
continue
|
|
124
|
+
res_attrs, scopes = {}, []
|
|
125
|
+
for f2, _, v in _fields(rs):
|
|
126
|
+
if f2 == 1:
|
|
127
|
+
for f3, _, kv in _fields(v):
|
|
128
|
+
if f3 == 1:
|
|
129
|
+
k, val = _kv(kv)
|
|
130
|
+
res_attrs[k] = val
|
|
131
|
+
elif f2 == 2:
|
|
132
|
+
scopes.append(v)
|
|
133
|
+
for ss in scopes:
|
|
134
|
+
scope = ""
|
|
135
|
+
for f3, _, v in _fields(ss):
|
|
136
|
+
if f3 == 1:
|
|
137
|
+
for f4, _, x in _fields(v):
|
|
138
|
+
if f4 == 1:
|
|
139
|
+
scope = x.decode("utf-8", "replace")
|
|
140
|
+
elif f3 == 2:
|
|
141
|
+
out.append((res_attrs, scope, _span_pb(v)))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------- JSON decoding
|
|
146
|
+
|
|
147
|
+
def _json_any(v):
|
|
148
|
+
if not isinstance(v, dict):
|
|
149
|
+
return v
|
|
150
|
+
for k, val in v.items():
|
|
151
|
+
if k == "stringValue":
|
|
152
|
+
return val
|
|
153
|
+
if k == "boolValue":
|
|
154
|
+
return bool(val)
|
|
155
|
+
if k == "intValue":
|
|
156
|
+
return int(val)
|
|
157
|
+
if k == "doubleValue":
|
|
158
|
+
return float(val)
|
|
159
|
+
if k == "arrayValue":
|
|
160
|
+
return [_json_any(x) for x in (val or {}).get("values", [])]
|
|
161
|
+
if k == "kvlistValue":
|
|
162
|
+
return {x["key"]: _json_any(x.get("value")) for x in (val or {}).get("values", [])}
|
|
163
|
+
if k == "bytesValue":
|
|
164
|
+
return val
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _json_attrs(lst):
|
|
169
|
+
return {a["key"]: _json_any(a.get("value")) for a in lst or []}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _hexid(v):
|
|
173
|
+
"""OTLP/JSON ids are hex strings; some exporters send base64. Normalize to hex."""
|
|
174
|
+
if not v:
|
|
175
|
+
return None
|
|
176
|
+
if all(c in "0123456789abcdefABCDEF" for c in v):
|
|
177
|
+
return v.lower()
|
|
178
|
+
import base64
|
|
179
|
+
try:
|
|
180
|
+
return base64.b64decode(v).hex()
|
|
181
|
+
except ValueError:
|
|
182
|
+
return v
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def decode_json(doc):
|
|
186
|
+
out = []
|
|
187
|
+
for rs in doc.get("resourceSpans", []):
|
|
188
|
+
res_attrs = _json_attrs((rs.get("resource") or {}).get("attributes"))
|
|
189
|
+
for ss in rs.get("scopeSpans", []) or rs.get("instrumentationLibrarySpans", []):
|
|
190
|
+
scope = (ss.get("scope") or ss.get("instrumentationLibrary") or {}).get("name", "")
|
|
191
|
+
for sp in ss.get("spans", []):
|
|
192
|
+
out.append((res_attrs, scope, {
|
|
193
|
+
"traceId": _hexid(sp.get("traceId")), "spanId": _hexid(sp.get("spanId")),
|
|
194
|
+
"parentSpanId": _hexid(sp.get("parentSpanId")), "name": sp.get("name"), "kind": sp.get("kind"),
|
|
195
|
+
"startTimeUnixNano": int(sp.get("startTimeUnixNano") or 0), "endTimeUnixNano": int(sp.get("endTimeUnixNano") or 0),
|
|
196
|
+
"attributes": _json_attrs(sp.get("attributes")),
|
|
197
|
+
"events": [{"name": e.get("name"), "timeUnixNano": int(e.get("timeUnixNano") or 0), "attributes": _json_attrs(e.get("attributes"))}
|
|
198
|
+
for e in sp.get("events", [])],
|
|
199
|
+
"status": {"code": {"STATUS_CODE_ERROR": 2, "STATUS_CODE_OK": 1}.get(sp.get("status", {}).get("code"), sp.get("status", {}).get("code")),
|
|
200
|
+
"message": sp.get("status", {}).get("message")},
|
|
201
|
+
}))
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------- semantic-convention mapping
|
|
206
|
+
|
|
207
|
+
def _g(a, *keys):
|
|
208
|
+
for k in keys:
|
|
209
|
+
v = a.get(k)
|
|
210
|
+
if v not in (None, "", []):
|
|
211
|
+
return v
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _num(v):
|
|
216
|
+
try:
|
|
217
|
+
return float(v)
|
|
218
|
+
except (TypeError, ValueError):
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _metadata(a):
|
|
223
|
+
md = a.get("metadata")
|
|
224
|
+
if isinstance(md, str):
|
|
225
|
+
try:
|
|
226
|
+
md = json.loads(md)
|
|
227
|
+
except ValueError:
|
|
228
|
+
md = {}
|
|
229
|
+
out = dict(md) if isinstance(md, dict) else {}
|
|
230
|
+
for k, v in a.items():
|
|
231
|
+
if k.startswith("metadata.") or k.startswith("langsmith.metadata."):
|
|
232
|
+
out[k.split("metadata.", 1)[1]] = v
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
OI_KIND = {"LLM": "llm", "TOOL": "tool", "CHAIN": "chain", "AGENT": "agent", "RETRIEVER": "retriever", "EMBEDDING": "embedding",
|
|
237
|
+
"RERANKER": "retriever", "GUARDRAIL": "guardrail", "EVALUATOR": "evaluator"}
|
|
238
|
+
GENAI_OP = {"chat": "llm", "text_completion": "llm", "generate_content": "llm", "completion": "llm", "execute_tool": "tool",
|
|
239
|
+
"invoke_agent": "agent", "create_agent": "agent", "embeddings": "embedding", "handoff": "handoff"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _doc_count(a, kind):
|
|
243
|
+
"""Retrieved documents: explicit count, a list, or OpenInference's flattened retrieval.documents.<i>.* keys."""
|
|
244
|
+
if kind != "retriever":
|
|
245
|
+
return None
|
|
246
|
+
if _num(a.get("retrieval.documents.count")) is not None:
|
|
247
|
+
return int(_num(a["retrieval.documents.count"]))
|
|
248
|
+
if isinstance(a.get("retrieval.documents"), list):
|
|
249
|
+
return len(a["retrieval.documents"])
|
|
250
|
+
idx = {k.split(".")[2] for k in a if k.startswith("retrieval.documents.") and k.split(".")[2].isdigit()}
|
|
251
|
+
return len(idx)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def map_span(res, scope, sp):
|
|
255
|
+
a = sp["attributes"]
|
|
256
|
+
md = _metadata(a)
|
|
257
|
+
oi = str(a.get("openinference.span.kind") or "").upper()
|
|
258
|
+
tl = str(a.get("traceloop.span.kind") or "").lower()
|
|
259
|
+
ls = str(a.get("langsmith.span.kind") or "").lower()
|
|
260
|
+
op = str(a.get("gen_ai.operation.name") or "").lower()
|
|
261
|
+
if oi in OI_KIND:
|
|
262
|
+
kind = OI_KIND[oi]
|
|
263
|
+
elif op in GENAI_OP:
|
|
264
|
+
kind = GENAI_OP[op]
|
|
265
|
+
elif ls:
|
|
266
|
+
kind = {"llm": "llm", "tool": "tool", "retriever": "retriever", "embedding": "embedding", "chain": "chain", "prompt": "chain", "parser": "chain"}.get(ls, "chain")
|
|
267
|
+
elif tl:
|
|
268
|
+
kind = {"workflow": "chain", "task": "chain", "agent": "agent", "tool": "tool"}.get(tl, "chain")
|
|
269
|
+
elif _g(a, "gen_ai.request.model", "llm.model_name", "gen_ai.response.model"):
|
|
270
|
+
kind = "llm"
|
|
271
|
+
elif a.get("gen_ai.tool.name") or a.get("tool.name"):
|
|
272
|
+
kind = "tool"
|
|
273
|
+
else:
|
|
274
|
+
kind = "span"
|
|
275
|
+
if md.get("langgraph_node") and kind == "chain" and sp.get("name") == md.get("langgraph_node"):
|
|
276
|
+
kind = "node"
|
|
277
|
+
|
|
278
|
+
status = sp.get("status") or {}
|
|
279
|
+
err = None
|
|
280
|
+
if status.get("code") == 2:
|
|
281
|
+
err = status.get("message") or "error"
|
|
282
|
+
for ev in sp.get("events", []):
|
|
283
|
+
if ev.get("name") == "exception":
|
|
284
|
+
ea = ev.get("attributes", {})
|
|
285
|
+
err = f"{ea.get('exception.type', '')}: {ea.get('exception.message', '')}".strip(": ") or err
|
|
286
|
+
finish = _g(a, "gen_ai.response.finish_reasons", "llm.finish_reason", "gen_ai.response.finish_reason")
|
|
287
|
+
if isinstance(finish, list):
|
|
288
|
+
finish = finish[0] if finish else None
|
|
289
|
+
ttft = _num(_g(a, "gen_ai.response.time_to_first_token", "gen_ai.server.time_to_first_token", "llm.time_to_first_token"))
|
|
290
|
+
if ttft is not None and ttft < 100: # seconds -> ms
|
|
291
|
+
ttft *= 1000
|
|
292
|
+
start = sp["startTimeUnixNano"] / 1e9 if sp.get("startTimeUnixNano") else None
|
|
293
|
+
end = sp["endTimeUnixNano"] / 1e9 if sp.get("endTimeUnixNano") else None
|
|
294
|
+
inp = _g(a, "input.value", "gen_ai.prompt", "traceloop.entity.input", "gen_ai.input.messages", "langsmith.inputs", "gen_ai.prompt.0.content",
|
|
295
|
+
"gen_ai.tool.call.arguments")
|
|
296
|
+
out = _g(a, "output.value", "gen_ai.completion", "traceloop.entity.output", "gen_ai.output.messages", "langsmith.outputs",
|
|
297
|
+
"gen_ai.completion.0.content", "gen_ai.tool.call.result")
|
|
298
|
+
name = sp.get("name")
|
|
299
|
+
if kind == "tool":
|
|
300
|
+
name = _g(a, "gen_ai.tool.name", "tool.name") or name
|
|
301
|
+
fw = "otel"
|
|
302
|
+
if oi:
|
|
303
|
+
fw = "openinference"
|
|
304
|
+
elif tl:
|
|
305
|
+
fw = "openllmetry"
|
|
306
|
+
elif ls:
|
|
307
|
+
fw = "langsmith-otel"
|
|
308
|
+
elif op:
|
|
309
|
+
fw = "genai-semconv"
|
|
310
|
+
if md.get("langgraph_node") or "langgraph" in (scope or "").lower():
|
|
311
|
+
fw = "langgraph"
|
|
312
|
+
feedback = []
|
|
313
|
+
for k, v in a.items():
|
|
314
|
+
if k.startswith("feedback.") and _num(v) is not None:
|
|
315
|
+
feedback.append({"key": k[9:], "score": _num(v)})
|
|
316
|
+
return {
|
|
317
|
+
"trace_id": sp.get("traceId"), "span_id": sp.get("spanId"), "parent_id": sp.get("parentSpanId") or None,
|
|
318
|
+
"name": name, "kind": kind, "start": start, "end": end, "status": "error" if err else "ok", "error": err,
|
|
319
|
+
"model": _g(a, "gen_ai.response.model", "gen_ai.request.model", "llm.model_name", "llm.request.model"),
|
|
320
|
+
"provider": _g(a, "gen_ai.system", "gen_ai.provider.name", "llm.provider", "llm.system"),
|
|
321
|
+
"input_tokens": _num(_g(a, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "llm.token_count.prompt", "llm.usage.prompt_tokens")) or 0,
|
|
322
|
+
"output_tokens": _num(_g(a, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "llm.token_count.completion", "llm.usage.completion_tokens")) or 0,
|
|
323
|
+
"cache_read": _num(_g(a, "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_read_input_tokens", "llm.token_count.prompt_details.cache_read",
|
|
324
|
+
"gen_ai.usage.input_tokens.cached")) or 0,
|
|
325
|
+
"cache_write": _num(_g(a, "gen_ai.usage.cache_creation.input_tokens", "gen_ai.usage.cache_creation_input_tokens",
|
|
326
|
+
"llm.token_count.prompt_details.cache_write")) or 0,
|
|
327
|
+
"thinking_tokens": _num(_g(a, "gen_ai.usage.reasoning_tokens", "llm.token_count.completion_details.reasoning")) or 0,
|
|
328
|
+
"cost": _num(_g(a, "gen_ai.usage.cost", "llm.cost.total", "langsmith.total_cost")),
|
|
329
|
+
"stop_reason": finish, "ttft_ms": ttft, "input": inp, "output": out,
|
|
330
|
+
"node": md.get("langgraph_node") or a.get("langgraph.node") or a.get("graph.node.id"),
|
|
331
|
+
"agent": _g(a, "gen_ai.agent.name", "agent.name", "graph.node.parent_id") or md.get("agent_name"),
|
|
332
|
+
"workflow": _g(a, "traceloop.workflow.name", "gen_ai.workflow.name") or None,
|
|
333
|
+
"project": _g(res, "service.name") if _g(res, "service.name") not in (None, "unknown_service") else md.get("project"),
|
|
334
|
+
"environment": _g(res, "deployment.environment.name", "deployment.environment") or md.get("environment"),
|
|
335
|
+
"session_id": _g(a, "session.id", "gen_ai.conversation.id", "langsmith.trace.session_id") or md.get("thread_id") or md.get("session_id"),
|
|
336
|
+
"user_id": _g(a, "user.id", "enduser.id") or md.get("user_id"),
|
|
337
|
+
"framework": fw, "docs": _doc_count(a, kind),
|
|
338
|
+
"feedback": feedback, "tags": a.get("tag.tags") if isinstance(a.get("tag.tags"), list) else [],
|
|
339
|
+
"source": "otlp",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def parse_request(body, content_type="application/json"):
|
|
344
|
+
"""Return canonical spans from an OTLP/HTTP export request body."""
|
|
345
|
+
if "protobuf" in (content_type or "") or (body[:1] not in (b"{", b"[") and body):
|
|
346
|
+
records = decode_protobuf(body)
|
|
347
|
+
else:
|
|
348
|
+
records = decode_json(json.loads(body))
|
|
349
|
+
return [map_span(r, sc, sp) for r, sc, sp in records if sp.get("traceId") and sp.get("spanId")]
|