diff --git a/.changelog/4842.added b/.changelog/4842.added new file mode 100644 index 0000000000..8a8f325280 --- /dev/null +++ b/.changelog/4842.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-discord-py`: add prefix and app command tracing diff --git a/docs/instrumentation/discord_py/discord_py.rst b/docs/instrumentation/discord_py/discord_py.rst new file mode 100644 index 0000000000..2d5c126a27 --- /dev/null +++ b/docs/instrumentation/discord_py/discord_py.rst @@ -0,0 +1,7 @@ +.. include:: ../../../instrumentation/opentelemetry-instrumentation-discord-py/README.rst + :end-before: References + +.. automodule:: opentelemetry.instrumentation.discord_py + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/README.rst b/instrumentation/opentelemetry-instrumentation-discord-py/README.rst new file mode 100644 index 0000000000..9fb832c3d3 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/README.rst @@ -0,0 +1,23 @@ +OpenTelemetry discord.py Instrumentation +========================================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-discord-py.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-discord-py/ + +This library allows tracing requests made by discord.py bots (prefix +commands and application/slash commands). + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-discord-py + +References +---------- + +* `OpenTelemetry discord.py Instrumentation `_ +* `OpenTelemetry Project `_ diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/pyproject.toml b/instrumentation/opentelemetry-instrumentation-discord-py/pyproject.toml new file mode 100644 index 0000000000..55434917dc --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-discord-py" +dynamic = ["version"] +description = "OpenTelemetry discord.py instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation == 0.65b0.dev", + "wrapt >= 1.0.0, < 2.0.0", +] + +[project.optional-dependencies] +instruments = [ + "discord.py >= 2.0", +] + +[project.entry-points.opentelemetry_instrumentor] +discord_py = "opentelemetry.instrumentation.discord_py:DiscordPyInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-discord-py" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/discord_py/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/__init__.py b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/__init__.py new file mode 100644 index 0000000000..6cb543df4d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/__init__.py @@ -0,0 +1,157 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +This library allows tracing discord.py bots: prefix commands (``ext.commands``) +and application/slash commands (``discord.app_commands``) are wrapped in spans +automatically once instrumented. + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.discord_py import DiscordPyInstrumentor + + DiscordPyInstrumentor().instrument() + + # ... create and run your discord.py Bot as usual + +Every prefix command invocation produces a span named ``discord.command`` and +every application (slash) command invocation produces a span named +``discord.app_command``. Both carry ``discord.command.name``, +``discord.guild.id``, ``discord.channel.id`` and ``discord.user.id`` as +attributes. + +API +--- +""" + +# pylint: disable=no-name-in-module,no-self-use + +from typing import Collection + +import discord.app_commands.tree +import discord.ext.commands.bot +import wrapt + +from opentelemetry.instrumentation.discord_py.package import _instruments +from opentelemetry.instrumentation.discord_py.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.trace import SpanKind, Status, StatusCode, get_tracer + +_PREFIX_COMMAND_SPAN_NAME = "discord.command" +_APP_COMMAND_SPAN_NAME = "discord.app_command" + + +def _set_common_attributes(span, *, guild_id, channel_id, user_id, command_name): + """Attach the low-cardinality attributes every discord.py span carries. + + ``None`` values are dropped rather than set, so call sites can pass + optional discord.py fields (e.g. ``guild_id`` on a DM interaction) + without branching. + """ + if command_name is not None: + span.set_attribute("discord.command.name", command_name) + if guild_id is not None: + span.set_attribute("discord.guild.id", guild_id) + if channel_id is not None: + span.set_attribute("discord.channel.id", channel_id) + if user_id is not None: + span.set_attribute("discord.user.id", user_id) + + +class DiscordPyInstrumentor(BaseInstrumentor): + """An instrumentor for discord.py. + + See `BaseInstrumentor`. + """ + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer( + __name__, + __version__, + tracer_provider, + ) + + wrapt.wrap_function_wrapper( + "discord.ext.commands.bot", + "Bot.invoke", + _wrap_prefix_command_invoke(tracer), + ) + wrapt.wrap_function_wrapper( + "discord.app_commands.tree", + "CommandTree._call", + _wrap_app_command_call(tracer), + ) + + def _uninstrument(self, **kwargs): + unwrap(discord.ext.commands.bot.Bot, "invoke") + unwrap(discord.app_commands.tree.CommandTree, "_call") + + +def _wrap_prefix_command_invoke(tracer): + async def wrapper(wrapped, instance, args, kwargs): + # signature: Bot.invoke(self, ctx) + ctx = args[0] if args else kwargs.get("ctx") + command = getattr(ctx, "command", None) + command_name = command.qualified_name if command else None + + with tracer.start_as_current_span( + _PREFIX_COMMAND_SPAN_NAME, + kind=SpanKind.SERVER, + ) as span: + if span.is_recording(): + _set_common_attributes( + span, + guild_id=getattr(getattr(ctx, "guild", None), "id", None), + channel_id=getattr(getattr(ctx, "channel", None), "id", None), + user_id=getattr(getattr(ctx, "author", None), "id", None), + command_name=command_name, + ) + try: + return await wrapped(*args, **kwargs) + except Exception as exc: + span.set_status(Status(StatusCode.ERROR, str(exc))) + span.record_exception(exc) + raise + + return wrapper + + +def _wrap_app_command_call(tracer): + async def wrapper(wrapped, instance, args, kwargs): + # signature: CommandTree._call(self, interaction) + interaction = args[0] if args else kwargs.get("interaction") + + with tracer.start_as_current_span( + _APP_COMMAND_SPAN_NAME, + kind=SpanKind.SERVER, + ) as span: + try: + return await wrapped(*args, **kwargs) + finally: + # The command isn't resolved on the interaction until + # after ``_call`` runs its internal lookup, so read it back + # out afterwards rather than before invoking ``wrapped``. + if span.is_recording(): + command = getattr(interaction, "command", None) + command_name = ( + command.qualified_name if command else None + ) + _set_common_attributes( + span, + guild_id=getattr(interaction, "guild_id", None), + channel_id=getattr(interaction, "channel_id", None), + user_id=getattr( + getattr(interaction, "user", None), "id", None + ), + command_name=command_name, + ) + + return wrapper diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/package.py b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/package.py new file mode 100644 index 0000000000..6b00b17974 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/package.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +_instruments = ("discord.py >= 2.0",) diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/version.py b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/version.py new file mode 100644 index 0000000000..3716e78195 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/src/opentelemetry/instrumentation/discord_py/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.1.dev0" diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-discord-py/test-requirements.txt new file mode 100644 index 0000000000..c184b78608 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/test-requirements.txt @@ -0,0 +1,5 @@ +discord.py==2.4.0 +pytest==7.4.4 +wrapt==1.16.0 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-discord-py diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-discord-py/tests/__init__.py new file mode 100644 index 0000000000..e57cf4aba9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/instrumentation/opentelemetry-instrumentation-discord-py/tests/test_discord_py_instrumentation.py b/instrumentation/opentelemetry-instrumentation-discord-py/tests/test_discord_py_instrumentation.py new file mode 100644 index 0000000000..17813483ab --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-discord-py/tests/test_discord_py_instrumentation.py @@ -0,0 +1,116 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=invalid-name,no-name-in-module + +from unittest import IsolatedAsyncioTestCase, mock + +import discord.app_commands.tree +import discord.ext.commands.bot + +from opentelemetry.instrumentation.discord_py import DiscordPyInstrumentor +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import SpanKind, StatusCode + + +class TestDiscordPyInstrumentor(TestBase, IsolatedAsyncioTestCase): + def setUp(self): + super().setUp() + DiscordPyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + super().tearDown() + DiscordPyInstrumentor().uninstrument() + + async def test_prefix_command_creates_span(self): + ctx = mock.Mock() + ctx.command.qualified_name = "ping" + ctx.guild.id = 111 + ctx.channel.id = 222 + ctx.author.id = 333 + + async def fake_invoke(self, ctx): + return None + + with mock.patch.object( + discord.ext.commands.bot.Bot, "invoke", new=fake_invoke + ): + # Re-instrument so the patch applied above is what gets wrapped. + DiscordPyInstrumentor().uninstrument() + DiscordPyInstrumentor().instrument( + tracer_provider=self.tracer_provider + ) + fake_bot = discord.ext.commands.bot.Bot.__new__( + discord.ext.commands.bot.Bot + ) + await fake_bot.invoke(ctx) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "discord.command") + self.assertEqual(span.kind, SpanKind.SERVER) + self.assertEqual(span.attributes["discord.command.name"], "ping") + self.assertEqual(span.attributes["discord.guild.id"], 111) + self.assertEqual(span.attributes["discord.channel.id"], 222) + self.assertEqual(span.attributes["discord.user.id"], 333) + + async def test_prefix_command_records_exception(self): + ctx = mock.Mock() + ctx.command = None + ctx.guild = None + ctx.channel.id = 222 + ctx.author.id = 333 + + async def failing_invoke(self, ctx): + raise RuntimeError("boom") + + with mock.patch.object( + discord.ext.commands.bot.Bot, "invoke", new=failing_invoke + ): + DiscordPyInstrumentor().uninstrument() + DiscordPyInstrumentor().instrument( + tracer_provider=self.tracer_provider + ) + fake_bot = discord.ext.commands.bot.Bot.__new__( + discord.ext.commands.bot.Bot + ) + with self.assertRaises(RuntimeError): + await fake_bot.invoke(ctx) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].status.status_code, StatusCode.ERROR) + + async def test_app_command_creates_span(self): + interaction = mock.Mock() + interaction.command.qualified_name = "slash-ping" + interaction.guild_id = 444 + interaction.channel_id = 555 + interaction.user.id = 666 + + async def fake_call(self, interaction): + return None + + with mock.patch.object( + discord.app_commands.tree.CommandTree, "_call", new=fake_call + ): + DiscordPyInstrumentor().uninstrument() + DiscordPyInstrumentor().instrument( + tracer_provider=self.tracer_provider + ) + fake_tree = discord.app_commands.tree.CommandTree.__new__( + discord.app_commands.tree.CommandTree + ) + await fake_tree._call(interaction) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "discord.app_command") + self.assertEqual( + span.attributes["discord.command.name"], "slash-ping" + ) + self.assertEqual(span.attributes["discord.guild.id"], 444) + self.assertEqual(span.attributes["discord.channel.id"], 555) + self.assertEqual(span.attributes["discord.user.id"], 666) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index ac9adc84a3..66975d965f 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -77,6 +77,10 @@ "library": "confluent-kafka >= 1.8.2, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.65b0.dev", }, + { + "library": "discord.py >= 2.0", + "instrumentation": "opentelemetry-instrumentation-discord-py==0.1.dev0", + }, { "library": "django >= 2.0", "instrumentation": "opentelemetry-instrumentation-django==0.65b0.dev", diff --git a/tox.ini b/tox.ini index 81ba52d1f5..29ab8d96ac 100644 --- a/tox.ini +++ b/tox.ini @@ -206,6 +206,11 @@ envlist = lint-instrumentation-logging benchmark-instrumentation-logging + ; opentelemetry-instrumentation-discord-py + py3{10,11,12,13,14}-test-instrumentation-discord_py + pypy3-test-instrumentation-discord_py + lint-instrumentation-discord_py + ; opentelemetry-instrumentation-exceptions py3{10,11,12,13,14}-test-instrumentation-exceptions pypy3-test-instrumentation-exceptions @@ -710,6 +715,9 @@ deps = logging: {[testenv]test_deps} logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt benchmark-instrumentation-logging: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmark-requirements.txt + + discord_py: {[testenv]test_deps} + discord_py: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-discord-py/test-requirements.txt exceptions: {[testenv]test_deps} exceptions: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-exceptions/test-requirements.txt @@ -920,6 +928,9 @@ commands = lint-instrumentation-logging: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-logging" benchmark-instrumentation-logging: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-logging/benchmarks {posargs} --benchmark-json=instrumentation-logging-benchmark.json + test-instrumentation-discord_py: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-discord-py/tests {posargs} + lint-instrumentation-discord_py: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-discord-py" + test-instrumentation-exceptions: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-exceptions/tests {posargs} lint-instrumentation-exceptions: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-exceptions"