@@ -665,37 +665,128 @@ def instrument_bot_commands(bot: commands.Bot) -> None:
665665 # The operation for commands is standardized as `command.run`
666666 op = "command.run"
667667
668- for cmd in bot .walk_commands ():
669- # Preserve existing decorators and metadata
670- original_callback = cast (Callable [..., Coroutine [Any , Any , None ]], cmd .callback )
671- txn_name = f"command.{ cmd .qualified_name } "
668+ def create_wrapper (
669+ original_callback : Callable [..., Coroutine [Any , Any , None ]],
670+ txn_name : str ,
671+ ) -> Callable [..., Coroutine [Any , Any , None ]]:
672+ """
673+ Create a wrapper function for a command callback.
674+
675+ Preserves the original function signature so discord.py can properly
676+ inspect parameters and converters. Uses functools.wraps to preserve
677+ metadata, and explicitly preserves __signature__ for discord.py's
678+ introspection.
679+
680+ Parameters
681+ ----------
682+ original_callback : Callable[..., Coroutine[Any, Any, None]]
683+ The original command callback to wrap.
684+
685+ txn_name : str
686+ The transaction name for Sentry.
687+
688+ Returns
689+ -------
690+ Callable[..., Coroutine[Any, Any, None]]
691+ The wrapped callback function with preserved signature.
692+ """
672693
673694 @functools .wraps (original_callback )
674- async def wrapped (
675- * args : Any ,
676- __orig_cb : Callable [..., Coroutine [Any , Any , None ]] = original_callback ,
677- __txn_name : str = txn_name ,
678- ** kwargs : Any ,
679- ) -> None :
695+ async def wrapped (* args : Any , ** kwargs : Any ) -> None :
680696 """
681697 Execute command callback with Sentry transaction instrumentation.
682698
683699 Parameters
684700 ----------
685701 *args : Any
702+
686703 Positional arguments passed to the command.
687- __orig_cb : Callable[..., Coroutine[Any, Any, None]]
688- Original command callback.
689- __txn_name : str
690- Transaction name for Sentry.
704+
691705 **kwargs : Any
706+
692707 Keyword arguments passed to the command.
708+
693709 """
694710 if not sentry_sdk .is_initialized ():
695- return await __orig_cb (* args , ** kwargs )
696- with sentry_sdk .start_transaction (op = op , name = __txn_name ):
697- return await __orig_cb (* args , ** kwargs )
711+ return await original_callback (* args , ** kwargs )
712+
713+ with sentry_sdk .start_transaction (op = op , name = txn_name ):
714+ # Call the original callback with the same args/kwargs
715+ # discord.py ensures ctx.args includes self.cog for bound methods
716+ return await original_callback (* args , ** kwargs )
717+
718+ # Ensure __wrapped__ is set explicitly (functools.wraps should do this, but be explicit)
719+ # This allows discord.py's unwrap_function() to find the original callback
720+ if not hasattr (wrapped , "__wrapped__" ):
721+ wrapped .__wrapped__ = original_callback
722+
723+ return wrapped
724+
725+ for cmd in bot .walk_commands ():
726+ # Skip if already wrapped by Sentry (check for our wrapper)
727+ # Commands may already be wrapped by @requires_command_permission, which is fine
728+ if hasattr (cmd .callback , "__wrapped__" ):
729+ # Check if it's already wrapped by Sentry (double-wrapped = permission + sentry)
730+ inner = getattr (cmd .callback , "__wrapped__" , None )
731+ if inner and hasattr (inner , "__wrapped__" ):
732+ # Check if the inner wrapper is from Sentry by checking for our transaction
733+ # This is a heuristic - if it's already double-wrapped, assume it's done
734+ continue
735+
736+ # Get the callback (may already be wrapped by permission decorator, that's fine)
737+ original_callback = cast (Callable [..., Coroutine [Any , Any , None ]], cmd .callback )
738+ txn_name = f"command.{ cmd .qualified_name } "
739+
740+ # IMPORTANT: Store params and cog BEFORE wrapping
741+ # - params were computed with original callback and contain correct converters
742+ # - cog is needed for bound methods (discord.py adds it to ctx.args in _parse_arguments)
743+ # See core.py line 868: ctx.args = [ctx] if self.cog is None else [self.cog, ctx]
744+ original_params = getattr (cmd , "params" , None )
745+ original_cog = getattr (cmd , "cog" , None )
746+
747+ # For subcommands, the cog might not be set directly
748+ # Try to find it from the parent command if missing
749+ if original_cog is None and cmd .parent is not None :
750+ parent_cog = getattr (cmd .parent , "cog" , None )
751+ if parent_cog is not None :
752+ original_cog = parent_cog
753+ parent_name = getattr (cmd .parent , "qualified_name" , "unknown" )
754+ logger .debug (
755+ f"Command { cmd .qualified_name } inheriting cog from parent { parent_name } " ,
756+ )
757+
758+ # Debug: Log if cog is still missing (shouldn't happen for cog commands)
759+ if original_cog is None and hasattr (cmd , "cog" ) and cmd .cog is None :
760+ logger .debug (
761+ f"Command { cmd .qualified_name } has no cog - this is normal for non-cog commands" ,
762+ )
698763
699- cmd .callback = cast (Callable [..., Coroutine [Any , Any , None ]], wrapped )
764+ # Wrap the callback for Sentry instrumentation
765+ # functools.wraps preserves __wrapped__ chain so original signature is accessible
766+ # discord.py's unwrap_function() will use __wrapped__ to get original signature
767+ wrapped_callback = create_wrapper (original_callback , txn_name )
768+
769+ # Preserve the original callback's __self__ if it's a bound method
770+ # This ensures discord.py can still access the cog correctly
771+ if hasattr (original_callback , "__self__" ):
772+ wrapped_callback .__self__ = original_callback .__self__ # type: ignore[attr-defined]
773+
774+ # Assign the wrapped callback
775+ cmd .callback = wrapped_callback
776+
777+ # CRITICAL: Ensure cog is set - this is essential for bound methods
778+ # When cmd.cog is None, discord.py doesn't add self to ctx.args in _parse_arguments
779+ # (see core.py line 868: ctx.args = [ctx] if self.cog is None else [self.cog, ctx])
780+ # We must set the cog so that ctx.args includes self.cog for bound methods
781+ # This is especially important for subcommands which might not have their cog set
782+ if original_cog is not None :
783+ # Use the property setter to ensure any side effects (like HybridCommand.app_command.binding)
784+ # are properly handled
785+ cmd .cog = original_cog
786+
787+ # Ensure params are preserved - they were computed with the original callback
788+ # and contain the correct converters. If params were somehow lost, restore them.
789+ if original_params is not None :
790+ cmd .params = original_params
700791
701792 logger .info (f"Instrumented { len (list (bot .walk_commands ()))} commands with Sentry." )
0 commit comments