From f169a84e53c20667618ea81828526bd273e46e87 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 1 Jun 2026 13:16:52 +0100 Subject: [PATCH 01/11] feat: support logging of exceptions --- src/firebase_functions/logger.py | 55 ++++++++++++++++++++++++++- tests/test_logger.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 0e22fc5e..9ab47b75 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -6,6 +6,7 @@ import json as _json import sys as _sys import typing as _typing +import traceback as _traceback import typing_extensions as _typing_extensions @@ -71,6 +72,43 @@ def _entry_from_args(severity: LogSeverity, *args, **kwargs) -> LogEntry: return _typing.cast(LogEntry, entry) +def _exception_from_args( + exception: BaseException, refs: set[_typing.Any] | None = None +) -> dict[str, _typing.Any]: + """ + Creates a JSON-safe representation of an exception. + """ + + details: dict[str, _typing.Any] = { + "type": exception.__class__.__name__, + "message": _safe_exception_string(exception), + } + if exception.args: + details["args"] = _remove_circular(exception.args, refs) + if exception.__traceback__ is not None: + try: + details["stack_trace"] = "".join( + _traceback.format_exception( + exception.__class__, exception, exception.__traceback__ + ) + ) + except Exception: + details["stack_trace"] = "".join(_traceback.format_tb(exception.__traceback__)) + details["stack_trace"] += f"{exception.__class__.__name__}: {details['message']}\n" + return details + + +def _safe_exception_string(exception: BaseException) -> str: + """ + Returns a string representation of an exception without propagating repr/str errors. + """ + + try: + return str(exception) + except Exception: + return exception.__class__.__name__ + + def _remove_circular(obj: _typing.Any, refs: set[_typing.Any] | None = None): """ 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): # Recursively process the object based on its type result: _typing.Any - if isinstance(obj, dict): + if isinstance(obj, BaseException): + result = _exception_from_args(obj, refs) + elif isinstance(obj, dict): result = {key: _remove_circular(value, refs) for key, value in obj.items()} elif isinstance(obj, list): result = [_remove_circular(item, refs) for item in obj] @@ -149,3 +189,16 @@ def error(*args, **kwargs) -> None: Logs an error message. """ write(_entry_from_args(LogSeverity.ERROR, *args, **kwargs)) + + +def exception(*args, **kwargs) -> None: + """ + Logs an error message and includes the active stack trace. + """ + entry = _entry_from_args(LogSeverity.ERROR, *args, **kwargs) + exc_type, exc_value, exc_traceback = _sys.exc_info() + if exc_type is not None and exc_value is not None and exc_traceback is not None: + entry["stack_trace"] = "".join( + _traceback.format_exception(exc_type, exc_value, exc_traceback) + ) + write(entry) diff --git a/tests/test_logger.py b/tests/test_logger.py index 8f6aaee6..860cf01c 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -59,6 +59,56 @@ def test_severity_should_be_error(self, capsys: pytest.CaptureFixture[str]): log_output = json.loads(raw_log_output) assert log_output["severity"] == "ERROR" + def test_error_should_accept_exception(self, capsys: pytest.CaptureFixture[str]): + try: + raise ValueError("boom") + except ValueError as exception: + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["message"] == "boom" + assert "stack_trace" in log_output["error"] + assert "ValueError: boom" in log_output["error"]["stack_trace"] + + def test_error_should_accept_self_referential_exception(self, capsys: pytest.CaptureFixture[str]): + class SelfArgError(Exception): + pass + + exception = SelfArgError("boom") + exception.args = (exception,) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "SelfArgError" + assert log_output["error"]["args"] == ["[CIRCULAR]"] + + def test_error_should_accept_exception_with_cyclic_payload( + self, capsys: pytest.CaptureFixture[str] + ): + payload = {} + payload["self"] = payload + exception = ValueError(payload) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["args"] == [{"self": "[CIRCULAR]"}] + def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]): logger.log("bar") raw_log_output = capsys.readouterr().out @@ -78,6 +128,20 @@ def test_message_should_be_space_separated(self, capsys: pytest.CaptureFixture[s log_output = json.loads(raw_log_output) assert log_output["message"] == expected_message + def test_exception_should_include_stack_trace(self, capsys: pytest.CaptureFixture[str]): + try: + raise ValueError("boom") + except ValueError: + logger.exception("failed") + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert "stack_trace" in log_output + assert "ValueError: boom" in log_output["stack_trace"] + def test_remove_circular_references(self, capsys: pytest.CaptureFixture[str]): # Create an object with a circular reference. circ = {"b": "foo"} From 845ee24e49ef72193fd7374011519fb9b3029207 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 1 Jun 2026 13:22:41 +0100 Subject: [PATCH 02/11] chore: fix linting and formatting --- src/firebase_functions/logger.py | 6 ++---- tests/test_logger.py | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 9ab47b75..c3b7abc6 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -5,8 +5,8 @@ import enum as _enum import json as _json import sys as _sys -import typing as _typing import traceback as _traceback +import typing as _typing import typing_extensions as _typing_extensions @@ -88,9 +88,7 @@ def _exception_from_args( if exception.__traceback__ is not None: try: details["stack_trace"] = "".join( - _traceback.format_exception( - exception.__class__, exception, exception.__traceback__ - ) + _traceback.format_exception(exception.__class__, exception, exception.__traceback__) ) except Exception: details["stack_trace"] = "".join(_traceback.format_tb(exception.__traceback__)) diff --git a/tests/test_logger.py b/tests/test_logger.py index 860cf01c..8de6f5d0 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -75,7 +75,9 @@ def test_error_should_accept_exception(self, capsys: pytest.CaptureFixture[str]) assert "stack_trace" in log_output["error"] assert "ValueError: boom" in log_output["error"]["stack_trace"] - def test_error_should_accept_self_referential_exception(self, capsys: pytest.CaptureFixture[str]): + def test_error_should_accept_self_referential_exception( + self, capsys: pytest.CaptureFixture[str] + ): class SelfArgError(Exception): pass From 3098d2f050db4ed17a1199d5844c7909d7eb881a Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 1 Jun 2026 13:27:12 +0100 Subject: [PATCH 03/11] chore: fix dictionary type --- src/firebase_functions/logger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index c3b7abc6..54933f56 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -44,6 +44,7 @@ class LogEntry(_typing.TypedDict): severity: _typing_extensions.Required[LogSeverity] message: _typing_extensions.NotRequired[str] + stack_trace: _typing_extensions.NotRequired[str] def _entry_from_args(severity: LogSeverity, *args, **kwargs) -> LogEntry: From b7845aecab9517261141d6cf8c7978b05806a420 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 3 Jun 2026 11:03:05 +0100 Subject: [PATCH 04/11] chore: use more precise type for refs param --- src/firebase_functions/logger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 54933f56..a9994bd6 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -74,7 +74,7 @@ def _entry_from_args(severity: LogSeverity, *args, **kwargs) -> LogEntry: def _exception_from_args( - exception: BaseException, refs: set[_typing.Any] | None = None + exception: BaseException, refs: set[int] | None = None ) -> dict[str, _typing.Any]: """ Creates a JSON-safe representation of an exception. @@ -108,7 +108,7 @@ def _safe_exception_string(exception: BaseException) -> str: return exception.__class__.__name__ -def _remove_circular(obj: _typing.Any, refs: set[_typing.Any] | None = None): +def _remove_circular(obj: _typing.Any, refs: set[int] | None = None): """ Removes circular references from the given object and replaces them with "[CIRCULAR]". """ From 7fdd0545002b6638ed676173a26e5390f97708d8 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 15:16:02 +0100 Subject: [PATCH 05/11] fix: serialize sys.exc_info exception types in structured logs --- src/firebase_functions/logger.py | 26 ++++++++++++++++++++++++++ tests/test_logger.py | 17 +++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index a9994bd6..231432f0 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -97,6 +97,30 @@ def _exception_from_args( return details +def _exception_type_from_args( + exception_type: type[BaseException], +) -> dict[str, _typing.Any]: + """ + Creates a JSON-safe representation of an exception class. + + If the class matches the active exception from `sys.exc_info()`, include + the current exception message and stack trace as well. + """ + + details: dict[str, _typing.Any] = { + "type": exception_type.__name__, + "message": exception_type.__name__, + } + exc_type, exc_value, exc_traceback = _sys.exc_info() + if exc_type is exception_type and exc_value is not None: + details["message"] = _safe_exception_string(exc_value) + if exc_traceback is not None: + details["stack_trace"] = "".join( + _traceback.format_exception(exc_type, exc_value, exc_traceback) + ) + return details + + def _safe_exception_string(exception: BaseException) -> str: """ Returns a string representation of an exception without propagating repr/str errors. @@ -128,6 +152,8 @@ def _remove_circular(obj: _typing.Any, refs: set[int] | None = None): result: _typing.Any if isinstance(obj, BaseException): result = _exception_from_args(obj, refs) + elif isinstance(obj, type) and issubclass(obj, BaseException): + result = _exception_type_from_args(obj) elif isinstance(obj, dict): result = {key: _remove_circular(value, refs) for key, value in obj.items()} elif isinstance(obj, list): diff --git a/tests/test_logger.py b/tests/test_logger.py index 8de6f5d0..84c1f2f4 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -4,6 +4,7 @@ """ import json +import sys import pytest @@ -75,6 +76,22 @@ def test_error_should_accept_exception(self, capsys: pytest.CaptureFixture[str]) assert "stack_trace" in log_output["error"] assert "ValueError: boom" in log_output["error"]["stack_trace"] + def test_error_should_accept_exception_type(self, capsys: pytest.CaptureFixture[str]): + try: + raise TypeError("boom") + except TypeError: + logger.error("failed", error=sys.exc_info()[0]) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "TypeError" + assert log_output["error"]["message"] == "boom" + assert "stack_trace" in log_output["error"] + assert "TypeError: boom" in log_output["error"]["stack_trace"] + def test_error_should_accept_self_referential_exception( self, capsys: pytest.CaptureFixture[str] ): From b0746766e88e3b50c874c6a9dd9feedce96a5449 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 15:45:08 +0100 Subject: [PATCH 06/11] fix: stringify non-JSON-serializable exception args --- src/firebase_functions/logger.py | 26 +++++++++++++++++++++++++- tests/test_logger.py | 16 ++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 231432f0..822ba601 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -85,7 +85,7 @@ def _exception_from_args( "message": _safe_exception_string(exception), } if exception.args: - details["args"] = _remove_circular(exception.args, refs) + details["args"] = _json_safe_exception_args(exception.args, refs) if exception.__traceback__ is not None: try: details["stack_trace"] = "".join( @@ -132,6 +132,30 @@ def _safe_exception_string(exception: BaseException) -> str: return exception.__class__.__name__ +def _json_safe_exception_args(args: tuple[_typing.Any, ...], refs: set[int] | None = None): + """ + Returns exception args in a form that can be encoded as JSON. + """ + + return _coerce_json_safe(_remove_circular(args, refs)) + + +def _coerce_json_safe(obj: _typing.Any): + """ + Converts values that survive circular-reference removal into JSON-safe values. + """ + + if isinstance(obj, str | int | float | bool | type(None)): + return obj + if isinstance(obj, dict): + return {key: _coerce_json_safe(value) for key, value in obj.items()} + if isinstance(obj, list): + return [_coerce_json_safe(item) for item in obj] + if isinstance(obj, tuple): + return tuple(_coerce_json_safe(item) for item in obj) + return repr(obj) + + def _remove_circular(obj: _typing.Any, refs: set[int] | None = None): """ Removes circular references from the given object and replaces them with "[CIRCULAR]". diff --git a/tests/test_logger.py b/tests/test_logger.py index 84c1f2f4..2d2e2e9c 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -128,6 +128,22 @@ def test_error_should_accept_exception_with_cyclic_payload( assert log_output["error"]["type"] == "ValueError" assert log_output["error"]["args"] == [{"self": "[CIRCULAR]"}] + def test_error_should_accept_exception_with_non_json_serializable_args( + self, capsys: pytest.CaptureFixture[str] + ): + payload = object() + exception = ValueError(payload) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["args"] == [repr(payload)] + def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]): logger.log("bar") raw_log_output = capsys.readouterr().out From e0d7cd2675a9fd92ca4a214b87e5d3be8314a00c Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 15:55:41 +0100 Subject: [PATCH 07/11] fix: harden exception arg serialization for repr and dict keys --- src/firebase_functions/logger.py | 17 ++++++++++++++-- tests/test_logger.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 822ba601..9c60f662 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -148,12 +148,25 @@ def _coerce_json_safe(obj: _typing.Any): if isinstance(obj, str | int | float | bool | type(None)): return obj if isinstance(obj, dict): - return {key: _coerce_json_safe(value) for key, value in obj.items()} + return { + _coerce_json_safe(key): _coerce_json_safe(value) for key, value in obj.items() + } if isinstance(obj, list): return [_coerce_json_safe(item) for item in obj] if isinstance(obj, tuple): return tuple(_coerce_json_safe(item) for item in obj) - return repr(obj) + return _safe_repr(obj) + + +def _safe_repr(obj: _typing.Any) -> str: + """ + Returns a repr without propagating repr errors. + """ + + try: + return repr(obj) + except Exception: + return obj.__class__.__name__ def _remove_circular(obj: _typing.Any, refs: set[int] | None = None): diff --git a/tests/test_logger.py b/tests/test_logger.py index 2d2e2e9c..58886c23 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -144,6 +144,41 @@ def test_error_should_accept_exception_with_non_json_serializable_args( assert log_output["error"]["type"] == "ValueError" assert log_output["error"]["args"] == [repr(payload)] + def test_error_should_accept_exception_with_repr_raising_arg( + self, capsys: pytest.CaptureFixture[str] + ): + class BadRepr: + def __repr__(self): + raise RuntimeError("boom") + + exception = ValueError(BadRepr()) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["args"] == ["BadRepr"] + + def test_error_should_accept_exception_with_non_json_serializable_dict_key( + self, capsys: pytest.CaptureFixture[str] + ): + payload = {object(): "value"} + exception = ValueError(payload) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["args"] == [{repr(next(iter(payload.keys()))): "value"}] + def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]): logger.log("bar") raw_log_output = capsys.readouterr().out From fe9a2f88d3c8daa37f48b8063ecea260f043233e Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 15:58:18 +0100 Subject: [PATCH 08/11] chore: fix linting --- src/firebase_functions/logger.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 9c60f662..b796aef7 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -148,9 +148,7 @@ def _coerce_json_safe(obj: _typing.Any): if isinstance(obj, str | int | float | bool | type(None)): return obj if isinstance(obj, dict): - return { - _coerce_json_safe(key): _coerce_json_safe(value) for key, value in obj.items() - } + return {_coerce_json_safe(key): _coerce_json_safe(value) for key, value in obj.items()} if isinstance(obj, list): return [_coerce_json_safe(item) for item in obj] if isinstance(obj, tuple): From b2c4a15ab46354365b7a72478192861cc0f26afc Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 16:17:16 +0100 Subject: [PATCH 09/11] fix: avoid duplicate stack traces in logger.exception --- src/firebase_functions/logger.py | 8 ++++--- tests/test_logger.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index b796aef7..8a4d13a1 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -258,7 +258,9 @@ def exception(*args, **kwargs) -> None: entry = _entry_from_args(LogSeverity.ERROR, *args, **kwargs) exc_type, exc_value, exc_traceback = _sys.exc_info() if exc_type is not None and exc_value is not None and exc_traceback is not None: - entry["stack_trace"] = "".join( - _traceback.format_exception(exc_type, exc_value, exc_traceback) - ) + error = entry.get("error") + if not isinstance(error, dict) or "stack_trace" not in error: + entry["stack_trace"] = "".join( + _traceback.format_exception(exc_type, exc_value, exc_traceback) + ) write(entry) diff --git a/tests/test_logger.py b/tests/test_logger.py index 58886c23..5f4fbd4e 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -212,6 +212,44 @@ def test_exception_should_include_stack_trace(self, capsys: pytest.CaptureFixtur assert "stack_trace" in log_output assert "ValueError: boom" in log_output["stack_trace"] + def test_exception_should_not_duplicate_stack_trace_for_exception_error( + self, capsys: pytest.CaptureFixture[str] + ): + try: + raise ValueError("boom") + except ValueError as exception: + logger.exception("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert "stack_trace" not in log_output + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["message"] == "boom" + assert "stack_trace" in log_output["error"] + assert "ValueError: boom" in log_output["error"]["stack_trace"] + + def test_exception_should_not_duplicate_stack_trace_for_exception_type_error( + self, capsys: pytest.CaptureFixture[str] + ): + try: + raise TypeError("boom") + except TypeError: + logger.exception("failed", error=sys.exc_info()[0]) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert "stack_trace" not in log_output + assert log_output["error"]["type"] == "TypeError" + assert log_output["error"]["message"] == "boom" + assert "stack_trace" in log_output["error"] + assert "TypeError: boom" in log_output["error"]["stack_trace"] + def test_remove_circular_references(self, capsys: pytest.CaptureFixture[str]): # Create an object with a circular reference. circ = {"b": "foo"} From e38f7cefef2d1302dae7f2cbff3029f88e1698ba Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 16:26:16 +0100 Subject: [PATCH 10/11] fix: stringify tuple dict keys in exception args --- src/firebase_functions/logger.py | 17 ++++++++++++++++- tests/test_logger.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 8a4d13a1..063833be 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -148,7 +148,9 @@ def _coerce_json_safe(obj: _typing.Any): if isinstance(obj, str | int | float | bool | type(None)): return obj if isinstance(obj, dict): - return {_coerce_json_safe(key): _coerce_json_safe(value) for key, value in obj.items()} + return { + _coerce_json_safe_dict_key(key): _coerce_json_safe(value) for key, value in obj.items() + } if isinstance(obj, list): return [_coerce_json_safe(item) for item in obj] if isinstance(obj, tuple): @@ -156,6 +158,19 @@ def _coerce_json_safe(obj: _typing.Any): return _safe_repr(obj) +def _coerce_json_safe_dict_key(obj: _typing.Any): + """ + Converts dictionary keys into values accepted by JSON object encoding. + """ + + if isinstance(obj, str | int | float | bool | type(None)): + return obj + coerced = _coerce_json_safe(obj) + if isinstance(coerced, str | int | float | bool | type(None)): + return coerced + return _safe_repr(coerced) + + def _safe_repr(obj: _typing.Any) -> str: """ Returns a repr without propagating repr errors. diff --git a/tests/test_logger.py b/tests/test_logger.py index 5f4fbd4e..fb6620dd 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -179,6 +179,22 @@ def test_error_should_accept_exception_with_non_json_serializable_dict_key( assert log_output["error"]["type"] == "ValueError" assert log_output["error"]["args"] == [{repr(next(iter(payload.keys()))): "value"}] + def test_error_should_accept_exception_with_tuple_dict_key( + self, capsys: pytest.CaptureFixture[str] + ): + payload = {(1, "two"): "value"} + exception = ValueError(payload) + + logger.error("failed", error=exception) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"]["type"] == "ValueError" + assert log_output["error"]["args"] == [{"(1, 'two')": "value"}] + def test_log_should_have_message(self, capsys: pytest.CaptureFixture[str]): logger.log("bar") raw_log_output = capsys.readouterr().out From f1ab1854dd589dca3d09ab6e87bbc9bb241303db Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 5 Jun 2026 16:36:59 +0100 Subject: [PATCH 11/11] fix: preserve active traceback for custom error payloads --- src/firebase_functions/logger.py | 5 +++-- tests/test_logger.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py index 063833be..6d93072a 100644 --- a/src/firebase_functions/logger.py +++ b/src/firebase_functions/logger.py @@ -270,11 +270,12 @@ def exception(*args, **kwargs) -> None: """ Logs an error message and includes the active stack trace. """ + raw_error = kwargs.get("error") entry = _entry_from_args(LogSeverity.ERROR, *args, **kwargs) exc_type, exc_value, exc_traceback = _sys.exc_info() if exc_type is not None and exc_value is not None and exc_traceback is not None: - error = entry.get("error") - if not isinstance(error, dict) or "stack_trace" not in error: + uses_active_error_traceback = raw_error is exc_value or raw_error is exc_type + if not uses_active_error_traceback: entry["stack_trace"] = "".join( _traceback.format_exception(exc_type, exc_value, exc_traceback) ) diff --git a/tests/test_logger.py b/tests/test_logger.py index fb6620dd..76941322 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -266,6 +266,23 @@ def test_exception_should_not_duplicate_stack_trace_for_exception_type_error( assert "stack_trace" in log_output["error"] assert "TypeError: boom" in log_output["error"]["stack_trace"] + def test_exception_should_include_active_stack_trace_for_error_dict( + self, capsys: pytest.CaptureFixture[str] + ): + try: + raise ValueError("boom") + except ValueError: + logger.exception("failed", error={"stack_trace": "custom traceback"}) + + raw_log_output = capsys.readouterr().err + log_output = json.loads(raw_log_output) + + assert log_output["severity"] == "ERROR" + assert log_output["message"] == "failed" + assert log_output["error"] == {"stack_trace": "custom traceback"} + assert "stack_trace" in log_output + assert "ValueError: boom" in log_output["stack_trace"] + def test_remove_circular_references(self, capsys: pytest.CaptureFixture[str]): # Create an object with a circular reference. circ = {"b": "foo"}