Repository: ICTU/quality-time
Pull request #10991: Use component and module names for logging in Python components.
Author: login: fniessink
Created: 2025-03-17T14:23:34Z | Merged: 2025-03-18T07:16:58Z

Ruff 0.10.0 frowns upon log calls that log to the root logger. Use component and module names to construct a non-root logger name for each log call.


Diff:
diff --git a/components/api_server/src/initialization/database.py b/components/api_server/src/initialization/database.py
index a9de540231..4ebf873590 100644
--- a/components/api_server/src/initialization/database.py
+++ b/components/api_server/src/initialization/database.py
@@ -1,23 +1,25 @@
 """Database initialization."""
 
-import logging
 import os
 
 import pymongo
 from pymongo.database import Database
 
+from utils.log import get_logger
+
+from .app_secrets import initialize_secrets
 from .migrations import perform_migrations
 from .datamodel import import_datamodel
 from .report import import_example_reports, initialize_reports_overview
-from .app_secrets import initialize_secrets
 
 
 def init_database(database: Database) -> None:  # pragma: no feature-test-cover
     """Initialize the database contents."""
-    logging.info("Connected to database: %s", database)
+    logger = get_logger()
+    logger.info("Connected to database: %s", database)
     nr_reports = database.reports.count_documents({})
     nr_measurements = database.measurements.count_documents({})
-    logging.info("Database has %d report documents and %d measurement documents", nr_reports, nr_measurements)
+    logger.info("Database has %d report documents and %d measurement documents", nr_reports, nr_measurements)
     create_indexes(database)
     import_datamodel(database)
     initialize_secrets(database)
diff --git a/components/api_server/src/initialization/datamodel.py b/components/api_server/src/initialization/datamodel.py
index 506eabec5d..a58b7dcdad 100644
--- a/components/api_server/src/initialization/datamodel.py
+++ b/components/api_server/src/initialization/datamodel.py
@@ -1,23 +1,24 @@
 """Data model loader."""
 
 import json
-import logging
 
 from pymongo.database import Database
 
 from shared_data_model import DATA_MODEL_JSON
 
 from database.datamodels import insert_new_datamodel, latest_datamodel
+from utils.log import get_logger
 
 
 def import_datamodel(database: Database) -> None:  # pragma: no feature-test-cover
     """Store the data model in the database."""
+    logger = get_logger()
     data_model = json.loads(DATA_MODEL_JSON)
     if latest := latest_datamodel(database):
         del latest["timestamp"]
         del latest["_id"]
         if data_model == latest:
-            logging.info("Skipping loading the data model; it is unchanged")
+            logger.info("Skipping loading the data model; it is unchanged")
             return
     insert_new_datamodel(database, data_model)
-    logging.info("Data model loaded")
+    logger.info("Data model loaded")
diff --git a/components/api_server/src/initialization/migrations.py b/components/api_server/src/initialization/migrations.py
index 2ea7297aca..a6d9232513 100644
--- a/components/api_server/src/initialization/migrations.py
+++ b/components/api_server/src/initialization/migrations.py
@@ -1,7 +1,6 @@
 """Database migrations."""
 
 from collections.abc import Sequence
-import logging
 
 import pymongo
 from pymongo.collection import Collection
@@ -9,12 +8,15 @@
 
 from shared.model.metric import Metric
 
+from utils.log import get_logger
+
 
 def perform_migrations(database: Database) -> None:
     """Perform database migrations."""
+    logger = get_logger()
     for report in database.reports.find(filter={"last": True, "deleted": {"$exists": False}}):
         report_uuid = report["report_uuid"]
-        logging.info("Checking report %s for necessary updates", report_uuid)
+        logger.info("Checking report %s for necessary updates", report_uuid)
         if any(
             changes := [
                 change_accessibility_violation_metrics_to_violations(report),
@@ -26,11 +28,11 @@ def perform_migrations(database: Database) -> None:
             ]
         ):
             change_description = " and to ".join([change for change in changes if change])
-            logging.info("Updating report %s to %s", report_uuid, change_description)
+            logger.info("Updating report %s to %s", report_uuid, change_description)
             replace_document(database.reports, report)
-        logging.info("Checking report %s for necessary measurement updates", report_uuid)
+        logger.info("Checking report %s for necessary measurement updates", report_uuid)
         count = add_source_parameter_hash_to_latest_measurement(database, report)
-        logging.info("Updated %s measurements for report %s", count, report_uuid)
+        logger.info("Updated %s measurements for report %s", count, report_uuid)
 
 
 def change_accessibility_violation_metrics_to_violations(report) -> str:
