Skip to content

Commit 1149e07

Browse files
authored
Merge pull request #8 from modern-python/3-feature-add-logging
add logging instrument
2 parents 8283ab7 + ae8b876 commit 1149e07

15 files changed

+273
-22
lines changed

lite_bootstrap/bootstraps/base.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ class BaseBootstrap(abc.ABC, typing.Generic[ApplicationT]):
1313

1414
def bootstrap(self) -> None:
1515
for one_instrument in self.instruments:
16-
if one_instrument.is_ready():
16+
if one_instrument.is_ready(self.service_config):
1717
one_instrument.bootstrap(self.service_config, self.application)
1818

1919
def teardown(self) -> None:
2020
for one_instrument in self.instruments:
21-
if one_instrument.is_ready():
21+
if one_instrument.is_ready(self.service_config):
2222
one_instrument.teardown(self.application)

lite_bootstrap/bootstraps/fastapi_bootstrap/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
from lite_bootstrap.bootstraps.base import BaseBootstrap
77
from lite_bootstrap.bootstraps.fastapi_bootstrap.healthchecks_instrument import FastAPIHealthChecksInstrument
8+
from lite_bootstrap.bootstraps.fastapi_bootstrap.logging_instrument import FastAPILoggingInstrument
89
from lite_bootstrap.bootstraps.fastapi_bootstrap.opentelemetry_instrument import FastAPIOpenTelemetryInstrument
910
from lite_bootstrap.bootstraps.fastapi_bootstrap.sentry_instrument import FastAPISentryInstrument
1011

1112

1213
__all__ = [
1314
"FastAPIBootstrap",
1415
"FastAPIHealthChecksInstrument",
16+
"FastAPILoggingInstrument",
1517
"FastAPIOpenTelemetryInstrument",
1618
"FastAPISentryInstrument",
1719
]
@@ -23,6 +25,9 @@
2325
class FastAPIBootstrap(BaseBootstrap[fastapi.FastAPI]):
2426
application: fastapi.FastAPI
2527
instruments: typing.Sequence[
26-
FastAPIOpenTelemetryInstrument | FastAPISentryInstrument | FastAPIHealthChecksInstrument
28+
FastAPIOpenTelemetryInstrument
29+
| FastAPISentryInstrument
30+
| FastAPIHealthChecksInstrument
31+
| FastAPILoggingInstrument
2732
]
2833
service_config: ServiceConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import dataclasses
2+
3+
from lite_bootstrap.instruments.logging_instrument import LoggingInstrument
4+
5+
6+
@dataclasses.dataclass(kw_only=True, frozen=True)
7+
class FastAPILoggingInstrument(LoggingInstrument): ...

lite_bootstrap/bootstraps/fastapi_bootstrap/opentelemetry_instrument.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import dataclasses
33

44
import fastapi
5+
from opentelemetry.trace import get_tracer_provider
56

67
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument
78
from lite_bootstrap.service_config import ServiceConfig
@@ -11,15 +12,15 @@
1112
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
1213

1314

14-
@dataclasses.dataclass(kw_only=True)
15+
@dataclasses.dataclass(kw_only=True, frozen=True)
1516
class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument):
1617
excluded_urls: list[str] = dataclasses.field(default_factory=list)
1718

1819
def bootstrap(self, service_config: ServiceConfig, application: fastapi.FastAPI | None = None) -> None:
1920
super().bootstrap(service_config, application)
2021
FastAPIInstrumentor.instrument_app(
2122
app=application,
22-
tracer_provider=self.tracer_provider,
23+
tracer_provider=get_tracer_provider(),
2324
excluded_urls=",".join(self.excluded_urls),
2425
)
2526

