Skip to content

Commit 62b7a35

Browse files
committed
refactor(tracing): enhance command callback wrapping for Sentry instrumentation
- Introduced a `create_wrapper` function to streamline the wrapping of command callbacks, preserving function signatures and metadata for better compatibility with discord.py. - Improved transaction handling by ensuring original callbacks are called with the correct arguments and context. - Added checks to prevent double-wrapping of commands already instrumented with Sentry, enhancing reliability. - Preserved original parameters and cog information to maintain functionality for bound methods and subcommands. - Enhanced logging for better visibility into the command instrumentation process.
1 parent 819a7e5 commit 62b7a35

File tree

1 file changed

+109
-18
lines changed

1 file changed

+109
-18
lines changed

src/tux/services/sentry/tracing.py

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)