Skip to content

Commit d18d9b5

Browse files
ohmayrgcf-owl-bot[bot]vchudnov-gparthea
authored
feat: add automatic logging config to support debug logging (#754)
* feat: add base logger to enable debug logging * address PR feedback * address PR feedback * add tests * address PR feedback * address PR feedback * address PR feedback * address PR feedback * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * address PR feedback * address PR feedback * address PR feedback * add coverage for already configured scope * Add partial documentation; clarify test base logger * address PR feedback * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com> Co-authored-by: Victor Chudnovsky <[email protected]> Co-authored-by: Anthonios Partheniou <[email protected]>
1 parent e298ebd commit d18d9b5

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed

google/api_core/client_logging.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import logging
2+
import json
3+
import os
4+
5+
from typing import List, Optional
6+
7+
_LOGGING_INITIALIZED = False
8+
_BASE_LOGGER_NAME = "google"
9+
10+
# Fields to be included in the StructuredLogFormatter.
11+
#
12+
# TODO(https://github.com/googleapis/python-api-core/issues/761): Update this list to support additional logging fields.
13+
_recognized_logging_fields = [
14+
"httpRequest",
15+
"rpcName",
16+
"serviceName",
17+
"credentialsType",
18+
"credentialInfo",
19+
"universeDomain",
20+
"request",
21+
"response",
22+
"metadata",
23+
"retryAttempt",
24+
] # Additional fields to be Logged.
25+
26+
27+
def logger_configured(logger) -> bool:
28+
"""Determines whether `logger` has non-default configuration
29+
30+
Args:
31+
logger: The logger to check.
32+
33+
Returns:
34+
bool: Whether the logger has any non-default configuration.
35+
"""
36+
return (
37+
logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate
38+
)
39+
40+
41+
def initialize_logging():
42+
"""Initializes "google" loggers, partly based on the environment variable
43+
44+
Initializes the "google" logger and any loggers (at the "google"
45+
level or lower) specified by the environment variable
46+
GOOGLE_SDK_PYTHON_LOGGING_SCOPE, as long as none of these loggers
47+
were previously configured. If any such loggers (including the
48+
"google" logger) are initialized, they are set to NOT propagate
49+
log events up to their parent loggers.
50+
51+
This initialization is executed only once, and hence the
52+
environment variable is only processed the first time this
53+
function is called.
54+
"""
55+
global _LOGGING_INITIALIZED
56+
if _LOGGING_INITIALIZED:
57+
return
58+
scopes = os.getenv("GOOGLE_SDK_PYTHON_LOGGING_SCOPE", "")
59+
setup_logging(scopes)
60+
_LOGGING_INITIALIZED = True
61+
62+
63+
def parse_logging_scopes(scopes: Optional[str] = None) -> List[str]:
64+
"""Returns a list of logger names.
65+
66+
Splits the single string of comma-separated logger names into a list of individual logger name strings.
67+
68+
Args:
69+
scopes: The name of a single logger. (In the future, this will be a comma-separated list of multiple loggers.)
70+
71+
Returns:
72+
A list of all the logger names in scopes.
73+
"""
74+
if not scopes:
75+
return []
76+
# TODO(https://github.com/googleapis/python-api-core/issues/759): check if the namespace is a valid namespace.
77+
# TODO(b/380481951): Support logging multiple scopes.
78+
# TODO(b/380483756): Raise or log a warning for an invalid scope.
79+
namespaces = [scopes]
80+
return namespaces
81+
82+
83+
def configure_defaults(logger):
84+
"""Configures `logger` to emit structured info to stdout."""
85+
if not logger_configured(logger):
86+
console_handler = logging.StreamHandler()
87+
logger.setLevel("DEBUG")
88+
logger.propagate = False
89+
formatter = StructuredLogFormatter()
90+
console_handler.setFormatter(formatter)
91+
logger.addHandler(console_handler)
92+
93+
94+
def setup_logging(scopes: str = ""):
95+
"""Sets up logging for the specified `scopes`.
96+
97+
If the loggers specified in `scopes` have not been previously
98+
configured, this will configure them to emit structured log
99+
entries to stdout, and to not propagate their log events to their
100+
parent loggers. Additionally, if the "google" logger (whether it
101+
was specified in `scopes` or not) was not previously configured,
102+
it will also configure it to not propagate log events to the root
103+
logger.
104+
105+
Args:
106+
scopes: The name of a single logger. (In the future, this will be a comma-separated list of multiple loggers.)
107+
108+
"""
109+
110+
# only returns valid logger scopes (namespaces)
111+
# this list has at most one element.
112+
logger_names = parse_logging_scopes(scopes)
113+
114+
for namespace in logger_names:
115+
# This will either create a module level logger or get the reference of the base logger instantiated above.
116+
logger = logging.getLogger(namespace)
117+
118+
# Configure default settings.
119+
configure_defaults(logger)
120+
121+
# disable log propagation at base logger level to the root logger only if a base logger is not already configured via code changes.
122+
base_logger = logging.getLogger(_BASE_LOGGER_NAME)
123+
if not logger_configured(base_logger):
124+
base_logger.propagate = False
125+
126+
127+
# TODO(https://github.com/googleapis/python-api-core/issues/763): Expand documentation.
128+
class StructuredLogFormatter(logging.Formatter):
129+
# TODO(https://github.com/googleapis/python-api-core/issues/761): ensure that additional fields such as
130+
# function name, file name, and line no. appear in a log output.
131+
def format(self, record: logging.LogRecord):
132+
log_obj = {
133+
"timestamp": self.formatTime(record),
134+
"severity": record.levelname,
135+
"name": record.name,
136+
"message": record.getMessage(),
137+
}
138+
139+
for field_name in _recognized_logging_fields:
140+
value = getattr(record, field_name, None)
141+
if value is not None:
142+
log_obj[field_name] = value
143+
return json.dumps(log_obj)

tests/unit/test_client_logging.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import json
2+
import logging
3+
from unittest import mock
4+
5+
from google.api_core.client_logging import (
6+
setup_logging,
7+
initialize_logging,
8+
StructuredLogFormatter,
9+
)
10+
11+
12+
def reset_logger(scope):
13+
logger = logging.getLogger(scope)
14+
logger.handlers = []
15+
logger.setLevel(logging.NOTSET)
16+
logger.propagate = True
17+
18+
19+
def test_setup_logging_w_no_scopes():
20+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
21+
setup_logging()
22+
base_logger = logging.getLogger("foogle")
23+
assert base_logger.handlers == []
24+
assert not base_logger.propagate
25+
assert base_logger.level == logging.NOTSET
26+
27+
reset_logger("foogle")
28+
29+
30+
def test_setup_logging_w_base_scope():
31+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
32+
setup_logging("foogle")
33+
base_logger = logging.getLogger("foogle")
34+
assert isinstance(base_logger.handlers[0], logging.StreamHandler)
35+
assert not base_logger.propagate
36+
assert base_logger.level == logging.DEBUG
37+
38+
reset_logger("foogle")
39+
40+
41+
def test_setup_logging_w_configured_scope():
42+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
43+
base_logger = logging.getLogger("foogle")
44+
base_logger.propagate = False
45+
setup_logging("foogle")
46+
assert base_logger.handlers == []
47+
assert not base_logger.propagate
48+
assert base_logger.level == logging.NOTSET
49+
50+
reset_logger("foogle")
51+
52+
53+
def test_setup_logging_w_module_scope():
54+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
55+
setup_logging("foogle.bar")
56+
57+
base_logger = logging.getLogger("foogle")
58+
assert base_logger.handlers == []
59+
assert not base_logger.propagate
60+
assert base_logger.level == logging.NOTSET
61+
62+
module_logger = logging.getLogger("foogle.bar")
63+
assert isinstance(module_logger.handlers[0], logging.StreamHandler)
64+
assert not module_logger.propagate
65+
assert module_logger.level == logging.DEBUG
66+
67+
reset_logger("foogle")
68+
reset_logger("foogle.bar")
69+
70+
71+
def test_setup_logging_w_incorrect_scope():
72+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
73+
setup_logging("abc")
74+
75+
base_logger = logging.getLogger("foogle")
76+
assert base_logger.handlers == []
77+
assert not base_logger.propagate
78+
assert base_logger.level == logging.NOTSET
79+
80+
# TODO(https://github.com/googleapis/python-api-core/issues/759): update test once we add logic to ignore an incorrect scope.
81+
logger = logging.getLogger("abc")
82+
assert isinstance(logger.handlers[0], logging.StreamHandler)
83+
assert not logger.propagate
84+
assert logger.level == logging.DEBUG
85+
86+
reset_logger("foogle")
87+
reset_logger("abc")
88+
89+
90+
def test_initialize_logging():
91+
92+
with mock.patch("os.getenv", return_value="foogle.bar"):
93+
with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"):
94+
initialize_logging()
95+
96+
base_logger = logging.getLogger("foogle")
97+
assert base_logger.handlers == []
98+
assert not base_logger.propagate
99+
assert base_logger.level == logging.NOTSET
100+
101+
module_logger = logging.getLogger("foogle.bar")
102+
assert isinstance(module_logger.handlers[0], logging.StreamHandler)
103+
assert not module_logger.propagate
104+
assert module_logger.level == logging.DEBUG
105+
106+
# Check that `initialize_logging()` is a no-op after the first time by verifying that user-set configs are not modified:
107+
base_logger.propagate = True
108+
module_logger.propagate = True
109+
110+
initialize_logging()
111+
112+
assert base_logger.propagate
113+
assert module_logger.propagate
114+
115+
reset_logger("foogle")
116+
reset_logger("foogle.bar")
117+
118+
119+
def test_structured_log_formatter():
120+
# TODO(https://github.com/googleapis/python-api-core/issues/761): Test additional fields when implemented.
121+
record = logging.LogRecord(
122+
name="Appelation",
123+
level=logging.DEBUG,
124+
msg="This is a test message.",
125+
pathname="some/path",
126+
lineno=25,
127+
args=None,
128+
exc_info=None,
129+
)
130+
131+
# Extra fields:
132+
record.rpcName = "bar"
133+
134+
formatted_msg = StructuredLogFormatter().format(record)
135+
parsed_msg = json.loads(formatted_msg)
136+
137+
assert parsed_msg["name"] == "Appelation"
138+
assert parsed_msg["severity"] == "DEBUG"
139+
assert parsed_msg["message"] == "This is a test message."
140+
assert parsed_msg["rpcName"] == "bar"

0 commit comments

Comments
 (0)