Skip to content

Commit 591a8f7

Browse files
committed
Logs configuration improvements
Signed-off-by: Federico Busetti <[email protected]>
1 parent c56a858 commit 591a8f7

File tree

5 files changed

+136
-90
lines changed

5 files changed

+136
-90
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ source = ["src"]
8080
omit = [
8181
"src/alembic/*",
8282
"src/bootstrap/config.py",
83+
"src/bootstrap/logs/*",
8384
]
8485
# It's not necessary to configure concurrency here
8586
# because pytest-cov takes care of that

src/bootstrap/bootstrap.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from domains import init_celery, init_domains
55
from pydantic import BaseModel, ConfigDict
66

7-
from .config import AppConfig, init_logger
7+
from .config import AppConfig
88
from .di_container import Container
9+
from .logs import init_logger
910
from .storage import init_storage
1011

1112

src/bootstrap/config.py

Lines changed: 1 addition & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
import logging
21
from pathlib import Path
3-
from typing import Dict, List, Literal, Optional
2+
from typing import Dict, Literal, Optional
43

5-
import structlog
6-
from opentelemetry import trace
74
from pydantic import BaseModel
85
from pydantic_settings import BaseSettings, SettingsConfigDict
96
from sqlalchemy_bind_manager import SQLAlchemyAsyncConfig
10-
from structlog.typing import Processor
117

128
TYPE_ENVIRONMENT = Literal["local", "test", "staging", "production"]
139

@@ -65,87 +61,3 @@ class AppConfig(BaseSettings):
6561
),
6662
),
6763
)
68-
69-
70-
def init_logger(config: AppConfig) -> None:
71-
"""
72-
Configure structlog and stdlib logging with shared handler and formatter.
73-
74-
:param config: The app configuration
75-
:type config: AppConfig
76-
:return:
77-
"""
78-
# These processors will be used by both structlog and stdlib logger
79-
processors: List[Processor] = [
80-
structlog.contextvars.merge_contextvars,
81-
structlog.stdlib.add_logger_name,
82-
structlog.stdlib.PositionalArgumentsFormatter(),
83-
structlog.processors.add_log_level,
84-
structlog.processors.StackInfoRenderer(),
85-
_add_logging_open_telemetry_spans,
86-
]
87-
88-
log_level = logging.DEBUG if config.DEBUG else logging.INFO
89-
if config.ENVIRONMENT not in ["local", "test"]:
90-
processors.append(structlog.stdlib.ProcessorFormatter.remove_processors_meta)
91-
processors.append(structlog.processors.TimeStamper(fmt="iso", utc=True))
92-
processors.append(structlog.processors.dict_tracebacks)
93-
processors.append(structlog.processors.JSONRenderer())
94-
else:
95-
processors.append(structlog.stdlib.ProcessorFormatter.remove_processors_meta)
96-
processors.append(
97-
structlog.processors.TimeStamper(fmt="%d-%m-%Y %H:%M:%S", utc=True)
98-
)
99-
processors.append(structlog.dev.ConsoleRenderer())
100-
101-
structlog.stdlib.recreate_defaults()
102-
"""
103-
Even if we set the loglevel using the stdlib setLevel later,
104-
using make_filtering_bound_logger will filter events before
105-
in the chain, producing a performance improvement
106-
"""
107-
structlog.configure(
108-
wrapper_class=structlog.make_filtering_bound_logger(log_level),
109-
processors=[
110-
# This prepares the log events to be handled by stdlib
111-
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
112-
],
113-
logger_factory=structlog.stdlib.LoggerFactory(),
114-
cache_logger_on_first_use=True,
115-
)
116-
stdlib_handler = logging.StreamHandler()
117-
# Use structlog `ProcessorFormatter` to format all `logging` entries
118-
stdlib_handler.setFormatter(
119-
structlog.stdlib.ProcessorFormatter(
120-
processors=processors,
121-
)
122-
)
123-
stdlib_logger = logging.getLogger()
124-
stdlib_logger.handlers.clear()
125-
stdlib_logger.addHandler(stdlib_handler)
126-
stdlib_logger.setLevel(log_level)
127-
128-
for _log in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
129-
# Clear the log handlers for uvicorn loggers, and enable propagation
130-
# so the messages are caught by our root logger and formatted correctly
131-
# by structlog. Initial messages from reloader startup are not caught.
132-
logging.getLogger(_log).handlers.clear()
133-
logging.getLogger(_log).propagate = True
134-
135-
136-
def _add_logging_open_telemetry_spans(_, __, event_dict):
137-
span = trace.get_current_span()
138-
if not span.is_recording():
139-
event_dict["span"] = None
140-
return event_dict
141-
142-
ctx = span.get_span_context()
143-
parent = getattr(span, "parent", None)
144-
145-
event_dict["span"] = {
146-
"span_id": hex(ctx.span_id),
147-
"trace_id": hex(ctx.trace_id),
148-
"parent_span_id": None if not parent else hex(parent.span_id),
149-
}
150-
151-
return event_dict

