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

lite_bootstrap/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import typing
2+
3+
4+
ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any)

tests/conftest.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import typing
22

33
import pytest
4-
from fastapi import APIRouter, FastAPI
5-
from fastapi.responses import JSONResponse
4+
from fastapi import FastAPI
65
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
76

7+
from lite_bootstrap.service_config import ServiceConfig
8+
89

910
class CustomInstrumentor(BaseInstrumentor): # type: ignore[misc]
1011
def instrumentation_dependencies(self) -> typing.Collection[str]:
@@ -16,12 +17,13 @@ def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None:
1617

1718
@pytest.fixture
1819
def fastapi_app() -> FastAPI:
19-
app: typing.Final = FastAPI()
20-
router: typing.Final = APIRouter()
20+
return FastAPI()
2121

22-
@router.get("/test")
23-
async def for_test_endpoint() -> JSONResponse:
24-
return JSONResponse(content={"key": "value"})
2522

26-
app.include_router(router)
27-
return app
23+
@pytest.fixture
24+
def service_config() -> ServiceConfig:
25+
return ServiceConfig(
26+
service_name="microservice",
27+
service_version="2.0.0",
28+
service_environment="test",
29+
)

tests/instruments/__init__.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
2+
3+
from lite_bootstrap.instruments.opentelemetry_instrument import InstrumentorWithParams, OpenTelemetryInstrument
4+
from lite_bootstrap.service_config import ServiceConfig
5+
from tests.conftest import CustomInstrumentor
6+
7+
8+
def test_opentelemetry_instrument(service_config: ServiceConfig) -> None:
9+
opentelemetry = OpenTelemetryInstrument(
10+
endpoint="otl",
11+
instrumentors=[
12+
InstrumentorWithParams(instrumentor=CustomInstrumentor(), additional_params={"key": "value"}),
13+
CustomInstrumentor(),
14+
],
15+
span_exporter=ConsoleSpanExporter(),
16+
)
17+
try:
18+
opentelemetry.bootstrap(service_config)
19+
finally:
20+
opentelemetry.teardown()
21+
22+
23+
def test_opentelemetry_instrument_empty_instruments(service_config: ServiceConfig) -> None:
24+
opentelemetry = OpenTelemetryInstrument(
25+
endpoint="otl",
26+
span_exporter=ConsoleSpanExporter(),
27+
)
28+
try:
29+
opentelemetry.bootstrap(service_config)
30+
finally:
31+
opentelemetry.teardown()

0 commit comments

Comments
 (0)