Skip to content

Commit 8283ab7

Browse files
authored
Merge pull request #7 from modern-python/2-feature-add-healthchecks
add health-checks instrument and refactor bootstrap configuration
2 parents 468ef36 + 50a49c0 commit 8283ab7

18 files changed

+196
-111
lines changed

lite_bootstrap/bootstraps/base.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22
import typing
33

44
from lite_bootstrap.instruments.base import BaseInstrument
5+
from lite_bootstrap.service_config import ServiceConfig
6+
from lite_bootstrap.types import ApplicationT
57

68

7-
class BaseBootstrap(abc.ABC):
9+
class BaseBootstrap(abc.ABC, typing.Generic[ApplicationT]):
10+
application: ApplicationT
811
instruments: typing.Sequence[BaseInstrument]
12+
service_config: ServiceConfig
913

1014
def bootstrap(self) -> None:
1115
for one_instrument in self.instruments:
1216
if one_instrument.is_ready():
13-
one_instrument.bootstrap()
17+
one_instrument.bootstrap(self.service_config, self.application)
1418

1519
def teardown(self) -> None:
1620
for one_instrument in self.instruments:
1721
if one_instrument.is_ready():
18-
one_instrument.teardown()
22+
one_instrument.teardown(self.application)

lite_bootstrap/bootstraps/fastapi_bootstrap/__init__.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,25 @@
44
import fastapi
55

66
from lite_bootstrap.bootstraps.base import BaseBootstrap
7+
from lite_bootstrap.bootstraps.fastapi_bootstrap.healthchecks_instrument import FastAPIHealthChecksInstrument
78
from lite_bootstrap.bootstraps.fastapi_bootstrap.opentelemetry_instrument import FastAPIOpenTelemetryInstrument
89
from lite_bootstrap.bootstraps.fastapi_bootstrap.sentry_instrument import FastAPISentryInstrument
910

1011

1112
__all__ = [
1213
"FastAPIBootstrap",
14+
"FastAPIHealthChecksInstrument",
1315
"FastAPIOpenTelemetryInstrument",
1416
"FastAPISentryInstrument",
1517
]
1618

19+
from lite_bootstrap.service_config import ServiceConfig
1720

18-
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
19-
class FastAPIBootstrap(BaseBootstrap):
20-
app: fastapi.FastAPI
21-
instruments: typing.Sequence[FastAPIOpenTelemetryInstrument | FastAPISentryInstrument]
2221

23-
def __post_init__(self) -> None:
24-
for one_instrument in self.instruments:
25-
one_instrument.app = self.app
22+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
23+
class FastAPIBootstrap(BaseBootstrap[fastapi.FastAPI]):
24+
application: fastapi.FastAPI
25+
instruments: typing.Sequence[
26+
FastAPIOpenTelemetryInstrument | FastAPISentryInstrument | FastAPIHealthChecksInstrument
27+
]
28+
service_config: ServiceConfig
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import dataclasses
2+
import typing
3+
4+
import fastapi
5+
6+
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksInstrument, HealthCheckTypedDict
7+
from lite_bootstrap.service_config import ServiceConfig
8+
9+
10+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
11+
class FastAPIHealthChecksInstrument(HealthChecksInstrument):
12+
enabled: bool = True
13+
path: str = "/health/"
14+
include_in_schema: bool = False
15+
16+
def build_fastapi_health_check_router(self, service_config: ServiceConfig) -> fastapi.APIRouter:
17+
fastapi_router: typing.Final = fastapi.APIRouter(
18+
tags=["probes"],
19+
include_in_schema=self.include_in_schema,
20+
)
21+
22+
@fastapi_router.get(self.path)
23+
async def health_check_handler() -> HealthCheckTypedDict:
24+
return self.render_health_check_data(service_config)
25+
26+
return fastapi_router
27+
28+
def bootstrap(self, service_config: ServiceConfig, application: fastapi.FastAPI | None = None) -> None:
29+
if application:
30+
application.include_router(self.build_fastapi_health_check_router(service_config))

lite_bootstrap/bootstraps/fastapi_bootstrap/opentelemetry_instrument.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import fastapi
55

66
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument
7+
from lite_bootstrap.service_config import ServiceConfig
78

89

