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,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")]