src/bootstrap/logs/__init__.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import logging
2+
from typing import List
3+
4+
import structlog
5+
from opentelemetry import trace
6+
from structlog.typing import EventDict, Processor
7+
8+
from ..config import AppConfig
9+
from .processors import (
10+
add_logging_open_telemetry_spans,
11+
drop_color_message_key,
12+
extract_from_record,
13+
)
14+
15+
16+
def init_logger(config: AppConfig) -> None:
17+
"""
18+
Configure structlog and stdlib logging with shared handler and formatter.
19+
20+
:param config: The app configuration
21+
:type config: AppConfig
22+
:return:
23+
"""
24+
# Strongly inspired by https://gist.github.com/nymous/f138c7f06062b7c43c060bf03759c29e
25+
26+
# These processors will be used by both structlog and stdlib logger
27+
shared_processors: List[Processor] = [
28+
structlog.contextvars.merge_contextvars,
29+
structlog.stdlib.add_logger_name,
30+
structlog.stdlib.add_log_level,
31+
structlog.stdlib.PositionalArgumentsFormatter(),
32+
structlog.stdlib.ExtraAdder(),
33+
drop_color_message_key,
34+
add_logging_open_telemetry_spans,
35+
structlog.processors.StackInfoRenderer(),
36+
]
37+
38+
# stdlib_processors are executed before the shared ones, so processors
39+
# accessing processor metadata such as `_extract_from_record` must
40+
# run here, before `remove_processors_meta`
41+
stdlib_processors: List[Processor] = [
42+
extract_from_record,
43+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
44+
]
45+
46+
log_level = logging.DEBUG if config.DEBUG else logging.INFO
47+
if config.ENVIRONMENT in ["local", "test"]:
48+
shared_processors.append(
49+
structlog.processors.TimeStamper(fmt="%d-%m-%Y %H:%M:%S", utc=True)
50+
)
51+
stdlib_processors.append(structlog.dev.ConsoleRenderer())
52+
else:
53+
shared_processors.append(structlog.processors.TimeStamper(fmt="iso", utc=True))
54+
shared_processors.append(structlog.processors.dict_tracebacks)
55+
stdlib_processors.append(structlog.processors.JSONRenderer())
56+
57+
"""
58+
Even if we set the loglevel using the stdlib setLevel later,
59+
using make_filtering_bound_logger will filter events before
60+
in the chain, producing a performance improvement
61+
"""
62+
structlog.configure(
63+
wrapper_class=structlog.make_filtering_bound_logger(log_level),
64+
processors=[
65+
*shared_processors,
66+
# This prepares the log events to be handled by stdlib
67+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
68+
],
69+
logger_factory=structlog.stdlib.LoggerFactory(),
70+
cache_logger_on_first_use=True,
71+
)
72+
73+
# Create a handler for stdlib logger
74+
stdlib_handler = logging.StreamHandler()
75+
stdlib_handler.setFormatter(
76+
structlog.stdlib.ProcessorFormatter(
77+
foreign_pre_chain=shared_processors,
78+
processors=stdlib_processors,
79+
)
80+
)
81+
82+
# Use structlog to format logs coming from stdlib logger
83+
stdlib_logger = logging.getLogger()
84+
# stdlib_logger.handlers.clear()
85+
stdlib_logger.addHandler(stdlib_handler)
86+
stdlib_logger.setLevel(log_level)
87+
88+
for _log in ["uvicorn", "uvicorn.error", "uvicorn.access"]:
89+
# Clear the log handlers for uvicorn loggers, and enable propagation
90+
# so the messages are caught by our root logger and formatted correctly
91+
# by structlog. Initial messages from reloader startup are not caught.
92+
logging.getLogger(_log).handlers.clear()
93+
logging.getLogger(_log).propagate = True

src/bootstrap/logs/processors.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from opentelemetry import trace
2+
from structlog.typing import EventDict
3+
4+
5+
def extract_from_record(_, __, event_dict: EventDict) -> EventDict:
6+
"""
7+
Extract thread and process names and add them to the event dict.
8+
"""
9+
record = event_dict["_record"]
10+
event_dict["thread_name"] = record.threadName
11+
event_dict["process_name"] = record.processName
12+
return event_dict
13+
14+
15+
def drop_color_message_key(_, __, event_dict: EventDict) -> EventDict:
16+
"""
17+
Uvicorn logs the message a second time in the extra `color_message`, but we don't
18+
need it. This processor drops the key from the event dict if it exists.
19+
"""
20+
event_dict.pop("color_message", None)
21+
return event_dict
22+
23+
24+
def add_logging_open_telemetry_spans(_, __, event_dict: EventDict) -> EventDict:
25+
span = trace.get_current_span()
26+
if not span.is_recording():
27+
event_dict["span"] = None
28+
return event_dict
29+
30+
ctx = span.get_span_context()
31+
parent = getattr(span, "parent", None)
32+
33+
event_dict["span"] = {
34+
"span_id": hex(ctx.span_id),
35+
"trace_id": hex(ctx.trace_id),
36+
"parent_span_id": None if not parent else hex(parent.span_id),
37+
}
38+
39+
return event_dict

0 commit comments

Comments
 (0)