Skip to content

Commit 6bf76b5

Browse files
authored
Improves observability (#254)
* Make logger setup compatible with OTEL logs * Remove direct structlog usage * Replace base otel collector with grafana alloy * Add note to README.md
1 parent ec9b7ff commit 6bf76b5

File tree

10 files changed

+83
-53
lines changed

10 files changed

+83
-53
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ This template provides out of the box some commonly used functionalities:
2727
* Database migrations using [Alembic](https://alembic.sqlalchemy.org/en/latest/) (configured supporting both sync and async SQLAlchemy engines)
2828
* Database fixtures support using customized [Alembic](https://alembic.sqlalchemy.org/en/latest/) configuration
2929
* Authentication and Identity Provider using [ORY Zero Trust architecture](https://www.ory.sh/docs/kratos/guides/zero-trust-iap-proxy-identity-access-proxy)
30+
* Full observability setup using [OpenTelemetry](https://opentelemetry.io/) (Metrics, Traces and Logs)
3031
* Example CI/CD deployment pipeline for GitLab (The focus for this repository is still GitHub but, in case you want to use GitLab 🤷)
3132
* [TODO] Producer and consumer to emit and consume events using [CloudEvents](https://cloudevents.io/) format using HTTP, to be used with [Knative Eventing](https://knative.dev/docs/eventing/)
3233

config.alloy

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
otelcol.exporter.otlp "jaeger" {
2+
client {
3+
endpoint = "jaeger:4317"
4+
5+
tls {
6+
insecure = true
7+
}
8+
}
9+
}
10+
11+
otelcol.exporter.debug "console" {
12+
verbosity = "Detailed"
13+
}
14+
15+
otelcol.processor.batch "default" {
16+
output {
17+
traces = [otelcol.exporter.otlp.jaeger.input]
18+
}
19+
}
20+
21+
otelcol.receiver.otlp "default" {
22+
grpc { }
23+
24+
output {
25+
metrics = [otelcol.exporter.debug.console.input]
26+
logs = [otelcol.exporter.debug.console.input]
27+
traces = [otelcol.processor.batch.default.input]
28+
}
29+
}

docker-compose.yaml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ services:
5959
- "14268:14268" # HTTP port for spans
6060

6161
otel-collector:
62-
image: otel/opentelemetry-collector-contrib
62+
image: grafana/alloy:latest
6363
depends_on:
6464
- jaeger
65+
ports:
66+
- "12345:12345"
6567
volumes:
66-
- ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
68+
- ./config.alloy:/etc/alloy/config.alloy
69+
command:
70+
- run
71+
- --server.http.listen-addr=0.0.0.0:12345
72+
- --stability.level=experimental
73+
# - --stability.level=public-preview
74+
- /etc/alloy/config.alloy
6775

6876
redis:
6977
image: redis

local.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ ENVIRONMENT: "local"
22
AUTH__JWKS_URL: "http://oathkeeper:4456/.well-known/jwks.json"
33
#DRAMATIQ__REDIS_URL: "redis://redis:6379/0"
44
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"
5+
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true"

otel-collector-config.yaml

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

src/common/logs/__init__.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import List
33

44
import structlog
5+
from opentelemetry.sdk._logs import LoggingHandler
56
from structlog.typing import Processor
67

78
from ..config import AppConfig
@@ -14,11 +15,22 @@
1415

1516
def init_logger(config: AppConfig) -> None:
1617
"""
17-
Configure structlog and stdlib logging with shared handler and formatter.
18+
Function to initialize logging configuration using `structlog` and Python's standard
19+
logging module. It supports dynamic log level adjustment, shared processors for
20+
structlog and standard loggers, and tailored configurations for different environments
21+
(local, test, or production). Ensures consistent formatting across application logs
22+
and integrates handlers for OpenTelemetry logs if present.
23+
24+
Args:
25+
config (AppConfig): Configuration object containing application-wide settings
26+
such as DEBUG flag and environment status.
27+
28+
Raises:
29+
None
30+
31+
Returns:
32+
None
1833
19-
:param config: The app configuration
20-
:type config: AppConfig
21-
:return:
2234
"""
2335
# Strongly inspired by https://gist.github.com/nymous/f138c7f06062b7c43c060bf03759c29e
2436

@@ -76,9 +88,17 @@ def init_logger(config: AppConfig) -> None:
7688
)
7789
)
7890

79-
# Use structlog to format logs coming from stdlib logger
91+
# Get root logger
8092
stdlib_logger = logging.getLogger()
81-
stdlib_logger.handlers.clear()
93+
94+
# In case there's a OTEL handler we keep it but remove all the others,
95+
# in case this function is called multiple times.
96+
# NOTE all the processors are not applied to OTEL logs!
97+
for handler in stdlib_logger.handlers:
98+
if not isinstance(handler, LoggingHandler):
99+
stdlib_logger.removeHandler(handler)
100+
101+
# Use structlog to format logs coming from stdlib logger
82102
stdlib_logger.addHandler(stdlib_handler)
83103
stdlib_logger.setLevel(log_level)
84104

src/domains/books/_service.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import logging
12
from collections.abc import Iterable
23

34
from anyio import to_thread
45
from dependency_injector.wiring import Provide, inject
5-
from structlog import get_logger
66

77
from common.tracing import trace_function
88
from common.utils import apply_decorator_to_methods
@@ -58,8 +58,12 @@ async def book_created_event_handler(
5858
book_id: int,
5959
) -> None: # pragma: no cover
6060
# This is just an example placeholder, there's nothing to test.
61-
logger = get_logger()
62-
await logger.ainfo(f"Processed book crated event for id `{book_id}`")
61+
logging.info(
62+
"Processed book crated event`",
63+
extra={
64+
"book_id": book_id,
65+
},
66+
)
6367

6468
def _some_cpu_intensive_blocking_task(self, book: dict) -> dict:
6569
# This is just an example placeholder,

src/gateways/event.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1+
import logging
2+
13
from cloudevents_pydantic.events import CloudEvent
2-
from structlog import get_logger
34

45

56
class NullEventGateway:
67
async def emit(self, event: CloudEvent) -> None: # pragma: no cover # No need to test this
7-
logger = get_logger()
8-
await logger.ainfo(
9-
"Event emitted",
10-
cloudevent=event.model_dump(),
11-
)
8+
logging.info("Event emitted", extra={"cloudevent": event.model_dump()})

src/http_app/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import logging
12
from typing import Union
23

34
from fastapi import FastAPI, Request
45
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
56
from starlette.responses import JSONResponse
67
from starlette_prometheus import PrometheusMiddleware, metrics
7-
from structlog import get_logger
88

99
from common import AppConfig, application_init
1010
from http_app import context
@@ -62,6 +62,5 @@ async def add_exception_middleware(request: Request, call_next):
6262
try:
6363
return await call_next(request)
6464
except Exception as e:
65-
logger = get_logger(__name__)
66-
await logger.aexception(e)
65+
logging.exception(e)
6766
return JSONResponse({"error": "Internal server error"}, status_code=500)
Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from unittest.mock import AsyncMock, MagicMock, patch
1+
from unittest.mock import MagicMock, patch
22

33
from fastapi import FastAPI
44
from fastapi.testclient import TestClient
55

66

7-
@patch("http_app.get_logger")
7+
@patch("logging.exception")
88
async def test_exception_is_logged_handler_returns_500(
9-
mocked_get_logger: MagicMock,
9+
mocked_logging_exception: MagicMock,
1010
testapp: FastAPI,
1111
):
1212
my_exc = Exception("Some random exception")
@@ -15,12 +15,9 @@ async def test_exception_is_logged_handler_returns_500(
1515
async def fake_endpoint():
1616
raise my_exc
1717

18-
mocked_get_logger.return_value.aexception = AsyncMock(return_value=None)
19-
2018
ac = TestClient(app=testapp, base_url="http://test")
2119
response = ac.get("/ppp")
2220

2321
assert response.status_code == 500
2422
assert response.json() == {"error": "Internal server error"}
25-
mocked_get_logger.assert_called_once()
26-
mocked_get_logger.return_value.aexception.assert_called_once_with(my_exc)
23+
mocked_logging_exception.assert_called_once_with(my_exc)

0 commit comments

Comments
 (0)