failstep 0.1.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.
- failstep/__init__.py +3 -0
- failstep/__main__.py +4 -0
- failstep/adapters.py +628 -0
- failstep/cli.py +227 -0
- failstep/compare.py +119 -0
- failstep/detectors/__init__.py +29 -0
- failstep/detectors/malformed.py +115 -0
- failstep/detectors/retrieval.py +292 -0
- failstep/detectors/retry.py +52 -0
- failstep/detectors/schema.py +134 -0
- failstep/detectors/timeout.py +83 -0
- failstep/detectors/tool_error.py +95 -0
- failstep/diagnose.py +56 -0
- failstep/errors.py +11 -0
- failstep/evidence.py +67 -0
- failstep/llm.py +234 -0
- failstep/models.py +93 -0
- failstep/normalize.py +147 -0
- failstep/parser.py +137 -0
- failstep/redact.py +25 -0
- failstep/report.py +617 -0
- failstep-0.1.0.dist-info/METADATA +159 -0
- failstep-0.1.0.dist-info/RECORD +26 -0
- failstep-0.1.0.dist-info/WHEEL +4 -0
- failstep-0.1.0.dist-info/entry_points.txt +2 -0
- failstep-0.1.0.dist-info/licenses/LICENSE +21 -0
failstep/__init__.py
ADDED
failstep/__main__.py
ADDED
failstep/adapters.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def adapt(data: Any) -> dict[str, Any] | None:
|
|
8
|
+
if isinstance(data, dict) and _is_openai(data):
|
|
9
|
+
payload = _openai(data)
|
|
10
|
+
elif isinstance(data, dict) and _is_langchain(data):
|
|
11
|
+
payload = _langchain(data)
|
|
12
|
+
elif _is_otel(data):
|
|
13
|
+
payload = _otel(data)
|
|
14
|
+
else:
|
|
15
|
+
return None
|
|
16
|
+
steps = payload.get("steps")
|
|
17
|
+
if not isinstance(steps, list) or not steps:
|
|
18
|
+
return None
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_otel_payload(data: Any) -> bool:
|
|
23
|
+
return _is_otel(data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_openai(data: dict[str, Any]) -> bool:
|
|
27
|
+
messages = data.get("messages")
|
|
28
|
+
if not isinstance(messages, list) or not messages:
|
|
29
|
+
return False
|
|
30
|
+
return any(isinstance(item, dict) and "role" in item for item in messages)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_langchain(data: dict[str, Any]) -> bool:
|
|
34
|
+
steps = data.get("intermediate_steps")
|
|
35
|
+
return isinstance(steps, list) and bool(steps)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _openai(data: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
steps: list[dict[str, Any]] = []
|
|
40
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
41
|
+
for message in data.get("messages") or []:
|
|
42
|
+
if not isinstance(message, dict):
|
|
43
|
+
continue
|
|
44
|
+
role = message.get("role")
|
|
45
|
+
if role == "assistant":
|
|
46
|
+
tool_calls = message.get("tool_calls") or []
|
|
47
|
+
content = message.get("content")
|
|
48
|
+
if content and not tool_calls:
|
|
49
|
+
steps.append(
|
|
50
|
+
{
|
|
51
|
+
"type": "llm",
|
|
52
|
+
"name": "assistant",
|
|
53
|
+
"output": content,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
if not isinstance(tool_calls, list):
|
|
57
|
+
continue
|
|
58
|
+
for call in tool_calls:
|
|
59
|
+
if not isinstance(call, dict):
|
|
60
|
+
continue
|
|
61
|
+
fn = (
|
|
62
|
+
call.get("function")
|
|
63
|
+
if isinstance(call.get("function"), dict)
|
|
64
|
+
else {}
|
|
65
|
+
)
|
|
66
|
+
args: Any = fn.get("arguments", {})
|
|
67
|
+
if isinstance(args, str):
|
|
68
|
+
try:
|
|
69
|
+
args = json.loads(args)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
pass
|
|
72
|
+
step = {
|
|
73
|
+
"id": call.get("id"),
|
|
74
|
+
"type": "tool",
|
|
75
|
+
"name": fn.get("name") or "tool",
|
|
76
|
+
"input": args,
|
|
77
|
+
}
|
|
78
|
+
call_id = call.get("id")
|
|
79
|
+
if isinstance(call_id, str):
|
|
80
|
+
pending[call_id] = step
|
|
81
|
+
steps.append(step)
|
|
82
|
+
elif role == "tool":
|
|
83
|
+
output = _maybe_json(message.get("content"))
|
|
84
|
+
error = message.get("error")
|
|
85
|
+
if not (isinstance(error, str) and error):
|
|
86
|
+
error = None
|
|
87
|
+
if error is None and isinstance(output, str) and _looks_error(output):
|
|
88
|
+
error = output
|
|
89
|
+
output = None
|
|
90
|
+
call_id = message.get("tool_call_id")
|
|
91
|
+
target = pending.get(call_id) if isinstance(call_id, str) else None
|
|
92
|
+
if target is not None:
|
|
93
|
+
target["output"] = output
|
|
94
|
+
if error:
|
|
95
|
+
target["error"] = error
|
|
96
|
+
else:
|
|
97
|
+
steps.append(
|
|
98
|
+
{
|
|
99
|
+
"type": "tool",
|
|
100
|
+
"name": message.get("name") or "tool",
|
|
101
|
+
"output": output,
|
|
102
|
+
"error": error,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
run_id = data.get("run_id") or data.get("id") or "openai-run"
|
|
106
|
+
if not isinstance(run_id, str):
|
|
107
|
+
run_id = "openai-run"
|
|
108
|
+
return {
|
|
109
|
+
"run_id": run_id,
|
|
110
|
+
"status": data.get("status") or "unknown",
|
|
111
|
+
"steps": steps,
|
|
112
|
+
"tokens_in": data.get("tokens_in"),
|
|
113
|
+
"tokens_out": data.get("tokens_out"),
|
|
114
|
+
"duration_ms": data.get("duration_ms"),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _langchain(data: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
steps: list[dict[str, Any]] = []
|
|
120
|
+
for item in data.get("intermediate_steps") or []:
|
|
121
|
+
action, observation = _pair(item)
|
|
122
|
+
tool = action.get("tool") or action.get("name") or "tool"
|
|
123
|
+
tool_input = action.get("tool_input", action.get("input"))
|
|
124
|
+
error = None
|
|
125
|
+
output: Any = observation
|
|
126
|
+
if isinstance(observation, str) and _looks_error(observation):
|
|
127
|
+
error = observation
|
|
128
|
+
output = None
|
|
129
|
+
steps.append(
|
|
130
|
+
{
|
|
131
|
+
"type": "tool",
|
|
132
|
+
"name": tool,
|
|
133
|
+
"input": tool_input,
|
|
134
|
+
"output": output,
|
|
135
|
+
"error": error,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
final = data.get("output")
|
|
139
|
+
if final is not None:
|
|
140
|
+
steps.append({"type": "llm", "name": "output", "output": final})
|
|
141
|
+
run_id = data.get("run_id") or "langchain-run"
|
|
142
|
+
if not isinstance(run_id, str):
|
|
143
|
+
run_id = "langchain-run"
|
|
144
|
+
return {
|
|
145
|
+
"run_id": run_id,
|
|
146
|
+
"status": data.get("status") or "unknown",
|
|
147
|
+
"steps": steps,
|
|
148
|
+
"tokens_in": data.get("tokens_in"),
|
|
149
|
+
"tokens_out": data.get("tokens_out"),
|
|
150
|
+
"duration_ms": data.get("duration_ms"),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pair(item: Any) -> tuple[dict[str, Any], Any]:
|
|
155
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
156
|
+
action, observation = item[0], item[1]
|
|
157
|
+
elif isinstance(item, dict) and "action" in item:
|
|
158
|
+
action, observation = item.get("action"), item.get("observation")
|
|
159
|
+
else:
|
|
160
|
+
action, observation = item, None
|
|
161
|
+
if not isinstance(action, dict):
|
|
162
|
+
action = {"tool": str(action)}
|
|
163
|
+
return action, observation
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _maybe_json(value: Any) -> Any:
|
|
167
|
+
if not isinstance(value, str):
|
|
168
|
+
return value
|
|
169
|
+
text = value.strip()
|
|
170
|
+
if text[:1] in "{[":
|
|
171
|
+
try:
|
|
172
|
+
return json.loads(text)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
return value
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _looks_error(text: str) -> bool:
|
|
179
|
+
head = text[:80].lower()
|
|
180
|
+
return "error" in head or "traceback" in head or "exception" in head
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_LLM_OPS = {"chat", "generate_content", "text_completion"}
|
|
184
|
+
_TOOL_OPS = {"execute_tool"}
|
|
185
|
+
_RETRIEVAL_OPS = {"retrieval"}
|
|
186
|
+
_WRAPPER_OPS = {"invoke_agent", "invoke_workflow", "create_agent"}
|
|
187
|
+
_ERROR_STATUS = {2, "2", "STATUS_CODE_ERROR", "ERROR", "error"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_otel(data: Any) -> bool:
|
|
191
|
+
return any(_has_gen_ai(span) for span in _collect_spans(data))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _has_gen_ai(span: dict[str, Any]) -> bool:
|
|
195
|
+
attrs = _attr_map(span)
|
|
196
|
+
return any(key.startswith("gen_ai.") for key in attrs)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _collect_spans(data: Any) -> list[dict[str, Any]]:
|
|
200
|
+
if isinstance(data, list):
|
|
201
|
+
if data and all(isinstance(item, dict) for item in data):
|
|
202
|
+
if any(_looks_like_otel_span(item) for item in data):
|
|
203
|
+
return [item for item in data if isinstance(item, dict)]
|
|
204
|
+
return []
|
|
205
|
+
if not isinstance(data, dict):
|
|
206
|
+
return []
|
|
207
|
+
for key in ("resourceSpans", "resource_spans"):
|
|
208
|
+
blocks = data.get(key)
|
|
209
|
+
if not isinstance(blocks, list):
|
|
210
|
+
continue
|
|
211
|
+
out: list[dict[str, Any]] = []
|
|
212
|
+
for block in blocks:
|
|
213
|
+
if not isinstance(block, dict):
|
|
214
|
+
continue
|
|
215
|
+
for scope_key in ("scopeSpans", "scope_spans"):
|
|
216
|
+
scopes = block.get(scope_key) or []
|
|
217
|
+
if not isinstance(scopes, list):
|
|
218
|
+
continue
|
|
219
|
+
for scope in scopes:
|
|
220
|
+
if not isinstance(scope, dict):
|
|
221
|
+
continue
|
|
222
|
+
spans = scope.get("spans")
|
|
223
|
+
if isinstance(spans, list):
|
|
224
|
+
out.extend(
|
|
225
|
+
item for item in spans if isinstance(item, dict)
|
|
226
|
+
)
|
|
227
|
+
if out:
|
|
228
|
+
return out
|
|
229
|
+
spans = data.get("spans")
|
|
230
|
+
if isinstance(spans, list) and any(
|
|
231
|
+
isinstance(item, dict) and _looks_like_otel_span(item) for item in spans
|
|
232
|
+
):
|
|
233
|
+
return [item for item in spans if isinstance(item, dict)]
|
|
234
|
+
steps = data.get("steps")
|
|
235
|
+
if (
|
|
236
|
+
isinstance(steps, list)
|
|
237
|
+
and steps
|
|
238
|
+
and all(
|
|
239
|
+
isinstance(item, dict) and _looks_like_otel_span(item)
|
|
240
|
+
for item in steps
|
|
241
|
+
)
|
|
242
|
+
):
|
|
243
|
+
return [item for item in steps if isinstance(item, dict)]
|
|
244
|
+
if _looks_like_otel_span(data):
|
|
245
|
+
return [data]
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _looks_like_otel_span(item: dict[str, Any]) -> bool:
|
|
250
|
+
attrs = _attr_map(item)
|
|
251
|
+
if any(key.startswith("gen_ai.") for key in attrs):
|
|
252
|
+
return True
|
|
253
|
+
if item.get("spanId") or item.get("span_id") or item.get("traceId"):
|
|
254
|
+
if "attributes" in item or "startTimeUnixNano" in item:
|
|
255
|
+
return True
|
|
256
|
+
context = item.get("context")
|
|
257
|
+
if isinstance(context, dict) and (
|
|
258
|
+
context.get("span_id") or context.get("trace_id")
|
|
259
|
+
):
|
|
260
|
+
if "attributes" in item:
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _otel(data: Any) -> dict[str, Any]:
|
|
266
|
+
spans = _collect_spans(data)
|
|
267
|
+
nanos = [_start_nano(span) for span in spans]
|
|
268
|
+
if nanos and all(value is not None for value in nanos):
|
|
269
|
+
order = sorted(range(len(spans)), key=lambda i: (nanos[i], i))
|
|
270
|
+
else:
|
|
271
|
+
order = list(range(len(spans)))
|
|
272
|
+
|
|
273
|
+
steps: list[dict[str, Any]] = []
|
|
274
|
+
for index in order:
|
|
275
|
+
mapped = _span_to_step(spans[index])
|
|
276
|
+
if mapped is not None:
|
|
277
|
+
steps.append(mapped)
|
|
278
|
+
|
|
279
|
+
tokens_in = _sum_int(step.get("tokens_in") for step in steps)
|
|
280
|
+
tokens_out = _sum_int(step.get("tokens_out") for step in steps)
|
|
281
|
+
duration_ms = _run_duration_ms(spans, steps)
|
|
282
|
+
status = "failed" if any(step.get("error") for step in steps) else "success"
|
|
283
|
+
if status == "success":
|
|
284
|
+
if any(_is_error_status(_status_code(span)) for span in spans):
|
|
285
|
+
status = "failed"
|
|
286
|
+
run_id = _run_id(data, steps)
|
|
287
|
+
return {
|
|
288
|
+
"run_id": run_id,
|
|
289
|
+
"status": status,
|
|
290
|
+
"steps": steps,
|
|
291
|
+
"tokens_in": tokens_in,
|
|
292
|
+
"tokens_out": tokens_out,
|
|
293
|
+
"duration_ms": duration_ms,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _span_to_step(span: dict[str, Any]) -> dict[str, Any] | None:
|
|
298
|
+
attrs = _attr_map(span)
|
|
299
|
+
op = attrs.get("gen_ai.operation.name")
|
|
300
|
+
if not isinstance(op, str) or not op:
|
|
301
|
+
op = _op_from_name(span.get("name"))
|
|
302
|
+
if not op and "gen_ai.tool.name" in attrs:
|
|
303
|
+
op = "execute_tool"
|
|
304
|
+
if not op and "gen_ai.request.model" in attrs:
|
|
305
|
+
op = "chat"
|
|
306
|
+
if not op and not any(key.startswith("gen_ai.") for key in attrs):
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
if op in _TOOL_OPS:
|
|
310
|
+
step_type = "tool"
|
|
311
|
+
name = attrs.get("gen_ai.tool.name") or _name_tail(
|
|
312
|
+
span.get("name"), "tool"
|
|
313
|
+
)
|
|
314
|
+
incoming = _maybe_json(attrs.get("gen_ai.tool.call.arguments"))
|
|
315
|
+
outgoing = _maybe_json(attrs.get("gen_ai.tool.call.result"))
|
|
316
|
+
schema = _tool_schema(attrs, name if isinstance(name, str) else "")
|
|
317
|
+
elif op in _LLM_OPS:
|
|
318
|
+
step_type = "llm"
|
|
319
|
+
name = attrs.get("gen_ai.request.model") or _name_tail(
|
|
320
|
+
span.get("name"), "chat"
|
|
321
|
+
)
|
|
322
|
+
incoming = _maybe_json(attrs.get("gen_ai.input.messages"))
|
|
323
|
+
outgoing = _maybe_json(attrs.get("gen_ai.output.messages"))
|
|
324
|
+
schema = None
|
|
325
|
+
elif op in _RETRIEVAL_OPS:
|
|
326
|
+
step_type = "retrieval"
|
|
327
|
+
name = attrs.get("gen_ai.tool.name") or _name_tail(
|
|
328
|
+
span.get("name"), "retrieval"
|
|
329
|
+
)
|
|
330
|
+
incoming = attrs.get("gen_ai.retrieval.query.text")
|
|
331
|
+
if incoming is not None:
|
|
332
|
+
incoming = {"query": incoming}
|
|
333
|
+
outgoing = _maybe_json(attrs.get("gen_ai.retrieval.documents"))
|
|
334
|
+
schema = None
|
|
335
|
+
else:
|
|
336
|
+
step_type = "other"
|
|
337
|
+
name = attrs.get("gen_ai.agent.name") or _name_tail(
|
|
338
|
+
span.get("name"), op or "span"
|
|
339
|
+
)
|
|
340
|
+
incoming = _maybe_json(attrs.get("gen_ai.input.messages"))
|
|
341
|
+
outgoing = _maybe_json(attrs.get("gen_ai.output.messages"))
|
|
342
|
+
schema = None
|
|
343
|
+
|
|
344
|
+
if not isinstance(name, str) or not name:
|
|
345
|
+
name = op or "span"
|
|
346
|
+
|
|
347
|
+
error = _span_error(span, attrs)
|
|
348
|
+
latency = None if op in _WRAPPER_OPS else _latency_ms(span)
|
|
349
|
+
step: dict[str, Any] = {
|
|
350
|
+
"id": _span_id(span),
|
|
351
|
+
"type": step_type,
|
|
352
|
+
"name": name,
|
|
353
|
+
"input": incoming,
|
|
354
|
+
"output": outgoing,
|
|
355
|
+
"error": error,
|
|
356
|
+
"latency_ms": latency,
|
|
357
|
+
"tokens_in": _as_int(attrs.get("gen_ai.usage.input_tokens")),
|
|
358
|
+
"tokens_out": _as_int(attrs.get("gen_ai.usage.output_tokens")),
|
|
359
|
+
}
|
|
360
|
+
if schema is not None:
|
|
361
|
+
step["schema"] = schema
|
|
362
|
+
parent = _parent_id(span)
|
|
363
|
+
meta: dict[str, Any] = {}
|
|
364
|
+
if parent:
|
|
365
|
+
meta["parent_id"] = parent
|
|
366
|
+
if op:
|
|
367
|
+
meta["gen_ai.operation.name"] = op
|
|
368
|
+
if meta:
|
|
369
|
+
step["metadata"] = meta
|
|
370
|
+
return step
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _tool_schema(attrs: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
|
|
374
|
+
raw = attrs.get("gen_ai.tool.definitions")
|
|
375
|
+
raw = _maybe_json(raw)
|
|
376
|
+
if isinstance(raw, dict):
|
|
377
|
+
raw = [raw]
|
|
378
|
+
if not isinstance(raw, list):
|
|
379
|
+
return None
|
|
380
|
+
for item in raw:
|
|
381
|
+
if not isinstance(item, dict):
|
|
382
|
+
continue
|
|
383
|
+
if tool_name and item.get("name") not in {None, tool_name}:
|
|
384
|
+
continue
|
|
385
|
+
params = item.get("parameters") or item.get("schema")
|
|
386
|
+
if isinstance(params, dict) and (
|
|
387
|
+
"required" in params or "properties" in params
|
|
388
|
+
):
|
|
389
|
+
return params
|
|
390
|
+
if "required" in item or "properties" in item:
|
|
391
|
+
return item
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _span_error(span: dict[str, Any], attrs: dict[str, Any]) -> str | None:
|
|
396
|
+
status = span.get("status") if isinstance(span.get("status"), dict) else {}
|
|
397
|
+
message = status.get("message") or status.get("description")
|
|
398
|
+
if isinstance(message, str) and message.strip():
|
|
399
|
+
return message
|
|
400
|
+
for key in ("exception.message", "error.type", "error.message"):
|
|
401
|
+
value = attrs.get(key)
|
|
402
|
+
if isinstance(value, str) and value.strip():
|
|
403
|
+
return value
|
|
404
|
+
events = span.get("events")
|
|
405
|
+
if isinstance(events, list):
|
|
406
|
+
for event in events:
|
|
407
|
+
if not isinstance(event, dict):
|
|
408
|
+
continue
|
|
409
|
+
name = event.get("name") or event.get("event_name")
|
|
410
|
+
event_attrs = _attr_map(event) if "attributes" in event else event
|
|
411
|
+
if name == "exception" or event_attrs.get("exception.message"):
|
|
412
|
+
text = event_attrs.get("exception.message")
|
|
413
|
+
if isinstance(text, str) and text.strip():
|
|
414
|
+
return text
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _status_code(span: dict[str, Any]) -> Any:
|
|
419
|
+
status = span.get("status")
|
|
420
|
+
if not isinstance(status, dict):
|
|
421
|
+
return None
|
|
422
|
+
if "code" in status:
|
|
423
|
+
return status.get("code")
|
|
424
|
+
return status.get("status_code")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _is_error_status(code: Any) -> bool:
|
|
428
|
+
return code in _ERROR_STATUS
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _attr_map(item: dict[str, Any]) -> dict[str, Any]:
|
|
432
|
+
raw = item.get("attributes")
|
|
433
|
+
if isinstance(raw, dict):
|
|
434
|
+
mapped: dict[str, Any] = {}
|
|
435
|
+
for key, value in raw.items():
|
|
436
|
+
mapped[str(key)] = (
|
|
437
|
+
_maybe_json(value) if isinstance(value, str) else value
|
|
438
|
+
)
|
|
439
|
+
return mapped
|
|
440
|
+
if not isinstance(raw, list):
|
|
441
|
+
return {}
|
|
442
|
+
out: dict[str, Any] = {}
|
|
443
|
+
for entry in raw:
|
|
444
|
+
if not isinstance(entry, dict):
|
|
445
|
+
continue
|
|
446
|
+
key = entry.get("key")
|
|
447
|
+
if not isinstance(key, str):
|
|
448
|
+
continue
|
|
449
|
+
out[key] = _otel_value(entry.get("value"))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _otel_value(value: Any) -> Any:
|
|
454
|
+
if not isinstance(value, dict):
|
|
455
|
+
return _maybe_json(value) if isinstance(value, str) else value
|
|
456
|
+
if "stringValue" in value:
|
|
457
|
+
return _maybe_json(value["stringValue"])
|
|
458
|
+
if "intValue" in value:
|
|
459
|
+
return _as_int(value["intValue"])
|
|
460
|
+
if "doubleValue" in value:
|
|
461
|
+
return value["doubleValue"]
|
|
462
|
+
if "boolValue" in value:
|
|
463
|
+
return value["boolValue"]
|
|
464
|
+
if "arrayValue" in value and isinstance(value["arrayValue"], dict):
|
|
465
|
+
values = value["arrayValue"].get("values") or []
|
|
466
|
+
if isinstance(values, list):
|
|
467
|
+
return [_otel_value(item) for item in values]
|
|
468
|
+
if "kvlistValue" in value and isinstance(value["kvlistValue"], dict):
|
|
469
|
+
values = value["kvlistValue"].get("values") or []
|
|
470
|
+
mapped: dict[str, Any] = {}
|
|
471
|
+
if isinstance(values, list):
|
|
472
|
+
for entry in values:
|
|
473
|
+
if isinstance(entry, dict) and isinstance(entry.get("key"), str):
|
|
474
|
+
mapped[entry["key"]] = _otel_value(entry.get("value"))
|
|
475
|
+
return mapped
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _span_id(span: dict[str, Any]) -> str | None:
|
|
480
|
+
for key in ("spanId", "span_id"):
|
|
481
|
+
value = span.get(key)
|
|
482
|
+
if isinstance(value, str) and value:
|
|
483
|
+
return value
|
|
484
|
+
context = span.get("context")
|
|
485
|
+
if isinstance(context, dict):
|
|
486
|
+
value = context.get("span_id") or context.get("spanId")
|
|
487
|
+
if isinstance(value, str) and value:
|
|
488
|
+
return value
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _parent_id(span: dict[str, Any]) -> str | None:
|
|
493
|
+
for key in ("parentSpanId", "parent_span_id", "parent_id"):
|
|
494
|
+
value = span.get(key)
|
|
495
|
+
if isinstance(value, str) and value:
|
|
496
|
+
return value
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _start_nano(span: dict[str, Any]) -> int | None:
|
|
501
|
+
for key in ("startTimeUnixNano", "start_time_unix_nano", "start_time"):
|
|
502
|
+
nano = _as_nano(span.get(key))
|
|
503
|
+
if nano is not None:
|
|
504
|
+
return nano
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _end_nano(span: dict[str, Any]) -> int | None:
|
|
509
|
+
for key in ("endTimeUnixNano", "end_time_unix_nano", "end_time"):
|
|
510
|
+
nano = _as_nano(span.get(key))
|
|
511
|
+
if nano is not None:
|
|
512
|
+
return nano
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _as_nano(value: Any) -> int | None:
|
|
517
|
+
number = _as_int(value)
|
|
518
|
+
if number is not None:
|
|
519
|
+
return number
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _latency_ms(span: dict[str, Any]) -> int | None:
|
|
524
|
+
start = _start_nano(span)
|
|
525
|
+
end = _end_nano(span)
|
|
526
|
+
if start is None or end is None or end < start:
|
|
527
|
+
return None
|
|
528
|
+
return (end - start) // 1_000_000
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _run_duration_ms(
|
|
532
|
+
spans: list[dict[str, Any]], steps: list[dict[str, Any]]
|
|
533
|
+
) -> int | None:
|
|
534
|
+
starts = [_start_nano(span) for span in spans]
|
|
535
|
+
ends = [_end_nano(span) for span in spans]
|
|
536
|
+
present = [
|
|
537
|
+
(start, end)
|
|
538
|
+
for start, end in zip(starts, ends, strict=True)
|
|
539
|
+
if start is not None and end is not None
|
|
540
|
+
]
|
|
541
|
+
if present:
|
|
542
|
+
first = min(start for start, _end in present)
|
|
543
|
+
last = max(end for _start, end in present)
|
|
544
|
+
return (last - first) // 1_000_000
|
|
545
|
+
latencies = [
|
|
546
|
+
step.get("latency_ms")
|
|
547
|
+
for step in steps
|
|
548
|
+
if isinstance(step.get("latency_ms"), int)
|
|
549
|
+
]
|
|
550
|
+
if latencies:
|
|
551
|
+
return max(latencies)
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _run_id(data: Any, steps: list[dict[str, Any]]) -> str:
|
|
556
|
+
if isinstance(data, dict):
|
|
557
|
+
for key in ("run_id", "id"):
|
|
558
|
+
value = data.get(key)
|
|
559
|
+
if isinstance(value, str) and value:
|
|
560
|
+
return value
|
|
561
|
+
for block_key in ("resourceSpans", "resource_spans"):
|
|
562
|
+
blocks = data.get(block_key)
|
|
563
|
+
if not isinstance(blocks, list):
|
|
564
|
+
continue
|
|
565
|
+
for block in blocks:
|
|
566
|
+
if not isinstance(block, dict):
|
|
567
|
+
continue
|
|
568
|
+
resource = block.get("resource")
|
|
569
|
+
if not isinstance(resource, dict):
|
|
570
|
+
continue
|
|
571
|
+
attrs = _attr_map(resource)
|
|
572
|
+
name = attrs.get("service.name")
|
|
573
|
+
if isinstance(name, str) and name:
|
|
574
|
+
return name
|
|
575
|
+
for step in steps:
|
|
576
|
+
if step.get("type") == "other" and step.get("name"):
|
|
577
|
+
return str(step["name"])
|
|
578
|
+
return "otel-run"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _op_from_name(name: Any) -> str | None:
|
|
582
|
+
if not isinstance(name, str) or not name:
|
|
583
|
+
return None
|
|
584
|
+
head = name.split()[0]
|
|
585
|
+
known = _LLM_OPS | _TOOL_OPS | _RETRIEVAL_OPS | {
|
|
586
|
+
"invoke_agent",
|
|
587
|
+
"invoke_workflow",
|
|
588
|
+
"create_agent",
|
|
589
|
+
"embeddings",
|
|
590
|
+
}
|
|
591
|
+
if head in known:
|
|
592
|
+
return head
|
|
593
|
+
if name in known:
|
|
594
|
+
return name
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _name_tail(name: Any, default: str) -> str:
|
|
599
|
+
if not isinstance(name, str) or not name:
|
|
600
|
+
return default
|
|
601
|
+
parts = name.split(None, 1)
|
|
602
|
+
if len(parts) == 2:
|
|
603
|
+
return parts[1]
|
|
604
|
+
return name
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _as_int(value: Any) -> int | None:
|
|
608
|
+
if isinstance(value, bool) or value is None:
|
|
609
|
+
return None
|
|
610
|
+
if isinstance(value, int):
|
|
611
|
+
return value
|
|
612
|
+
if isinstance(value, float) and value.is_integer():
|
|
613
|
+
return int(value)
|
|
614
|
+
if isinstance(value, str) and value.lstrip("-").isdigit():
|
|
615
|
+
return int(value)
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _sum_int(values: Any) -> int | None:
|
|
620
|
+
total = 0
|
|
621
|
+
found = False
|
|
622
|
+
for value in values:
|
|
623
|
+
number = _as_int(value)
|
|
624
|
+
if number is None:
|
|
625
|
+
continue
|
|
626
|
+
total += number
|
|
627
|
+
found = True
|
|
628
|
+
return total if found else None
|