Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/4842.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-discord-py`: add prefix and app command tracing
7 changes: 7 additions & 0 deletions docs/instrumentation/discord_py/discord_py.rst
Original file line number Diff line number Diff line change
@@ -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:
Original file line number Diff line number Diff line change
@@ -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 <https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/discord_py/discord_py.html>`_
* `OpenTelemetry Project <https://opentelemetry.io/>`_
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

_instruments = ("discord.py >= 2.0",)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

__version__ = "0.1.dev0"
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading