Skip to content

Commit d6c1a91

Browse files
TimPansinomergify[bot]hmstepanek
authored
Update NewRelicLogForwardingHandler (#1038)
* Update log forwarding handler * Parametrize logger in conftest * Update tests for logging * Change NewRelicLogForwardingHandler to reproduce instrumentation * Replace testing for forwarding handler * Clean out imports * Add test for custom formatter which returns a dict * Refactor to reduce code complexity --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Hannah Stepanek <[email protected]>
1 parent 2b14392 commit d6c1a91

File tree

5 files changed

+182
-57
lines changed

5 files changed

+182
-57
lines changed

newrelic/api/log.py

+48-16
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import logging
1717
import re
1818
import warnings
19-
from logging import Formatter, LogRecord
2019

20+
from newrelic.api.application import application_instance
2121
from newrelic.api.time_trace import get_linking_metadata
2222
from newrelic.api.transaction import current_transaction, record_log_event
2323
from newrelic.common import agent_http
@@ -66,8 +66,8 @@ def safe_json_encode(obj, ignore_string_types=False, **kwargs):
6666
return "<unprintable %s object>" % type(obj).__name__
6767

6868

69-
class NewRelicContextFormatter(Formatter):
70-
DEFAULT_LOG_RECORD_KEYS = frozenset(set(vars(LogRecord("", 0, "", 0, "", (), None))) | {"message"})
69+
class NewRelicContextFormatter(logging.Formatter):
70+
DEFAULT_LOG_RECORD_KEYS = frozenset(set(vars(logging.LogRecord("", 0, "", 0, "", (), None))) | {"message"})
7171

7272
def __init__(self, *args, **kwargs):
7373
super(NewRelicContextFormatter, self).__init__()
@@ -104,29 +104,61 @@ def format(self, record):
104104

105105

106106
class NewRelicLogForwardingHandler(logging.Handler):
107-
DEFAULT_LOG_RECORD_KEYS = frozenset(set(vars(LogRecord("", 0, "", 0, "", (), None))) | {"message"})
107+
IGNORED_LOG_RECORD_KEYS = set(["message", "msg"])
108108

109109
def emit(self, record):
110110
try:
111-
# Avoid getting local log decorated message
112-
if hasattr(record, "_nr_original_message"):
113-
message = record._nr_original_message()
111+
nr = None
112+
transaction = current_transaction()
113+
# Retrieve settings
114+
if transaction:
115+
settings = transaction.settings
116+
nr = transaction
114117
else:
115-
message = record.getMessage()
116-
117-
attrs = self.filter_record_attributes(record)
118-
record_log_event(message, record.levelname, int(record.created * 1000), attributes=attrs)
118+
application = application_instance(activate=False)
119+
if application and application.enabled:
120+
nr = application
121+
settings = application.settings
122+
else:
123+
# If no settings have been found, fallback to global settings
124+
settings = global_settings()
125+
126+
# If logging is enabled and the application or transaction is not None.
127+
if settings and settings.application_logging.enabled and nr:
128+
level_name = str(getattr(record, "levelname", "UNKNOWN"))
129+
if settings.application_logging.metrics.enabled:
130+
nr.record_custom_metric("Logging/lines", {"count": 1})
131+
nr.record_custom_metric("Logging/lines/%s" % level_name, {"count": 1})
132+
133+
if settings.application_logging.forwarding.enabled:
134+
if self.formatter:
135+
# Formatter supplied, allow log records to be formatted into a string
136+
message = self.format(record)
137+
else:
138+
# No formatter supplied, attempt to handle dict log records
139+
message = record.msg
140+
if not isinstance(message, dict):
141+
# Allow python to convert the message to a string and template it with args.
142+
message = record.getMessage()
143+
144+
# Grab and filter context attributes from log record
145+
context_attrs = self.filter_record_attributes(record)
146+
147+
record_log_event(
148+
message=message,
149+
level=level_name,
150+
timestamp=int(record.created * 1000),
151+
attributes=context_attrs,
152+
)
153+
except RecursionError: # Emulates behavior of CPython.
154+
raise
119155
except Exception:
120156
self.handleError(record)
121157

122158
@classmethod
123159
def filter_record_attributes(cls, record):
124160
record_attrs = vars(record)
125-
DEFAULT_LOG_RECORD_KEYS = cls.DEFAULT_LOG_RECORD_KEYS
126-
if len(record_attrs) > len(DEFAULT_LOG_RECORD_KEYS):
127-
return {k: v for k, v in six.iteritems(vars(record)) if k not in DEFAULT_LOG_RECORD_KEYS}
128-
else:
129-
return None
161+
return {k: record_attrs[k] for k in record_attrs if k not in cls.IGNORED_LOG_RECORD_KEYS}
130162

131163

132164
class NewRelicLogHandler(logging.Handler):

tests/logger_logging/conftest.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
collector_available_fixture,
2121
)
2222

