Repository: python-telegram-bot/python-telegram-bot
Pull request: Overhaul loggers
<!--
Hey! You're PRing? Cool! Please have a look at the below checklist. It's here to help both you and the maintainers to remember some aspects. Make sure to check out our contribution guide (https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.github/CONTRIBUTING.rst).
-->

Closes #3591

I decided against documenting the logger names somewhere for now because

* I didn't really know where to put that information
* I think that might be over-engineered …

### Checklist for PRs

- [ ] Added `.. versionadded:: version`, `.. versionchanged:: version` or `.. deprecated:: version` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes)
- [x] Created new or adapted existing unit tests
- [x] Documented code changes according to the [CSI standard](https://standards.mousepawmedia.com/en/stable/csi.html)


Problem statement:
Overhaul logger names
We currently use `logging.getLogger(__name__)` to get the logger that we use in a module. While this is common practice, this explicitly uses the naming of our files, leading to output like

```
DEBUG:telegram.ext._updater:Updater started (polling)
```

This exposes the protected modules that are protected for the reason that users should not use them directly.

I suggest to revisit every call to `logging.getLogger` in our code base and

* evaluate whether we need a logger on class-instance, class, or module level
* evaluate which name that logger should carry. The name should
  * reflect the public API of PTB
  * make it clear to the user which component is doing the logging

If some auxiliary functionality can be used to automate the derivation of the name, this is a welcome addition and can be implemented in a new `telegram._utils.logging` moduile.

IMO it would be desirable to extend existing logging-related unit tests to check the set name, especially if some automation functionality is used for the name derivation.

Note that so far we have made no promisies about the names of the loggers in the documentation, so that I don't consider this a breaking change. We might want to give a promise in the future.

Hints from issue discussion:
In our internal group came up that we would like to have a way to disable tracebacks for our code unless the user explicitly enables this. Right now the error messages are quite cluttered and not helpful, it should only expose external function calls. We haven't come up with a definite way to hide it yet, but it should be included when tackling this issue.
Moving https://github.com/python-telegram-bot/python-telegram-bot/issues/3591#issuecomment-1477791229 to #3613 :)

Commit message: Rest of tests

Patch to review:
diff --git a/telegram/_bot.py b/telegram/_bot.py
index 902443edca8..6c43bccb942 100644
--- a/telegram/_bot.py
+++ b/telegram/_bot.py
@@ -22,7 +22,6 @@
 import contextlib
 import copy
 import functools
-import logging
 import pickle
 from datetime import datetime
 from types import TracebackType
@@ -94,6 +93,7 @@
 from telegram._utils.argumentparsing import parse_sequence_arg
 from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
 from telegram._utils.files import is_local_file, parse_file_input
+from telegram._utils.logging import get_logger
 from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup
 from telegram._utils.warnings import warn
 from telegram._utils.warnings_transition import warn_about_thumb_return_thumbnail
@@ -206,6 +206,8 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]):
 
     """
 
+    _LOGGER = get_logger(__name__)
+
     __slots__ = (
         "_token",
         "_base_url",
@@ -213,7 +215,6 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]):
         "_private_key",
         "_bot_user",
         "_request",
-        "_logger",
         "_initialized",
         "_local_mode",
     )
@@ -239,7 +240,6 @@ def __init__(
         self._local_mode: bool = local_mode
         self._bot_user: Optional[User] = None
         self._private_key: Optional[bytes] = None
-        self._logger = logging.getLogger(__name__)
         self._initialized = False
 
         self._request: Tuple[BaseRequest, BaseRequest] = (
@@ -361,14 +361,13 @@ def __deepcopy__(self, memodict: Dict[int, object]) -> NoReturn:
     # TODO: After https://youtrack.jetbrains.com/issue/PY-50952 is fixed, we can revisit this and
     # consider adding Paramspec from typing_extensions to properly fix this. Currently a workaround
     def _log(func: Any):  # type: ignore[no-untyped-def] # skipcq: PY-D0003
-        logger = logging.getLogger(func.__module__)
-
         @functools.wraps(func)
-        async def decorator(*args, **kwargs):  # type: ignore[no-untyped-def]
-            logger.debug("Entering: %s", func.__name__)
-            result = await func(*args, **kwargs)  # skipcq: PYL-E1102
-            logger.debug(result)
-            logger.debug("Exiting: %s", func.__name__)
+        async def decorator(self, *args, **kwargs):  # type: ignore[no-untyped-def]
+            # pylint: disable=protected-access
+            self._LOGGER.debug("Entering: %s", func.__name__)
+            result = await func(self, *args, **kwargs)  # skipcq: PYL-E1102
+            self._LOGGER.debug(result)
+            self._LOGGER.debug("Exiting: %s", func.__name__)
             return result
 
         return decorator
@@ -569,7 +568,7 @@ async def initialize(self) -> None:
         .. versionadded:: 20.0
         """
         if self._initialized:
-            self._logger.debug("This Bot is already initialized.")
+            self._LOGGER.debug("This Bot is already initialized.")
             return
 
         await asyncio.gather(self._request[0].initialize(), self._request[1].initialize())
