-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathtrace.py
More file actions
448 lines (376 loc) · 15.8 KB
/
Copy pathtrace.py
File metadata and controls
448 lines (376 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
"""
Trace objects for accessing spans in evaluations.
This module provides the LocalTrace class which allows scorers to access
spans from the current evaluation task without making server round-trips.
"""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, Protocol, TypedDict
from braintrust.functions.invoke import invoke
from braintrust.logger import BraintrustState, ObjectFetcher
from braintrust.types import Metadata
class SpanData:
"""Span data returned by get_spans()."""
def __init__(
self,
input: Any | None = None,
output: Any | None = None,
metadata: Metadata | None = None,
span_id: str | None = None,
span_parents: list[str] | None = None,
span_attributes: dict[str, Any] | None = None,
**kwargs: Any,
):
self.input = input
self.output = output
self.metadata = metadata
self.span_id = span_id
self.span_parents = span_parents
self.span_attributes = span_attributes
# Store any additional fields
for key, value in kwargs.items():
setattr(self, key, value)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SpanData":
"""Create SpanData from a dictionary."""
return cls(**data)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
result = {}
for key, value in self.__dict__.items():
if value is not None:
result[key] = value
return result
class SpanFetcher(ObjectFetcher[dict[str, Any]]):
"""
Fetcher for spans by root_span_id, using the ObjectFetcher pattern.
Handles pagination automatically via cursor-based iteration.
"""
def __init__(
self,
object_type: str, # Literal["experiment", "project_logs", "playground_logs"]
object_id: str,
root_span_id: str,
state: BraintrustState,
span_type_filter: list[str] | None = None,
):
# Build the filter expression for root_span_id and optionally span_attributes.type
filter_expr = self._build_filter(root_span_id, span_type_filter)
super().__init__(
object_type=object_type,
_internal_btql={"filter": filter_expr},
)
self._object_id = object_id
self._state = state
@staticmethod
def _build_filter(root_span_id: str, span_type_filter: list[str] | None = None) -> dict[str, Any]:
"""Build BTQL filter expression."""
children = [
# Base filter: root_span_id = 'value'
{
"op": "eq",
"left": {"op": "ident", "name": ["root_span_id"]},
"right": {"op": "literal", "value": root_span_id},
},
# Exclude span_attributes.purpose = 'scorer'
{
"op": "or",
"children": [
{
"op": "isnull",
"expr": {"op": "ident", "name": ["span_attributes", "purpose"]},
},
{
"op": "ne",
"left": {"op": "ident", "name": ["span_attributes", "purpose"]},
"right": {"op": "literal", "value": "scorer"},
},
],
},
]
# If span type filter specified, add it
if span_type_filter and len(span_type_filter) > 0:
children.append(
{
"op": "in",
"left": {"op": "ident", "name": ["span_attributes", "type"]},
"right": {"op": "literal", "value": span_type_filter},
}
)
return {"op": "and", "children": children}
@property
def id(self) -> str:
return self._object_id
def _get_state(self) -> BraintrustState:
return self._state
SpanFetchFn = Callable[[list[str] | None], Awaitable[list[SpanData]]]
class GetThreadOptions(TypedDict, total=False):
preprocessor: str
class CachedSpanFetcher:
"""
Cached span fetcher that handles fetching and caching spans by type.
Caching strategy:
- Cache spans by span type (dict[spanType, list[SpanData]])
- Track if all spans have been fetched (all_fetched flag)
- When filtering by spanType, only fetch types not already in cache
"""
def __init__(
self,
object_type: str | None = None, # Literal["experiment", "project_logs", "playground_logs"]
object_id: str | None = None,
root_span_id: str | None = None,
get_state: Callable[[], Awaitable[BraintrustState]] | None = None,
fetch_fn: SpanFetchFn | None = None,
):
self._span_cache: dict[str, list[SpanData]] = {}
self._all_fetched = False
if fetch_fn is not None:
# Direct fetch function injection (for testing)
self._fetch_fn = fetch_fn
else:
# Standard constructor with SpanFetcher
if object_type is None or object_id is None or root_span_id is None or get_state is None:
raise ValueError(
"Must provide either fetch_fn or all of object_type, object_id, root_span_id, get_state"
)
async def _fetch_fn(span_type: list[str] | None) -> list[SpanData]:
state = await get_state()
fetcher = SpanFetcher(
object_type=object_type,
object_id=object_id,
root_span_id=root_span_id,
state=state,
span_type_filter=span_type,
)
rows = list(fetcher.fetch())
# Filter out scorer spans
filtered = [
row
for row in rows
if not (
isinstance(row.get("span_attributes"), dict)
and row.get("span_attributes", {}).get("purpose") == "scorer"
)
]
return [
SpanData(
input=row.get("input"),
output=row.get("output"),
metadata=row.get("metadata"),
span_id=row.get("span_id"),
span_parents=row.get("span_parents"),
span_attributes=row.get("span_attributes"),
id=row.get("id"),
_xact_id=row.get("_xact_id"),
_pagination_key=row.get("_pagination_key"),
root_span_id=row.get("root_span_id"),
)
for row in filtered
]
self._fetch_fn = _fetch_fn
async def get_spans(self, span_type: list[str] | None = None) -> list[SpanData]:
"""
Get spans, using cache when possible.
Args:
span_type: Optional list of span types to filter by
Returns:
List of matching spans
"""
# If we've fetched all spans, just filter from cache
if self._all_fetched:
return self._get_from_cache(span_type)
# If no filter requested, fetch everything
if not span_type or len(span_type) == 0:
await self._fetch_spans(None)
if self._span_cache: # Only cache if we got results
self._all_fetched = True
return self._get_from_cache(None)
# Find which spanTypes we don't have in cache yet
missing_types = [t for t in span_type if t not in self._span_cache]
# If all requested types are cached, return from cache
if not missing_types:
return self._get_from_cache(span_type)
# Fetch only the missing types
await self._fetch_spans(missing_types)
return self._get_from_cache(span_type)
async def _fetch_spans(self, span_type: list[str] | None) -> None:
"""Fetch spans from the server."""
spans = await self._fetch_fn(span_type)
for span in spans:
span_attrs = span.span_attributes or {}
span_type_str = span_attrs.get("type", "")
if span_type_str not in self._span_cache:
self._span_cache[span_type_str] = []
self._span_cache[span_type_str].append(span)
def _get_from_cache(self, span_type: list[str] | None) -> list[SpanData]:
"""Get spans from cache, optionally filtering by type."""
if not span_type or len(span_type) == 0:
# Return all spans
result = []
for spans in self._span_cache.values():
result.extend(spans)
return result
# Return only requested types
result = []
for type_str in span_type:
if type_str in self._span_cache:
result.extend(self._span_cache[type_str])
return result
class Trace(Protocol):
"""
Interface for trace objects that can be used by scorers.
Both the SDK's LocalTrace class and the API wrapper's WrapperTrace implement this.
"""
def get_configuration(self) -> dict[str, str]:
"""Get the trace configuration (object_type, object_id, root_span_id)."""
...
async def get_spans(self, span_type: list[str] | None = None) -> list[SpanData]:
"""
Fetch all spans for this root span.
Args:
span_type: Optional list of span types to filter by
Returns:
List of matching spans
"""
...
async def get_thread(self, options: GetThreadOptions | None = None) -> list[Any]:
"""
Get the thread (preprocessed messages) for this trace.
Args:
options: Optional options object. Supports "preprocessor".
Returns:
The preprocessed thread as an array of messages.
"""
...
class LocalTrace(dict):
"""
SDK implementation of Trace that uses local span cache and falls back to BTQL.
Carries identifying information about the evaluation so scorers can perform
richer logging or side effects.
Inherits from dict so that it serializes to {"trace_ref": {...}} when passed
to json.dumps(). This allows LocalTrace to be transparently serialized when
passed through invoke() or other JSON-serializing code paths.
"""
def __init__(
self,
object_type: str, # Literal["experiment", "project_logs", "playground_logs"]
object_id: str,
root_span_id: str,
ensure_spans_flushed: Callable[[], Awaitable[None]] | None,
state: BraintrustState,
):
# Initialize dict with trace_ref for JSON serialization
super().__init__(
{
"trace_ref": {
"object_type": object_type,
"object_id": object_id,
"root_span_id": root_span_id,
}
}
)
self._object_type = object_type
self._object_id = object_id
self._root_span_id = root_span_id
self._ensure_spans_flushed = ensure_spans_flushed
self._state = state
self._spans_flushed = False
self._spans_flush_promise: asyncio.Task[None] | None = None
self._thread_cache: dict[str, asyncio.Task[list[Any]]] = {}
async def get_state() -> BraintrustState:
await self._ensure_spans_ready()
# Ensure state is logged in
await asyncio.get_event_loop().run_in_executor(None, lambda: state.login())
return state
self._cached_fetcher = CachedSpanFetcher(
object_type=object_type,
object_id=object_id,
root_span_id=root_span_id,
get_state=get_state,
)
def get_configuration(self) -> dict[str, str]:
"""Get the trace configuration."""
return {
"object_type": self._object_type,
"object_id": self._object_id,
"root_span_id": self._root_span_id,
}
async def get_spans(self, span_type: list[str] | None = None) -> list[SpanData]:
"""
Fetch all rows for this root span from its parent object (experiment or project logs).
First checks the local span cache for recently logged spans, then falls
back to CachedSpanFetcher which handles BTQL fetching and caching.
Args:
span_type: Optional list of span types to filter by
Returns:
List of matching spans
"""
# Try local span cache first (for recently logged spans not yet flushed)
cached_spans = self._state.span_cache.get_by_root_span_id(self._root_span_id)
if cached_spans and len(cached_spans) > 0:
# Filter by purpose
spans = [span for span in cached_spans if not (span.span_attributes or {}).get("purpose") == "scorer"]
# Filter by span type if requested
if span_type and len(span_type) > 0:
spans = [span for span in spans if (span.span_attributes or {}).get("type", "") in span_type]
# Convert to SpanData
return [
SpanData(
input=span.input,
output=span.output,
metadata=span.metadata,
span_id=span.span_id,
span_parents=span.span_parents,
span_attributes=span.span_attributes,
)
for span in spans
]
# Fall back to CachedSpanFetcher for BTQL fetching with caching
return await self._cached_fetcher.get_spans(span_type)
async def get_thread(self, options: GetThreadOptions | None = None) -> list[Any]:
"""
Get the thread (preprocessed messages) for this trace.
Uses the project default preprocessor, falling back to global "thread".
"""
preprocessor = options.get("preprocessor") if options and options.get("preprocessor") else None
cache_key = preprocessor or "project_default"
if cache_key not in self._thread_cache:
self._thread_cache[cache_key] = asyncio.create_task(self._fetch_thread(options))
return await self._thread_cache[cache_key]
async def _fetch_thread(self, options: GetThreadOptions | None = None) -> list[Any]:
"""Fetch thread messages via preprocessor invocation."""
await self._ensure_spans_ready()
await asyncio.get_event_loop().run_in_executor(None, lambda: self._state.login())
preprocessor = options.get("preprocessor") if options and options.get("preprocessor") else None
trace_min_xact_id = self._state.get_trace_write_xact_id(self._object_id, self._root_span_id)
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: invoke(
global_function=preprocessor or "project_default",
function_type="preprocessor",
mode="json",
input={
"trace_ref": {
"object_type": self._object_type,
"object_id": self._object_id,
"root_span_id": self._root_span_id,
}
},
trace_min_xact_id=trace_min_xact_id,
),
)
return result if isinstance(result, list) else []
async def _ensure_spans_ready(self) -> None:
"""Ensure spans are flushed before fetching."""
if self._spans_flushed or not self._ensure_spans_flushed:
return
if self._spans_flush_promise is None:
async def flush_and_mark():
try:
await self._ensure_spans_flushed()
self._spans_flushed = True
except Exception as err:
self._spans_flush_promise = None
raise err
self._spans_flush_promise = asyncio.create_task(flush_and_mark())
await self._spans_flush_promise