lite_bootstrap/instruments/base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ def bootstrap(self, service_config: ServiceConfig, application: ApplicationT | N
1010
def teardown(self, application: ApplicationT | None = None) -> None: ... # noqa: B027
1111

1212
@abc.abstractmethod
13-
def is_ready(self) -> bool: ...
13+
def is_ready(self, service_config: ServiceConfig) -> bool: ...

lite_bootstrap/instruments/healthchecks_instrument.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class HealthChecksInstrument(BaseInstrument):
1818
path: str = "/health/"
1919
include_in_schema: bool = False
2020

21-
def is_ready(self) -> bool:
21+
def is_ready(self, _: ServiceConfig) -> bool:
2222
return self.enabled
2323

2424
@staticmethod
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import contextlib
2+
import dataclasses
3+
import logging
4+
import logging.handlers
5+
import typing
6+
7+
from lite_bootstrap.instruments.base import BaseInstrument
8+
from lite_bootstrap.service_config import ServiceConfig
9+
from lite_bootstrap.types import ApplicationT
10+
11+
12+
if typing.TYPE_CHECKING:
13+
from structlog.typing import EventDict, WrappedLogger
14+
15+
16+
with contextlib.suppress(ImportError):
17+
import structlog
18+
19+
20+
ScopeType = typing.MutableMapping[str, typing.Any]
21+
22+
23+
class AddressProtocol(typing.Protocol):
24+
host: str
25+
port: int
26+
27+
28+
class RequestProtocol(typing.Protocol):
29+
client: AddressProtocol
30+
scope: ScopeType
31+
method: str
32+
33+
34+
def tracer_injection(_: "WrappedLogger", __: str, event_dict: "EventDict") -> "EventDict":
35+
try:
36+
from opentelemetry import trace
37+
except ImportError: # pragma: no cover
38+
return event_dict
39+
40+
event_dict["tracing"] = {}
41+
current_span = trace.get_current_span()
42+
if current_span == trace.INVALID_SPAN:
43+
return event_dict
44+
45+
span_context = current_span.get_span_context()
46+
if span_context == trace.INVALID_SPAN_CONTEXT: # pragma: no cover
47+
return event_dict
48+
49+
event_dict["tracing"]["trace_id"] = format(span_context.span_id, "016x")
50+
event_dict["tracing"]["span_id"] = format(span_context.trace_id, "032x")
51+
52+
return event_dict
53+
54+
55+
DEFAULT_STRUCTLOG_PROCESSORS: typing.Final[list[typing.Any]] = [
56+
structlog.stdlib.filter_by_level,
57+
structlog.stdlib.add_log_level,
58+
structlog.stdlib.add_logger_name,
59+
tracer_injection,
60+
structlog.stdlib.PositionalArgumentsFormatter(),
61+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
62+
structlog.processors.StackInfoRenderer(),
63+
structlog.processors.format_exc_info,
64+
structlog.processors.UnicodeDecoder(),
65+
]
66+
DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR: typing.Final = structlog.processors.JSONRenderer()
67+
68+
69+
class MemoryLoggerFactory(structlog.stdlib.LoggerFactory):
70+
def __init__(
71+
self,
72+
*args: typing.Any, # noqa: ANN401
73+
logging_buffer_capacity: int,
74+
logging_flush_level: int,
75+
logging_log_level: int,
76+
log_stream: typing.Any = None, # noqa: ANN401
77+
**kwargs: typing.Any, # noqa: ANN401
78+
) -> None:
79+
super().__init__(*args, **kwargs)
80+
self.logging_buffer_capacity = logging_buffer_capacity
81+
self.logging_flush_level = logging_flush_level
82+
self.logging_log_level = logging_log_level
83+
self.log_stream = log_stream
84+
85+
def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401
86+
logger: typing.Final = super().__call__(*args)
87+
stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream)
88+
handler: typing.Final = logging.handlers.MemoryHandler(
89+
capacity=self.logging_buffer_capacity,
90+
flushLevel=self.logging_flush_level,
91+
target=stream_handler,
92+
)
93+
logger.addHandler(handler)
94+
logger.setLevel(self.logging_log_level)
95+
logger.propagate = False
96+
return logger
97+
98+
99+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
100+
class LoggingInstrument(BaseInstrument):
101+
logging_log_level: int = logging.INFO
102+
logging_flush_level: int = logging.ERROR
103+
logging_buffer_capacity: int = 10
104+
logging_extra_processors: list[typing.Any] = dataclasses.field(default_factory=list)
105+
logging_unset_handlers: list[str] = dataclasses.field(
106+
default_factory=list,
107+
)
108+
109+
def is_ready(self, service_config: ServiceConfig) -> bool:
110+
return not service_config.service_debug
111+
112+
def bootstrap(self, _: ServiceConfig, __: ApplicationT | None = None) -> None:
113+
for unset_handlers_logger in self.logging_unset_handlers:
114+
logging.getLogger(unset_handlers_logger).handlers = []
115+
116+
structlog.configure(
117+
processors=[
118+
*DEFAULT_STRUCTLOG_PROCESSORS,
119+
*self.logging_extra_processors,
120+
DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR,
121+
],
122+
context_class=dict,
123+
logger_factory=MemoryLoggerFactory(
124+
logging_buffer_capacity=self.logging_buffer_capacity,
125+
logging_flush_level=self.logging_flush_level,
126+
logging_log_level=self.logging_log_level,
127+
),
128+
wrapper_class=structlog.stdlib.BoundLogger,
129+
cache_logger_on_first_use=True,
130+
)
131+
132+
def teardown(self, _: ApplicationT | None = None) -> None:
133+
structlog.reset_defaults()