@@ -590,7 +589,7 @@ async def shutdown(self) -> None:
         .. versionadded:: 20.0
         """
         if not self._initialized:
-            self._logger.debug("This Bot is already shut down. Returning.")
+            self._LOGGER.debug("This Bot is already shut down. Returning.")
             return
 
         await asyncio.gather(self._request[0].shutdown(), self._request[1].shutdown())
@@ -3614,9 +3613,9 @@ async def get_updates(
         )
 
         if result:
-            self._logger.debug("Getting updates: %s", [u["update_id"] for u in result])
+            self._LOGGER.debug("Getting updates: %s", [u["update_id"] for u in result])
         else:
-            self._logger.debug("No new updates found.")
+            self._LOGGER.debug("No new updates found.")
 
         return Update.de_list(result, self)
 
diff --git a/telegram/_files/inputfile.py b/telegram/_files/inputfile.py
index 3a0df5546c9..8cc8b8ba9f9 100644
--- a/telegram/_files/inputfile.py
+++ b/telegram/_files/inputfile.py
@@ -18,7 +18,6 @@
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
 """This module contains an object that represents a Telegram InputFile."""
 
-import logging
 import mimetypes
 from typing import IO, Optional, Union
 from uuid import uuid4
@@ -27,7 +26,6 @@
 from telegram._utils.types import FieldTuple
 
 _DEFAULT_MIME_TYPE = "application/octet-stream"
-logger = logging.getLogger(__name__)
 
 
 class InputFile:
diff --git a/telegram/_utils/logging.py b/telegram/_utils/logging.py
new file mode 100644
index 00000000000..909905df529
--- /dev/null
+++ b/telegram/_utils/logging.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+#
+# A library that provides a Python interface to the Telegram Bot API
+# Copyright (C) 2015-2023
+# Leandro Toledo de Souza <devs@python-telegram-bot.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Lesser Public License for more details.
+#
+# You should have received a copy of the GNU Lesser Public License
+# along with this program.  If not, see [http://www.gnu.org/licenses/].
+"""This module contains helper functions related to logging.
+
+Warning:
+    Contents of this module are intended to be used internally by the library and *not* by the
+    user. Changes to this module are not considered breaking changes and may not be documented in
+    the changelog.
+"""
+import logging
+
+
+def get_logger(file_name: str, class_name: str = None) -> logging.Logger:
+    """Returns a logger with an appropriate name. Use as follows::
+
+        logger = get_logger(__name__)
+
+    If for example `__name__` is `telegram.ext._updater`, the logger will be named
+    `telegram.ext.Updater`. If `class_name` is passed, this will result in
+    `telegram.ext.<class_name>`. Useful e.g. for CamelCase class names.
+
+    If the file name points to a utils module, the logger name will simply be `telegram(.ext)`.
+
+    Returns:
+        :class:`logging.Logger`: The logger.
+    """
+    parts = file_name.split("_")
+    if parts[1].startswith("utils") and class_name is None:
+        name = parts[0].rstrip(".")
+    else:
+        name = f"{parts[0]}{class_name or parts[1].capitalize()}"
+    return logging.getLogger(name)
diff --git a/telegram/ext/_aioratelimiter.py b/telegram/ext/_aioratelimiter.py
index c948075ac21..54b0690b2d6 100644
--- a/telegram/ext/_aioratelimiter.py
+++ b/telegram/ext/_aioratelimiter.py
@@ -21,7 +21,6 @@
 """
 import asyncio
 import contextlib
-import logging
 import sys
 from typing import Any, AsyncIterator, Callable, Coroutine, Dict, List, Optional, Union
 
@@ -32,6 +31,7 @@
 except ImportError:
     AIO_LIMITER_AVAILABLE = False
 
+from telegram._utils.logging import get_logger
 from telegram._utils.types import JSONDict
 from telegram.error import RetryAfter
 from telegram.ext._baseratelimiter import BaseRateLimiter
@@ -48,6 +48,9 @@ async def null_context() -> AsyncIterator[None]:
         yield None
 
 
