Skip to content

Commit f169a84

Browse files
author
Izaak Gough
committed
feat: support logging of exceptions
1 parent 534b3b0 commit f169a84

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

‎src/firebase_functions/logger.py‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json as _json
77
import sys as _sys
88
import typing as _typing
9+
import traceback as _traceback
910

1011
import typing_extensions as _typing_extensions
1112

@@ -71,6 +72,43 @@ def _entry_from_args(severity: LogSeverity, *args, **kwargs) -> LogEntry:
7172
return _typing.cast(LogEntry, entry)
7273

7374

75+
def _exception_from_args(
76+
exception: BaseException, refs: set[_typing.Any] | None = None
77+
) -> dict[str, _typing.Any]:
78+
"""
79+
Creates a JSON-safe representation of an exception.
80+
"""
81+
82+
details: dict[str, _typing.Any] = {
83+
"type": exception.__class__.__name__,
84+
"message": _safe_exception_string(exception),
85+
}
86+
if exception.args:
87+
details["args"] = _remove_circular(exception.args, refs)
88+
if exception.__traceback__ is not None:
89+
try:
90+
details["stack_trace"] = "".join(
91+
_traceback.format_exception(
92+
exception.__class__, exception, exception.__traceback__
93+
)
94+
)
95+
except Exception:
96+
details["stack_trace"] = "".join(_traceback.format_tb(exception.__traceback__))
97+
details["stack_trace"] += f"{exception.__class__.__name__}: {details['message']}\n"
98+
return details
99+
100+
101+
def _safe_exception_string(exception: BaseException) -> str:
102+
"""
103+
Returns a string representation of an exception without propagating repr/str errors.
104+
"""
105+
106+
try:
107+
return str(exception)
108+
except Exception:
109+
return exception.__class__.__name__
110+
111+
74112
def _remove_circular(obj: _typing.Any, refs: set[_typing.Any] | None = None):
75113
"""
76114
Removes circular references from the given object and replaces them with "[CIRCULAR]".
@@ -89,7 +127,9 @@ def _remove_circular(obj: _typing.Any, refs: set[_typing.Any] | None = None):
89127

90128
# Recursively process the object based on its type
91129
result: _typing.Any
92-
if isinstance(obj, dict):
130+
if isinstance(obj, BaseException):
131+
result = _exception_from_args(obj, refs)
132+
elif isinstance(obj, dict):
93133
result = {key: _remove_circular(value, refs) for key, value in obj.items()}
94134
elif isinstance(obj, list):
95135
result = [_remove_circular(item, refs) for item in obj]
@@ -149,3 +189,16 @@ def error(*args, **kwargs) -> None:
149189
Logs an error message.
150190
"""
151191
write(_entry_from_args(LogSeverity.ERROR, *args, **kwargs))
192+
193+
194+
def exception(*args, **kwargs) -> None:
195+
"""
196+
Logs an error message and includes the active stack trace.
197+
"""
198+
entry = _entry_from_args(LogSeverity.ERROR, *args, **kwargs)
199+
exc_type, exc_value, exc_traceback = _sys.exc_info()
200+
if exc_type is not None and exc_value is not None and exc_traceback is not None:
201+
entry["stack_trace"] = "".join(
202+
_traceback.format_exception(exc_type, exc_value, exc_traceback)
203+
)
204+
write(entry)

‎tests/test_logger.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,56 @@ def test_severity_should_be_error(self, capsys: pytest.CaptureFixture[str]):
5959
log_output = json.loads(raw_log_output)
6060
assert log_output["severity"] == "ERROR"
6161

62+
def test_error_should_accept_exception(self, capsys: pytest.CaptureFixture[str]):
63+
try:
64+
raise ValueError("boom")
65+
except ValueError as exception:
66+
logger.error("failed", error=exception)
67+
68+
raw_log_output = capsys.readouterr().err
69+
log_output = json.loads(raw_log_output)
70+
71+
assert log_output["severity"] == "ERROR"
72+
assert log_output["message"] == "failed"
73+
assert log_output["error"]["type"] == "ValueError"
74+
assert log_output["error"]["message"] == "boom"
75+
assert "stack_trace" in log_output["error"]
76+
assert "ValueError: boom" in log_output["error"]["stack_trace"]
77+
78+
def test_error_should_accept_self_referential_exception(self, capsys: pytest.CaptureFixture[str]):
79+
class SelfArgError(Exception):
80+
pass
81+
82+
exception = SelfArgError("boom")
83+
exception.args = (exception,)
84+
85+
logger.error("failed", error=exception)
86+
87+
raw_log_output = capsys.readouterr().err
88+
log_output = json.loads(raw_log_output)
89+
90+
assert log_output["severity"] == "ERROR"
91+
assert log_output["message"] == "failed"
92+
assert log_output["error"]["type"] == "SelfArgError"
93+
assert log_output["error"]["args"] == ["[CIRCULAR]"]
94+
95+
def test_error_should_accept_exception_with_cyclic_payload(
96+
self, capsys: pytest.CaptureFixture[str]
97+
):
98+
payload = {}
99+
payload["self"] = payload
100+
exception = ValueError(payload)
101+
102+
logger.error("failed", error=exception)
103+
104+
raw_log_output = capsys.readouterr().err
105+
log_output = json.loads(raw_log_output)
106+
107+
assert log_output["severity"] == "ERROR"
108+
assert log_output["message"] == "failed"
109+
assert log_output["error"]["type"] == "ValueError"
110+
assert log_output["error"]["args"] == [{"self": "[CIRCULAR]"}]
111+
62112
def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]):
63113
logger.log("bar")
64114
raw_log_output = capsys.readouterr().out
@@ -78,6 +128,20 @@ def test_message_should_be_space_separated(self, capsys: pytest.CaptureFixture[s
78128
log_output = json.loads(raw_log_output)
79129
assert log_output["message"] == expected_message
80130

131+
def test_exception_should_include_stack_trace(self, capsys: pytest.CaptureFixture[str]):
132+
try:
133+
raise ValueError("boom")
134+
except ValueError:
135+
logger.exception("failed")
136+
137+
raw_log_output = capsys.readouterr().err
138+
log_output = json.loads(raw_log_output)
139+
140+
assert log_output["severity"] == "ERROR"
141+
assert log_output["message"] == "failed"
142+
assert "stack_trace" in log_output
143+
assert "ValueError: boom" in log_output["stack_trace"]
144+
81145
def test_remove_circular_references(self, capsys: pytest.CaptureFixture[str]):
82146
# Create an object with a circular reference.
83147
circ = {"b": "foo"}

0 commit comments

Comments
 (0)