lite_bootstrap/instruments/opentelemetry_instrument.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import dataclasses
33
import typing
44

5+
from opentelemetry.trace import set_tracer_provider
6+
57
from lite_bootstrap.instruments.base import BaseInstrument
68
from lite_bootstrap.service_config import ServiceConfig
79
from lite_bootstrap.types import ApplicationT
@@ -21,7 +23,7 @@ class InstrumentorWithParams:
2123
additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
2224

2325

24-
@dataclasses.dataclass(kw_only=True, slots=True)
26+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
2527
class OpenTelemetryInstrument(BaseInstrument):
2628
container_name: str | None = None
2729
endpoint: str | None = None
@@ -30,9 +32,7 @@ class OpenTelemetryInstrument(BaseInstrument):
3032
instrumentors: list[InstrumentorWithParams | BaseInstrumentor] = dataclasses.field(default_factory=list)
3133
span_exporter: SpanExporter | None = None
3234

33-
tracer_provider: TracerProvider = dataclasses.field(init=False)
34-
35-
def is_ready(self) -> bool:
35+
def is_ready(self, _: ServiceConfig) -> bool:
3636
return bool(self.endpoint)
3737

3838
def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:
@@ -46,8 +46,8 @@ def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None
4646
resource: typing.Final = resources.Resource.create(
4747
attributes={k: v for k, v in attributes.items() if v},
4848
)
49-
self.tracer_provider = TracerProvider(resource=resource)
50-
self.tracer_provider.add_span_processor(
49+
tracer_provider = TracerProvider(resource=resource)
50+
tracer_provider.add_span_processor(
5151
BatchSpanProcessor(
5252
self.span_exporter
5353
or OTLPSpanExporter(
@@ -59,11 +59,12 @@ def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None
5959
for one_instrumentor in self.instrumentors:
6060
if isinstance(one_instrumentor, InstrumentorWithParams):
6161
one_instrumentor.instrumentor.instrument(
62-
tracer_provider=self.tracer_provider,
62+
tracer_provider=tracer_provider,
6363
**one_instrumentor.additional_params,
6464
)
6565
else:
66-
one_instrumentor.instrument(tracer_provider=self.tracer_provider)
66+
one_instrumentor.instrument(tracer_provider=tracer_provider)
67+
set_tracer_provider(tracer_provider)
6768

6869
def teardown(self, _: ApplicationT | None = None) -> None:
6970
for one_instrumentor in self.instrumentors:

lite_bootstrap/instruments/sentry_instrument.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class SentryInstrument(BaseInstrument):
2424
additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
2525
tags: dict[str, str] | None = None
2626

27-
def is_ready(self) -> bool:
27+
def is_ready(self, _: ServiceConfig) -> bool:
2828
return bool(self.dsn)
2929

3030
def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:

lite_bootstrap/service_config.py

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ class ServiceConfig:
66
service_name: str = "micro-service"
77
service_version: str = "1.0.0"
88
service_environment: str | None = None
9+
service_debug: bool = True

pyproject.toml

+6
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,18 @@ otl = [
4343
"opentelemetry-exporter-otlp",
4444
"opentelemetry-instrumentation",
4545
]
46+
logging = [
47+
"structlog",
48+
]
4649
fastapi = [
4750
"fastapi",
4851
]
4952
fastapi-otl = [
5053
"opentelemetry-instrumentation-fastapi",
5154
]
55+
fastapi-all = [
56+
"lite-bootstrap[sentry,otl,logging,fastapi,fastapi-otl]"
57+
]
5258

5359
[dependency-groups]
5460
dev = [

tests/conftest.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing
2+
from unittest.mock import Mock
23

34
import pytest
45
from fastapi import FastAPI
@@ -26,4 +27,10 @@ def service_config() -> ServiceConfig:
2627
service_name="microservice",
2728
service_version="2.0.0",
2829
service_environment="test",
30+
service_debug=False,
2931
)
32+
33+
34+
@pytest.fixture(autouse=True)
35+
def mock_sentry_init(monkeypatch: pytest.MonkeyPatch) -> None:
36+
monkeypatch.setattr("sentry_sdk.init", Mock)

0 commit comments

Comments
 (0)