Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ Google Logging API is supposed to be done by an external agent.

In particular, this package provides the following configuration by default:

* Logs are formatted as JSON using the [Google Cloud Logging log
format](https://cloud.google.com/logging/docs/structured-logging)
* The [Python standard library's
`logging`](https://docs.python.org/3/library/logging.html) log levels are
available and translated to their GCP equivalents.
* Exceptions and `CRITICAL` log messages will be reported into [Google Error
Reporting dashboard](https://cloud.google.com/error-reporting/)
* Logs are formatted as JSON using the [Google Cloud Logging log format](https://cloud.google.com/logging/docs/structured-logging)
* The [Python standard library's `logging`](https://docs.python.org/3/library/logging.html)
log levels are available and translated to their GCP equivalents.
* Exceptions and `CRITICAL` log messages will be reported into [Google Error Reporting dashboard](https://cloud.google.com/error-reporting/)
* Additional logger bound arguments will be reported as `labels` in GCP.


Expand Down Expand Up @@ -63,6 +60,27 @@ if not converted:
logger.critical("This is not supposed to happen", converted=converted)
```

### Errors

Errors are automatically reported to the [Google Error Reporting service](https://cloud.google.com/error-reporting/).

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

* By default, the library assumes to run with Cloud Function environment
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).
* You can also pass the service name and revision at configuration time with:

```python
import structlog
import structlog_gcp

processors = structlog_gcp.build_processors(
service="my-service",
version="v1.2.3",
)
structlog.configure(processors=processors)
```

## Examples

Check out the [`examples` folder](https://github.com/multani/structlog-gcp/tree/main/examples) to see how it can be used.
Expand Down
7 changes: 5 additions & 2 deletions structlog_gcp/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
from . import errors, processors


def build_processors() -> list[Processor]:
def build_processors(
service: str | None = None,
version: str | None = None,
) -> list[Processor]:
procs = []

procs.extend(processors.CoreCloudLogging().setup())
procs.extend(processors.LogSeverity().setup())
procs.extend(processors.CodeLocation().setup())
procs.extend(errors.ReportException().setup())
procs.extend(errors.ReportError(["CRITICAL"]).setup())
procs.append(errors.add_service_context)
procs.extend(errors.ServiceContext(service, version).setup())
procs.extend(processors.FormatAsCloudLogging().setup())

return procs
42 changes: 25 additions & 17 deletions structlog_gcp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,36 @@
from .types import CLOUD_LOGGING_KEY, ERROR_EVENT_TYPE, SOURCE_LOCATION_KEY


def add_service_context(
logger: WrappedLogger, method_name: str, event_dict: EventDict
) -> EventDict:
"""Add a service context in which an error has occurred.
class ServiceContext:
def __init__(self, service: str | None = None, version: str | None = None) -> None:
# https://cloud.google.com/functions/docs/configuring/env-var#runtime_environment_variables_set_automatically
if service is None:
service = os.environ.get("K_SERVICE", "unknown service")

This is part of the Error Reporting API, so it's only added when an error happens.
"""
if version is None:
version = os.environ.get("K_REVISION", "unknown version")

event_type = event_dict[CLOUD_LOGGING_KEY].get("@type")
if event_type != ERROR_EVENT_TYPE:
return event_dict
self.service_context = {"service": service, "version": version}

service_context = {
# https://cloud.google.com/functions/docs/configuring/env-var#runtime_environment_variables_set_automatically
"service": os.environ.get("K_SERVICE", "unknown service"),
"version": os.environ.get("K_REVISION", "unknown version"),
}
def setup(self) -> list[Processor]:
return [self]

# https://cloud.google.com/error-reporting/reference/rest/v1beta1/ServiceContext
event_dict[CLOUD_LOGGING_KEY]["serviceContext"] = service_context
def __call__(
self, logger: WrappedLogger, method_name: str, event_dict: EventDict
) -> EventDict:
"""Add a service context in which an error has occurred.

This is part of the Error Reporting API, so it's only added when an error happens.
"""

event_type = event_dict[CLOUD_LOGGING_KEY].get("@type")
if event_type != ERROR_EVENT_TYPE:
return event_dict

return event_dict
# https://cloud.google.com/error-reporting/reference/rest/v1beta1/ServiceContext
event_dict[CLOUD_LOGGING_KEY]["serviceContext"] = self.service_context

return event_dict


class ReportException:
Expand Down
20 changes: 13 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@


@pytest.fixture
def logger():
"""Setup a logger for testing and return it"""

def mock_logger_env():
with (
patch(
"structlog.processors.CallsiteParameterAdder",
Expand All @@ -22,9 +20,17 @@ def logger():
"structlog.processors.format_exc_info", side_effect=fakes.format_exc_info
),
):
processors = structlog_gcp.build_processors()
structlog.configure(processors=processors)
logger = structlog.get_logger()
yield logger
yield

@pytest.fixture
def logger(mock_logger_env):
"""Setup a logger for testing and return it"""

structlog.reset_defaults()

processors = structlog_gcp.build_processors()
structlog.configure(processors=processors)
logger = structlog.get_logger()
yield logger

structlog.reset_defaults()
59 changes: 59 additions & 0 deletions tests/test_log.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import json
from unittest.mock import patch

import structlog

import structlog_gcp


def test_normal(capsys, logger):
Expand Down Expand Up @@ -60,3 +65,57 @@ def test_error(capsys, logger):
"time": "2023-04-01T08:00:00.000000Z",
}
assert expected == msg


def test_service_context_default(capsys, logger):
try:
1 / 0
except ZeroDivisionError:
logger.exception("oh noes")

msg = json.loads(capsys.readouterr().out)

assert msg["serviceContext"] == {
"service": "unknown service",
"version": "unknown version",
}


@patch.dict("os.environ", {"K_SERVICE": "test-service", "K_REVISION": "test-version"})
def test_service_context_envvar(capsys, mock_logger_env):
processors = structlog_gcp.build_processors()
structlog.configure(processors=processors)
logger = structlog.get_logger()

try:
1 / 0
except ZeroDivisionError:
logger.exception("oh noes")

msg = json.loads(capsys.readouterr().out)

assert msg["serviceContext"] == {
"service": "test-service",
"version": "test-version",
}


def test_service_context_custom(capsys, mock_logger_env):
processors = structlog_gcp.build_processors(
service="my-service",
version="deadbeef",
)
structlog.configure(processors=processors)
logger = structlog.get_logger()

try:
1 / 0
except ZeroDivisionError:
logger.exception("oh noes")

msg = json.loads(capsys.readouterr().out)

assert msg["serviceContext"] == {
"service": "my-service",
"version": "deadbeef",
}