Skip to content

Commit b3f11e8

Browse files
authored
Allow to override the log level while logging exceptions (#79)
While logging information during an exception `except` block, instead of overwriting the log level with `CRITICAL`, the log level used by the caller will be set instead. This allows the caller to use: ```python try: raise ValueError() except Exception as exc: logger.info(f"I was expecting this error: {exc}") ``` The log message will be logged as `INFO` level, whereas it was logged as `CRITICAL` before. If an `exception` attribute is still passed to the logger, as in `logger.info("something", exception=exc)`, the log level will be `INFO` as expected but the error will also still be correctly reported (in Error Reporting + the bubble in Cloud Logging)
1 parent 5fe2c9a commit b3f11e8

File tree

8 files changed

+154
-40
lines changed

8 files changed

+154
-40
lines changed

README.md

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ except:
5858

5959
if not converted:
6060
logger.critical("This is not supposed to happen", converted=converted)
61+
62+
try:
63+
1 / 0
64+
except ZeroDivisionError as exc:
65+
logger.info("This was known to happen! {exc}")
6166
```
6267

6368
The `structlog_gcp.build_processors()` function constructs structlog processors to:
@@ -70,12 +75,66 @@ For more advanced usage, see [Advanced Configuration](#advanced-configuration)
7075

7176
### Errors
7277

73-
Errors are automatically reported to the [Google Error Reporting service](https://cloud.google.com/error-reporting/).
78+
79+
Errors are automatically reported to the [Google Error Reporting service](https://cloud.google.com/error-reporting/), most of the time.
80+
81+
#### Using `logger.exception`
82+
83+
Using:
84+
85+
```python
86+
try:
87+
1 / 0
88+
except:
89+
logger.exception("oh no")
90+
```
91+
92+
Will give you:
93+
94+
* The current exception can automatically added into the log event
95+
* The log level will be `ERROR`
96+
* The exception will be reported in Error Reporting
97+
98+
##### Using `logger.$LEVEL(..., exception=exc)`
99+
100+
Using:
101+
102+
```python
103+
try:
104+
1 / 0
105+
except Exception as exc
106+
logger.info("oh no", exception=exc)
107+
```
108+
Will give you:
109+
110+
* The specified exception will be part of the log event
111+
* The log level will be `INFO`, or whichever log level you used
112+
* The exception will be reported in Error Reporting
113+
114+
115+
##### Using `logger.$LEVEL(...)`
116+
117+
Not passing any `exception` argument to the logger, as in:
118+
119+
```python
120+
try:
121+
1 / 0
122+
except Exception as exc
123+
logger.warning(f"oh no: {exc}")
124+
```
125+
126+
Will give you:
127+
128+
* The exception will **not** be part of the log event.
129+
* The log level will be `WARNING` (or whichever log level you used)
130+
* AND the exception will **not** be reported in Error Reporting
131+
132+
### Configuration
74133

75134
You can configure the service name and the version used during the report with 2 different ways:
76135

77-
* By default, the library assumes to run with Cloud Function environment
78-
variables configured, in particular [the `K_SERVICE` and `K_REVISION` variables](https://cloud.google.com/functions/docs/configuring/env-var#runtime_environment_variables_set_automatically).
136+
* By default, the library assumes to run with Cloud Run environment
137+
variables configured, in particular [the `K_SERVICE` and `K_REVISION` variables](https://cloud.google.com/run/docs/configuring/services/overview-environment-variables#reserved_environment_variables_for_functions).
79138
* You can also pass the service name and revision at configuration time with:
80139

81140
```python

structlog_gcp/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def build_gcp_processors(
5454
procs.append(structlog.processors.TimeStamper(fmt="iso"))
5555
procs.append(processors.init_cloud_logging)
5656

57-
procs.extend(processors.setup_log_severity())
57+
procs.append(processors.LogSeverity())
5858
procs.extend(processors.setup_code_location())
5959

6060
# Errors: log exceptions

structlog_gcp/constants.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
ERROR_EVENT_TYPE = (
2+
"type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent"
3+
)
4+
5+
SOURCE_LOCATION_KEY = "logging.googleapis.com/sourceLocation"
6+
7+
CLOUD_LOGGING_KEY = "cloud-logging"
8+
9+
# From Python's logging level to Google level
10+
# https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
11+
SEVERITY_MAPPING = {
12+
"notset": "DEFAULT", # The log entry has no assigned severity level.
13+
"debug": "DEBUG", # Debug or trace information.
14+
"info": "INFO", # Routine information, such as ongoing status or performance.
15+
# "notice": "NOTICE", # Normal but significant events, such as start up, shut down, or a configuration change.
16+
"warn": "WARNING", # Warning events might cause problems.
17+
"warning": "WARNING", # Warning events might cause problems.
18+
"error": "ERROR", # Error events are likely to cause problems.
19+
"critical": "CRITICAL", # Critical events cause more severe problems or outages.
20+
# "alert": "ALERT", # A person must take an action immediately.
21+
# "emergency": "EMERGENCY", # One or more systems are unusable.
22+
}

structlog_gcp/error_reporting.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
import structlog.processors
44
from structlog.typing import EventDict, Processor, WrappedLogger
55

6-
from .types import CLOUD_LOGGING_KEY, ERROR_EVENT_TYPE, SOURCE_LOCATION_KEY
6+
from .constants import CLOUD_LOGGING_KEY, ERROR_EVENT_TYPE, SOURCE_LOCATION_KEY
77

88

9-
def setup_exceptions(log_level: str = "CRITICAL") -> list[Processor]:
10-
return [structlog.processors.format_exc_info, ReportException(log_level)]
9+
def setup_exceptions() -> list[Processor]:
10+
return [structlog.processors.format_exc_info, ReportException()]
1111

1212

1313
class ReportException:
@@ -16,8 +16,8 @@ class ReportException:
1616
# https://cloud.google.com/error-reporting/reference/rest/v1beta1/projects.events/report
1717
# https://cloud.google.com/error-reporting/docs/formatting-error-messages#log-entry-examples
1818

19-
def __init__(self, log_level: str = "CRITICAL") -> None:
20-
self.log_level = log_level
19+
def __init__(self) -> None:
20+
pass
2121

2222
def __call__(
2323
self, logger: WrappedLogger, method_name: str, event_dict: EventDict
@@ -27,7 +27,6 @@ def __call__(
2727
return event_dict
2828

2929
event_dict[CLOUD_LOGGING_KEY]["@type"] = ERROR_EVENT_TYPE
30-
event_dict[CLOUD_LOGGING_KEY]["severity"] = self.log_level
3130

3231
# https://cloud.google.com/error-reporting/docs/formatting-error-messages
3332
message = event_dict[CLOUD_LOGGING_KEY]["message"]
@@ -53,8 +52,9 @@ def __call__(
5352
self, logger: WrappedLogger, method_name: str, event_dict: EventDict
5453
) -> EventDict:
5554
severity = event_dict[CLOUD_LOGGING_KEY]["severity"]
55+
has_strack_trace = "stack_trace" in event_dict[CLOUD_LOGGING_KEY]
5656

57-
if severity not in self.severities:
57+
if severity not in self.severities and not has_strack_trace:
5858
return event_dict
5959

6060
# https://cloud.google.com/error-reporting/reference/rest/v1beta1/ErrorContext

structlog_gcp/processors.py

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@
66
import structlog.processors
77
from structlog.typing import EventDict, Processor, WrappedLogger
88

9-
from .types import CLOUD_LOGGING_KEY, SOURCE_LOCATION_KEY
10-
11-
12-
def setup_log_severity() -> list[Processor]:
13-
return [structlog.processors.add_log_level, LogSeverity()]
9+
from .constants import CLOUD_LOGGING_KEY, SEVERITY_MAPPING, SOURCE_LOCATION_KEY
1410

1511

1612
def setup_code_location() -> list[Processor]:
@@ -77,27 +73,14 @@ class LogSeverity:
7773

7874
def __init__(self) -> None:
7975
self.default = "notset"
80-
81-
# From Python's logging level to Google level
82-
self.mapping = {
83-
"notset": "DEFAULT", # The log entry has no assigned severity level.
84-
"debug": "DEBUG", # Debug or trace information.
85-
"info": "INFO", # Routine information, such as ongoing status or performance.
86-
# "notice": "NOTICE", # Normal but significant events, such as start up, shut down, or a configuration change.
87-
"warn": "WARNING", # Warning events might cause problems.
88-
"warning": "WARNING", # Warning events might cause problems.
89-
"error": "ERROR", # Error events are likely to cause problems.
90-
"critical": "CRITICAL", # Critical events cause more severe problems or outages.
91-
# "alert": "ALERT", # A person must take an action immediately.
92-
# "emergency": "EMERGENCY", # One or more systems are unusable.
93-
}
76+
self.mapping = SEVERITY_MAPPING.copy()
9477

9578
def __call__(
9679
self, logger: WrappedLogger, method_name: str, event_dict: EventDict
9780
) -> EventDict:
9881
"""Format a Python log level value as a GCP log severity."""
9982

100-
log_level = event_dict.pop("level")
83+
log_level = method_name
10184
severity = self.mapping.get(log_level, self.default)
10285

10386
event_dict[CLOUD_LOGGING_KEY]["severity"] = severity

structlog_gcp/types.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ def logger(mock_logger_env: None) -> Generator[WrappedLogger, None, None]:
3131
"""Setup a logger for testing and return it"""
3232

3333
structlog.reset_defaults()
34+
structlog.contextvars.clear_contextvars()
3435

3536
processors = structlog_gcp.build_processors()
3637
structlog.configure(processors=processors)
3738
logger = structlog.get_logger()
39+
3840
yield logger
3941

4042
structlog.reset_defaults()

tests/test_log.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_normal(stdout: T_stdout, logger: WrappedLogger) -> None:
2929
assert msg == expected
3030

3131

32-
def test_error(stdout: T_stdout, logger: WrappedLogger) -> None:
32+
def test_exception(stdout: T_stdout, logger: WrappedLogger) -> None:
3333
try:
3434
1 / 0
3535
except ZeroDivisionError:
@@ -56,7 +56,7 @@ def test_error(stdout: T_stdout, logger: WrappedLogger) -> None:
5656
"version": "unknown version",
5757
},
5858
"foo": "bar",
59-
"severity": "CRITICAL",
59+
"severity": "ERROR",
6060
"message": "oh noes",
6161
"stack_trace": "oh noes\nTraceback blabla",
6262
"time": "2023-04-01T08:00:00.000000Z",
@@ -190,4 +190,59 @@ def test_core_processors_only(stdout: T_stdout, mock_logger_env: None) -> None:
190190
# No JSON formmating, no contextvars
191191
expected = "message='test' time='2023-04-01T08:00:00.000000Z' severity='INFO' logging.googleapis.com/sourceLocation={'file': '/app/test.py', 'line': '42', 'function': 'test:test123'}"
192192

193+
assert expected == msg
194+
195+
196+
def test_exception_different_level(stdout: T_stdout, logger: WrappedLogger) -> None:
197+
try:
198+
1 / 0
199+
except ZeroDivisionError as exc:
200+
logger.warning("oh no; anyways", exception=exc)
201+
202+
msg = json.loads(stdout())
203+
204+
expected = {
205+
"@type": "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent",
206+
"context": {
207+
"reportLocation": {
208+
"file": "/app/test.py",
209+
"function": "test:test123",
210+
"line": "42",
211+
},
212+
},
213+
"logging.googleapis.com/sourceLocation": {
214+
"file": "/app/test.py",
215+
"function": "test:test123",
216+
"line": "42",
217+
},
218+
"serviceContext": {
219+
"service": "unknown service",
220+
"version": "unknown version",
221+
},
222+
"severity": "WARNING",
223+
"message": "oh no; anyways",
224+
"stack_trace": "oh no; anyways\ndivision by zero",
225+
"time": "2023-04-01T08:00:00.000000Z",
226+
}
227+
assert msg == expected
228+
229+
230+
def test_exception_handled(stdout: T_stdout, logger: WrappedLogger) -> None:
231+
try:
232+
1 / 0
233+
except ZeroDivisionError as exc:
234+
logger.info(f"I was expecting that error: {exc}")
235+
236+
msg = json.loads(stdout())
237+
238+
expected = {
239+
"logging.googleapis.com/sourceLocation": {
240+
"file": "/app/test.py",
241+
"function": "test:test123",
242+
"line": "42",
243+
},
244+
"severity": "INFO",
245+
"message": "I was expecting that error: division by zero",
246+
"time": "2023-04-01T08:00:00.000000Z",
247+
}
193248
assert msg == expected

0 commit comments

Comments
 (0)