910
with contextlib.suppress(ImportError):
@@ -13,16 +14,16 @@
1314
@dataclasses.dataclass(kw_only=True)
1415
class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument):
1516
excluded_urls: list[str] = dataclasses.field(default_factory=list)
16-
app: fastapi.FastAPI = dataclasses.field(init=False)
1717

18-
def bootstrap(self) -> None:
19-
super().bootstrap()
18+
def bootstrap(self, service_config: ServiceConfig, application: fastapi.FastAPI | None = None) -> None:
19+
super().bootstrap(service_config, application)
2020
FastAPIInstrumentor.instrument_app(
21-
app=self.app,
21+
app=application,
2222
tracer_provider=self.tracer_provider,
2323
excluded_urls=",".join(self.excluded_urls),
2424
)
2525

26-
def teardown(self) -> None:
27-
FastAPIInstrumentor.uninstrument_app(self.app)
26+
def teardown(self, application: fastapi.FastAPI | None = None) -> None:
27+
if application:
28+
FastAPIInstrumentor.uninstrument_app(application)
2829
super().teardown()
Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
1-
import contextlib
21
import dataclasses
32

4-
import fastapi
5-
63
from lite_bootstrap.instruments.sentry_instrument import SentryInstrument
74

85

9-
with contextlib.suppress(ImportError):
10-
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
11-
12-
13-
@dataclasses.dataclass(kw_only=True)
14-
class FastAPISentryInstrument(SentryInstrument):
15-
app: fastapi.FastAPI = dataclasses.field(init=False)
16-
17-
def bootstrap(self) -> None:
18-
super().bootstrap()
19-
self.app.add_middleware(SentryAsgiMiddleware) # type: ignore[arg-type]
6+
@dataclasses.dataclass(kw_only=True, frozen=True)
7+
class FastAPISentryInstrument(SentryInstrument): ...

lite_bootstrap/instruments/base.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import abc
22

3+
from lite_bootstrap.service_config import ServiceConfig
4+
from lite_bootstrap.types import ApplicationT
5+
36

47
class BaseInstrument(abc.ABC):
5-
@abc.abstractmethod
6-
def bootstrap(self) -> None: ...
8+
def bootstrap(self, service_config: ServiceConfig, application: ApplicationT | None = None) -> None: ... # noqa: B027
79

8-
@abc.abstractmethod
9-
def teardown(self) -> None: ...
10+
def teardown(self, application: ApplicationT | None = None) -> None: ... # noqa: B027
1011

1112
@abc.abstractmethod
1213
def is_ready(self) -> bool: ...
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import dataclasses
2+
3+
import typing_extensions
4+
5+
from lite_bootstrap.instruments.base import BaseInstrument
6+
from lite_bootstrap.service_config import ServiceConfig
7+
8+
9+
class HealthCheckTypedDict(typing_extensions.TypedDict, total=False):
10+
service_version: str | None
11+
service_name: str | None
12+
health_status: bool
13+
14+
15+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
16+
class HealthChecksInstrument(BaseInstrument):
17+
enabled: bool = True
18+
path: str = "/health/"
19+
include_in_schema: bool = False
20+
21+
def is_ready(self) -> bool:
22+
return self.enabled
23+
24+
@staticmethod
25+
def render_health_check_data(service_config: ServiceConfig) -> HealthCheckTypedDict:
26+
return {
27+
"service_version": service_config.service_version,
28+
"service_name": service_config.service_name,
29+
"health_status": True,
30+
}

lite_bootstrap/instruments/opentelemetry_instrument.py

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
import typing
44

55
from lite_bootstrap.instruments.base import BaseInstrument
6+
from lite_bootstrap.service_config import ServiceConfig
7+
from lite_bootstrap.types import ApplicationT
68

79

810
with contextlib.suppress(ImportError):
911
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
1012
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
1113
from opentelemetry.sdk import resources
1214
from opentelemetry.sdk.trace import TracerProvider
13-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
15+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
1416

1517

1618
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@@ -21,37 +23,24 @@ class InstrumentorWithParams:
2123

2224
@dataclasses.dataclass(kw_only=True, slots=True)
2325
class OpenTelemetryInstrument(BaseInstrument):
24-
service_version: str = "1.0.0"
25-
service_name: str | None = None
2626
container_name: str | None = None
2727
endpoint: str | None = None
2828
namespace: str | None = None
2929
insecure: bool = True
3030
instrumentors: list[InstrumentorWithParams | BaseInstrumentor] = dataclasses.field(default_factory=list)
31+
span_exporter: SpanExporter | None = None
3132

