evalkeep 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.
- evalkeep/__init__.py +12 -0
- evalkeep/__main__.py +6 -0
- evalkeep/adapters/__init__.py +45 -0
- evalkeep/adapters/base.py +92 -0
- evalkeep/adapters/jsonl.py +164 -0
- evalkeep/adapters/langsmith.py +436 -0
- evalkeep/adapters/otlp.py +442 -0
- evalkeep/adapters/semconv.py +208 -0
- evalkeep/analysis.py +174 -0
- evalkeep/analysis_run.py +160 -0
- evalkeep/analyzers/__init__.py +52 -0
- evalkeep/analyzers/anthropic.py +145 -0
- evalkeep/analyzers/stub.py +34 -0
- evalkeep/cache.py +122 -0
- evalkeep/cli.py +1933 -0
- evalkeep/clustering.py +383 -0
- evalkeep/clusters.py +101 -0
- evalkeep/commands/__init__.py +1 -0
- evalkeep/commands/analyze_cmd.py +100 -0
- evalkeep/commands/compare_cmd.py +169 -0
- evalkeep/commands/dataset_cmd.py +182 -0
- evalkeep/commands/detect_cmd.py +154 -0
- evalkeep/commands/discover_cmd.py +274 -0
- evalkeep/commands/ingest_cmd.py +50 -0
- evalkeep/commands/init_cmd.py +151 -0
- evalkeep/commands/pipeline_cmd.py +156 -0
- evalkeep/commands/review_cmd.py +141 -0
- evalkeep/commands/run_cmd.py +131 -0
- evalkeep/commands/target_cmd.py +109 -0
- evalkeep/commands/trace_cmd.py +58 -0
- evalkeep/comparison.py +432 -0
- evalkeep/config.py +209 -0
- evalkeep/detection.py +94 -0
- evalkeep/detectors.py +182 -0
- evalkeep/discovery.py +208 -0
- evalkeep/embeddings/__init__.py +31 -0
- evalkeep/embeddings/base.py +32 -0
- evalkeep/embeddings/hashing.py +98 -0
- evalkeep/errors.py +42 -0
- evalkeep/examples/__init__.py +37 -0
- evalkeep/examples/langsmith/runs.jsonl +18 -0
- evalkeep/examples/opentelemetry/spans.json +898 -0
- evalkeep/examples/refund-agent/agents/baseline.py +66 -0
- evalkeep/examples/refund-agent/agents/candidate.py +66 -0
- evalkeep/examples/refund-agent/traces.jsonl +5 -0
- evalkeep/examples/tau-bench/prepare.py +230 -0
- evalkeep/exporters/__init__.py +45 -0
- evalkeep/exporters/generic.py +31 -0
- evalkeep/exporters/promptfoo.py +219 -0
- evalkeep/failures.py +95 -0
- evalkeep/generation.py +303 -0
- evalkeep/hashing.py +56 -0
- evalkeep/ingest.py +257 -0
- evalkeep/prompts.py +127 -0
- evalkeep/pseudonyms.py +82 -0
- evalkeep/py.typed +0 -0
- evalkeep/redaction.py +333 -0
- evalkeep/regression.py +409 -0
- evalkeep/review.py +309 -0
- evalkeep/runner.py +302 -0
- evalkeep/runs.py +185 -0
- evalkeep/storage/__init__.py +37 -0
- evalkeep/storage/clusters.py +163 -0
- evalkeep/storage/failures.py +254 -0
- evalkeep/storage/migrations.py +370 -0
- evalkeep/storage/regression.py +136 -0
- evalkeep/storage/runs.py +223 -0
- evalkeep/storage/store.py +429 -0
- evalkeep/targets.py +205 -0
- evalkeep/trace.py +238 -0
- evalkeep-0.1.0.dist-info/METADATA +221 -0
- evalkeep-0.1.0.dist-info/RECORD +75 -0
- evalkeep-0.1.0.dist-info/WHEEL +4 -0
- evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
- evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Reading OpenTelemetry traces exported as OTLP JSON.
|
|
2
|
+
|
|
3
|
+
This is the adapter that matters most for adoption, because OTel is the hub
|
|
4
|
+
rather than another vendor: Langfuse, Braintrust and Phoenix all ingest OTLP, so
|
|
5
|
+
an application instrumented for any of them can be pointed here without changing
|
|
6
|
+
its instrumentation.
|
|
7
|
+
|
|
8
|
+
**One OTel trace becomes one Evalkeep trace.** Spans sharing a trace ID are a
|
|
9
|
+
single interaction, and Evalkeep's unit is the interaction, so they are grouped
|
|
10
|
+
rather than emitted one by one.
|
|
11
|
+
|
|
12
|
+
That grouping is the one place this adapter differs from the JSONL one, which
|
|
13
|
+
streams a trace per line. Spans of a trace can appear anywhere in an export, so
|
|
14
|
+
they are held until the file ends. Memory is therefore proportional to the file,
|
|
15
|
+
not constant -- see the measured figures in `docs/pipeline.md`. Split very large
|
|
16
|
+
exports by time range.
|
|
17
|
+
|
|
18
|
+
**What it does not do.** OTel records what an application did, not whether the
|
|
19
|
+
answer was any good. A span with an ERROR status is real evidence and becomes an
|
|
20
|
+
``error`` outcome; everything else becomes ``unknown``, and it is detection's job
|
|
21
|
+
to say it found nothing. Inventing a `success` here would be inventing evidence.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from collections.abc import Iterable, Iterator
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, ClassVar
|
|
33
|
+
|
|
34
|
+
from pydantic import ValidationError
|
|
35
|
+
|
|
36
|
+
from evalkeep.adapters.base import AdapterRecord, IssueKind, TraceIssue
|
|
37
|
+
from evalkeep.adapters.semconv import (
|
|
38
|
+
GEN_AI_REQUEST_MODEL,
|
|
39
|
+
GEN_AI_SYSTEM,
|
|
40
|
+
GEN_AI_TOOL_ARGUMENTS,
|
|
41
|
+
GEN_AI_TOOL_NAME,
|
|
42
|
+
INPUT_VALUE,
|
|
43
|
+
KIND_TOOL,
|
|
44
|
+
LLM_MODEL_NAME,
|
|
45
|
+
OUTPUT_VALUE,
|
|
46
|
+
SERVICE_NAME,
|
|
47
|
+
SPAN_KIND,
|
|
48
|
+
TOOL_NAME,
|
|
49
|
+
TOOL_PARAMETERS,
|
|
50
|
+
decode_attributes,
|
|
51
|
+
json_object,
|
|
52
|
+
messages,
|
|
53
|
+
text,
|
|
54
|
+
tool_calls,
|
|
55
|
+
)
|
|
56
|
+
from evalkeep.trace import NormalizedTrace
|
|
57
|
+
|
|
58
|
+
#: OTLP status codes. 2 is ERROR; 0 is unset and 1 is OK.
|
|
59
|
+
_STATUS_ERROR = 2
|
|
60
|
+
|
|
61
|
+
_NANOSECONDS = 1_000_000_000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Span:
|
|
66
|
+
"""One OTel span, decoded far enough to be useful."""
|
|
67
|
+
|
|
68
|
+
trace_id: str
|
|
69
|
+
span_id: str
|
|
70
|
+
parent_span_id: str | None
|
|
71
|
+
name: str
|
|
72
|
+
start_nanos: int
|
|
73
|
+
end_nanos: int
|
|
74
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
resource: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
status_code: int = 0
|
|
77
|
+
status_message: str | None = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def errored(self) -> bool:
|
|
81
|
+
return self.status_code == _STATUS_ERROR
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def kind(self) -> str:
|
|
85
|
+
value = self.attributes.get(SPAN_KIND)
|
|
86
|
+
return value.upper() if isinstance(value, str) else ""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def started_at(self) -> datetime | None:
|
|
90
|
+
if not self.start_nanos:
|
|
91
|
+
return None
|
|
92
|
+
return datetime.fromtimestamp(self.start_nanos / _NANOSECONDS, tz=UTC)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class OtlpAdapter:
|
|
96
|
+
"""Reads OTLP JSON, in either of the two shapes exporters produce."""
|
|
97
|
+
|
|
98
|
+
name: ClassVar[str] = "otlp"
|
|
99
|
+
description: ClassVar[str] = (
|
|
100
|
+
"OpenTelemetry spans as OTLP JSON (OpenInference or gen_ai conventions)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def read(self, path: Path) -> Iterator[AdapterRecord]:
|
|
104
|
+
try:
|
|
105
|
+
documents = list(_documents(path))
|
|
106
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
107
|
+
yield AdapterRecord.rejected(
|
|
108
|
+
1,
|
|
109
|
+
TraceIssue(
|
|
110
|
+
line=1, kind=IssueKind.ENCODING, message=f"could not read {path}: {exc}"
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
spans: list[Span] = []
|
|
116
|
+
for line, document in documents:
|
|
117
|
+
if isinstance(document, Exception):
|
|
118
|
+
yield AdapterRecord.rejected(
|
|
119
|
+
line,
|
|
120
|
+
TraceIssue(
|
|
121
|
+
line=line,
|
|
122
|
+
kind=IssueKind.JSON,
|
|
123
|
+
message=f"invalid JSON: {document}",
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
spans.extend(_spans(document))
|
|
128
|
+
|
|
129
|
+
yield from self._group(spans)
|
|
130
|
+
|
|
131
|
+
def _group(self, spans: list[Span]) -> Iterator[AdapterRecord]:
|
|
132
|
+
"""One record per OTel trace, in first-seen order."""
|
|
133
|
+
grouped: dict[str, list[Span]] = {}
|
|
134
|
+
for span in spans:
|
|
135
|
+
grouped.setdefault(span.trace_id, []).append(span)
|
|
136
|
+
|
|
137
|
+
for line, (trace_id, group) in enumerate(grouped.items(), start=1):
|
|
138
|
+
group.sort(key=lambda span: (span.start_nanos, span.span_id))
|
|
139
|
+
try:
|
|
140
|
+
yield AdapterRecord.valid(line, _build(trace_id, group))
|
|
141
|
+
except ValidationError as exc:
|
|
142
|
+
yield AdapterRecord.rejected(
|
|
143
|
+
line,
|
|
144
|
+
*[
|
|
145
|
+
TraceIssue(
|
|
146
|
+
line=line,
|
|
147
|
+
kind=IssueKind.SCHEMA,
|
|
148
|
+
message=detail["msg"],
|
|
149
|
+
trace_id=trace_id,
|
|
150
|
+
field=".".join(str(part) for part in detail["loc"]) or None,
|
|
151
|
+
)
|
|
152
|
+
for detail in exc.errors()
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _documents(path: Path) -> Iterable[tuple[int, Any]]:
|
|
158
|
+
"""Yield each JSON document in the file, with the line it started on.
|
|
159
|
+
|
|
160
|
+
Exporters produce either one JSON document per file or one per line; both
|
|
161
|
+
are common enough that requiring the right one would just be a papercut.
|
|
162
|
+
"""
|
|
163
|
+
raw = path.read_text(encoding="utf-8")
|
|
164
|
+
stripped = raw.strip()
|
|
165
|
+
if not stripped:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
yield 1, json.loads(stripped)
|
|
170
|
+
return
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
175
|
+
if not line.strip():
|
|
176
|
+
continue
|
|
177
|
+
try:
|
|
178
|
+
yield line_number, json.loads(line)
|
|
179
|
+
except json.JSONDecodeError as exc:
|
|
180
|
+
yield line_number, exc
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _spans(document: Any) -> Iterator[Span]:
|
|
184
|
+
"""Walk the OTLP envelope down to individual spans."""
|
|
185
|
+
if not isinstance(document, dict):
|
|
186
|
+
return
|
|
187
|
+
for resource_spans in document.get("resourceSpans") or []:
|
|
188
|
+
if not isinstance(resource_spans, dict):
|
|
189
|
+
continue
|
|
190
|
+
resource = decode_attributes((resource_spans.get("resource") or {}).get("attributes"))
|
|
191
|
+
for scope_spans in resource_spans.get("scopeSpans") or []:
|
|
192
|
+
if not isinstance(scope_spans, dict):
|
|
193
|
+
continue
|
|
194
|
+
for raw in scope_spans.get("spans") or []:
|
|
195
|
+
span = _span(raw, resource)
|
|
196
|
+
if span is not None:
|
|
197
|
+
yield span
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _span(raw: Any, resource: dict[str, Any]) -> Span | None:
|
|
201
|
+
if not isinstance(raw, dict):
|
|
202
|
+
return None
|
|
203
|
+
trace_id = raw.get("traceId")
|
|
204
|
+
span_id = raw.get("spanId")
|
|
205
|
+
if not isinstance(trace_id, str) or not trace_id:
|
|
206
|
+
return None
|
|
207
|
+
status = raw.get("status") or {}
|
|
208
|
+
return Span(
|
|
209
|
+
trace_id=trace_id,
|
|
210
|
+
span_id=span_id if isinstance(span_id, str) else "",
|
|
211
|
+
parent_span_id=raw.get("parentSpanId") or None,
|
|
212
|
+
name=str(raw.get("name") or ""),
|
|
213
|
+
start_nanos=_nanos(raw.get("startTimeUnixNano")),
|
|
214
|
+
end_nanos=_nanos(raw.get("endTimeUnixNano")),
|
|
215
|
+
attributes=decode_attributes(raw.get("attributes")),
|
|
216
|
+
resource=resource,
|
|
217
|
+
status_code=int(status.get("code") or 0),
|
|
218
|
+
status_message=status.get("message") or None,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _nanos(value: Any) -> int:
|
|
223
|
+
try:
|
|
224
|
+
return int(value)
|
|
225
|
+
except (TypeError, ValueError):
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _build(trace_id: str, spans: list[Span]) -> NormalizedTrace:
|
|
230
|
+
"""Assemble one Evalkeep trace from the spans of one OTel trace."""
|
|
231
|
+
root = _root(spans)
|
|
232
|
+
payload: dict[str, Any] = {
|
|
233
|
+
"trace_id": trace_id,
|
|
234
|
+
"input": _input(root, spans),
|
|
235
|
+
"events": _events(spans),
|
|
236
|
+
"outcome": _outcome(spans),
|
|
237
|
+
"metadata": _metadata(root, spans),
|
|
238
|
+
}
|
|
239
|
+
output = _output(root)
|
|
240
|
+
if output is not None:
|
|
241
|
+
payload["output"] = output
|
|
242
|
+
return NormalizedTrace.model_validate(payload)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _root(spans: list[Span]) -> Span:
|
|
246
|
+
"""The span nothing else in this trace parents, or the earliest one."""
|
|
247
|
+
known = {span.span_id for span in spans}
|
|
248
|
+
for span in spans:
|
|
249
|
+
if not span.parent_span_id or span.parent_span_id not in known:
|
|
250
|
+
return span
|
|
251
|
+
return spans[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _input(root: Span, spans: list[Span]) -> dict[str, Any]:
|
|
255
|
+
"""What the interaction was asked to do.
|
|
256
|
+
|
|
257
|
+
Falls back through the trace when the root span carries nothing, because
|
|
258
|
+
instrumentation often records the prompt on the first LLM span rather than
|
|
259
|
+
on the enclosing agent span.
|
|
260
|
+
"""
|
|
261
|
+
for span in [root, *spans]:
|
|
262
|
+
conversation = messages(span.attributes)
|
|
263
|
+
if conversation:
|
|
264
|
+
return {"messages": conversation}
|
|
265
|
+
value = text(span.attributes, INPUT_VALUE)
|
|
266
|
+
if value:
|
|
267
|
+
return {"text": value}
|
|
268
|
+
return {"text": root.name or "(no recorded input)"}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _output(root: Span) -> dict[str, Any] | None:
|
|
272
|
+
conversation = messages(root.attributes, output=True)
|
|
273
|
+
if conversation:
|
|
274
|
+
return {"messages": conversation}
|
|
275
|
+
value = text(root.attributes, OUTPUT_VALUE)
|
|
276
|
+
return {"text": value} if value else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _events(spans: list[Span]) -> list[dict[str, Any]]:
|
|
280
|
+
"""Tool spans become a call and its result; unexecuted intents become calls.
|
|
281
|
+
|
|
282
|
+
A tool span records both the request and what came back, so it produces two
|
|
283
|
+
events -- which is what lets an expectation assert on the arguments and a
|
|
284
|
+
fixture replay the result.
|
|
285
|
+
|
|
286
|
+
The same call is usually recorded twice: the LLM span declares the intent in
|
|
287
|
+
`message.tool_calls`, and a sibling tool span records the execution. Emitting
|
|
288
|
+
both would double every tool call, so an intent that a tool span accounts for
|
|
289
|
+
is dropped, one for one. An intent with no matching span is kept -- a tool
|
|
290
|
+
the agent asked for and never ran is a real observation, and often the
|
|
291
|
+
interesting one.
|
|
292
|
+
"""
|
|
293
|
+
executed = Counter(
|
|
294
|
+
_call_key(_safe_tool_name(name), _tool_arguments(span))
|
|
295
|
+
for span in spans
|
|
296
|
+
if (name := _tool_name(span)) is not None
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
events: list[dict[str, Any]] = []
|
|
300
|
+
for span in spans:
|
|
301
|
+
name = _tool_name(span)
|
|
302
|
+
if name is not None:
|
|
303
|
+
events.extend(_tool_span_events(span, _safe_tool_name(name), len(events)))
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
for index, (tool, arguments) in enumerate(tool_calls(span.attributes)):
|
|
307
|
+
key = _call_key(_safe_tool_name(tool), arguments)
|
|
308
|
+
if executed.get(key, 0) > 0:
|
|
309
|
+
executed[key] -= 1
|
|
310
|
+
continue
|
|
311
|
+
events.append(
|
|
312
|
+
{
|
|
313
|
+
"event_id": f"{span.span_id or len(events)}-tool-{index}",
|
|
314
|
+
"type": "tool_call",
|
|
315
|
+
"tool": _safe_tool_name(tool),
|
|
316
|
+
"arguments": arguments,
|
|
317
|
+
"timestamp": _timestamp(span.start_nanos),
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
return events
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _call_key(tool: str, arguments: dict[str, Any]) -> str:
|
|
324
|
+
return f"{tool}:{json.dumps(arguments, sort_keys=True, default=str)}"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _tool_span_events(span: Span, tool: str, position: int) -> list[dict[str, Any]]:
|
|
328
|
+
call_id = span.span_id or f"call-{position}"
|
|
329
|
+
events: list[dict[str, Any]] = [
|
|
330
|
+
{
|
|
331
|
+
"event_id": f"{span.span_id or position}-call",
|
|
332
|
+
"type": "tool_call",
|
|
333
|
+
"tool": tool,
|
|
334
|
+
"call_id": call_id,
|
|
335
|
+
"arguments": _tool_arguments(span),
|
|
336
|
+
"timestamp": _timestamp(span.start_nanos),
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
result = text(span.attributes, OUTPUT_VALUE)
|
|
340
|
+
if result is not None or span.errored:
|
|
341
|
+
events.append(
|
|
342
|
+
{
|
|
343
|
+
"event_id": f"{span.span_id or position}-result",
|
|
344
|
+
"type": "tool_result",
|
|
345
|
+
"tool": tool,
|
|
346
|
+
"call_id": call_id,
|
|
347
|
+
"result": _maybe_json(result),
|
|
348
|
+
"error": span.status_message if span.errored else None,
|
|
349
|
+
"timestamp": _timestamp(span.end_nanos or span.start_nanos),
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
return events
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _tool_name(span: Span) -> str | None:
|
|
356
|
+
"""The tool this span invoked, if it is a tool span at all."""
|
|
357
|
+
for key in (TOOL_NAME, GEN_AI_TOOL_NAME):
|
|
358
|
+
value = span.attributes.get(key)
|
|
359
|
+
if isinstance(value, str) and value:
|
|
360
|
+
return value
|
|
361
|
+
if span.kind == KIND_TOOL:
|
|
362
|
+
return span.name or None
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _tool_arguments(span: Span) -> dict[str, Any]:
|
|
367
|
+
for key in (TOOL_PARAMETERS, GEN_AI_TOOL_ARGUMENTS, INPUT_VALUE):
|
|
368
|
+
arguments = json_object(span.attributes, key)
|
|
369
|
+
if arguments:
|
|
370
|
+
return arguments
|
|
371
|
+
return {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _safe_tool_name(name: str) -> str:
|
|
375
|
+
"""Coerce a span name into something the trace schema accepts as a tool.
|
|
376
|
+
|
|
377
|
+
Instrumentation names tool spans freely -- "Tool: refund order" is common --
|
|
378
|
+
and the schema requires an identifier, so the alternative to normalizing is
|
|
379
|
+
rejecting traces over a cosmetic difference.
|
|
380
|
+
"""
|
|
381
|
+
cleaned = "".join(character if character.isalnum() else "_" for character in name.strip())
|
|
382
|
+
cleaned = cleaned.strip("_") or "tool"
|
|
383
|
+
if not (cleaned[0].isalpha() or cleaned[0] == "_"):
|
|
384
|
+
cleaned = f"_{cleaned}"
|
|
385
|
+
return cleaned[:128]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _outcome(spans: list[Span]) -> dict[str, Any]:
|
|
389
|
+
"""An errored span is evidence. Everything else is silence, not success."""
|
|
390
|
+
failed = [span for span in spans if span.errored]
|
|
391
|
+
if not failed:
|
|
392
|
+
return {"status": "unknown"}
|
|
393
|
+
return {
|
|
394
|
+
"status": "error",
|
|
395
|
+
"evaluations": [
|
|
396
|
+
{
|
|
397
|
+
"name": span.name or "span",
|
|
398
|
+
"passed": False,
|
|
399
|
+
"reason": span.status_message or "the span reported an error status",
|
|
400
|
+
}
|
|
401
|
+
for span in failed
|
|
402
|
+
],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _metadata(root: Span, spans: list[Span]) -> dict[str, Any]:
|
|
407
|
+
model = text(root.attributes, LLM_MODEL_NAME) or text(root.attributes, GEN_AI_REQUEST_MODEL)
|
|
408
|
+
if model is None:
|
|
409
|
+
for span in spans:
|
|
410
|
+
model = text(span.attributes, LLM_MODEL_NAME) or text(
|
|
411
|
+
span.attributes, GEN_AI_REQUEST_MODEL
|
|
412
|
+
)
|
|
413
|
+
if model:
|
|
414
|
+
break
|
|
415
|
+
started = root.started_at
|
|
416
|
+
return {
|
|
417
|
+
"source": "opentelemetry",
|
|
418
|
+
"agent": text(root.resource, SERVICE_NAME),
|
|
419
|
+
"model": model,
|
|
420
|
+
"recorded_at": started.isoformat() if started else None,
|
|
421
|
+
"extra": {
|
|
422
|
+
"spans": len(spans),
|
|
423
|
+
"root_span": root.name,
|
|
424
|
+
"gen_ai_system": text(root.attributes, GEN_AI_SYSTEM),
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _timestamp(nanos: int) -> str | None:
|
|
430
|
+
if not nanos:
|
|
431
|
+
return None
|
|
432
|
+
return datetime.fromtimestamp(nanos / _NANOSECONDS, tz=UTC).isoformat()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _maybe_json(value: str | None) -> Any:
|
|
436
|
+
"""Tool results are often JSON in a string; keep the structure when so."""
|
|
437
|
+
if value is None:
|
|
438
|
+
return None
|
|
439
|
+
try:
|
|
440
|
+
return json.loads(value)
|
|
441
|
+
except json.JSONDecodeError:
|
|
442
|
+
return value
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Reading OpenTelemetry span attributes, across two competing conventions.
|
|
2
|
+
|
|
3
|
+
OTel attributes are a flat list of typed key/value pairs, so anything nested has
|
|
4
|
+
to be flattened into indexed keys on the way in::
|
|
5
|
+
|
|
6
|
+
llm.input_messages.0.message.role = "user"
|
|
7
|
+
llm.output_messages.0.message.tool_calls.0.tool_call.function.name = "refund_order"
|
|
8
|
+
|
|
9
|
+
Undoing that is most of the work here.
|
|
10
|
+
|
|
11
|
+
The other half is that there is no single convention for GenAI spans. Two are in
|
|
12
|
+
use and neither has won:
|
|
13
|
+
|
|
14
|
+
* **OpenInference** (Arize) -- purpose-built for agents. It models tool calls and
|
|
15
|
+
their arguments directly, which is what Evalkeep's expectations are written
|
|
16
|
+
against, so it is the convention this adapter targets.
|
|
17
|
+
* **OTel GenAI** (``gen_ai.*``) -- broader vendor support, and its messages are
|
|
18
|
+
whole JSON documents rather than flattened fields. Supported as a fallback for
|
|
19
|
+
the fields where the mapping is unambiguous.
|
|
20
|
+
|
|
21
|
+
Where a trace carries both, OpenInference wins: it is the more specific of the
|
|
22
|
+
two, and guessing between them would be worse than preferring the one that can
|
|
23
|
+
actually express a tool call.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# -- OpenInference ---------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
SPAN_KIND = "openinference.span.kind"
|
|
34
|
+
INPUT_VALUE = "input.value"
|
|
35
|
+
OUTPUT_VALUE = "output.value"
|
|
36
|
+
LLM_INPUT_MESSAGES = "llm.input_messages"
|
|
37
|
+
LLM_OUTPUT_MESSAGES = "llm.output_messages"
|
|
38
|
+
MESSAGE_ROLE = "message.role"
|
|
39
|
+
MESSAGE_CONTENT = "message.content"
|
|
40
|
+
MESSAGE_TOOL_CALLS = "message.tool_calls"
|
|
41
|
+
TOOL_CALL_NAME = "tool_call.function.name"
|
|
42
|
+
TOOL_CALL_ARGUMENTS = "tool_call.function.arguments"
|
|
43
|
+
TOOL_NAME = "tool.name"
|
|
44
|
+
TOOL_PARAMETERS = "tool.parameters"
|
|
45
|
+
LLM_MODEL_NAME = "llm.model_name"
|
|
46
|
+
|
|
47
|
+
#: The span kinds Evalkeep reads. Others are carried as context only.
|
|
48
|
+
KIND_TOOL = "TOOL"
|
|
49
|
+
KIND_LLM = "LLM"
|
|
50
|
+
KIND_AGENT = "AGENT"
|
|
51
|
+
KIND_CHAIN = "CHAIN"
|
|
52
|
+
|
|
53
|
+
# -- OTel GenAI ------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
|
|
56
|
+
GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
|
|
57
|
+
GEN_AI_TOOL_NAME = "gen_ai.tool.name"
|
|
58
|
+
GEN_AI_TOOL_ARGUMENTS = "gen_ai.tool.call.arguments"
|
|
59
|
+
GEN_AI_OPERATION = "gen_ai.operation.name"
|
|
60
|
+
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
|
|
61
|
+
GEN_AI_SYSTEM = "gen_ai.system"
|
|
62
|
+
|
|
63
|
+
SERVICE_NAME = "service.name"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_attributes(raw: Any) -> dict[str, Any]:
|
|
67
|
+
"""Turn OTLP's ``[{key, value: {...}}]`` list into a flat dictionary."""
|
|
68
|
+
decoded: dict[str, Any] = {}
|
|
69
|
+
if not isinstance(raw, list):
|
|
70
|
+
return decoded
|
|
71
|
+
for item in raw:
|
|
72
|
+
if not isinstance(item, dict):
|
|
73
|
+
continue
|
|
74
|
+
key = item.get("key")
|
|
75
|
+
if isinstance(key, str):
|
|
76
|
+
decoded[key] = decode_value(item.get("value"))
|
|
77
|
+
return decoded
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def decode_value(value: Any) -> Any:
|
|
81
|
+
"""One OTLP ``AnyValue``. Unknown shapes decode to ``None`` rather than raise."""
|
|
82
|
+
if not isinstance(value, dict):
|
|
83
|
+
return value
|
|
84
|
+
for field in ("stringValue", "boolValue", "doubleValue"):
|
|
85
|
+
if field in value:
|
|
86
|
+
return value[field]
|
|
87
|
+
if "intValue" in value:
|
|
88
|
+
raw = value["intValue"]
|
|
89
|
+
# Protobuf renders 64-bit integers as strings in JSON.
|
|
90
|
+
try:
|
|
91
|
+
return int(raw)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return raw
|
|
94
|
+
if "arrayValue" in value:
|
|
95
|
+
values = (value["arrayValue"] or {}).get("values") or []
|
|
96
|
+
return [decode_value(item) for item in values]
|
|
97
|
+
if "kvlistValue" in value:
|
|
98
|
+
return decode_attributes((value["kvlistValue"] or {}).get("values"))
|
|
99
|
+
if "bytesValue" in value:
|
|
100
|
+
return value["bytesValue"]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def indexed(attributes: dict[str, Any], prefix: str) -> list[dict[str, Any]]:
|
|
105
|
+
"""Collect ``prefix.<i>.rest`` keys back into a list of dictionaries.
|
|
106
|
+
|
|
107
|
+
Ordering follows the index, not the order the attributes happened to arrive
|
|
108
|
+
in, because a message list that reorders itself is a different conversation.
|
|
109
|
+
"""
|
|
110
|
+
grouped: dict[int, dict[str, Any]] = {}
|
|
111
|
+
marker = f"{prefix}."
|
|
112
|
+
for key, value in attributes.items():
|
|
113
|
+
if not key.startswith(marker):
|
|
114
|
+
continue
|
|
115
|
+
remainder = key[len(marker) :]
|
|
116
|
+
index, separator, rest = remainder.partition(".")
|
|
117
|
+
if not separator or not index.isdigit():
|
|
118
|
+
continue
|
|
119
|
+
grouped.setdefault(int(index), {})[rest] = value
|
|
120
|
+
return [grouped[index] for index in sorted(grouped)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def text(attributes: dict[str, Any], key: str) -> str | None:
|
|
124
|
+
"""A string attribute, or ``None`` when absent or empty."""
|
|
125
|
+
value = attributes.get(key)
|
|
126
|
+
if value is None:
|
|
127
|
+
return None
|
|
128
|
+
rendered = value if isinstance(value, str) else json.dumps(value, default=str)
|
|
129
|
+
return rendered or None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def json_object(attributes: dict[str, Any], key: str) -> dict[str, Any]:
|
|
133
|
+
"""An attribute holding a JSON object, however it was encoded.
|
|
134
|
+
|
|
135
|
+
Conventions disagree about whether structured values are JSON strings or
|
|
136
|
+
real key/value lists, so both are accepted rather than one being declared
|
|
137
|
+
correct.
|
|
138
|
+
"""
|
|
139
|
+
value = attributes.get(key)
|
|
140
|
+
if isinstance(value, dict):
|
|
141
|
+
return value
|
|
142
|
+
if isinstance(value, str) and value.strip():
|
|
143
|
+
try:
|
|
144
|
+
parsed = json.loads(value)
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
return {}
|
|
147
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def json_list(attributes: dict[str, Any], key: str) -> list[Any]:
|
|
152
|
+
"""An attribute holding a JSON array, however it was encoded."""
|
|
153
|
+
value = attributes.get(key)
|
|
154
|
+
if isinstance(value, list):
|
|
155
|
+
return value
|
|
156
|
+
if isinstance(value, str) and value.strip():
|
|
157
|
+
try:
|
|
158
|
+
parsed = json.loads(value)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
return []
|
|
161
|
+
return parsed if isinstance(parsed, list) else []
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def messages(attributes: dict[str, Any], *, output: bool = False) -> list[dict[str, str]]:
|
|
166
|
+
"""Conversation messages from either convention, as role/content pairs."""
|
|
167
|
+
prefix = LLM_OUTPUT_MESSAGES if output else LLM_INPUT_MESSAGES
|
|
168
|
+
found: list[dict[str, str]] = []
|
|
169
|
+
for entry in indexed(attributes, prefix):
|
|
170
|
+
role = entry.get(MESSAGE_ROLE)
|
|
171
|
+
content = entry.get(MESSAGE_CONTENT)
|
|
172
|
+
if isinstance(role, str) and isinstance(content, str) and content:
|
|
173
|
+
found.append({"role": role, "content": content})
|
|
174
|
+
if found:
|
|
175
|
+
return found
|
|
176
|
+
|
|
177
|
+
# OTel GenAI keeps whole messages as one JSON document.
|
|
178
|
+
key = GEN_AI_OUTPUT_MESSAGES if output else GEN_AI_INPUT_MESSAGES
|
|
179
|
+
for entry in json_list(attributes, key):
|
|
180
|
+
if not isinstance(entry, dict):
|
|
181
|
+
continue
|
|
182
|
+
role = entry.get("role")
|
|
183
|
+
content = _flatten_content(entry.get("content") or entry.get("parts"))
|
|
184
|
+
if isinstance(role, str) and content:
|
|
185
|
+
found.append({"role": role, "content": content})
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _flatten_content(content: Any) -> str:
|
|
190
|
+
"""Message content, whether a string or a list of typed parts."""
|
|
191
|
+
if isinstance(content, str):
|
|
192
|
+
return content
|
|
193
|
+
if isinstance(content, list):
|
|
194
|
+
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
|
|
195
|
+
return "".join(part for part in parts if part)
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def tool_calls(attributes: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
200
|
+
"""Tool calls recorded on an LLM span's output messages."""
|
|
201
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
202
|
+
for message in indexed(attributes, LLM_OUTPUT_MESSAGES):
|
|
203
|
+
for call in indexed(message, MESSAGE_TOOL_CALLS):
|
|
204
|
+
name = call.get(TOOL_CALL_NAME)
|
|
205
|
+
if not isinstance(name, str) or not name:
|
|
206
|
+
continue
|
|
207
|
+
calls.append((name, json_object(call, TOOL_CALL_ARGUMENTS)))
|
|
208
|
+
return calls
|