Skip to content

Commit 47581e9

Browse files
fritzdjTimPansinomergify[bot]
authored
NewRelicContextFormatter update to support adding a stack trace (#1168)
* NewRelicContextFormatter update to support adding a stack trace * Adding test coverage * Remove typing for Python 2 compatibility * Refactor instance methods to class methods and export * Add truthy safeguard to log tests * Fix Python 2 issues in tests * lint:fix --------- Co-authored-by: Tim Pansino <[email protected]> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 5469c4a commit 47581e9

File tree

2 files changed

+168
-22
lines changed

2 files changed

+168
-22
lines changed

newrelic/api/log.py

+48-20
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import logging
1717
import re
1818
import warnings
19+
from traceback import format_tb
1920

2021
from newrelic.api.application import application_instance
2122
from newrelic.api.time_trace import get_linking_metadata
@@ -28,22 +29,6 @@
2829
from newrelic.packages import six
2930

3031

31-
def format_exc_info(exc_info):
32-
_, _, fullnames, message = parse_exc_info(exc_info)
33-
fullname = fullnames[0]
34-
35-
formatted = {
36-
"error.class": fullname,
37-
"error.message": message,
38-
}
39-
40-
expected = is_expected_error(exc_info)
41-
if expected is not None:
42-
formatted["error.expected"] = expected
43-
44-
return formatted
45-
46-
4732
def safe_json_encode(obj, ignore_string_types=False, **kwargs):
4833
# Performs the same operation as json_encode but replaces unserializable objects with a string containing their class name.
4934
# If ignore_string_types is True, do not encode string types further.
@@ -70,10 +55,48 @@ class NewRelicContextFormatter(logging.Formatter):
7055
DEFAULT_LOG_RECORD_KEYS = frozenset(set(vars(logging.LogRecord("", 0, "", 0, "", (), None))) | {"message"})
7156

7257
def __init__(self, *args, **kwargs):
73-
super(NewRelicContextFormatter, self).__init__()
58+
"""
59+
:param Optional[int] stack_trace_limit:
60+
Specifies the maximum number of frames to include for stack traces.
61+
Defaults to `0` to suppress stack traces.
62+
Setting this to `None` will make it so all available frames are included.
63+
"""
64+
stack_trace_limit = kwargs.pop("stack_trace_limit", 0)
65+
66+
if stack_trace_limit is not None:
67+
if not isinstance(stack_trace_limit, int):
68+
raise TypeError("stack_trace_limit must be None or a non-negative integer")
69+
if stack_trace_limit < 0:
70+
raise ValueError("stack_trace_limit must be None or a non-negative integer")
71+
self._stack_trace_limit = stack_trace_limit
72+
73+
super(NewRelicContextFormatter, self).__init__(*args, **kwargs)
7474

7575
@classmethod
76-
def log_record_to_dict(cls, record):
76+
def format_exc_info(cls, exc_info, stack_trace_limit=0):
77+
_, _, fullnames, message = parse_exc_info(exc_info)
78+
fullname = fullnames[0]
79+
80+
formatted = {
81+
"error.class": fullname,
82+
"error.message": message,
83+
}
84+
85+
expected = is_expected_error(exc_info)
86+
if expected is not None:
87+
formatted["error.expected"] = expected
88+
89+
if stack_trace_limit is None or stack_trace_limit > 0:
90+
if exc_info[2] is not None:
91+
stack_trace = "".join(format_tb(exc_info[2], limit=stack_trace_limit)) or None
92+
else:
93+
stack_trace = None
94+
formatted["error.stack_trace"] = stack_trace
95+
96+
return formatted
97+
98+
@classmethod
99+
def log_record_to_dict(cls, record, stack_trace_limit=0):
77100
output = {
78101
"timestamp": int(record.created * 1000),
79102
"message": record.getMessage(),
@@ -96,12 +119,17 @@ def log_record_to_dict(cls, record):
96119
output["extra." + key] = getattr(record, key)
97120

98121
if record.exc_info:
99-
output.update(format_exc_info(record.exc_info))
122+
output.update(cls.format_exc_info(record.exc_info, stack_trace_limit))
100123

101124
return output
102125

103126
def format(self, record):
104-
return json.dumps(self.log_record_to_dict(record), default=safe_json_encode, separators=(",", ":"))
127+
return json.dumps(self.log_record_to_dict(record, self._stack_trace_limit), default=safe_json_encode, separators=(",", ":"))
128+
129+
130+
# Export class methods as top level functions for compatibility
131+
log_record_to_dict = NewRelicContextFormatter.log_record_to_dict
132+
format_exc_info = NewRelicContextFormatter.format_exc_info
105133

106134

107135
class NewRelicLogForwardingHandler(logging.Handler):

tests/agent_features/test_logs_in_context.py

+120-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import json
1616
import logging
17+
import sys
18+
from traceback import format_tb
1719

1820
import pytest
1921

@@ -47,6 +49,22 @@ def log_buffer(caplog):
4749
_logger.removeHandler(_handler)
4850

4951

52+
@pytest.fixture
53+
def log_buffer_with_stack_trace(caplog):
54+
buf = Buffer()
55+
56+
_formatter = NewRelicContextFormatter("", datefmt="ISO8601", stack_trace_limit=None)
57+
_handler = logging.StreamHandler(buf)
58+
_handler.setFormatter(_formatter)
59+
60+
_logger.addHandler(_handler)
61+
caplog.set_level(logging.INFO, logger=__name__)
62+
63+
yield buf
64+
65+
_logger.removeHandler(_handler)
66+
67+
5068
class NonPrintableObject(object):
5169
def __str__(self):
5270
raise RuntimeError("Unable to print object.")
@@ -174,7 +192,7 @@ class ExceptionForTest(ValueError):
174192

175193

176194
@background_task()
177-
def test_newrelic_logger_error_inside_transaction(log_buffer):
195+
def test_newrelic_logger_error_inside_transaction_no_stack_trace(log_buffer):
178196
try:
179197
raise ExceptionForTest
180198
except ExceptionForTest:
@@ -220,7 +238,59 @@ def test_newrelic_logger_error_inside_transaction(log_buffer):
220238
assert set(message.keys()) == set(expected_extra_txn_keys)
221239

222240

223-
def test_newrelic_logger_error_outside_transaction(log_buffer):
241+
@background_task()
242+
def test_newrelic_logger_error_inside_transaction_with_stack_trace(log_buffer_with_stack_trace):
243+
expected_stack_trace = ""
244+
try:
245+
raise ExceptionForTest
246+
except ExceptionForTest:
247+
_logger.exception("oops")
248+
expected_stack_trace = "".join(format_tb(sys.exc_info()[2]))
249+
250+
log_buffer_with_stack_trace.seek(0)
251+
message = json.load(log_buffer_with_stack_trace)
252+
253+
timestamp = message.pop("timestamp")
254+
thread_id = message.pop("thread.id")
255+
process_id = message.pop("process.id")
256+
filename = message.pop("file.name")
257+
line_number = message.pop("line.number")
258+
stack_trace = message.pop("error.stack_trace")
259+
260+
assert isinstance(timestamp, int)
261+
assert isinstance(thread_id, int)
262+
assert isinstance(process_id, int)
263+
assert filename.endswith("/test_logs_in_context.py")
264+
assert isinstance(line_number, int)
265+
assert isinstance(stack_trace, six.string_types)
266+
assert stack_trace and stack_trace == expected_stack_trace
267+
268+
expected = {
269+
"entity.name": "Python Agent Test (agent_features)",
270+
"entity.type": "SERVICE",
271+
"message": "oops",
272+
"log.level": "ERROR",
273+
"logger.name": "test_logs_in_context",
274+
"thread.name": "MainThread",
275+
"process.name": "MainProcess",
276+
"error.class": "test_logs_in_context:ExceptionForTest",
277+
"error.message": "",
278+
"error.expected": False
279+
}
280+
expected_extra_txn_keys = (
281+
"trace.id",
282+
"span.id",
283+
"entity.guid",
284+
"hostname"
285+
)
286+
287+
for k, v in expected.items():
288+
assert message.pop(k) == v
289+
290+
assert set(message.keys()) == set(expected_extra_txn_keys)
291+
292+
293+
def test_newrelic_logger_error_outside_transaction_no_stack_trace(log_buffer):
224294
try:
225295
raise ExceptionForTest
226296
except ExceptionForTest:
@@ -263,6 +333,54 @@ def test_newrelic_logger_error_outside_transaction(log_buffer):
263333
assert set(message.keys()) == set(expected_extra_txn_keys)
264334

265335

336+
def test_newrelic_logger_error_outside_transaction_with_stack_trace(log_buffer_with_stack_trace):
337+
expected_stack_trace = ""
338+
try:
339+
raise ExceptionForTest
340+
except ExceptionForTest:
341+
_logger.exception("oops")
342+
expected_stack_trace = "".join(format_tb(sys.exc_info()[2]))
343+
344+
log_buffer_with_stack_trace.seek(0)
345+
message = json.load(log_buffer_with_stack_trace)
346+
347+
timestamp = message.pop("timestamp")
348+
thread_id = message.pop("thread.id")
349+
process_id = message.pop("process.id")
350+
filename = message.pop("file.name")
351+
line_number = message.pop("line.number")
352+
stack_trace = message.pop("error.stack_trace")
353+
354+
assert isinstance(timestamp, int)
355+
assert isinstance(thread_id, int)
356+
assert isinstance(process_id, int)
357+
assert filename.endswith("/test_logs_in_context.py")
358+
assert isinstance(line_number, int)
359+
assert isinstance(stack_trace, six.string_types)
360+
assert stack_trace and stack_trace == expected_stack_trace
361+
362+
expected = {
363+
"entity.name": "Python Agent Test (agent_features)",
364+
"entity.type": "SERVICE",
365+
"message": "oops",
366+
"log.level": "ERROR",
367+
"logger.name": "test_logs_in_context",
368+
"thread.name": "MainThread",
369+
"process.name": "MainProcess",
370+
"error.class": "test_logs_in_context:ExceptionForTest",
371+
"error.message": "",
372+
}
373+
expected_extra_txn_keys = (
374+
"entity.guid",
375+
"hostname",
376+
)
377+
378+
for k, v in expected.items():
379+
assert message.pop(k) == v
380+
381+
assert set(message.keys()) == set(expected_extra_txn_keys)
382+
383+
266384
EXPECTED_KEYS_TXN = (
267385
"trace.id",
268386
"span.id",

0 commit comments

Comments
 (0)