+_logger = get_logger(__name__, class_name="AIORateLimiter")
+
+
 class AIORateLimiter(BaseRateLimiter[int]):
     """
     Implementation of :class:`~telegram.ext.BaseRateLimiter` using the library
@@ -118,7 +121,6 @@ class AIORateLimiter(BaseRateLimiter[int]):
         "_group_limiters",
         "_group_max_rate",
         "_group_time_period",
-        "_logger",
         "_max_retries",
         "_retry_after_event",
     )
@@ -152,7 +154,6 @@ def __init__(
 
         self._group_limiters: Dict[Union[str, int], AsyncLimiter] = {}
         self._max_retries: int = max_retries
-        self._logger = logging.getLogger(__name__)
         self._retry_after_event = asyncio.Event()
         self._retry_after_event.set()
 
@@ -247,13 +248,13 @@ async def process_request(
                 )
             except RetryAfter as exc:
                 if i == max_retries:
-                    self._logger.exception(
+                    _logger.exception(
                         "Rate limit hit after maximum of %d retries", max_retries, exc_info=exc
                     )
                     raise exc
 
                 sleep = exc.retry_after + 0.1
-                self._logger.info("Rate limit hit. Retrying after %f seconds", sleep)
+                _logger.info("Rate limit hit. Retrying after %f seconds", sleep)
                 # Make sure we don't allow other requests to be processed
                 self._retry_after_event.clear()
                 await asyncio.sleep(sleep)
diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py
index 4952bd804ac..aebddca0639 100644
--- a/telegram/ext/_application.py
+++ b/telegram/ext/_application.py
@@ -21,7 +21,6 @@
 import contextlib
 import inspect
 import itertools
-import logging
 import platform
 import signal
 from collections import defaultdict
@@ -53,6 +52,7 @@
 
 from telegram._update import Update
 from telegram._utils.defaultvalue import DEFAULT_NONE, DEFAULT_TRUE, DefaultValue
+from telegram._utils.logging import get_logger
 from telegram._utils.types import SCT, DVType, ODVInput
 from telegram._utils.warnings import warn
 from telegram.error import TelegramError
@@ -77,7 +77,7 @@
 _STOP_SIGNAL = object()
 _DEFAULT_0 = DefaultValue(0)
 
-_logger = logging.getLogger(__name__)
+_logger = get_logger(__name__)
 
 
 class ApplicationHandlerStop(Exception):
diff --git a/telegram/ext/_conversationhandler.py b/telegram/ext/_conversationhandler.py
index 884b3872883..902f1c32561 100644
--- a/telegram/ext/_conversationhandler.py
+++ b/telegram/ext/_conversationhandler.py
@@ -19,7 +19,6 @@
 """This module contains the ConversationHandler."""
 import asyncio
 import datetime
-import logging
 from dataclasses import dataclass
 from typing import (
     TYPE_CHECKING,
@@ -38,6 +37,7 @@
 
 from telegram import Update
 from telegram._utils.defaultvalue import DEFAULT_TRUE, DefaultValue
+from telegram._utils.logging import get_logger
 from telegram._utils.types import DVType
 from telegram._utils.warnings import warn
 from telegram.ext._application import ApplicationHandlerStop
@@ -56,7 +56,7 @@
     from telegram.ext import Application, Job, JobQueue
 _CheckUpdateType = Tuple[object, ConversationKey, BaseHandler[Update, CCT], object]
 
-_logger = logging.getLogger(__name__)
+_logger = get_logger(__name__, class_name="ConversationHandler")
 
 
 @dataclass
diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py
index fb87e49a86a..e8f02cb3461 100644
--- a/telegram/ext/_extbot.py
+++ b/telegram/ext/_extbot.py
@@ -85,6 +85,7 @@
 )
 from telegram._utils.datetime import to_timestamp
 from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue
+from telegram._utils.logging import get_logger
 from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup
 from telegram._utils.warnings import warn
 from telegram.ext._callbackdatacache import CallbackDataCache
@@ -156,6 +157,8 @@ class ExtBot(Bot, Generic[RLARGS]):
 
     __slots__ = ("_callback_data_cache", "_defaults", "_rate_limiter")
 
+    _LOGGER = get_logger(__name__, class_name="ExtBot")
+
     # using object() would be a tiny bit safer, but a string plays better with the typing setup
     __RL_KEY = uuid4().hex
 
@@ -324,7 +327,7 @@ async def _do_post(
             "connect_timeout": connect_timeout,
             "pool_timeout": pool_timeout,
         }
-        self._logger.debug(
+        self._LOGGER.debug(
             "Passing request through rate limiter of type %s with rate_limit_args %s",
             type(self.rate_limiter),
             rate_limit_args,
diff --git a/telegram/ext/_updater.py b/telegram/ext/_updater.py
index 73ca65c3f25..5c7091998e8 100644
--- a/telegram/ext/_updater.py
+++ b/telegram/ext/_updater.py
@@ -19,7 +19,6 @@
 """This module contains the class Updater, which tries to make creating Telegram bots intuitive."""
 import asyncio
 import contextlib
-import logging
 import ssl
 from pathlib import Path
 from types import TracebackType
@@ -36,6 +35,7 @@
 )
 
 from telegram._utils.defaultvalue import DEFAULT_NONE
+from telegram._utils.logging import get_logger
 from telegram._utils.types import ODVInput
 from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut
 
@@ -51,6 +51,7 @@
 
 
 _UpdaterType = TypeVar("_UpdaterType", bound="Updater")  # pylint: disable=invalid-name
+_logger = get_logger(__name__)
 
 
 class Updater(AsyncContextManager["Updater"]):
@@ -97,7 +98,6 @@ class Updater(AsyncContextManager["Updater"]):
 
     __slots__ = (
         "bot",
-        "_logger",
         "update_queue",
         "_last_update_id",
         "_running",
@@ -121,7 +121,6 @@ def __init__(
         self._httpd: Optional[WebhookServer] = None
         self.__lock = asyncio.Lock()
         self.__polling_task: Optional[asyncio.Task] = None
-        self._logger = logging.getLogger(__name__)
 
     @property
     def running(self) -> bool:
@@ -135,7 +134,7 @@ async def initialize(self) -> None:
             :meth:`shutdown`
         """
         if self._initialized:
-            self._logger.debug("This Updater is already initialized.")
+            _logger.debug("This Updater is already initialized.")
             return
 
         await self.bot.initialize()
@@ -155,12 +154,12 @@ async def shutdown(self) -> None:
             raise RuntimeError("This Updater is still running!")
 
         if not self._initialized:
-            self._logger.debug("This Updater is already shut down. Returning.")
+            _logger.debug("This Updater is already shut down. Returning.")
             return
 
         await self.bot.shutdown()
         self._initialized = False
-        self._logger.debug("Shut down of Updater complete")
+        _logger.debug("Shut down of Updater complete")
 
     async def __aenter__(self: _UpdaterType) -> _UpdaterType:
         """Simple context manager which initializes the Updater."""
