braintrust 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (128) hide show
  1. {braintrust-0.4.0 → braintrust-0.4.1}/PKG-INFO +1 -1
  2. braintrust-0.4.1/src/braintrust/bt_json.py +275 -0
  3. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/framework.py +11 -2
  4. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/logger.py +30 -117
  5. braintrust-0.4.1/src/braintrust/test_bt_json.py +644 -0
  6. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_framework.py +56 -0
  7. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_logger.py +211 -107
  8. braintrust-0.4.1/src/braintrust/version.py +4 -0
  9. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/google_genai/__init__.py +2 -15
  10. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/pydantic_ai.py +209 -95
  11. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_google_genai.py +62 -1
  12. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_pydantic_ai_integration.py +819 -22
  13. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/PKG-INFO +1 -1
  14. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/SOURCES.txt +1 -0
  15. braintrust-0.4.0/src/braintrust/bt_json.py +0 -116
  16. braintrust-0.4.0/src/braintrust/version.py +0 -4
  17. {braintrust-0.4.0 → braintrust-0.4.1}/README.md +0 -0
  18. {braintrust-0.4.0 → braintrust-0.4.1}/setup.cfg +0 -0
  19. {braintrust-0.4.0 → braintrust-0.4.1}/setup.py +0 -0
  20. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/__init__.py +0 -0
  21. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/_generated_types.py +0 -0
  22. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/audit.py +0 -0
  23. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/aws.py +0 -0
  24. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/__init__.py +0 -0
  25. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/__main__.py +0 -0
  26. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/eval.py +0 -0
  27. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/__init__.py +0 -0
  28. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/api.py +0 -0
  29. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/bump_versions.py +0 -0
  30. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/logs.py +0 -0
  31. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/redshift.py +0 -0
  32. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/install/run_migrations.py +0 -0
  33. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/cli/push.py +0 -0
  34. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/conftest.py +0 -0
  35. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/context.py +0 -0
  36. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/contrib/__init__.py +0 -0
  37. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/contrib/temporal/__init__.py +0 -0
  38. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/contrib/temporal/test_temporal.py +0 -0
  39. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/db_fields.py +0 -0
  40. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/__init__.py +0 -0
  41. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/auth.py +0 -0
  42. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/cache.py +0 -0
  43. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/cors.py +0 -0
  44. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/dataset.py +0 -0
  45. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/eval_hooks.py +0 -0
  46. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/schemas.py +0 -0
  47. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/server.py +0 -0
  48. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/test_cached_login.py +0 -0
  49. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/test_lru_cache.py +0 -0
  50. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/devserver/test_server_integration.py +0 -0
  51. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/framework2.py +0 -0
  52. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/functions/__init__.py +0 -0
  53. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/functions/constants.py +0 -0
  54. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/functions/invoke.py +0 -0
  55. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/functions/stream.py +0 -0
  56. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/generated_types.py +0 -0
  57. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/git_fields.py +0 -0
  58. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/gitutil.py +0 -0
  59. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/graph_util.py +0 -0
  60. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/http_headers.py +0 -0
  61. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/id_gen.py +0 -0
  62. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/merge_row_batch.py +0 -0
  63. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/oai.py +0 -0
  64. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/object.py +0 -0
  65. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/otel/__init__.py +0 -0
  66. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/otel/context.py +0 -0
  67. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/otel/test_distributed_tracing.py +0 -0
  68. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/otel/test_otel_bt_integration.py +0 -0
  69. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/parameters.py +0 -0
  70. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt.py +0 -0
  71. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/__init__.py +0 -0
  72. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/disk_cache.py +0 -0
  73. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/lru_cache.py +0 -0
  74. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/prompt_cache.py +0 -0
  75. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/test_disk_cache.py +0 -0
  76. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/test_lru_cache.py +0 -0
  77. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/prompt_cache/test_prompt_cache.py +0 -0
  78. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/py.typed +0 -0
  79. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/queue.py +0 -0
  80. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/resource_manager.py +0 -0
  81. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/score.py +0 -0
  82. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/serializable_data_class.py +0 -0
  83. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/span_identifier_v1.py +0 -0
  84. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/span_identifier_v2.py +0 -0
  85. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/span_identifier_v3.py +0 -0
  86. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/span_identifier_v4.py +0 -0
  87. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/span_types.py +0 -0
  88. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_framework2.py +0 -0
  89. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_helpers.py +0 -0
  90. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_id_gen.py +0 -0
  91. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_otel.py +0 -0
  92. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_queue.py +0 -0
  93. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_score.py +0 -0
  94. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_serializable_data_class.py +0 -0
  95. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_span_components.py +0 -0
  96. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_util.py +0 -0
  97. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/test_version.py +0 -0
  98. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/util.py +0 -0
  99. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/__init__.py +0 -0
  100. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/_anthropic_utils.py +0 -0
  101. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/__init__.py +0 -0
  102. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/agent.py +0 -0
  103. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/function_call.py +0 -0
  104. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/model.py +0 -0
  105. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/team.py +0 -0
  106. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/agno/utils.py +0 -0
  107. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/anthropic.py +0 -0
  108. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/claude_agent_sdk/__init__.py +0 -0
  109. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/claude_agent_sdk/_wrapper.py +0 -0
  110. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/claude_agent_sdk/test_wrapper.py +0 -0
  111. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/dspy.py +0 -0
  112. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/langchain.py +0 -0
  113. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/litellm.py +0 -0
  114. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/openai.py +0 -0
  115. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_agno.py +0 -0
  116. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_anthropic.py +0 -0
  117. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_dspy.py +0 -0
  118. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_litellm.py +0 -0
  119. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_oai_attachments.py +0 -0
  120. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_openai.py +0 -0
  121. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_openrouter.py +0 -0
  122. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_pydantic_ai_wrap_openai.py +0 -0
  123. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/wrappers/test_utils.py +0 -0
  124. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust/xact_ids.py +0 -0
  125. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/dependency_links.txt +0 -0
  126. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/entry_points.txt +0 -0
  127. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/requires.txt +0 -0
  128. {braintrust-0.4.0 → braintrust-0.4.1}/src/braintrust.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: braintrust
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: SDK for integrating Braintrust
5
5
  Home-page: https://www.braintrust.dev