23+
from newrelic.api.log import NewRelicLogForwardingHandler
24+
2325
_default_settings = {
2426
"transaction_tracer.explain_threshold": 0.0,
2527
"transaction_tracer.transaction_threshold": 0.0,
@@ -54,13 +56,45 @@ def emit(self, record):
5456
self.records.append(self.format(record))
5557

5658

59+
@pytest.fixture(scope="function", params=["instrumented_logger", "forwarding_handler"])
60+
def logger(request):
61+
_logger = logging.getLogger("my_app")
62+
caplog = CaplogHandler()
63+
_logger.addHandler(caplog)
64+
_logger.caplog = caplog
65+
_logger.setLevel(logging.WARNING)
66+
67+
# Save instrumentation so we can disable it
68+
instrumented = logging.Logger.callHandlers
69+
70+
forwarding_handler = None
71+
if request.param == "forwarding_handler":
72+
forwarding_handler = NewRelicLogForwardingHandler()
73+
_logger.addHandler(forwarding_handler)
74+
75+
# Uninstrument Logging
76+
logging.Logger.callHandlers = logging.Logger.callHandlers.__wrapped__ # noqa, pylint: disable=E1101
77+
78+
yield _logger
79+
del caplog.records[:]
80+
81+
_logger.removeHandler(caplog)
82+
if forwarding_handler:
83+
_logger.removeHandler(forwarding_handler)
84+
85+
# Reinstrument logging in case it was uninstrumented
86+
logging.Logger.callHandlers = instrumented
87+
88+
5789
@pytest.fixture(scope="function")
58-
def logger():
90+
def instrumented_logger():
5991
_logger = logging.getLogger("my_app")
6092
caplog = CaplogHandler()
6193
_logger.addHandler(caplog)
6294
_logger.caplog = caplog
6395
_logger.setLevel(logging.WARNING)
96+
6497
yield _logger
6598
del caplog.records[:]
99+
66100
_logger.removeHandler(caplog)

tests/logger_logging/test_local_decorating.py

+11-9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import platform
1616

17+
import pytest
18+
1719
from testing_support.fixtures import reset_core_stats_engine
1820
from testing_support.validators.validate_log_event_count import validate_log_event_count
1921
from testing_support.validators.validate_log_event_count_outside_transaction import (
@@ -64,32 +66,32 @@ def get_metadata_string(log_message, is_txn):
6466

6567

6668
@reset_core_stats_engine()
67-
def test_local_log_decoration_inside_transaction(logger):
69+
def test_local_log_decoration_inside_transaction(instrumented_logger):
6870
@validate_log_event_count(1)
6971
@background_task()
7072
def test():
71-
exercise_logging(logger)
72-
assert logger.caplog.records[0] == get_metadata_string("C", True)
73+
exercise_logging(instrumented_logger)
74+
assert instrumented_logger.caplog.records[0] == get_metadata_string("C", True)
7375

7476
test()
7577

7678

7779
@reset_core_stats_engine()
78-
def test_local_log_decoration_outside_transaction(logger):
80+
def test_local_log_decoration_outside_transaction(instrumented_logger):
7981
@validate_log_event_count_outside_transaction(1)
8082
def test():
81-
exercise_logging(logger)
82-
assert logger.caplog.records[0] == get_metadata_string("C", False)
83+
exercise_logging(instrumented_logger)
84+
assert instrumented_logger.caplog.records[0] == get_metadata_string("C", False)
8385

8486
test()
8587

8688

8789
@reset_core_stats_engine()
88-
def test_local_log_decoration_dict_message(logger):
90+
def test_local_log_decoration_dict_message(instrumented_logger):
8991
@validate_log_event_count(1)
9092
@background_task()
9193
def test():
92-
exercise_logging(logger, {"message": "dict_message"})
93-
assert logger.caplog.records[0] == get_metadata_string("{'message': 'dict_message'}", True)
94+
exercise_logging(instrumented_logger, {"message": "dict_message"})
95+
assert instrumented_logger.caplog.records[0] == get_metadata_string("{'message': 'dict_message'}", True)
9496

9597
test()

tests/logger_logging/test_logging_handler.py

+84-27
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import logging
1616

1717
import pytest
18-
from conftest import logger as conf_logger # noqa, pylint: disable=W0611
18+
from conftest import instrumented_logger as conf_logger # noqa, pylint: disable=W0611
1919
from testing_support.fixtures import (
2020
override_application_settings,
2121
reset_core_stats_engine,
@@ -46,8 +46,9 @@ def uninstrument_logging():
4646

4747

4848
@pytest.fixture(scope="function")
49-
def logger(conf_logger, uninstrument_logging):
49+
def formatting_logger(conf_logger, uninstrument_logging):
5050
handler = NewRelicLogForwardingHandler()
51+
handler.setFormatter(logging.Formatter(r"%(levelname)s - %(message)s"))
5152
conf_logger.addHandler(handler)
5253
yield conf_logger
5354
conf_logger.removeHandler(handler)
@@ -62,50 +63,44 @@ def set_trace_ids():
6263
trace.guid = "abcdefgh"
6364

6465

65-
def exercise_logging(logger):
66-
set_trace_ids()
67-
logger.warning("C", extra={"key": "value"})
68-
assert len(logger.caplog.records) == 1
66+
class DictMessageFormatter(logging.Formatter):
67+
def format(self, record):
68+
message = record.msg
69+
if isinstance(message, dict):
70+
message["formatter_attr"] = 1
71+
return message
6972

7073

71-
@override_application_settings(
72-
{
73-
"application_logging.forwarding.context_data.enabled": True,
74-
}
75-
)
76-
def test_handler_inside_transaction(logger):
74+
def test_handler_with_formatter(formatting_logger):
7775
@validate_log_events(
7876
[
7977
{
80-
"message": "C",
78+
"message": "WARNING - C",
8179
"level": "WARNING",
8280
"timestamp": None,
8381
"hostname": None,
8482
"entity.name": "Python Agent Test (logger_logging)",
8583
"entity.guid": None,
8684
"span.id": "abcdefgh",
8785
"trace.id": "abcdefgh12345678",
88-
"context.key": "value",
86+
"context.key": "value", # Extra attr
87+
"context.module": "test_logging_handler", # Default attr
8988
}
9089
]
9190
)
9291
@validate_log_event_count(1)
9392
@validate_function_called("newrelic.api.log", "NewRelicLogForwardingHandler.emit")
9493
@background_task()
9594
def test():
96-
exercise_logging(logger)
95+
set_trace_ids()
96+
formatting_logger.warning("C", extra={"key": "value"})
97+
assert len(formatting_logger.caplog.records) == 1
9798

9899
test()
99100

100101

101-
@reset_core_stats_engine()
102-
@override_application_settings(
103-
{
104-
"application_logging.forwarding.context_data.enabled": True,
105-
}
106-
)
107-
def test_handler_outside_transaction(logger):
108-
@validate_log_events_outside_transaction(
102+
def test_handler_dict_message_no_formatter(formatting_logger):
103+
@validate_log_events(
109104
[
110105
{
111106
"message": "C",
@@ -114,13 +109,75 @@ def test_handler_outside_transaction(logger):
114109
"hostname": None,
115110
"entity.name": "Python Agent Test (logger_logging)",
116111
"entity.guid": None,
117-
"context.key": "value",
112+
"span.id": "abcdefgh",
113+
"trace.id": "abcdefgh12345678",
114+
"message.attr": 3, # Message attr
118115
}
119-
]
116+
],
117+
)
118+
@validate_log_event_count(1)
119+
@validate_function_called("newrelic.api.log", "NewRelicLogForwardingHandler.emit")
120+
@background_task()
121+
def test():
122+
set_trace_ids()
123+
formatting_logger.handlers[1].setFormatter(None) # Turn formatter off to enable dict message support
124+
formatting_logger.warning({"message": "C", "attr": 3})
125+
assert len(formatting_logger.caplog.records) == 1
126+
127+
test()
128+
129+
130+
def test_handler_dict_message_with_formatter(formatting_logger):
131+
@validate_log_events(
132+
[
133+
{
134+
"message": "WARNING - {'message': 'C', 'attr': 3}",
135+
"level": "WARNING",
136+
"timestamp": None,
137+
"hostname": None,
138+
"entity.name": "Python Agent Test (logger_logging)",
139+
"entity.guid": None,
140+
"span.id": "abcdefgh",
141+
"trace.id": "abcdefgh12345678",
142+
}
143+
],
144+
forgone_attrs=["message.attr"] # Explicit formatters take precedence over dict message support
120145
)
121-
@validate_log_event_count_outside_transaction(1)
146+
@validate_log_event_count(1)
122147
@validate_function_called("newrelic.api.log", "NewRelicLogForwardingHandler.emit")
148+
@background_task()
149+
def test():
150+
set_trace_ids()
151+
formatting_logger.warning({"message": "C", "attr": 3})
152+
assert len(formatting_logger.caplog.records) == 1
153+
154+
test()
155+
156+
157+
def test_handler_formatter_returns_dict_message(formatting_logger):
158+
@validate_log_events(
159+
[
160+
{
161+
"message": "C",
162+
"level": "WARNING",
163+
"timestamp": None,
164+
"hostname": None,
165+
"entity.name": "Python Agent Test (logger_logging)",
166+
"entity.guid": None,
167+
"span.id": "abcdefgh",
168+
"trace.id": "abcdefgh12345678",
169+
"message.attr": 3, # Message attr
170+
"message.formatter_attr": 1, # Formatter message attr
171+
}
172+
],
173+
)
174+
@validate_log_event_count(1)
175+
@validate_function_called("newrelic.api.log", "NewRelicLogForwardingHandler.emit")
176+
@background_task()
123177
def test():
124-
exercise_logging(logger)
178+
set_trace_ids()
179+
formatting_logger.handlers[1].setFormatter(DictMessageFormatter()) # Set formatter to return a dict
180+
formatting_logger.warning({"message": "C", "attr": 3})
181+
assert len(formatting_logger.caplog.records) == 1
125182

126183
test()

tests/logger_logging/test_settings.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,16 @@ def test():
5151

5252
@pytest.mark.parametrize("feature_setting,subfeature_setting,expected", _settings_matrix)
5353
@reset_core_stats_engine()
54-
def test_local_decorating_settings(logger, feature_setting, subfeature_setting, expected):
54+
def test_local_decorating_settings(instrumented_logger, feature_setting, subfeature_setting, expected):
5555
@override_application_settings({
5656
"application_logging.enabled": feature_setting,
5757
"application_logging.local_decorating.enabled": subfeature_setting,
5858
})
5959
@background_task()
6060
def test():
61-
basic_logging(logger)
62-
assert len(logger.caplog.records) == 1
63-
message = logger.caplog.records.pop()
61+
basic_logging(instrumented_logger)
62+
assert len(instrumented_logger.caplog.records) == 1
63+
message = instrumented_logger.caplog.records.pop()
6464
if expected:
6565
assert len(message) > 1
6666
else:

0 commit comments

Comments
 (0)