@@ -279,9 +278,9 @@ def callback(error: telegram.error.TelegramError)
                     error_callback=error_callback,
                 )
 
-                self._logger.debug("Waiting for polling to start")
+                _logger.debug("Waiting for polling to start")
                 await polling_ready.wait()
-                self._logger.debug("Polling updates from Telegram started")
+                _logger.debug("Polling updates from Telegram started")
 
                 return self.update_queue
             except Exception as exc:
@@ -302,7 +301,7 @@ async def _start_polling(
         ready: asyncio.Event,
         error_callback: Optional[Callable[[TelegramError], None]],
     ) -> None:
-        self._logger.debug("Updater started (polling)")
+        _logger.debug("Updater started (polling)")
 
         # the bootstrapping phase does two things:
         # 1) make sure there is no webhook set
@@ -314,7 +313,7 @@ async def _start_polling(
             allowed_updates=None,
         )
 
-        self._logger.debug("Bootstrap done")
+        _logger.debug("Bootstrap done")
 
         async def polling_action_cb() -> bool:
             try:
@@ -336,7 +335,7 @@ async def polling_action_cb() -> bool:
                 raise exc
             except Exception as exc:
                 # Other exceptions should not. Let's log them for now.
-                self._logger.critical(
+                _logger.critical(
                     "Something went wrong processing the data received from Telegram. "
                     "Received data was *not* processed!",
                     exc_info=exc,
@@ -345,7 +344,7 @@ async def polling_action_cb() -> bool:
 
             if updates:
                 if not self.running:
-                    self._logger.critical(
+                    _logger.critical(
                         "Updater stopped unexpectedly. Pulled updates will be ignored and again "
                         "on restart."
                     )
@@ -357,7 +356,7 @@ async def polling_action_cb() -> bool:
             return True  # Keep fetching updates & don't quit. Polls with poll_interval.
 
         def default_error_callback(exc: TelegramError) -> None:
-            self._logger.exception("Exception happened while polling for updates.", exc_info=exc)
+            _logger.exception("Exception happened while polling for updates.", exc_info=exc)
 
         # Start task that runs in background, pulls
         # updates from Telegram and inserts them in the update queue of the
@@ -496,9 +495,9 @@ async def start_webhook(
                     secret_token=secret_token,
                 )
 
-                self._logger.debug("Waiting for webhook server to start")
+                _logger.debug("Waiting for webhook server to start")
                 await webhook_ready.wait()
-                self._logger.debug("Webhook server started")
+                _logger.debug("Webhook server started")
             except Exception as exc:
                 self._running = False
                 raise exc
@@ -522,7 +521,7 @@ async def _start_webhook(
         max_connections: int = 40,
         secret_token: str = None,
     ) -> None:
-        self._logger.debug("Updater thread started (webhook)")
+        _logger.debug("Updater thread started (webhook)")
 
         if not url_path.startswith("/"):
             url_path = f"/{url_path}"
@@ -600,7 +599,7 @@ async def _network_loop_retry(
                 `action_cb`.
 
         """
-        self._logger.debug("Start network loop retry %s", description)
+        _logger.debug("Start network loop retry %s", description)
         cur_interval = interval
         while self.running:
             try:
@@ -608,17 +607,17 @@ async def _network_loop_retry(
                     if not await action_cb():
                         break
                 except RetryAfter as exc:
-                    self._logger.info("%s", exc)
+                    _logger.info("%s", exc)
                     cur_interval = 0.5 + exc.retry_after
                 except TimedOut as toe:
-                    self._logger.debug("Timed out %s: %s", description, toe)
+                    _logger.debug("Timed out %s: %s", description, toe)
                     # If failure is due to timeout, we should retry asap.
                     cur_interval = 0
                 except InvalidToken as pex:
-                    self._logger.error("Invalid token; aborting")
+                    _logger.error("Invalid token; aborting")
                     raise pex
                 except TelegramError as telegram_exc:
-                    self._logger.error("Error while %s: %s", description, telegram_exc)
+                    _logger.error("Error while %s: %s", description, telegram_exc)
                     on_err_cb(telegram_exc)
 
                     # increase waiting times on subsequent errors up to 30secs
@@ -630,7 +629,7 @@ async def _network_loop_retry(
                     await asyncio.sleep(cur_interval)
 
             except asyncio.CancelledError:
-                self._logger.debug("Network loop retry %s was cancelled", description)
+                _logger.debug("Network loop retry %s was cancelled", description)
                 break
 
     async def _bootstrap(
@@ -652,16 +651,16 @@ async def _bootstrap(
         retries = 0
 
         async def bootstrap_del_webhook() -> bool:
-            self._logger.debug("Deleting webhook")
+            _logger.debug("Deleting webhook")
             if drop_pending_updates:
-                self._logger.debug("Dropping pending updates from Telegram server")
+                _logger.debug("Dropping pending updates from Telegram server")
             await self.bot.delete_webhook(drop_pending_updates=drop_pending_updates)
             return False
 
         async def bootstrap_set_webhook() -> bool:
-            self._logger.debug("Setting webhook")
+            _logger.debug("Setting webhook")
             if drop_pending_updates:
-                self._logger.debug("Dropping pending updates from Telegram server")
+                _logger.debug("Dropping pending updates from Telegram server")
             await self.bot.set_webhook(
                 url=webhook_url,
                 certificate=cert,
@@ -680,11 +679,11 @@ def bootstrap_on_err_cb(exc: Exception) -> None:
 
             if not isinstance(exc, InvalidToken) and (max_retries < 0 or retries < max_retries):
                 retries += 1
-                self._logger.warning(
+                _logger.warning(
                     "Failed bootstrap phase; try=%s max_retries=%s", retries, max_retries
                 )
             else:
-                self._logger.error("Failed bootstrap phase after %s retries (%s)", retries, exc)
+                _logger.error("Failed bootstrap phase after %s retries (%s)", retries, exc)
                 raise exc
 
         # Dropping pending updates from TG can be efficiently done with the drop_pending_updates
@@ -725,26 +724,26 @@ async def stop(self) -> None:
             if not self.running:
                 raise RuntimeError("This Updater is not running!")
 
-            self._logger.debug("Stopping Updater")
+            _logger.debug("Stopping Updater")
 
             self._running = False
 
             await self._stop_httpd()
             await self._stop_polling()
 
-            self._logger.debug("Updater.stop() is complete")
+            _logger.debug("Updater.stop() is complete")
 
     async def _stop_httpd(self) -> None:
         """Stops the Webhook server by calling ``WebhookServer.shutdown()``"""
         if self._httpd:
-            self._logger.debug("Waiting for current webhook connection to be closed.")
+            _logger.debug("Waiting for current webhook connection to be closed.")
             await self._httpd.shutdown()
             self._httpd = None
 
     async def _stop_polling(self) -> None:
         """Stops the polling task by awaiting it."""
         if self.__polling_task:
-            self._logger.debug("Waiting background polling task to finish up.")
+            _logger.debug("Waiting background polling task to finish up.")
             self.__polling_task.cancel()
 
             with contextlib.suppress(asyncio.CancelledError):
diff --git a/telegram/ext/_utils/stack.py b/telegram/ext/_utils/stack.py
index e7d91d2305f..7531c219671 100644
--- a/telegram/ext/_utils/stack.py
+++ b/telegram/ext/_utils/stack.py
@@ -25,12 +25,13 @@
     user. Changes to this module are not considered breaking changes and may not be documented in
     the changelog.
 """
-import logging
 from pathlib import Path
 from types import FrameType
 from typing import Optional
 
-_logger = logging.getLogger(__name__)
+from telegram._utils.logging import get_logger
+
+_logger = get_logger(__name__)
 
 
 def was_called_by(frame: Optional[FrameType], caller: Path) -> bool:
diff --git a/telegram/ext/_utils/webhookhandler.py b/telegram/ext/_utils/webhookhandler.py
index 3643646335c..20b816128ff 100644
--- a/telegram/ext/_utils/webhookhandler.py
+++ b/telegram/ext/_utils/webhookhandler.py
@@ -19,7 +19,6 @@
 # pylint: disable=missing-module-docstring
 import asyncio
 import json
-import logging
 from http import HTTPStatus
 from ssl import SSLContext
 from types import TracebackType
@@ -31,11 +30,15 @@
 from tornado.httpserver import HTTPServer
 
 from telegram import Update
+from telegram._utils.logging import get_logger
 from telegram.ext._extbot import ExtBot
 
 if TYPE_CHECKING:
     from telegram import Bot
 
+# This module is not visible to users, so we log as Updater
+_logger = get_logger(__name__, class_name="Updater")
+
 
 class WebhookServer:
     """Thin wrapper around ``tornado.httpserver.HTTPServer``."""
@@ -44,7 +47,6 @@ class WebhookServer:
         "_http_server",
         "listen",
         "port",
-        "_logger",
         "is_running",
         "_server_lock",
         "_shutdown_lock",
@@ -56,7 +58,6 @@ def __init__(
         self._http_server = HTTPServer(webhook_app, ssl_options=ssl_ctx)
         self.listen = listen
         self.port = port
-        self._logger = logging.getLogger(__name__)
         self.is_running = False
         self._server_lock = asyncio.Lock()
         self._shutdown_lock = asyncio.Lock()
@@ -69,17 +70,17 @@ async def serve_forever(self, ready: asyncio.Event = None) -> None:
             if ready is not None:
                 ready.set()
 
-            self._logger.debug("Webhook Server started.")
+            _logger.debug("Webhook Server started.")
 
     async def shutdown(self) -> None:
         async with self._shutdown_lock:
             if not self.is_running:
-                self._logger.debug("Webhook Server is already shut down. Returning")
+                _logger.debug("Webhook Server is already shut down. Returning")
                 return
             self.is_running = False
             self._http_server.stop()
             await self._http_server.close_all_connections()
-            self._logger.debug("Webhook Server stopped")
+            _logger.debug("Webhook Server stopped")
 
 
 class WebhookAppClass(tornado.web.Application):
@@ -104,7 +105,7 @@ def log_request(self, handler: tornado.web.RequestHandler) -> None:
 class TelegramHandler(tornado.web.RequestHandler):
     """BaseHandler that processes incoming requests from Telegram"""
 
-    __slots__ = ("bot", "update_queue", "_logger", "secret_token")
+    __slots__ = ("bot", "update_queue", "secret_token")
 
     SUPPORTED_METHODS = ("POST",)  # type: ignore[assignment]
 
@@ -113,10 +114,9 @@ def initialize(self, bot: "Bot", update_queue: asyncio.Queue, secret_token: str)
         # pylint: disable=attribute-defined-outside-init
         self.bot = bot
         self.update_queue = update_queue  # skipcq: PYL-W0201
-        self._logger = logging.getLogger(__name__)  # skipcq: PYL-W0201
         self.secret_token = secret_token  # skipcq: PYL-W0201
         if secret_token:
-            self._logger.debug(
+            _logger.debug(
                 "The webhook server has a secret token, expecting it in incoming requests now"
             )
 
@@ -126,25 +126,25 @@ def set_default_headers(self) -> None:
 
     async def post(self) -> None:
         """Handle incoming POST request"""
-        self._logger.debug("Webhook triggered")
+        _logger.debug("Webhook triggered")
         self._validate_post()
 
         json_string = self.request.body.decode()
         data = json.loads(json_string)
         self.set_status(HTTPStatus.OK)
-        self._logger.debug("Webhook received data: %s", json_string)
+        _logger.debug("Webhook received data: %s", json_string)
 
         try:
             update = Update.de_json(data, self.bot)
         except Exception as exc:
-            self._logger.critical(
+            _logger.critical(
                 "Something went wrong processing the data received from Telegram. "
                 "Received data was *not* processed!",
                 exc_info=exc,
             )
 
         if update:
-            self._logger.debug(
+            _logger.debug(
                 "Received Update with ID %d on Webhook",
                 # For some reason pylint thinks update is a general TelegramObject
                 update.update_id,  # pylint: disable=no-member
@@ -165,12 +165,12 @@ def _validate_post(self) -> None:
         if self.secret_token is not None:
             token = self.request.headers.get("X-Telegram-Bot-Api-Secret-Token")
             if not token:
-                self._logger.debug("Request did not include the secret token")
+                _logger.debug("Request did not include the secret token")
                 raise tornado.web.HTTPError(
                     HTTPStatus.FORBIDDEN, reason="Request did not include the secret token"
                 )
             if token != self.secret_token:
-                self._logger.debug("Request had the wrong secret token: %s", token)
+                _logger.debug("Request had the wrong secret token: %s", token)
                 raise tornado.web.HTTPError(
                     HTTPStatus.FORBIDDEN, reason="Request had the wrong secret token"
                 )
@@ -182,7 +182,7 @@ def log_exception(
         tb: Optional[TracebackType],
     ) -> None:
         """Override the default logging and instead use our custom logging."""
-        self._logger.debug(
+        _logger.debug(
             "%s - %s",
             self.request.remote_ip,
             "Exception in TelegramHandler",
diff --git a/telegram/request/_httpxrequest.py b/telegram/request/_httpxrequest.py
index 837162b411a..37a5a3690c5 100644
--- a/telegram/request/_httpxrequest.py
+++ b/telegram/request/_httpxrequest.py
@@ -17,12 +17,12 @@
 # You should have received a copy of the GNU Lesser Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
 """This module contains methods to make POST and GET requests using the httpx library."""
-import logging
 from typing import Optional, Tuple
 
 import httpx
 
 from telegram._utils.defaultvalue import DefaultValue
+from telegram._utils.logging import get_logger
 from telegram._utils.types import ODVInput
 from telegram.error import NetworkError, TimedOut
 from telegram.request._baserequest import BaseRequest
@@ -33,7 +33,7 @@
 # https://www.python-httpx.org/contributing/#development-proxy-setup (also saved on archive.org)
 # That also works with socks5. Just pass `--mode socks5` to mitmproxy
 
-_logger = logging.getLogger(__name__)
+_logger = get_logger(__name__, "HTTPXRequest")
 
 
 class HTTPXRequest(BaseRequest):
diff --git a/tests/ext/_utils/test_stack.py b/tests/ext/_utils/test_stack.py
index fc5d42ef5ba..13305b5029d 100644
--- a/tests/ext/_utils/test_stack.py
+++ b/tests/ext/_utils/test_stack.py
@@ -56,6 +56,7 @@ def resolve(self):
             assert not was_called_by(inspect.currentframe(), None)
 
         assert len(caplog.records) == 1
+        assert caplog.records[0].name == "telegram.ext"
         assert caplog.records[0].levelno == logging.DEBUG
         assert caplog.records[0].getMessage().startswith("Failed to check")
         assert caplog.records[0].exc_info[0] is RuntimeError
diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py
index b68696e9831..8ee4d09ec0f 100644
--- a/tests/ext/test_application.py
+++ b/tests/ext/test_application.py
@@ -803,6 +803,7 @@ def test_double_add_error_handler(self, app, caplog):
         with caplog.at_level(logging.DEBUG):
             app.add_error_handler(self.error_handler_context)
             assert len(caplog.records) == 1
+            assert caplog.records[-1].name == "telegram.ext.Application"
             assert caplog.records[-1].getMessage().startswith("The callback is already registered")
 
     async def test_error_handler_that_raises_errors(self, app, caplog):
@@ -826,11 +827,11 @@ async def test_error_handler_that_raises_errors(self, app, caplog):
                 assert self.count == 0
                 assert self.received is None
                 assert len(caplog.records) > 0
-                log_messages = (record.getMessage() for record in caplog.records)
                 assert any(
                     "uncaught error was raised while handling the error with an error_handler"
-                    in message
-                    for message in log_messages
+                    in record.getMessage()
+                    and record.name == "telegram.ext.Application"
+                    for record in caplog.records
                 )
 
                 await app.update_queue.put("1")
@@ -988,6 +989,7 @@ async def test_non_blocking_no_error_handler(self, app, caplog):
                 assert (
                     caplog.records[-1].getMessage().startswith("No error handlers are registered")
                 )
+                assert caplog.records[-1].name == "telegram.ext.Application"
                 await app.stop()
 
     @pytest.mark.parametrize("handler_block", [True, False])
@@ -1090,6 +1092,7 @@ async def test_nonblocking_handler_raises_and_non_blocking_error_handler_raises(
                 await app.update_queue.put(1)
                 await asyncio.sleep(0.05)
                 assert len(caplog.records) == 1
+                assert caplog.records[-1].name == "telegram.ext.Application"
                 assert (
                     caplog.records[-1]
                     .getMessage()
diff --git a/tests/ext/test_basepersistence.py b/tests/ext/test_basepersistence.py
index dde7f23fb47..84b26c258f0 100644
--- a/tests/ext/test_basepersistence.py
+++ b/tests/ext/test_basepersistence.py
@@ -405,7 +405,7 @@ def test_set_bot_error(self, papp):
         assert bot.callback_data_cache is None
         assert papp.persistence.set_bot(bot) is None
 
-    def test_construction_with_bad_persistence(self, caplog, bot):
+    def test_construction_with_bad_persistence(self, bot):
         class MyPersistence:
             def __init__(self):
                 self.store_data = PersistenceInput(False, False, False, False)
@@ -1128,6 +1128,7 @@ async def error(update, context):
         assert len(caplog.records) == 6
         assert test_flag == [True, True, True, True, True, True]
         for record in caplog.records:
+            assert record.name == "telegram.ext.Application"
             message = record.getMessage()
             assert message.startswith("An error was raised and an uncaught")
 
@@ -1371,6 +1372,7 @@ async def callback(_, __):
         found_record = None
         for record in caplog.records:
             if record.getMessage().startswith("A ConversationHandlers state was not yet resolved"):
+                assert record.name == "telegram.ext.Application"
                 found_record = record
                 break
         assert found_record is not None
diff --git a/tests/ext/test_conversationhandler.py b/tests/ext/test_conversationhandler.py
index 913a6a6a7e3..c6e3aadd71c 100644
--- a/tests/ext/test_conversationhandler.py
+++ b/tests/ext/test_conversationhandler.py
@@ -1090,6 +1090,7 @@ class DictJB(JobQueue):
 
             assert len(caplog.records) == 1
             assert caplog.records[0].message == "Failed to schedule timeout."
+            assert caplog.records[0].name == "telegram.ext.ConversationHandler"
             assert str(caplog.records[0].exc_info[1]) == "job error"
 
             await app.stop()
@@ -1143,6 +1144,7 @@ async def raise_error(*a, **kw):
                 assert handler.check_update(Update(0, message=message))
             if test_type == "exception":
                 assert len(caplog.records) == 1
+                assert caplog.records[0].name == "telegram.ext.ConversationHandler"
                 assert (
                     caplog.records[0].message
                     == "Task function raised exception. Falling back to old state 1"
@@ -1189,6 +1191,7 @@ async def raise_error(*a, **kw):
                 # This also makes sure that we're still in the same state
                 assert handler.check_update(Update(0, message=message))
             assert len(caplog.records) == 1
+            assert caplog.records[0].name == "telegram.ext.ConversationHandler"
             assert (
                 caplog.records[0].message
                 == "Task function raised exception. Falling back to old state None"
diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py
index 82ee293ba1c..355b32c6611 100644
--- a/tests/ext/test_jobqueue.py
+++ b/tests/ext/test_jobqueue.py
@@ -557,6 +557,7 @@ async def test_process_error_that_raises_errors(self, job_queue, app, caplog):
             await job.run(app)
         assert len(caplog.records) == 1
         rec = caplog.records[-1]
+        assert rec.name == "telegram.ext.Application"
         assert "No error handlers are registered" in rec.getMessage()
 
     async def test_custom_context(self, bot, job_queue):
diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py
index 4eb779527f6..57b84e18454 100644
--- a/tests/ext/test_updater.py
+++ b/tests/ext/test_updater.py
@@ -399,8 +399,11 @@ async def get_updates(*args, **kwargs):
                         assert self.received == error
                     else:
                         assert len(caplog.records) > 0
-                        records = (record.getMessage() for record in caplog.records)
-                        assert "Error while getting Updates: TestMessage" in records
+                        assert any(
+                            "Error while getting Updates: TestMessage" in record.getMessage()
+                            and record.name == "telegram.ext.Updater"
+                            for record in caplog.records
+                        )
 
                 # Make sure that get_updates was called
                 assert get_updates_event.is_set()
@@ -418,8 +421,11 @@ async def get_updates(*args, **kwargs):
                     assert self.received == error
                 else:
                     assert len(caplog.records) > 0
-                    records = (record.getMessage() for record in caplog.records)
-                    assert "Error while getting Updates: TestMessage" in records
+                    assert any(
+                        "Error while getting Updates: TestMessage" in record.getMessage()
+                        and record.name == "telegram.ext.Updater"
+                        for record in caplog.records
+                    )
             await updater.stop()
 
     async def test_start_polling_unexpected_shutdown(self, updater, monkeypatch, caplog):
@@ -451,8 +457,11 @@ async def get_updates(*args, **kwargs):
 
                 await asyncio.sleep(0.1)
                 assert caplog.records
-                records = (record.getMessage() for record in caplog.records)
-                assert any("Updater stopped unexpectedly." in record for record in records)
+                assert any(
+                    "Updater stopped unexpectedly." in record.getMessage()
+                    and record.name == "telegram.ext.Updater"
+                    for record in caplog.records
+                )
 
         # Make sure that the update_id offset wasn't increased
         assert self.message_count == 2
@@ -503,6 +512,7 @@ async def delete_webhook(*args, **kwargs):
             assert len(caplog.records) > 0
             for record in caplog.records:
                 assert record.getMessage().startswith("Something went wrong processing")
+                assert record.name == "telegram.ext.Updater"
 
             # Make sure that everything works fine again when receiving proper updates
             raise_exception = False
@@ -914,6 +924,7 @@ def de_json_fails(*args, **kwargs):
 
             assert len(caplog.records) == 1
             assert caplog.records[-1].getMessage().startswith("Something went wrong processing")
+            assert caplog.records[-1].name == "telegram.ext.Updater"
 
             # Make sure that everything works fine again when receiving proper updates
             caplog.clear()
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 50398fdfd36..c38796fd4df 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -377,10 +377,13 @@ def test_api_methods_have_log_decorator(self, bot_class, bot_method_name, bot_me
             re.match(rf"\s*\@\_log\s*async def {bot_method_name}", source)
         ), f"{bot_method_name} is missing the @_log decorator"
 
-    async def test_log_decorator(self, bot: PytestExtBot, caplog):
+    @pytest.mark.parametrize(
+        ("cls", "logger_name"), [(Bot, "telegram.Bot"), (ExtBot, "telegram.ext.ExtBot")]
+    )
+    async def test_log_decorator(self, bot: PytestExtBot, cls, logger_name, caplog):
         # Second argument makes sure that we ignore logs from e.g. httpx
         with caplog.at_level(logging.DEBUG, logger="telegram"):
-            await ExtBot(bot.token).get_me()
+            await cls(bot.token).get_me()
             # Only for stabilizing this test-
             if len(caplog.records) == 4:
                 for idx, record in enumerate(caplog.records):
@@ -390,6 +393,8 @@ async def test_log_decorator(self, bot: PytestExtBot, caplog):
                     if record.getMessage().startswith("Task exception was never retrieved"):
                         caplog.records.pop(idx)
             assert len(caplog.records) == 3
+
+            assert all(caplog.records[i].name == logger_name for i in [-1, 0])
             assert caplog.records[0].getMessage().startswith("Entering: get_me")
             assert caplog.records[-1].getMessage().startswith("Exiting: get_me")
 
@@ -963,7 +968,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs):
 
         assert await bot.answer_inline_query(1234, results=inline_results, current_offset=0)
 
-    async def test_answer_inline_query_current_offset_callback(self, monkeypatch, bot, caplog):
+    async def test_answer_inline_query_current_offset_callback(self, monkeypatch, bot):
         # For now just test that our internals pass the correct data
         async def make_assertion(url, request_data: RequestData, *args, **kwargs):
             data = request_data.parameters


Code review comments:
diff_hunk: @@ -48,6 +48,9 @@ async def null_context() -> AsyncIterator[None]:
         yield None
 
 
+_logger = get_logger(__name__, class_name="AIORateLimiter")
path: telegram/ext/_aioratelimiter.py
text: @user1:
why is this not a class variable like in `Bot`?

@author:
For Bot I made it a class variable because we need different names for Bot and ExtBot. This is not needed for other classes so far

@user2:
worth a CSI in `Bot`?

@user1:
> worth a CSI in `Bot`?

I think so, yes

diff_hunk: @@ -0,0 +1,48 @@
+#!/usr/bin/env python
+#
+# A library that provides a Python interface to the Telegram Bot API
+# Copyright (C) 2015-2023
+# Leandro Toledo de Souza <devs@python-telegram-bot.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Lesser Public License for more details.
+#
+# You should have received a copy of the GNU Lesser Public License
+# along with this program.  If not, see [http://www.gnu.org/licenses/].
+"""This module contains helper functions related to logging.
+
+Warning:
+    Contents of this module are intended to be used internally by the library and *not* by the
+    user. Changes to this module are not considered breaking changes and may not be documented in
+    the changelog.
+"""
+import logging
+
+
+def get_logger(file_name: str, class_name: str = None) -> logging.Logger:
+    """Returns a logger with an appropriate name. Use as follows::
path: telegram/_utils/logging.py
text: @user1:
```suggestion
    """Returns a logger with an appropriate name. 
    
    Use as follows:
```
The first line is a summary line, would be strange for it to end with a colon

diff_hunk: @@ -345,7 +344,7 @@ async def polling_action_cb() -> bool:
 
             if updates:
                 if not self.running:
-                    self._logger.critical(
+                    _logger.critical(
                         "Updater stopped unexpectedly. Pulled updates will be ignored and again "
path: telegram/ext/_updater.py
text: @user1:
This is unrelated to the changes but something seems to be wrong with the phrase on the next line ("Pulled updates will be ignored and again..."). Maybe a verb is missing?