6
6
  Author: Braintrust
@@ -0,0 +1,275 @@
1
+ import dataclasses
2
+ import json
3
+ import math
4
+ from typing import Any, Callable, Mapping, NamedTuple, cast, overload
5
+
6
+ # Try to import orjson for better performance
7
+ # If not available, we'll use standard json
8
+ try:
9
+ import orjson
10
+
11
+ _HAS_ORJSON = True
12
+ except ImportError:
13
+ _HAS_ORJSON = False
14
+
15
+
16
+
17
+ def _to_bt_safe(v: Any) -> Any:
18
+ """
19
+ Converts the object to a Braintrust-safe representation (i.e. Attachment objects are safe (specially handled by background logger)).
20
+ """
21
+ # avoid circular imports
22
+ from braintrust.logger import BaseAttachment, Dataset, Experiment, Logger, ReadonlyAttachment, Span
23
+
24
+ if isinstance(v, Span):
25
+ return "<span>"
26
+
27
+ if isinstance(v, Experiment):
28
+ return "<experiment>"
29
+
30
+ if isinstance(v, Dataset):
31
+ return "<dataset>"
32
+
33
+ if isinstance(v, Logger):
34
+ return "<logger>"
35
+
36
+ if isinstance(v, BaseAttachment):
37
+ return v
38
+
39
+ if isinstance(v, ReadonlyAttachment):
40
+ return v.reference
41
+
42
+ if dataclasses.is_dataclass(v) and not isinstance(v, type):
43
+ # Use manual field iteration instead of dataclasses.asdict() because
44
+ # asdict() deep-copies values, which breaks objects like Attachment
45
+ # that contain non-copyable items (thread locks, file handles, etc.)
46
+ return {f.name: _to_bt_safe(getattr(v, f.name)) for f in dataclasses.fields(v)}
47
+
48
+ # Pydantic model classes (not instances) with model_json_schema
49
+ if isinstance(v, type) and hasattr(v, "model_json_schema") and callable(cast(Any, v).model_json_schema):
50
+ try:
51
+ return cast(Any, v).model_json_schema()
52
+ except Exception:
53
+ pass
54
+
55
+ # Attempt to dump a Pydantic v2 `BaseModel`.
56
+ try:
57
+ return cast(Any, v).model_dump(exclude_none=True)
58
+ except (AttributeError, TypeError):
59
+ pass
60
+
61
+ # Attempt to dump a Pydantic v1 `BaseModel`.
62
+ try:
63
+ return cast(Any, v).dict(exclude_none=True)
64
+ except (AttributeError, TypeError):
65
+ pass
66
+
67
+ if isinstance(v, float):
68
+ # Handle NaN and Infinity for JSON compatibility
69
+ if math.isnan(v):
70
+ return "NaN"
71
+
72
+ if math.isinf(v):
73
+ return "Infinity" if v > 0 else "-Infinity"
74
+
75
+ return v
76
+
77
+ if isinstance(v, (int, str, bool)) or v is None:
78
+ # Skip roundtrip for primitive types.
79
+ return v
80
+
81
+ # Note: we avoid using copy.deepcopy, because it's difficult to
82
+ # guarantee the independence of such copied types from their origin.
83
+ # E.g. the original type could have a `__del__` method that alters
84
+ # some shared internal state, and we need this deep copy to be
85
+ # fully-independent from the original.
86
+
87
+ # We pass `encoder=_str_encoder` since we've already tried converting rich objects to json safe objects.
88
+ return bt_loads(bt_dumps(v, encoder=_str_encoder))
89
+
90
+ @overload
91
+ def bt_safe_deep_copy(
92
+ obj: Mapping[str, Any],
93
+ max_depth: int = ...,
94
+ ) -> dict[str, Any]: ...
95
+
96
+ @overload
97
+ def bt_safe_deep_copy(
98
+ obj: list[Any],
99
+ max_depth: int = ...,
100
+ ) -> list[Any]: ...
101
+
102
+ @overload
103
+ def bt_safe_deep_copy(
104
+ obj: Any,
105
+ max_depth: int = ...,
106
+ ) -> Any: ...
107
+ def bt_safe_deep_copy(obj: Any, max_depth: int=200):
108
+ """
109
+ Creates a deep copy of the given object and converts rich objects to Braintrust-safe representations. See `_to_bt_safe` for more details.
110
+
111
+ Args:
112
+ obj: Object to deep copy and sanitize.
113
+ to_json_safe: Function to ensure the object is json safe.
114
+ max_depth: Maximum depth to copy.
115
+
116
+ Returns:
117
+ Deep copy of the object.
118
+ """
119
+ # Track visited objects to detect circular references
120
+ visited: set[int] = set()
121
+
122
+ def _deep_copy_object(v: Any, depth: int = 0) -> Any:
123
+ # Check depth limit - use >= to stop before exceeding
124
+ if depth >= max_depth:
125
+ return "<max depth exceeded>"
126
+
127
+ # Check for circular references in mutable containers
128
+ # Use id() to track object identity
129
+ if isinstance(v, (Mapping, list, tuple, set)):
130
+ obj_id = id(v)
131
+ if obj_id in visited:
132
+ return "<circular reference>"
133
+ visited.add(obj_id)
134
+ try:
135
+ if isinstance(v, Mapping):
136
+ # Prevent dict keys from holding references to user data. Note that
137
+ # `bt_json` already coerces keys to string, a behavior that comes from
138
+ # `json.dumps`. However, that runs at log upload time, while we want to
139
+ # cut out all the references to user objects synchronously in this
140
+ # function.
141
+ result = {}
142
+ for k in v:
143
+ try:
144
+ key_str = str(k)
145
+ except Exception:
146
+ # If str() fails on the key, use a fallback representation
147
+ key_str = f"<non-stringifiable-key: {type(k).__name__}>"
148
+ result[key_str] = _deep_copy_object(v[k], depth + 1)
149
+ return result
150
+ elif isinstance(v, (list, tuple, set)):
151
+ return [_deep_copy_object(x, depth + 1) for x in v]
152
+ finally:
153
+ # Remove from visited set after processing to allow the same object
154
+ # to appear in different branches of the tree
155
+ visited.discard(obj_id)
156
+
157
+ try:
158
+ return _to_bt_safe(v)
159
+ except Exception:
160
+ return f"<non-sanitizable: {type(v).__name__}>"
161
+
162
+ return _deep_copy_object(obj)
163
+
164
+ def _safe_str(obj: Any) -> str:
165
+ try:
166
+ return str(obj)
167
+ except Exception:
168
+ return f"<non-serializable: {type(obj).__name__}>"
169
+
170
+
171
+ def _to_json_safe(obj: Any) -> Any:
172
+ """
173
+ Handler for non-JSON-serializable objects. Returns a string representation of the object.
174
+ """
175
+ # avoid circular imports
176
+ from braintrust.logger import BaseAttachment
177
+
178
+ try:
179
+ v = _to_bt_safe(obj)
180
+
181
+ # JSON-safe representation of Attachment objects are their reference.
182
+ # If we get this object at this point, we have to assume someone has already uploaded the attachment!
183
+ if isinstance(v, BaseAttachment):
184
+ v = v.reference
185
+
186
+ return v
187
+ except Exception:
188
+ pass
189
+
190
+ # When everything fails, try to return the string representation of the object
191
+ return _safe_str(obj)
192
+
193
+
194
+ class BraintrustJSONEncoder(json.JSONEncoder):
195
+ """
196
+ Custom JSON encoder for standard json library.
197
+
198
+ This is used as a fallback when orjson is not available or fails.
199
+ """
200
+
201
+ def default(self, o: Any):
202
+ return _to_json_safe(o)
203
+
204
+
205
+ class BraintrustStrEncoder(json.JSONEncoder):
206
+ def default(self, o: Any):
207
+ return _safe_str(o)
208
+
209
+
210
+ class Encoder(NamedTuple):
211
+ native: type[json.JSONEncoder]
212
+ orjson: Callable[[Any], Any]
213
+
214
+ _json_encoder = Encoder(native=BraintrustJSONEncoder, orjson=_to_json_safe)
215
+ _str_encoder = Encoder(native=BraintrustStrEncoder, orjson=_safe_str)
216
+
217
+ def bt_dumps(obj: Any, encoder: Encoder | None=_json_encoder, **kwargs: Any) -> str:
218
+ """
219
+ Serialize obj to a JSON-formatted string.
220
+
221
+ Automatically uses orjson if available for better performance (3-5x faster),
222
+ with fallback to standard json library if orjson is not installed or fails.
223
+
224
+ Args:
225
+ obj: Object to serialize
226
+ encoder: Encoder to use, defaults to `_default_encoder`
227
+ **kwargs: Additional arguments (passed to json.dumps in fallback path)
228
+
229
+ Returns:
230
+ JSON string representation of obj
231
+ """
232
+ if _HAS_ORJSON:
233
+ # Try orjson first for better performance
234
+ try:
235
+ # pylint: disable=no-member # orjson is a C extension, pylint can't introspect it
236
+ return orjson.dumps( # type: ignore[possibly-unbound]
237
+ obj,
238
+ default=encoder.orjson if encoder else None,
239
+ # options match json.dumps behavior for bc
240
+ option=orjson.OPT_SORT_KEYS | orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS, # type: ignore[possibly-unbound]
241
+ ).decode("utf-8")
242
+ except Exception:
243
+ # If orjson fails, fall back to standard json
244
+ pass
245
+
246
+ # Use standard json (either orjson not available or it failed)
247
+ # Use sort_keys=True for deterministic output (matches orjson OPT_SORT_KEYS)
248
+ return json.dumps(obj, cls=encoder.native if encoder else None, allow_nan=False, sort_keys=True, **kwargs)
249
+
250
+
251
+ def bt_loads(s: str, **kwargs) -> Any:
252
+ """
253
+ Deserialize s (a str containing a JSON document) to a Python object.
254
+
255
+ Automatically uses orjson if available for better performance (2-3x faster),
256
+ with fallback to standard json library if orjson is not installed or fails.
257
+
258
+ Args:
259
+ s: JSON string to deserialize
260
+ **kwargs: Additional arguments (passed to json.loads in fallback path)
261
+
262
+ Returns:
263
+ Python object representation of JSON string
264
+ """
265
+ if _HAS_ORJSON:
266
+ # Try orjson first for better performance
267
+ try:
268
+ # pylint: disable=no-member # orjson is a C extension, pylint can't introspect it
269
+ return orjson.loads(s) # type: ignore[possibly-unbound]
270
+ except Exception:
271
+ # If orjson fails, fall back to standard json
272
+ pass
273
+
274
+ # Use standard json (either orjson not available or it failed)
275
+ return json.loads(s, **kwargs)
@@ -47,7 +47,7 @@ from .resource_manager import ResourceManager
47
47
  from .score import Score, is_score, is_scorer
