fix(core): Instrument Anthropic client in place instead of via a deep proxy#22305
Conversation
… proxy The Anthropic integration wrapped the client in a deep Proxy that ran every method with the raw object as `this`. That changed the client's observable semantics: internal delegation (`messages.stream()` calling `this.create()`) resolved against the raw object, so it escaped any outer wrapper and behaved differently than an uninstrumented client. Instrument the registered methods in place instead (own properties shadowing the prototype), invoking them with the caller's `this`. Instrumentation is now observationally transparent: private-field access and internal delegation work as they do on a plain client. A re-entrancy guard keyed on the active streaming helper span keeps `stream()`'s internal `create()` call from producing a duplicate span.
size-limit report 📦
|
nicohrubec
left a comment
There was a problem hiding this comment.
were you able to reproduce the broken behavior? we use the same deep proxy pattern in openai and google genai, if this approach is really inherently broken we should of course change it but we should first ensure that we are actually fixing anything imo. do we know what specifically causes the issues?
|
@nicohrubec Yep, reproduced it with a simulation of what braintrust does. The cause is our deep proxy binds every method to the raw underlying object, so when This kinda allows us to get out of the way while keeping the same capabilities we had. The other two instrumentations don't use |
chargome
left a comment
There was a problem hiding this comment.
+1 for adding integration tests, otherwise LGTM
Adds an integration test asserting that instrumenting the Anthropic client does not hide its own methods from an outer wrapper: `messages.stream()` delegates to `create` through `this`, so a co-instrumenting wrapper must still observe that internal call. Fails against the previous deep-proxy implementation and passes with in-place instrumentation.
|
Added a few contrived tests since this is a bit unconventional case. |
The dedup that stops `messages.stream()` from emitting a duplicate `create` span keyed on the active span being the streaming-helper span. That span stays active across the stream's async continuations, so a `create()` a user makes from a stream event handler was wrongly suppressed for the whole stream lifetime. Suppress only the helper's own internal `create`, which the SDK invokes synchronously, via a flag set for the duration of that synchronous call. Adds a regression test asserting a `create()` from a stream event handler is still traced.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 332d9d5. Configure here.
The nested-create test only checked that spans existed. Add a negative assertion that the stream helper's internal `create` delegation produces exactly one span for the streamed response, so a regressed suppress path that double-emits would fail.

Instruments the Anthropic client's methods in place instead of wrapping the client in a deep
Proxy, so our instrumentation stops changing the client's observable behavior.closes #20291