3233
tracer_provider: TracerProvider = dataclasses.field(init=False)
3334

3435
def is_ready(self) -> bool:
35-
return all(
36-
(
37-
self.endpoint,
38-
self.service_name,
39-
),
40-
)
41-
42-
def teardown(self) -> None:
43-
for one_instrumentor in self.instrumentors:
44-
if isinstance(one_instrumentor, InstrumentorWithParams):
45-
one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params)
46-
else:
47-
one_instrumentor.uninstrument()
36+
return bool(self.endpoint)
4837

49-
def bootstrap(self) -> None:
38+
def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:
5039
attributes = {
51-
resources.SERVICE_NAME: self.service_name,
40+
resources.SERVICE_NAME: service_config.service_name,
5241
resources.TELEMETRY_SDK_LANGUAGE: "python",
5342
resources.SERVICE_NAMESPACE: self.namespace,
54-
resources.SERVICE_VERSION: self.service_version,
43+
resources.SERVICE_VERSION: service_config.service_version,
5544
resources.CONTAINER_NAME: self.container_name,
5645
}
5746
resource: typing.Final = resources.Resource.create(
@@ -60,7 +49,8 @@ def bootstrap(self) -> None:
6049
self.tracer_provider = TracerProvider(resource=resource)
6150
self.tracer_provider.add_span_processor(
6251
BatchSpanProcessor(
63-
OTLPSpanExporter(
52+
self.span_exporter
53+
or OTLPSpanExporter(
6454
endpoint=self.endpoint,
6555
insecure=self.insecure,
6656
),
@@ -74,3 +64,10 @@ def bootstrap(self) -> None:
7464
)
7565
else:
7666
one_instrumentor.instrument(tracer_provider=self.tracer_provider)
67+
68+
def teardown(self, _: ApplicationT | None = None) -> None:
69+
for one_instrumentor in self.instrumentors:
70+
if isinstance(one_instrumentor, InstrumentorWithParams):
71+
one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params)
72+
else:
73+
one_instrumentor.uninstrument()

lite_bootstrap/instruments/sentry_instrument.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
import typing
44

55
from lite_bootstrap.instruments.base import BaseInstrument
6+
from lite_bootstrap.service_config import ServiceConfig
7+
from lite_bootstrap.types import ApplicationT
68

79

810
with contextlib.suppress(ImportError):
911
import sentry_sdk
1012
from sentry_sdk.integrations import Integration
1113

1214

13-
@dataclasses.dataclass(kw_only=True, slots=True)
15+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
1416
class SentryInstrument(BaseInstrument):
1517
dsn: str | None = None
1618
sample_rate: float = dataclasses.field(default=1.0)
1719
traces_sample_rate: float | None = None
18-
environment: str | None = None
1920
max_breadcrumbs: int = 15
2021
max_value_length: int = 16384
2122
attach_stacktrace: bool = True
@@ -26,12 +27,12 @@ class SentryInstrument(BaseInstrument):
2627
def is_ready(self) -> bool:
2728
return bool(self.dsn)
2829

29-
def bootstrap(self) -> None:
30+
def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:
3031
sentry_sdk.init(
3132
dsn=self.dsn,
3233
sample_rate=self.sample_rate,
3334
traces_sample_rate=self.traces_sample_rate,
34-
environment=self.environment,
35+
environment=service_config.service_environment,
3536
max_breadcrumbs=self.max_breadcrumbs,
3637
max_value_length=self.max_value_length,
3738
attach_stacktrace=self.attach_stacktrace,
@@ -41,4 +42,4 @@ def bootstrap(self) -> None:
4142
tags: dict[str, str] = self.tags or {}
4243
sentry_sdk.set_tags(tags)
4344

44-
def teardown(self) -> None: ...
45+
def teardown(self, application: ApplicationT | None = None) -> None: ...

lite_bootstrap/service_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import dataclasses
2+
3+
4+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
5+
class ServiceConfig:
6+
service_name: str = "micro-service"
7+
service_version: str = "1.0.0"
8+
service_environment: str | None = None

0 commit comments

Comments
 (0)