48
48
  from .serializable_data_class import SerializableDataClass
49
49
  from .span_types import SpanTypeAttribute
50
- from .util import bt_iscoroutinefunction, eprint
50
+ from .util import bt_iscoroutinefunction, eprint, merge_dicts
51
51
 
52
52
  Input = TypeVar("Input")
53
53
  Output = TypeVar("Output")
@@ -1284,8 +1284,17 @@ async def _run_evaluator_internal(
1284
1284
  event_loop = asyncio.get_event_loop()
1285
1285
 
1286
1286
  async def await_or_run_scorer(root_span, scorer, name, **kwargs):
1287
+ # Merge purpose into parent's propagated_event rather than replacing it
1288
+ parent_propagated = root_span.propagated_event or {}
1289
+ merged_propagated = merge_dicts(
1290
+ {**parent_propagated},
1291
+ {"span_attributes": {"purpose": "scorer"}},
1292
+ )
1287
1293
  with root_span.start_span(
1288
- name=name, span_attributes={"type": SpanTypeAttribute.SCORE}, input=dict(**kwargs)
1294
+ name=name,
1295
+ span_attributes={"type": SpanTypeAttribute.SCORE, "purpose": "scorer"},
1296
+ propagated_event=merged_propagated,
1297
+ input=dict(**kwargs),
1289
1298
  ) as span:
1290
1299
  score = scorer
1291
1300
  if hasattr(scorer, "eval_async"):
@@ -9,7 +9,6 @@ import inspect
9
9
  import io
10
10
  import json
11
11
  import logging
12
- import math
13
12
  import os
14
13
  import sys
15
14
  import textwrap
@@ -46,7 +45,7 @@ from requests.adapters import HTTPAdapter
46
45
  from urllib3.util.retry import Retry
47
46
 
48
47
  from . import context, id_gen
49
- from .bt_json import bt_dumps, bt_loads
48
+ from .bt_json import bt_dumps, bt_safe_deep_copy
50
49
  from .db_fields import (
51
50
  ASYNC_SCORING_CONTROL_FIELD,
52
51
  AUDIT_METADATA_FIELD,
@@ -271,6 +270,10 @@ class _NoopSpan(Span):
271
270
  def id(self):
272
271
  return ""
273
272
 
273
+ @property
274
+ def propagated_event(self):
275
+ return None
276
+
274
277
  def log(self, **event: Any):
275
278
  pass
276
279
 
@@ -739,13 +742,6 @@ def construct_logs3_data(items: Sequence[str]):
739
742
  return '{"rows": ' + rowsS + ', "api_version": ' + str(DATA_API_VERSION) + "}"
740
743
 
741
744
 
742
- def _check_json_serializable(event):
743
- try:
744
- return bt_dumps(event)
745
- except (TypeError, ValueError) as e:
746
- raise Exception(f"All logged values must be JSON-serializable: {event}") from e
747
-
748
-
749
745
  class _MaskingError:
750
746
  """Internal class to signal masking errors that need special handling."""
751
747
 
@@ -795,6 +791,7 @@ class _MemoryBackgroundLogger(_BackgroundLogger):
795
791
  self.lock = threading.Lock()
796
792
  self.logs = []
797
793
  self.masking_function: Callable[[Any], Any] | None = None
794
+ self.upload_attempts: list[BaseAttachment] = [] # Track upload attempts
798
795
 
799
796
  def enforce_queue_size_limit(self, enforce: bool) -> None:
800
797
  pass
@@ -808,7 +805,21 @@ class _MemoryBackgroundLogger(_BackgroundLogger):
808
805
  self.masking_function = masking_function
809
806
 
810
807
  def flush(self, batch_size: int | None = None):
811
- pass
808
+ """Flush the memory logger, extracting attachments and tracking upload attempts."""
809
+ with self.lock:
810
+ if not self.logs:
811
+ return
812
+
813
+ # Unwrap lazy values and extract attachments
814
+ logs = [l.get() for l in self.logs]
815
+
816
+ # Extract attachments from all logs
817
+ attachments: list[BaseAttachment] = []
818
+ for log in logs:
819
+ _extract_attachments(log, attachments)
820
+
821
+ # Track upload attempts (don't actually call upload() in tests)
822
+ self.upload_attempts.extend(attachments)
812
823
 
813
824
  def pop(self):
814
825
  with self.lock:
@@ -1959,24 +1970,14 @@ def get_span_parent_object(
1959
1970
 
1960
1971
  def _try_log_input(span, f_sig, f_args, f_kwargs):
1961
1972
  if f_sig:
1962
- bound_args = f_sig.bind(*f_args, **f_kwargs).arguments
1963
- input_serializable = bound_args
1973
+ input_data = f_sig.bind(*f_args, **f_kwargs).arguments
1964
1974
  else:
1965
- input_serializable = dict(args=f_args, kwargs=f_kwargs)
1966
- try:
1967
- _check_json_serializable(input_serializable)
1968
- except Exception as e:
1969
- input_serializable = "<input not json-serializable>: " + str(e)
1970
- span.log(input=input_serializable)
1975
+ input_data = dict(args=f_args, kwargs=f_kwargs)
1976
+ span.log(input=input_data)
1971
1977
 
1972
1978
 
1973
1979
  def _try_log_output(span, output):
1974
- output_serializable = output
1975
- try:
1976
- _check_json_serializable(output)
1977
- except Exception as e:
1978
- output_serializable = "<output not json-serializable>: " + str(e)
1979
- span.log(output=output_serializable)
1980
+ span.log(output=output)
1980
1981
 
1981
1982
 
1982
1983
  F = TypeVar("F", bound=Callable[..., Any])
@@ -2426,91 +2427,6 @@ def _validate_and_sanitize_experiment_log_full_args(event: Mapping[str, Any], ha
2426
2427
  return event
2427
2428
 
2428
2429
 
2429
- def _deep_copy_event(event: Mapping[str, Any]) -> dict[str, Any]:
2430
- """
2431
- Creates a deep copy of the given event. Replaces references to user objects
2432
- with placeholder strings to ensure serializability, except for `Attachment`
2433
- and `ExternalAttachment` objects, which are preserved and not deep-copied.
2434
-
2435
- Handles circular references and excessive nesting depth to prevent
2436
- RecursionError during serialization.
2437
- """
2438
- # Maximum depth to prevent hitting Python's recursion limit
2439
- # Python's default limit is ~1000, we use a conservative limit
2440
- # to account for existing call stack usage from pytest, application code, etc.
2441
- MAX_DEPTH = 200
2442
-
2443
- # Track visited objects to detect circular references
2444
- visited: set[int] = set()
2445
-
2446
- def _deep_copy_object(v: Any, depth: int = 0) -> Any:
2447
- # Check depth limit - use >= to stop before exceeding
2448
- if depth >= MAX_DEPTH:
2449
- return "<max depth exceeded>"
2450
-
2451
- # Check for circular references in mutable containers
2452
- # Use id() to track object identity
2453
- if isinstance(v, (Mapping, list, tuple, set)):
2454
- obj_id = id(v)
2455
- if obj_id in visited:
2456
- return "<circular reference>"
2457
- visited.add(obj_id)
2458
- try:
2459
- if isinstance(v, Mapping):
2460
- # Prevent dict keys from holding references to user data. Note that
2461
- # `bt_json` already coerces keys to string, a behavior that comes from
2462
- # `json.dumps`. However, that runs at log upload time, while we want to
2463
- # cut out all the references to user objects synchronously in this
2464
- # function.
2465
- result = {}
2466
- for k in v:
2467
- try:
2468
- key_str = str(k)
2469
- except Exception:
2470
- # If str() fails on the key, use a fallback representation
2471
- key_str = f"<non-stringifiable-key: {type(k).__name__}>"
2472
- result[key_str] = _deep_copy_object(v[k], depth + 1)
2473
- return result
2474
- elif isinstance(v, (list, tuple, set)):
2475
- return [_deep_copy_object(x, depth + 1) for x in v]
2476
- finally:
2477
- # Remove from visited set after processing to allow the same object
2478
- # to appear in different branches of the tree
2479
- visited.discard(obj_id)
2480
-
2481
- if isinstance(v, Span):
2482
- return "<span>"
2483
- elif isinstance(v, Experiment):
2484
- return "<experiment>"
2485
- elif isinstance(v, Dataset):
2486
- return "<dataset>"
2487
- elif isinstance(v, Logger):
2488
- return "<logger>"
2489
- elif isinstance(v, BaseAttachment):
2490
- return v
2491
- elif isinstance(v, ReadonlyAttachment):
2492
- return v.reference
2493
- elif isinstance(v, float):
2494
- # Handle NaN and Infinity for JSON compatibility
2495
- if math.isnan(v):
2496
- return "NaN"
2497
- elif math.isinf(v):
2498
- return "Infinity" if v > 0 else "-Infinity"
2499
- return v
2500
- elif isinstance(v, (int, str, bool)) or v is None:
2501
- # Skip roundtrip for primitive types.
2502
- return v
2503
- else:
2504
- # Note: we avoid using copy.deepcopy, because it's difficult to
2505
- # guarantee the independence of such copied types from their origin.
2506
- # E.g. the original type could have a `__del__` method that alters
2507
- # some shared internal state, and we need this deep copy to be
2508
- # fully-independent from the original.
2509
- return bt_loads(bt_dumps(v))
2510
-
2511
- return _deep_copy_object(event)
2512
-
2513
-
2514
2430
  class ObjectIterator(Generic[T]):
2515
2431
  def __init__(self, refetch_fn: Callable[[], Sequence[T]]):
2516
2432
  self.refetch_fn = refetch_fn
@@ -3060,7 +2976,7 @@ def _log_feedback_impl(
3060
2976
  metadata = update_event.pop("metadata")
3061
2977
  update_event = {k: v for k, v in update_event.items() if v is not None}
3062
2978
 
3063
- update_event = _deep_copy_event(update_event)
2979
+ update_event = bt_safe_deep_copy(update_event)
3064
2980
 
3065
2981
  def parent_ids():
3066
2982
  exporter = _get_exporter()
@@ -3116,7 +3032,7 @@ def _update_span_impl(
3116
3032
  event=event,
3117
3033
  )
3118
3034
 
3119
- update_event = _deep_copy_event(update_event)
3035
+ update_event = bt_safe_deep_copy(update_event)
3120
3036
 
3121
3037
  def parent_ids():
3122
3038
  exporter = _get_exporter()
@@ -3936,8 +3852,7 @@ class SpanImpl(Span):
3936
3852
  **{IS_MERGE_FIELD: self._is_merge},
3937
3853
  )
3938
3854
 
3939
- serializable_partial_record = _deep_copy_event(partial_record)
3940
- _check_json_serializable(serializable_partial_record)
3855
+ serializable_partial_record = bt_safe_deep_copy(partial_record)
3941
3856
  if serializable_partial_record.get("metrics", {}).get("end") is not None:
3942
3857
  self._logged_end_time = serializable_partial_record["metrics"]["end"]
3943
3858
 
@@ -4304,8 +4219,7 @@ class Dataset(ObjectFetcher[DatasetEvent]):
4304
4219
  args[IS_MERGE_FIELD] = True
4305
4220
  args = _filter_none_args(args) # If merging, then remove None values to prevent null value writes
4306
4221
 
4307
- _check_json_serializable(args)
4308
- args = _deep_copy_event(args)
4222
+ args = bt_safe_deep_copy(args)
4309
4223
 
4310
4224
  def compute_args() -> dict[str, Any]:
4311
4225
  return dict(
@@ -4408,8 +4322,7 @@ class Dataset(ObjectFetcher[DatasetEvent]):
4408
4322
  "_object_delete": True, # XXX potentially place this in the logging endpoint
4409
4323
  },
4410
4324
  )
4411
- _check_json_serializable(partial_args)
4412
- partial_args = _deep_copy_event(partial_args)
4325
+ partial_args = bt_safe_deep_copy(partial_args)
4413
4326
 
4414
4327
  def compute_args():
4415
4328
  return dict(