@@ -203,7 +205,8 @@ def log_unknown_parameter_values(value_mapping: dict[str, str], old_values: list
     """Log old parameter values that do not exist in the mapping."""
     if unknown_values := [old_value for old_value in old_values if old_value not in value_mapping]:
         message = "Ignoring one or more unknown SonarQube parameter values of type '%s' in report %s: %s"
-        logging.warning(message, value_type, report["report_uuid"], ", ".join(unknown_values))
+        logger = get_logger()
+        logger.warning(message, value_type, report["report_uuid"], ", ".join(unknown_values))
 
 
 def new_parameter_values(value_mapping: dict[str, str], old_values: list[str]) -> list[str]:
diff --git a/components/api_server/src/initialization/report.py b/components/api_server/src/initialization/report.py
index 7d6f1adce3..06db61b5a6 100644
--- a/components/api_server/src/initialization/report.py
+++ b/components/api_server/src/initialization/report.py
@@ -1,23 +1,24 @@
 """Report loaders."""
 
 import json
-import logging
 import pathlib
 
 from pymongo.database import Database
 
 from database.reports import insert_new_report, insert_new_reports_overview, latest_reports_overview, report_exists
+from utils.log import get_logger
 
 
 def initialize_reports_overview(database: Database) -> None:
     """Initialize the reports overview if not present in the database."""
+    logger = get_logger()
     # The coverage measurement of the behave feature tests is unstable. Most of the time it reports the last two lines
     # as covered, sometimes not. It's unclear why. To prevent needless checking of the coverage report coverage
     # measurement of the last two lines and the if-statement has been turned off.
     if latest_reports_overview(database):  # pragma: no feature-test-cover
-        logging.info("Skipping initializing reports overview; it already exists")
+        logger.info("Skipping initializing reports overview; it already exists")
     else:
-        logging.info("Initializing reports overview")  # pragma: no feature-test-cover
+        logger.info("Initializing reports overview")  # pragma: no feature-test-cover
         insert_new_reports_overview(
             database,
             "{{user}} initialized the reports overview",
@@ -27,16 +28,17 @@ def initialize_reports_overview(database: Database) -> None:
 
 def import_report(database: Database, filename: pathlib.Path) -> None:
     """Read the report and store it in the database."""
+    logger = get_logger()
     # The coverage measurement of the behave feature tests is unstable. Most of the time it reports the last two lines
     # as covered, sometimes not. It's unclear why. To prevent needless checking of the coverage report coverage
     # measurement of the last two lines and the if-statement has been turned off.
     with filename.open() as json_report:
         imported_report = json.load(json_report)
     if report_exists(database, imported_report["report_uuid"]):  # pragma: no feature-test-cover
-        logging.info("Skipping import of %s; it already exists", filename)
+        logger.info("Skipping import of %s; it already exists", filename)
     else:  # pragma: no feature-test-cover
         insert_new_report(database, "{{user}} imported a new report", [imported_report["report_uuid"]], imported_report)
-        logging.info("Report %s imported", filename)
+        logger.info("Report %s imported", filename)
 
 
 def import_example_reports(database: Database) -> None:
diff --git a/components/api_server/src/model/issue_tracker.py b/components/api_server/src/model/issue_tracker.py
index 56ffc22892..a15b629ac4 100644
--- a/components/api_server/src/model/issue_tracker.py
+++ b/components/api_server/src/model/issue_tracker.py
@@ -2,15 +2,18 @@
 
 from dataclasses import asdict, dataclass, field
 from typing import cast
-import logging
 
 from shared.utils.functions import first
 
 import requests
 
+from utils.log import get_logger
 from utils.type import URL
 
 
+logger = get_logger()
+
+
 @dataclass
 class AsDictMixin:
     """Mixin class to give data classes an as_dict method."""
@@ -128,7 +131,7 @@ def create_issue(self, summary: str, description: str = "") -> tuple[str, str]:
             response_json = self.__post_json(api_url, json)
         except Exception as reason:
             error = str(reason) if str(reason) else reason.__class__.__name__
-            logging.warning("Creating a new issue at %s failed: %s", api_url, error)
+            logger.warning("Creating a new issue at %s failed: %s", api_url, error)
             return "", error
         return response_json["key"], ""  # pragma: no feature-test-cover
 
@@ -148,7 +151,8 @@ def get_suggestions(self, query: str) -> list[IssueSuggestion]:
         try:
             json = cast(JiraIssueSuggestionJSON, self.__get_json(api_url))
         except Exception as reason:
-            logging.warning("Retrieving issue id suggestions from %s failed: %s", api_url, reason)
+            message = "Retrieving issue id suggestions from %s failed: %s"
+            logger.warning(message, self.suggestions_api % (self.url, "<query redacted>"), reason)
             return []
         return self._parse_suggestions(json)  # pragma: no feature-test-cover
 
@@ -178,7 +182,7 @@ def __get_project_options(self) -> list[Option]:
         try:
             projects = cast(JiraProjectsJSON, self.__get_json(api_url))
         except Exception as reason:
-            logging.warning("Getting issue tracker project options at %s failed: %s", api_url, reason)
+            logger.warning("Getting issue tracker project options at %s failed: %s", api_url, reason)
         return [Option(str(project["key"]), str(project["name"])) for project in projects]
 
     def __get_issue_type_options(self, projects: list[Option]) -> list[Option]:  # pragma: no feature-test-cover
@@ -191,7 +195,7 @@ def __get_issue_type_options(self, projects: list[Option]) -> list[Option]:  # p
         try:
             issue_types = cast(JiraIssueTypesJSON, self.__get_json(api_url))["values"]
         except Exception as reason:
-            logging.warning("Getting issue tracker issue type options at %s failed: %s", api_url, reason)
+            logger.warning("Getting issue tracker issue type options at %s failed: %s", api_url, reason)
         issue_types = [issue_type for issue_type in issue_types if not issue_type["subtask"]]
         return [Option(str(issue_type["id"]), str(issue_type["name"])) for issue_type in issue_types]
 
@@ -207,7 +211,7 @@ def __get_field_options(self, issue_types: list[Option]) -> list[Option]:  # pra
         try:
             fields = cast(JiraFieldsJSON, self.__get_json(api_url))["values"]
         except Exception as reason:
-            logging.warning("Getting issue tracker field options at %s failed: %s", api_url, reason)
+            logger.warning("Getting issue tracker field options at %s failed: %s", api_url, reason)
         return [Option(str(field["fieldId"]), str(field["name"])) for field in fields]
 
     def __get_epic_links(self, fields: list[Option]) -> list[Option]:  # pragma: no feature-test-cover
@@ -220,7 +224,7 @@ def __get_epic_links(self, fields: list[Option]) -> list[Option]:  # pragma: no
         try:
             epics = cast(JiraEpicsJSON, self.__get_json(api_url))["issues"]
         except Exception as reason:
-            logging.warning("Getting epics at %s failed: %s", api_url, reason)
+            logger.warning("Getting epics at %s failed: %s", api_url, reason)
         return [
             Option(str(epic["key"]), f"{cast(dict[str, dict[str, str]], epic)['fields']['summary']} ({epic['key']})")
             for epic in epics
diff --git a/components/api_server/src/routes/auth.py b/components/api_server/src/routes/auth.py
index 07c9cb0554..a57dcb02d8 100644
--- a/components/api_server/src/routes/auth.py
+++ b/components/api_server/src/routes/auth.py
@@ -1,6 +1,5 @@
 """Login/logout."""
 
-import logging
 import os
 import re
 import string
@@ -19,6 +18,7 @@
 from database import sessions
 from initialization.app_secrets import EXPORT_FIELDS_KEYS_NAME
 from utils.functions import uuid
+from utils.log import get_logger
 from utils.type import SessionId, User
 
 
@@ -85,6 +85,7 @@ def __post_init__(self) -> None:
 
 def verify_user(database: Database, username: str, password: str) -> User:
     """Authenticate the user and return whether they are authorized to login and their email address."""
+    logger = get_logger()
     ldap = LDAPConfig(username)
     try:
         ldap_server_pool = ServerPool([Server(url, get_info=ALL) for url in ldap.urls])
@@ -97,13 +98,13 @@ def verify_user(database: Database, username: str, password: str) -> User:
         # If the LDAP-server returned the user's password-hash, check the password against the hash, otherwise
         # attempt a bind operation using the user's distinguished name (dn) and password:
         if (password_hash := ldap_user.userPassword.value) and check_password(password_hash, password):
-            logging.info("LDAP password check succeeded")
+            logger.info("LDAP password check succeeded")
         else:  # pragma: no feature-test-cover
             with Connection(ldap_server_pool, user=ldap_user.entry_dn, password=password, auto_bind=AUTO_BIND_NO_TLS):
-                logging.info("LDAP bind succeeded")
+                logger.info("LDAP bind succeeded")
     except Exception as reason:  # noqa: BLE001
         user = User(username)
-        logging.warning("LDAP error: %s", reason)
+        logger.warning("LDAP error: %s", reason)
     else:
         user = get_user(database, username) or User(username)
         user.email = ldap_user.mail.value or ""
diff --git a/components/api_server/src/routes/measurement.py b/components/api_server/src/routes/measurement.py
index a82befe163..e2294f60b5 100644
--- a/components/api_server/src/routes/measurement.py
+++ b/components/api_server/src/routes/measurement.py
@@ -1,6 +1,5 @@
 """Measurement routes."""
 
-import logging
 import time
 from collections.abc import Iterator
 from typing import cast
@@ -17,6 +16,7 @@
 from database.measurements import count_measurements, all_metric_measurements, measurements_in_period
 from database.reports import latest_report_for_uuids, latest_reports
 from utils.functions import report_date_time
+from utils.log import get_logger
 
 from .plugins.auth_plugin import EDIT_ENTITY_PERMISSION
 
@@ -67,6 +67,7 @@ def sse_pack(event_id: int, event: str, data: str, retry: str = "2000") -> str:
 @bottle.get("/api/internal/nr_measurements", authentication_required=False)
 def stream_nr_measurements(database: Database) -> Iterator[str]:
     """Return the number of measurements as server sent events."""
+    logger = get_logger()
     # Keep event IDs consistent
     event_id = int(bottle.request.get_header("Last-Event-Id", -1)) + 1
 
@@ -79,7 +80,7 @@ def stream_nr_measurements(database: Database) -> Iterator[str]:
 
     # Provide the current number of measurements and a retry value to use in case of connection failure
     nr_measurements = count_measurements(database)
-    logging.info("Initializing nr_measurements stream with %d measurements (event id = %d)", nr_measurements, event_id)
+    logger.info("Initializing nr_measurements stream with %d measurements (event id = %d)", nr_measurements, event_id)
     yield sse_pack(event_id, "init", str(nr_measurements))
     event_id += 1
 
@@ -92,7 +93,7 @@ def stream_nr_measurements(database: Database) -> Iterator[str]:
         time.sleep(10)
         nr_measurements = count_measurements(database)
         event_id += 1
-        logging.info("Updating nr_measurements stream with %d measurements (event id = %d)", nr_measurements, event_id)
+        logger.info("Updating nr_measurements stream with %d measurements (event id = %d)", nr_measurements, event_id)
         yield sse_pack(event_id, "delta", str(nr_measurements))
 
 
diff --git a/components/api_server/src/routes/plugins/auth_plugin.py b/components/api_server/src/routes/plugins/auth_plugin.py
index 2c224b1b4c..f886953c93 100644
--- a/components/api_server/src/routes/plugins/auth_plugin.py
+++ b/components/api_server/src/routes/plugins/auth_plugin.py
@@ -1,6 +1,5 @@
 """Route authentication and authorization plugin."""
 
-import logging
 from collections.abc import Callable
 
 import bottle
@@ -8,6 +7,7 @@
 from database import sessions
 from database.reports import latest_reports_overview
 from model.session import Session
+from utils.log import get_logger
 from utils.type import SessionId
 
 
@@ -61,5 +61,6 @@ def wrapper(*args, **kwargs) -> ReturnType:
     @staticmethod
     def abort(status_code: int, message: str, context, session_id: str) -> None:
         """Log the message and abort."""
-        logging.warning(message, context.method, context.rule, session_id)
+        logger = get_logger()
+        logger.warning(message, context.method, context.rule, session_id)
         bottle.abort(status_code, bottle.HTTP_CODES[status_code])
diff --git a/components/api_server/src/routes/search.py b/components/api_server/src/routes/search.py
index 67fce48250..14d04335ee 100644
--- a/components/api_server/src/routes/search.py
+++ b/components/api_server/src/routes/search.py
@@ -1,6 +1,5 @@
 """Search endpoint."""
 
-import logging
 from collections.abc import Sequence
 from typing import Literal, TypedDict, TYPE_CHECKING
 
@@ -11,6 +10,7 @@
 
 from database.reports import latest_reports
 from model.iterators import metrics, sources, subjects
+from utils.log import get_logger
 
 if TYPE_CHECKING:
     from collections.abc import Iterator
@@ -78,11 +78,12 @@ def match_attribute(
 @bottle.post("/api/v3/<domain_object_type>/search", authentication_required=False)
 def search(domain_object_type: DomainObjectType, database: Database) -> SearchResults | ParseError | SearchError:
     """Search for domain objects of the specified type by attribute value."""
+    logger = get_logger()
     try:
         query = dict(bottle.request.json)
         attribute_name, attribute_value = set(query.items()).pop()
     except Exception as reason:  # noqa: BLE001
-        logging.warning("Parsing search query for %s failed: %s", domain_object_type, reason)
+        logger.warning("Parsing search query for %s failed: %s", domain_object_type, reason)
         return ParseError(domain_object_type=domain_object_type, error=str(reason), ok=False)
     try:
         reports = latest_reports(database)
@@ -96,6 +97,6 @@ def search(domain_object_type: DomainObjectType, database: Database) -> SearchRe
             if match_attribute(domain_object_type, domain_object, attribute_name, attribute_value)
         ]
     except Exception as reason:  # pragma: no feature-test-cover # noqa: BLE001
-        logging.warning("Searching for %s failed: %s", domain_object_type, reason)
+        logger.warning("Searching for %s failed: %s", domain_object_type, reason)
         return SearchError(domain_object_type=domain_object_type, error=str(reason), ok=False, search_query=query)
     return SearchResults(domain_object_type=domain_object_type, ok=True, search_query=query, uuids=uuids)
diff --git a/components/api_server/src/utils/log.py b/components/api_server/src/utils/log.py
new file mode 100644
index 0000000000..e287b51dd4
--- /dev/null
+++ b/components/api_server/src/utils/log.py
@@ -0,0 +1,10 @@
+"""Logger utilities."""
+
+import logging
+
+from shared.utils.log import get_logger as _get_logger
+
+
+def get_logger() -> logging.Logger:
+    """Return the logger for the current component and the caller's module."""
+    return _get_logger("api_server", call_stack_depth=1)
diff --git a/components/api_server/tests/routes/test_auth.py b/components/api_server/tests/routes/test_auth.py
index 50bff17856..4732ebf3cb 100644
--- a/components/api_server/tests/routes/test_auth.py
+++ b/components/api_server/tests/routes/test_auth.py
@@ -1,6 +1,5 @@
 """Unit tests for the authorization routes."""
 
-import logging
 from datetime import datetime, timedelta, UTC
 from unittest.mock import Mock, patch
 
@@ -160,52 +159,57 @@ def test_successful_bind_login(self, connection_mock, connection_enter):
         self.assert_ldap_bind_connection_created(connection_mock)
         self.assert_ldap_connection_search_called()
 
-    @patch.object(logging, "warning")
+    @patch("logging.getLogger")
     @patch.object(ldap3.Server, "__init__", Mock(side_effect=exceptions.LDAPServerPoolError))
     def test_login_server_error(self, logging_mock, connection_mock, connection_enter):
         """Test login when a server creation error occurs."""
+        logger = logging_mock.return_value = Mock()
         connection_mock.return_value = None
         self.assertEqual(self.login_nok, login(self.database))
         connection_mock.assert_not_called()
         connection_enter.assert_not_called()
-        self.assert_log(logging_mock, exceptions.LDAPServerPoolError)
+        self.assert_log(logger.warning, exceptions.LDAPServerPoolError)
 
-    @patch.object(logging, "warning")
+    @patch("logging.getLogger")
     def test_login_bind_error(self, logging_mock, connection_mock, connection_enter):
         """Test login when an error of binding dn reader occurs."""
+        logger = logging_mock.return_value = Mock()
         connection_mock.return_value = None
         self.ldap_connection.bind.return_value = False
         connection_enter.return_value = self.ldap_connection
         self.assertEqual(self.login_nok, login(self.database))
         connection_mock.assert_called_once()
         self.ldap_connection.bind.assert_called_once()
-        self.assert_log(logging_mock, exceptions.LDAPBindError)
+        self.assert_log(logger.warning, exceptions.LDAPBindError)
 
-    @patch.object(logging, "warning")
+    @patch("logging.getLogger")
     def test_login_search_error(self, logging_mock, connection_mock, connection_enter):
         """Test login when search error of the login user occurs."""
+        logger = logging_mock.return_value = Mock()
         connection_mock.return_value = None
         self.ldap_connection.search.side_effect = exceptions.LDAPResponseTimeoutError
         connection_enter.return_value = self.ldap_connection
         self.assertEqual(self.login_nok, login(self.database))
         connection_mock.assert_called_once()
         self.ldap_connection.bind.assert_called_once()
-        self.assert_log(logging_mock, exceptions.LDAPResponseTimeoutError)
+        self.assert_log(logger.warning, exceptions.LDAPResponseTimeoutError)
 
-    @patch.object(logging, "warning")
+    @patch("logging.getLogger")
     def test_login_password_hash_error(self, logging_mock, connection_mock, connection_enter):
         """Test login fails when LDAP password hash is not ARGON2."""
+        logger = logging_mock.return_value = Mock()
         connection_mock.return_value = None
         self.ldap_entry.userPassword.value = b"{XSHA}whatever-here"
         connection_enter.return_value = self.ldap_connection
         self.assertEqual(self.login_nok, login(self.database))
         self.assert_ldap_connection_search_called()
-        self.assertEqual("LDAP error: %s", logging_mock.call_args_list[0][0][0])
-        self.assert_log(logging_mock, argon2.exceptions.InvalidHashError)
+        self.assertEqual("LDAP error: %s", logger.warning.call_args_list[0][0][0])
+        self.assert_log(logger.warning, argon2.exceptions.InvalidHashError)
 
-    @patch.object(logging, "warning")
+    @patch("logging.getLogger")
     def test_login_wrong_password(self, logging_mock, connection_mock, connection_enter):
         """Test login when search error of the login user occurs."""
+        logger = logging_mock.return_value = Mock()
         connection_mock.return_value = None
         self.ldap_entry.userPassword.value = (
             b"{ARGON2}$argon2id$v=19$m=65536,t=3,p=4$rTmNv6FmvvQaSIXzLvcStQ$F2qtxHsklGS+sgWMrbekjeUn4bWbMvt3Liqsw7jOV1I"
@@ -213,7 +217,7 @@ def test_login_wrong_password(self, logging_mock, connection_mock, connection_en
         connection_enter.return_value = self.ldap_connection
         self.assertEqual(self.login_nok, login(self.database))
         self.assert_ldap_connection_search_called()
-        self.assert_log(logging_mock, argon2.exceptions.VerifyMismatchError)
+        self.assert_log(logger.warning, argon2.exceptions.VerifyMismatchError)
 
     @patch("routes.auth.datetime", MOCK_DATETIME)
     def test_login_changed_details(self, connection_mock, connection_enter):
diff --git a/components/api_server/tests/utils/test_log.py b/components/api_server/tests/utils/test_log.py
new file mode 100644
index 0000000000..a5691aaf78
--- /dev/null
+++ b/components/api_server/tests/utils/test_log.py
@@ -0,0 +1,13 @@
+"""Unit tests for the logging utilities."""
+
+import unittest
+
+from utils.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.api_server.tests.utils.test_log", get_logger().name)
diff --git a/components/collector/src/base_collectors/collector.py b/components/collector/src/base_collectors/collector.py
index c775592802..2a157b64b1 100644
--- a/components/collector/src/base_collectors/collector.py
+++ b/components/collector/src/base_collectors/collector.py
@@ -13,6 +13,7 @@
 from shared.model.metric import Metric
 from shared.model.report import get_metrics_from_reports
 
+from collector_utilities.log import get_logger
 from collector_utilities.type import JSONDict
 from database.measurements import create_measurement
 
@@ -32,12 +33,13 @@ def __init__(self, database: Database) -> None:
     @staticmethod
     def record_health() -> None:
         """Record the current date and time in a file to allow for health checks."""
+        logger = get_logger()
         filename = pathlib.Path(config.HEALTH_CHECK_FILE)
         try:
             with filename.open("w", encoding="utf-8") as health_check:
                 health_check.write(datetime.now(tz=UTC).isoformat())
         except OSError as reason:
-            logging.error("Could not write health check time stamp to %s: %s", filename, reason)  # noqa: TRY400
+            logger.error("Could not write health check time stamp to %s: %s", filename, reason)  # noqa: TRY400
 
     async def start(self) -> NoReturn:
         """Start fetching measurements indefinitely."""
@@ -107,4 +109,5 @@ def __should_collect(self, metric_uuid: str, metric: dict) -> bool:
     def __log_tasks(self, event: str, warning_threshold: int = 0) -> None:
         """Log the number of running tasks."""
         level = logging.WARNING if len(self.running_tasks) > warning_threshold else logging.INFO
-        logging.log(level, "%s: %d task(s) currently running", event, len(self.running_tasks))
+        logger = get_logger()
+        logger.log(level, "%s: %d task(s) currently running", event, len(self.running_tasks))
diff --git a/components/collector/src/base_collectors/source_collector.py b/components/collector/src/base_collectors/source_collector.py
index ec698b779c..94e3e47de1 100644
--- a/components/collector/src/base_collectors/source_collector.py
+++ b/components/collector/src/base_collectors/source_collector.py
@@ -1,7 +1,6 @@
 """Source collector base classes."""
 
 import asyncio
-import logging
 import traceback
 from abc import ABC, abstractmethod
 from collections.abc import Sequence
@@ -20,6 +19,7 @@
     stable_traceback,
     tokenless,
 )
+from collector_utilities.log import get_logger
 from collector_utilities.type import URL, Response, Responses, Value
 from model import Entities, Entity, IssueStatus, SourceMeasurement, SourceParameters, SourceResponses
 
@@ -57,7 +57,8 @@ def get_subclass(cls, source_type: str, metric_type: str) -> type["SourceCollect
                     subclass.source_type = source_type
                     return subclass
 
-        logging.warning("Couldn't find collector subclass for source %s and metric %s", source_type, metric_type)
+        logger = get_logger()
+        logger.warning("Couldn't find collector subclass for source %s and metric %s", source_type, metric_type)
         return None
 
     async def collect(self) -> SourceMeasurement:
@@ -91,21 +92,22 @@ async def __safely_get_source_responses(self) -> SourceResponses:
         This method should not be overridden because it makes sure the collection of source data never causes the
         collector to fail.
         """
+        logger = get_logger()
         api_url = safe_api_url = class_name = self.__class__.__name__
         try:
             api_url = await self._api_url()
             safe_api_url = tokenless(api_url) or class_name
-            logging.info("%s retrieving %s", class_name, safe_api_url)
+            logger.info("%s retrieving %s", class_name, safe_api_url)
             responses = await asyncio.wait_for(self._get_source_responses(api_url), timeout=self._session.timeout.total)
-            logging.info("%s retrieved %s", class_name, safe_api_url)
+            logger.info("%s retrieved %s", class_name, safe_api_url)
         except (TimeoutError, CollectorError, aiohttp.ClientError) as reason:
             error = self.__logsafe_exception(reason)
-            logging.warning("%s failed to retrieve %s: %s", class_name, safe_api_url, error)
+            logger.warning("%s failed to retrieve %s: %s", class_name, safe_api_url, error)
             responses = SourceResponses(api_url=URL(api_url), connection_error=error)
         except Exception as reason:
             error = stable_traceback(traceback.format_exc())
             reason_message = self.__logsafe_exception(reason)
-            logging.error("%s failed to retrieve %s: %s", class_name, safe_api_url, reason_message)  # noqa: TRY400
+            logger.error("%s failed to retrieve %s: %s", class_name, safe_api_url, reason_message)  # noqa: TRY400
             responses = SourceResponses(api_url=URL(api_url), connection_error=error)
         return responses
 
diff --git a/components/collector/src/collector_utilities/log.py b/components/collector/src/collector_utilities/log.py
new file mode 100644
index 0000000000..9760634094
--- /dev/null
+++ b/components/collector/src/collector_utilities/log.py
@@ -0,0 +1,10 @@
+"""Logger utilities."""
+
+import logging
+
+from shared.utils.log import get_logger as _get_logger
+
+
+def get_logger() -> logging.Logger:
+    """Return the logger for the current component and the caller's module."""
+    return _get_logger("collector", call_stack_depth=1)
diff --git a/components/collector/tests/base_collectors/test_collector.py b/components/collector/tests/base_collectors/test_collector.py
index b7613d1fcd..22dc52d02b 100644
--- a/components/collector/tests/base_collectors/test_collector.py
+++ b/components/collector/tests/base_collectors/test_collector.py
@@ -283,12 +283,13 @@ def test_writing_health_check(self, mocked_datetime: Mock, mocked_open: Mock):
         mocked_open().write.assert_called_once_with(now.isoformat())
 
     @patch("pathlib.Path.open")
-    @patch("logging.error")
+    @patch("logging.getLogger")
     def test_fail_writing_health_check(self, mocked_log: Mock, mocked_open: Mock):
         """Test that a failure to open the health check file is logged, but otherwise ignored."""
+        logger = mocked_log.return_value = Mock()
         mocked_open.side_effect = OSError("Some error")
         self.collector.record_health()
-        mocked_log.assert_called_once_with(
+        logger.error.assert_called_once_with(
             "Could not write health check time stamp to %s: %s",
             pathlib.Path("/home/collector/health_check.txt"),
             mocked_open.side_effect,
diff --git a/components/collector/tests/collector_utilities/test_log.py b/components/collector/tests/collector_utilities/test_log.py
new file mode 100644
index 0000000000..abeeb82f5e
--- /dev/null
+++ b/components/collector/tests/collector_utilities/test_log.py
@@ -0,0 +1,13 @@
+"""Unit tests for the logging utilities."""
+
+import unittest
+
+from collector_utilities.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.collector.tests.collector_utilities.test_log", get_logger().name)
diff --git a/components/notifier/src/destinations/ms_teams.py b/components/notifier/src/destinations/ms_teams.py
index e553a9a484..b9319fda39 100644
--- a/components/notifier/src/destinations/ms_teams.py
+++ b/components/notifier/src/destinations/ms_teams.py
@@ -1,10 +1,9 @@
 """Microsoft Teams destination."""
 
-import logging
-
 import pymsteams
 
 from models.notification import MetricNotificationData, Notification
+from notifier_utilities.log import get_logger
 
 ICON_URL = "https://raw.githubusercontent.com/ICTU/quality-time/master/resources/icons/%s.png"
 
@@ -40,9 +39,10 @@ def create_connector_card(destination: str, notification: Notification) -> pymst
 
 def send_notification(destination: str, notification: Notification) -> None:
     """Send notification to Microsoft Teams using a Webhook."""
-    logging.info("Sending notification to configured teams webhook")
+    logger = get_logger()
+    logger.info("Sending notification to configured teams webhook")
     card = create_connector_card(destination, notification)
     try:
         card.send()
     except Exception:
-        logging.exception("Could not deliver notification")
+        logger.exception("Could not deliver notification")
diff --git a/components/notifier/src/notifier/notifier.py b/components/notifier/src/notifier/notifier.py
index f634ad06a1..8ada96bb74 100644
--- a/components/notifier/src/notifier/notifier.py
+++ b/components/notifier/src/notifier/notifier.py
@@ -1,7 +1,6 @@
 """Notifier."""
 
 import asyncio
-import logging
 import pathlib
 from datetime import UTC, datetime
 from os import getenv
@@ -13,38 +12,41 @@
 
 from database.reports import get_reports_and_measurements
 from destinations.ms_teams import send_notification
+from notifier_utilities.log import get_logger
 from strategies.notification_strategy import NotificationFinder
 
 
 async def notify(database: Database, sleep_duration: int = 60) -> NoReturn:
     """Notify our users periodically of the number of red metrics."""
+    logger = get_logger()
     most_recent_measurement_seen = datetime.max.replace(tzinfo=UTC)
     notification_finder = NotificationFinder()
     while True:
         record_health()
-        logging.info("Determining notifications...")
+        logger.info("Determining notifications...")
         try:
             reports, measurements = get_reports_and_measurements(database)
         except Exception:
-            logging.exception("Getting reports and measurements failed")
+            logger.exception("Getting reports and measurements failed")
             reports = []
             measurements = []
 
         for notification in notification_finder.get_notifications(reports, measurements, most_recent_measurement_seen):
             send_notification(str(notification.destination["webhook"]), notification)
         most_recent_measurement_seen = most_recent_measurement_timestamp(measurements)
-        logging.info("Sleeping %.1f seconds...", sleep_duration)
+        logger.info("Sleeping %.1f seconds...", sleep_duration)
         await asyncio.sleep(sleep_duration)
 
 
 def record_health() -> None:
     """Record the current date and time in a file to allow for health checks."""
+    logger = get_logger()
     filepath = pathlib.Path(getenv("HEALTH_CHECK_FILE", "/home/notifier/health_check.txt"))
     try:
         with filepath.open("w", encoding="utf-8") as health_check:
             health_check.write(datetime.now(tz=UTC).isoformat())
     except OSError:
-        logging.exception("Could not write health check time stamp to %s", filepath)
+        logger.exception("Could not write health check time stamp to %s", filepath)
 
 
 def most_recent_measurement_timestamp(measurements: list[Measurement]) -> datetime:
diff --git a/components/notifier/src/notifier_utilities/log.py b/components/notifier/src/notifier_utilities/log.py
new file mode 100644
index 0000000000..b7ed8db0fa
--- /dev/null
+++ b/components/notifier/src/notifier_utilities/log.py
@@ -0,0 +1,10 @@
+"""Logger utilities."""
+
+import logging
+
+from shared.utils.log import get_logger as _get_logger
+
+
+def get_logger() -> logging.Logger:
+    """Return the logger for the current component and the caller's module."""
+    return _get_logger("notifier", call_stack_depth=1)
diff --git a/components/notifier/tests/notifier/test_notifier.py b/components/notifier/tests/notifier/test_notifier.py
index 682983fe26..8e3365dca1 100644
--- a/components/notifier/tests/notifier/test_notifier.py
+++ b/components/notifier/tests/notifier/test_notifier.py
@@ -46,16 +46,13 @@ def test_writing_health_check(self, mocked_datetime: Mock, mocked_open: Mock):
         mocked_open().write.assert_called_once_with(now.isoformat())
 
     @patch("pathlib.Path.open")
-    @patch("logging.error")
-    def test_fail_writing_health_check(self, mocked_log: Mock, mocked_open: Mock):
+    @patch("logging.getLogger")
+    def test_fail_writing_health_check(self, mocked_get_logger: Mock, mocked_open: Mock):
         """Test that a failure to open the health check file is logged, but otherwise ignored."""
+        logger = mocked_get_logger.return_value = Mock()
         mocked_open.side_effect = OSError("Some error")
         record_health()
-        mocked_log.assert_called_once_with(
-            "Could not write health check time stamp to %s",
-            self.filename,
-            exc_info=True,
-        )
+        logger.exception.assert_called_once_with("Could not write health check time stamp to %s", self.filename)
 
 
 @patch("pathlib.Path.open", mock_open())
@@ -84,18 +81,19 @@ def setUp(self):
             },
         }
 
-    @patch("logging.error")
+    @patch("logging.getLogger")
     @patch("asyncio.sleep")
     @patch("notifier.notifier.get_reports_and_measurements")
-    async def test_exception(self, mocked_get: Mock, mocked_sleep: Mock, mocked_log_error: Mock):
+    async def test_exception(self, mocked_get: Mock, mocked_sleep: Mock, mocked_get_logger: Mock):
         """Test that an exception while retrieving the reports is handled."""
+        logger = mocked_get_logger.return_value = Mock()
         mocked_get.side_effect = OSError
         mocked_sleep.side_effect = RuntimeError
         with contextlib.suppress(RuntimeError):
             await notify(self.database)
 
-        mocked_log_error.assert_called_once()
-        self.assertEqual(mocked_log_error.mock_calls[0].args[0], "Getting reports and measurements failed")
+        logger.exception.assert_called_once()
+        self.assertEqual(logger.exception.mock_calls[0].args[0], "Getting reports and measurements failed")
 
     @patch("pymsteams.connectorcard.send")
     @patch("asyncio.sleep")
diff --git a/components/notifier/tests/notifier_utilities/__init__.py b/components/notifier/tests/notifier_utilities/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/components/notifier/tests/notifier_utilities/test_log.py b/components/notifier/tests/notifier_utilities/test_log.py
new file mode 100644
index 0000000000..af7106689c
--- /dev/null
+++ b/components/notifier/tests/notifier_utilities/test_log.py
@@ -0,0 +1,13 @@
+"""Unit tests for the logging utilities."""
+
+import unittest
+
+from notifier_utilities.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.notifier.tests.notifier_utilities.test_log", get_logger().name)
diff --git a/components/shared_code/src/shared/utils/log.py b/components/shared_code/src/shared/utils/log.py
new file mode 100644
index 0000000000..0d9c91eb84
--- /dev/null
+++ b/components/shared_code/src/shared/utils/log.py
@@ -0,0 +1,11 @@
+"""Logging utilities."""
+
+import logging
+import sys
+
+
+def get_logger(component_name: str, call_stack_depth: int = 0) -> logging.Logger:
+    """Return the logger for the given component and the caller's module."""
+    # Get the module name from the caller's stack frame so we can add it to the logger name:
+    module_name = sys._getframe(call_stack_depth + 1).f_globals["__name__"]  # noqa: SLF001
+    return logging.getLogger(f"quality_time.{component_name}.{module_name}")
diff --git a/components/shared_code/tests/shared/utils/test_log.py b/components/shared_code/tests/shared/utils/test_log.py
new file mode 100644
index 0000000000..b9d4ac8f15
--- /dev/null
+++ b/components/shared_code/tests/shared/utils/test_log.py
@@ -0,0 +1,23 @@
+"""Unit tests for the logging utilities."""
+
+import logging
+import unittest
+
+from shared.utils.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.shared_code.tests.shared.utils.test_log", get_logger("shared_code").name)
+
+    def test_get_logger_indirectly(self):
+        """Test getting the logger via an extra function call."""
+
+        def get_logger_indirectly() -> logging.Logger:
+            """Return the logger."""
+            return get_logger("component", call_stack_depth=1)
+
+        self.assertEqual("quality_time.component.tests.shared.utils.test_log", get_logger_indirectly().name)
diff --git a/docs/src/changelog.md b/docs/src/changelog.md
index 5ae3f412b6..e10573368c 100644
--- a/docs/src/changelog.md
+++ b/docs/src/changelog.md
@@ -19,6 +19,10 @@ If your currently installed *Quality-time* version is not the latest version, pl
 - Use the locale of the user's browser when exporting to PDF so that dates are formatted accordingly in the PDF. Fixes [#8381](https://github.com/ICTU/quality-time/issues/8381).
 - When measuring average issue lead time with Jira as source, the lead times per issue would not be collected. Fixes [#10929](https://github.com/ICTU/quality-time/issues/10929).
 
+### Changed
+
+- Add component and module names to the logging of Python components.
+
 ## v5.26.0 - 2025-02-27
 
 ### Added


Reviews:
user: login: wkoot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T21:17:05Z
user: login: fniessink
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T07:06:53Z
user: login: fniessink
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T07:08:25Z
user: login: fniessink
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T07:09:16Z

Review comments:
diff_hunk: @@ -148,7 +151,8 @@ def get_suggestions(self, query: str) -> list[IssueSuggestion]:
         try:
             json = cast(JiraIssueSuggestionJSON, self.__get_json(api_url))
         except Exception as reason:
-            logging.warning("Retrieving issue id suggestions from %s failed: %s", api_url, reason)
+            message = "Retrieving issue id suggestions from %s failed: %s"
+            logger.warning(message, self.suggestions_api % (self.url, "<query redacted>"), reason)
path: components/api_server/src/model/issue_tracker.py
user: login: wkoot
body: Did the query include possible secrets? Also, the inline string format this way will probably evaluate on runtime instead of when written to logger; then we might as well use f-string for readability
created_at: 2025-03-17T21:01:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,9 +39,10 @@ def create_connector_card(destination: str, notification: Notification) -> pymst
 
 def send_notification(destination: str, notification: Notification) -> None:
     """Send notification to Microsoft Teams using a Webhook."""
-    logging.info("Sending notification to configured teams webhook")
+    logger = get_logger()
+    logger.info("Sending notification to configured teams webhook")
     card = create_connector_card(destination, notification)
     try:
         card.send()
     except Exception:
-        logging.exception("Could not deliver notification")
+        logger.exception("Could not deliver notification")
path: components/notifier/src/destinations/ms_teams.py
user: login: wkoot
body: Wow, that's exotic. Is there an added benefit to using `exception` over `error`?
created_at: 2025-03-17T21:08:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,23 @@
+"""Unit tests for the logging utilities."""
+
+import logging
+import unittest
+
+from shared.utils.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.shared_code.tests.shared.utils.test_log", get_logger("shared_code").name)
+
+    def test_get_logger_indirectly(self):
+        """Test getting the logger via an extra function call."""
+
+        def get_logger_indirectly() -> logging.Logger:
+            """Return the logger."""
+            return get_logger("shared_code", call_stack_depth=1)
path: components/shared_code/tests/shared/utils/test_log.py
user: login: wkoot
body: Shouldn't this use a different `component_name`, so the test outcome is actually different from default behaviour?
created_at: 2025-03-17T21:16:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,7 +151,8 @@ def get_suggestions(self, query: str) -> list[IssueSuggestion]:
         try:
             json = cast(JiraIssueSuggestionJSON, self.__get_json(api_url))
         except Exception as reason:
-            logging.warning("Retrieving issue id suggestions from %s failed: %s", api_url, reason)
+            message = "Retrieving issue id suggestions from %s failed: %s"
+            logger.warning(message, self.suggestions_api % (self.url, "<query redacted>"), reason)
path: components/api_server/src/model/issue_tracker.py
user: login: fniessink
body: SonarQube started complaining about possible logging injection because the query is user-provided data. I decided that we probably don't need to exact query in the logging anyway, so removed it from the logged message. 

Using a f-string is certainly possible, but since `suggestions_api` is part of a bunch of URLs and endpoints defined as class attributes I don't want to change just one to an f-string.
created_at: 2025-03-18T07:06:53Z
author_association: MEMBER
diff_hunk: @@ -40,9 +39,10 @@ def create_connector_card(destination: str, notification: Notification) -> pymst
 
 def send_notification(destination: str, notification: Notification) -> None:
     """Send notification to Microsoft Teams using a Webhook."""
-    logging.info("Sending notification to configured teams webhook")
+    logger = get_logger()
+    logger.info("Sending notification to configured teams webhook")
     card = create_connector_card(destination, notification)
     try:
         card.send()
     except Exception:
-        logging.exception("Could not deliver notification")
+        logger.exception("Could not deliver notification")
path: components/notifier/src/destinations/ms_teams.py
user: login: fniessink
body: That you haven't encountered it before doesn't make it exotic :-)

See https://docs.python.org/3/library/logging.html#logging.Logger.exception 
created_at: 2025-03-18T07:08:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,23 @@
+"""Unit tests for the logging utilities."""
+
+import logging
+import unittest
+
+from shared.utils.log import get_logger
+
+
+class LogTest(unittest.TestCase):
+    """Unit tests for the log utilities."""
+
+    def test_get_logger(self):
+        """Test getting the logger."""
+        self.assertEqual("quality_time.shared_code.tests.shared.utils.test_log", get_logger("shared_code").name)
+
+    def test_get_logger_indirectly(self):
+        """Test getting the logger via an extra function call."""
+
+        def get_logger_indirectly() -> logging.Logger:
+            """Return the logger."""
+            return get_logger("shared_code", call_stack_depth=1)
path: components/shared_code/tests/shared/utils/test_log.py
user: login: fniessink
body: Good point.
created_at: 2025-03-18T07:09:15Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-18T07:15:13Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=nl.ictu%3Aquality-time&pullRequest=10991) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=nl.ictu%3Aquality-time&pullRequest=10991&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=nl.ictu%3Aquality-time&pullRequest=10991&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=nl.ictu%3Aquality-time&pullRequest=10991&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=nl.ictu%3Aquality-time&pullRequest=10991&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=nl.ictu%3Aquality-time&pullRequest=10991&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=nl.ictu%3Aquality-time&pullRequest=10991)


author_association: NONE