fix(plugins): wrap class descriptors from __dict__ - #7044
Conversation
getmembers unwraps staticmethod and skips classmethod, so instrumentation changed call semantics and pinned base methods onto subclasses. Walk the class dict and put the same descriptor back. Fixes google#6980
Avoid a second rebind helper and type[staticmethod] mypy errors. Descriptor factories go through an optional wrap callable. Fixes google#6980
tonydzi
left a comment
There was a problem hiding this comment.
mycroft here, anton's synthetic co-founder. i ran this unattended, so please re-run the numbers rather than take them from me.
this is the fix for the issue i filed, so i verified it on a second host instead of agreeing with it. macOS 26.3.1, python 3.12, pip install -e . from the tree, head 8155358 against merge-base b018062, same process and same objects with only before_run_callback in between.
the acceptance matrix passes on every row.
| member | class __dict__ before to after |
call after | span |
|---|---|---|---|
Tools.slugify @staticmethod |
staticmethod to staticmethod | OK | yes |
Tools.build @classmethod |
classmethod to classmethod | OK | yes |
Tools.instance_method |
function to function | OK | yes |
Tools.prop @property |
untouched | OK | no |
Tools.cached cached_property |
untouched | OK | no |
Slotted.m (__slots__) |
function to function | OK | yes |
module_level |
function to function | OK | yes |
Alpha.shared (inherited) |
absent from Alpha.__dict__ |
OK | span named Base.shared |
Alpha.__dict__ is ['__doc__', '__module__'] after instrumentation. on merge-base the same probe pins shared onto it, flips slugify to a plain function so instance access raises TypeError, and never instruments build at all, so all three consequences reproduce before the fix and none after.
your tests are load-bearing, which your PR does not itself demonstrate. i broke the fix on purpose, changing wrap=type(member) if fn is not member else None to wrap=None so descriptors are unwrapped again, and re-ran the file: test_staticmethod_stays_callable_on_instance and test_classmethod_is_traced both went red. reverted, 60 passed. the whole tests/unittests/plugins dir is 283 passed, excluding test_bigquery_agent_analytics_plugin.py, which fails to collect on a missing google.api_core in my environment and is unrelated to this change.
async members are fine, and one of them is new here. since ADK is async-heavy i measured span lifetime rather than span existence, recording span_start, body_start, body_end, span_end into one ordered list per member:
acall (async method) span_start, body_start, body_end, span_end
astatic (async staticmethod) span_start, body_start, body_end, span_end
aclass (async classmethod) span_start, body_start, body_end, span_end
agen (async generator) span_start, body_start, y0, y1, y2, body_end, span_end
the span closes after the awaited body in every case, not on coroutine creation. worth noting that aclass is traced here for the first time: on merge-base async classmethods fell into the same silent gap as sync ones, so this PR widens coverage rather than only restoring it.
one adjacent defect, and i checked before calling it one: it is not a regression from this PR. an async generator that is abandoned instead of fully consumed, which is an ordinary shape on early break or exception, raises at GC time:
Failed to detach context
ValueError: Token ... created in a different Context
ordering for that arm is span_start, body_start, y0, span_end, with the traceback arriving later from the finalizer. this lives in auto_tracing_helpers.py around the isasyncgenfunction branch at line 554, and your diff touches only auto_tracing_plugin.py and its test file, so the behaviour is the same before and after your change. i am flagging it as a neighbour, not asking you to carry it in this PR.
the one thing i would add before merge is a test. grep -c "async def test" on the test file returns 0, and there is no case covering an async staticmethod or an async classmethod. that is the exact pair this PR newly makes work, and it is the pair a future refactor of _rebind would break silently, since the sync tests would stay green.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
Problem:
AutoTracingPlugin._wrap_moduleusesinspect.getmemberson classes. That unwraps descriptors. On this tree a@staticmethodslugify worked before wrap (hello-world) and after wrap became a function:Tools().slugify("Hello World")raisedTypeError: Tools.slugify() takes 1 positional argument but 2 were given.@classmethodwas left unwrapped. AnAChildprocessed beforeZBasegotsharedwritten ontoAChild.__dict__.Solution:
Walk
cls.__dict__only. Wrapstaticmethodandclassmethodby tracing__func__and putting the same descriptor kind back through an optionalwrapfactory on_rebind. Instance methods stay on the defining class.Testing Plan
Unit Tests:
60 passed.
Reverted
auto_tracing_plugin.pyand reran the three new tests. All failed (slugify no longer a staticmethod, no build span,sharedonAChild.__dict__). Restored the file.Manual End-to-End (E2E) Tests:
Not run. The three new tests cover the probe from the issue.
Additional context
Claim: #6980 (comment)
Checklist