Repository navigation
fix(bt_json): serialize nested values in logged dataclasses - #912
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e27140463
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| visited_discard(obj_id) | ||
|
|
||
| try: | ||
| if dataclasses.is_dataclass(v) and not isinstance(v, type): |
There was a problem hiding this comment.
Preserve dataclass-based BaseAttachment instances
When a user implements the public BaseAttachment ABC as a dataclass, this branch matches before _to_bt_safe() can apply its BaseAttachment check, so bt_safe_deep_copy() converts the attachment into a plain field dictionary. _extract_attachments() only uploads values that remain BaseAttachment instances, so logging this otherwise-valid custom attachment silently skips its upload and reference replacement; the parent implementation preserved it by passing through _to_bt_safe(). Check the special attachment types before flattening dataclasses.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 30c6cbe. Dataclass BaseAttachment and ReadonlyAttachment subclasses now retain their existing special handling before generic dataclass traversal. Added 14 regression cases covering direct and nested serialization plus local logger extraction; all failed on the reviewed head and pass with the fix. The original dataclass regressions also remain green (28 focused tests total), and test_core passes 1004 tests. Logger coverage verifies extraction/scheduling and reference replacement without real uploads. AI-assisted follow-up.
Route dataclasses in bt_safe_deep_copy through _to_bt_safe so the Span, Experiment, Dataset, Logger, and attachment checks run before fields are copied. The walker passes its own field copier, so nested values keep sharing its cycle and depth tracking instead of starting a new copy. Copy dataclass fields one at a time so a single unreadable field no longer drops the readable fields and their attachments. Share one cycle guard across the walker's branches, cache the lazy braintrust.logger import, and move the dataclass attachment fixture into test_helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fold the per-shape and per-type parametrizations into one case per behavior: nested attachments, SDK object special handling, shared cycle/depth tracking, and unreadable fields. Merge the two logger flush tests and drop the input/output parametrization, which exercised the same path. Remove the JSON-container test and the older dataclass attachment test, both covered by the remaining tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass the deep-copy walker and the field depth to `_to_bt_safe` as plain arguments so non-dataclass leaves no longer allocate a `functools.partial`. Return the cached logger types as a NamedTuple and use it in `_to_json_safe` instead of a second lazy import. Split the string-dispatched special-type test into one for SDK object placeholders and one for attachment subclasses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
thanks for the PR Gareth.Y (@linjiaye929) made some changes, and updated pr title and description |
5b0b410
into
braintrustdata:main
|
released with https://github.com/braintrustdata/braintrust-sdk-python/releases/tag/py-sdk-v0.46.1 - thanks again Gareth.Y (@linjiaye929) |
Summary
When you log a dataclass, Braintrust now copies its fields the same way it copies everything else you log. Attachments inside a dataclass's lists or dicts get uploaded, and nested objects come out as real JSON instead of Python's text form of the object.
output{"files": ["<braintrust.logger.Attachment object at 0x7f…>"]}{"files": [{"type": "braintrust_attachment", "key": "…", "filename": "report.pdf", …}]}report.pdfuploaded?Background: how logged values are sanitized
Before an event is sent,
span.log(...)makes a safe copy of everything you pass withbt_safe_deep_copy. The copy walks your data and:str,int,dict,list, …) as they areAttachmentobjects so the background logger can upload them and replace them with a referenceSpan,Experiment,Dataset,Logger) with short placeholders like"<span>"NaN/Infinityinto strings"<circular reference>"and stops at a maximum depthThe bug
A dataclass got a different, shallower treatment. Each field was converted once, and any list or dict inside a field went down a fallback path that turns objects into text. Attachments, pydantic models and nested dataclasses inside those containers became strings like
"<…Attachment object at 0x…>"or"P(value=1)".flowchart LR A["span.log(output=Result(...))"] --> B[bt_safe_deep_copy] B --> C{"dataclass?"} C -- "before" --> D["convert each field once"] D --> E["list / dict field: fallback converts objects to text"] E --> F["Attachment becomes a string, file never uploaded"] C -- "after" --> G["check special types first: Span, Experiment, Dataset, Logger, attachments"] G --> H["copy each field back through the same walker"] H --> I["Attachment kept, uploaded at flush, logged as a reference"]What changes for users
Only dataclass values change. Plain dicts, lists, pydantic models and other values log exactly as before.
"<braintrust.logger.Attachment object at 0x…>", not uploaded"x=1"{"x": 1}"P(value=1)"{"value": 1}Span/Experiment/Dataset/Loggerin a list field"<braintrust.logger._NoopSpan object at 0x…>""<span>"(etc.)setfield"{1, 2}"[1, 2]NaNin a list field"<non-sanitizable: P>"["NaN"]"<non-sanitizable: Bad>"{"ok": 1, "unset": "<non-sanitizable: Bad.unset>"}"P(value=[...])""<circular reference>"Note
If you have scorers, dashboards or queries that match the old text strings (for example
"P(value=1)"), they will now see structured JSON instead.How it works
bt_safe_deep_copysends a dataclass to_to_bt_safealong with the walker itself and the current depth._to_bt_saferuns its usual checks in order (SDK placeholders, then attachments) and only then copies dataclass fields. Each field goes back through the walker, so nested containers, cycle detection and the depth limit work exactly as they do for dicts and lists.Span,Experiment,DatasetorLoggerstill logs as its placeholder, so its internal fields never reach your logs. The same goes for attachment subclasses, which keep their special handling."<non-sanitizable: Class.field>". The other fields, and any attachments in them, are still logged.braintrust.loggerimport is cached, and the walker's four copies of the cycle-guard block are now one.Performance
Measured in the same process against
main, on Python 3.14.6:mainTesting
test_dataclass_nested_attachmentstest_dataclass_sdk_object_subclasses_keep_placeholderSpan/Experiment/Dataset/Loggertest_dataclass_attachment_subclasses_keep_special_handlingBaseAttachment/ReadonlyAttachmenttest_dataclass_cycles_and_depth_share_walker_statetest_dataclass_unreadable_field_keeps_readable_fieldstest_dataclass_attachments_reach_flush(logger)Attachment,ExternalAttachmentand a dataclass attachment get uploaded at flush, and the original payload isn't changedmain(9 of 10) and pass on this branch.nox -s test_core: 990 passed, 69 skipped, 12 xfailed (Python 3.14.6). An earlier run on Python 3.12.14 with orjson 3.13.0 and Pydantic 2.14.0 also passed.make lint(pre-commit hooks and pylint) passes.Follow-up (not in this PR)
Output from pydantic
model_dump()/.dict()isn't walked again. ASpannested inside a pydantic model stays a live object instead of becoming"<span>", andNaNthere isn't converted. Nested attachments in pydantic models are already kept, so the bug fixed here doesn't apply.AI assistance was used to implement and test this change.
🤖 Generated with Claude Code