lite-bootstrap 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/PKG-INFO +12 -1
  2. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/base.py +22 -0
  3. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/fastapi_bootstrap/__init__.py +33 -0
  4. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/fastapi_bootstrap/healthchecks_instrument.py +30 -0
  5. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/fastapi_bootstrap/logging_instrument.py +7 -0
  6. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/fastapi_bootstrap/opentelemetry_instrument.py +30 -0
  7. lite_bootstrap-0.4.0/lite_bootstrap/bootstraps/fastapi_bootstrap/sentry_instrument.py +7 -0
  8. lite_bootstrap-0.4.0/lite_bootstrap/instruments/base.py +13 -0
  9. lite_bootstrap-0.4.0/lite_bootstrap/instruments/healthchecks_instrument.py +30 -0
  10. lite_bootstrap-0.4.0/lite_bootstrap/instruments/logging_instrument.py +133 -0
  11. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/instruments/opentelemetry_instrument.py +34 -34
  12. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/instruments/sentry_instrument.py +14 -11
  13. lite_bootstrap-0.4.0/lite_bootstrap/service_config.py +9 -0
  14. lite_bootstrap-0.4.0/lite_bootstrap/types.py +4 -0
  15. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/pyproject.toml +6 -0
  16. lite_bootstrap-0.4.0/tests/conftest.py +36 -0
  17. lite_bootstrap-0.4.0/tests/instruments/__init__.py +0 -0
  18. lite_bootstrap-0.4.0/tests/instruments/test_logging_instrument.py +83 -0
  19. lite_bootstrap-0.4.0/tests/instruments/test_opentelemetry_instrument.py +31 -0
  20. lite_bootstrap-0.4.0/tests/instruments/test_sentry_instrument.py +10 -0
  21. lite_bootstrap-0.4.0/tests/test_fastapi_bootstrap.py +48 -0
  22. lite_bootstrap-0.2.0/lite_bootstrap/bootstraps/base.py +0 -18
  23. lite_bootstrap-0.2.0/lite_bootstrap/bootstraps/fastapi_bootstrap.py +0 -51
  24. lite_bootstrap-0.2.0/lite_bootstrap/instruments/base.py +0 -12
  25. lite_bootstrap-0.2.0/tests/conftest.py +0 -27
  26. lite_bootstrap-0.2.0/tests/test_fastapi_bootstrap.py +0 -33
  27. lite_bootstrap-0.2.0/tests/test_opentelemetry_bootstrap.py +0 -24
  28. lite_bootstrap-0.2.0/tests/test_sentry_bootstrap.py +0 -9
  29. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/.github/workflows/ci.yml +0 -0
  30. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/.github/workflows/publish.yml +0 -0
  31. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/.gitignore +0 -0
  32. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/Justfile +0 -0
  33. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/README.md +0 -0
  34. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/__init__.py +0 -0
  35. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/bootstraps/__init__.py +0 -0
  36. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/instruments/__init__.py +0 -0
  37. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/lite_bootstrap/py.typed +0 -0
  38. {lite_bootstrap-0.2.0 → lite_bootstrap-0.4.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lite-bootstrap
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Lite package for bootstrapping new microservices
5
5
  Project-URL: repository, https://github.com/modern-python/lite-bootstrap
6
6
  Author-email: Artur Shiriev <me@shiriev.ru>
@@ -15,8 +15,19 @@ Classifier: Typing :: Typed
15
15
  Requires-Python: <4,>=3.10
16
16
  Provides-Extra: fastapi
17
17
  Requires-Dist: fastapi; extra == 'fastapi'
18
+ Provides-Extra: fastapi-all
19
+ Requires-Dist: fastapi; extra == 'fastapi-all'
20
+ Requires-Dist: opentelemetry-api; extra == 'fastapi-all'
21
+ Requires-Dist: opentelemetry-exporter-otlp; extra == 'fastapi-all'
22
+ Requires-Dist: opentelemetry-instrumentation; extra == 'fastapi-all'
23
+ Requires-Dist: opentelemetry-instrumentation-fastapi; extra == 'fastapi-all'
24
+ Requires-Dist: opentelemetry-sdk; extra == 'fastapi-all'
25
+ Requires-Dist: sentry-sdk; extra == 'fastapi-all'
26
+ Requires-Dist: structlog; extra == 'fastapi-all'
18
27
  Provides-Extra: fastapi-otl
19
28
  Requires-Dist: opentelemetry-instrumentation-fastapi; extra == 'fastapi-otl'
29
+ Provides-Extra: logging
30
+ Requires-Dist: structlog; extra == 'logging'
20
31
  Provides-Extra: otl
21
32
  Requires-Dist: opentelemetry-api; extra == 'otl'
22
33
  Requires-Dist: opentelemetry-exporter-otlp; extra == 'otl'
@@ -0,0 +1,22 @@
1
+ import abc
2
+ import typing
3
+
4
+ from lite_bootstrap.instruments.base import BaseInstrument
5
+ from lite_bootstrap.service_config import ServiceConfig
6
+ from lite_bootstrap.types import ApplicationT
7
+
8
+
9
+ class BaseBootstrap(abc.ABC, typing.Generic[ApplicationT]):
10
+ application: ApplicationT
11
+ instruments: typing.Sequence[BaseInstrument]
12
+ service_config: ServiceConfig
13
+
14
+ def bootstrap(self) -> None:
15
+ for one_instrument in self.instruments:
16
+ if one_instrument.is_ready(self.service_config):
17
+ one_instrument.bootstrap(self.service_config, self.application)
18
+
19
+ def teardown(self) -> None:
20
+ for one_instrument in self.instruments:
21
+ if one_instrument.is_ready(self.service_config):
22
+ one_instrument.teardown(self.application)
@@ -0,0 +1,33 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+ import fastapi
5
+
6
+ from lite_bootstrap.bootstraps.base import BaseBootstrap
7
+ from lite_bootstrap.bootstraps.fastapi_bootstrap.healthchecks_instrument import FastAPIHealthChecksInstrument
8
+ from lite_bootstrap.bootstraps.fastapi_bootstrap.logging_instrument import FastAPILoggingInstrument
9
+ from lite_bootstrap.bootstraps.fastapi_bootstrap.opentelemetry_instrument import FastAPIOpenTelemetryInstrument
10
+ from lite_bootstrap.bootstraps.fastapi_bootstrap.sentry_instrument import FastAPISentryInstrument
11
+
12
+
13
+ __all__ = [
14
+ "FastAPIBootstrap",
15
+ "FastAPIHealthChecksInstrument",
16
+ "FastAPILoggingInstrument",
17
+ "FastAPIOpenTelemetryInstrument",
18
+ "FastAPISentryInstrument",
19
+ ]
20
+
21
+ from lite_bootstrap.service_config import ServiceConfig
22
+
23
+
24
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
25
+ class FastAPIBootstrap(BaseBootstrap[fastapi.FastAPI]):
26
+ application: fastapi.FastAPI
27
+ instruments: typing.Sequence[
28
+ FastAPIOpenTelemetryInstrument
29
+ | FastAPISentryInstrument
30
+ | FastAPIHealthChecksInstrument
31
+ | FastAPILoggingInstrument
32
+ ]
33
+ service_config: ServiceConfig
@@ -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))
@@ -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): ...
@@ -0,0 +1,30 @@
1
+ import contextlib
2
+ import dataclasses
3
+
4
+ import fastapi
5
+ from opentelemetry.trace import get_tracer_provider
6
+
7
+ from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument
8
+ from lite_bootstrap.service_config import ServiceConfig
9
+
10
+
11
+ with contextlib.suppress(ImportError):
12
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
13
+
14
+
15
+ @dataclasses.dataclass(kw_only=True, frozen=True)
16
+ class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument):
17
+ excluded_urls: list[str] = dataclasses.field(default_factory=list)
18
+
19
+ def bootstrap(self, service_config: ServiceConfig, application: fastapi.FastAPI | None = None) -> None:
20
+ super().bootstrap(service_config, application)
21
+ FastAPIInstrumentor.instrument_app(
22
+ app=application,
23
+ tracer_provider=get_tracer_provider(),
24
+ excluded_urls=",".join(self.excluded_urls),
25
+ )
26
+
27
+ def teardown(self, application: fastapi.FastAPI | None = None) -> None:
28
+ if application:
29
+ FastAPIInstrumentor.uninstrument_app(application)
30
+ super().teardown()
@@ -0,0 +1,7 @@
1
+ import dataclasses
2
+
3
+ from lite_bootstrap.instruments.sentry_instrument import SentryInstrument
4
+
5
+
6
+ @dataclasses.dataclass(kw_only=True, frozen=True)
7
+ class FastAPISentryInstrument(SentryInstrument): ...
@@ -0,0 +1,13 @@
1
+ import abc
2
+
3
+ from lite_bootstrap.service_config import ServiceConfig
4
+ from lite_bootstrap.types import ApplicationT
5
+
6
+
7
+ class BaseInstrument(abc.ABC):
8
+ def bootstrap(self, service_config: ServiceConfig, application: ApplicationT | None = None) -> None: ... # noqa: B027
9
+
10
+ def teardown(self, application: ApplicationT | None = None) -> None: ... # noqa: B027
11
+
12
+ @abc.abstractmethod
13
+ def is_ready(self, service_config: ServiceConfig) -> bool: ...
@@ -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, _: ServiceConfig) -> 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
+ }
@@ -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()
@@ -1,14 +1,20 @@
1
+ import contextlib
1
2
  import dataclasses
2
3
  import typing
3
4
 
4
- import pydantic
5
- from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
6
- from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
7
- from opentelemetry.sdk import resources
8
- from opentelemetry.sdk.trace import TracerProvider
9
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
5
+ from opentelemetry.trace import set_tracer_provider
10
6
 
11
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
+ with contextlib.suppress(ImportError):
13
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
14
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
15
+ from opentelemetry.sdk import resources
16
+ from opentelemetry.sdk.trace import TracerProvider
17
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
12
18
 
13
19
 
14
20
  @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
@@ -17,48 +23,34 @@ class InstrumentorWithParams:
17
23
  additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
18
24
 
19
25
 
20
- @dataclasses.dataclass(kw_only=True, slots=True)
26
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
21
27
  class OpenTelemetryInstrument(BaseInstrument):
22
- service_version: str = "1.0.0"
23
- service_name: str | None = None
24
28
  container_name: str | None = None
25
29
  endpoint: str | None = None
26
30
  namespace: str | None = None
27
- insecure: bool = pydantic.Field(default=True)
31
+ insecure: bool = True
28
32
  instrumentors: list[InstrumentorWithParams | BaseInstrumentor] = dataclasses.field(default_factory=list)
33
+ span_exporter: SpanExporter | None = None
29
34
 
30
- tracer_provider: TracerProvider = dataclasses.field(init=False)
31
-
32
- def is_ready(self) -> bool:
33
- return all(
34
- (
35
- self.endpoint,
36
- self.service_name,
37
- ),
38
- )
39
-
40
- def teardown(self) -> None:
41
- for one_instrumentor in self.instrumentors:
42
- if isinstance(one_instrumentor, InstrumentorWithParams):
43
- one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params)
44
- else:
45
- one_instrumentor.uninstrument()
35
+ def is_ready(self, _: ServiceConfig) -> bool:
36
+ return bool(self.endpoint)
46
37
 
47
- def bootstrap(self) -> None:
38
+ def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:
48
39
  attributes = {
49
- resources.SERVICE_NAME: self.service_name,
40
+ resources.SERVICE_NAME: service_config.service_name,
50
41
  resources.TELEMETRY_SDK_LANGUAGE: "python",
51
42
  resources.SERVICE_NAMESPACE: self.namespace,
52
- resources.SERVICE_VERSION: self.service_version,
43
+ resources.SERVICE_VERSION: service_config.service_version,
53
44
  resources.CONTAINER_NAME: self.container_name,
54
45
  }
55
46
  resource: typing.Final = resources.Resource.create(
56
47
  attributes={k: v for k, v in attributes.items() if v},
57
48
  )
58
- self.tracer_provider = TracerProvider(resource=resource)
59
- self.tracer_provider.add_span_processor(
49
+ tracer_provider = TracerProvider(resource=resource)
50
+ tracer_provider.add_span_processor(
60
51
  BatchSpanProcessor(
61
- OTLPSpanExporter(
52
+ self.span_exporter
53
+ or OTLPSpanExporter(
62
54
  endpoint=self.endpoint,
63
55
  insecure=self.insecure,
64
56
  ),
@@ -67,8 +59,16 @@ class OpenTelemetryInstrument(BaseInstrument):
67
59
  for one_instrumentor in self.instrumentors:
68
60
  if isinstance(one_instrumentor, InstrumentorWithParams):
69
61
  one_instrumentor.instrumentor.instrument(
70
- tracer_provider=self.tracer_provider,
62
+ tracer_provider=tracer_provider,
71
63
  **one_instrumentor.additional_params,
72
64
  )
73
65
  else:
74
- one_instrumentor.instrument(tracer_provider=self.tracer_provider)
66
+ one_instrumentor.instrument(tracer_provider=tracer_provider)
67
+ set_tracer_provider(tracer_provider)
68
+
69
+ def teardown(self, _: ApplicationT | None = None) -> None:
70
+ for one_instrumentor in self.instrumentors:
71
+ if isinstance(one_instrumentor, InstrumentorWithParams):
72
+ one_instrumentor.instrumentor.uninstrument(**one_instrumentor.additional_params)
73
+ else:
74
+ one_instrumentor.uninstrument()
@@ -1,19 +1,22 @@
1
+ import contextlib
1
2
  import dataclasses
2
3
  import typing
3
4
 
4
- import pydantic
5
- import sentry_sdk
6
- from sentry_sdk.integrations import Integration
7
-
8
5
  from lite_bootstrap.instruments.base import BaseInstrument
6
+ from lite_bootstrap.service_config import ServiceConfig
7
+ from lite_bootstrap.types import ApplicationT
8
+
9
+
10
+ with contextlib.suppress(ImportError):
11
+ import sentry_sdk
12
+ from sentry_sdk.integrations import Integration
9
13
 
10
14
 
11
- @dataclasses.dataclass(kw_only=True, slots=True)
15
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
12
16
  class SentryInstrument(BaseInstrument):
13
17
  dsn: str | None = None
14
- sample_rate: float = pydantic.Field(default=1.0, le=1.0, ge=0.0)
18
+ sample_rate: float = dataclasses.field(default=1.0)
15
19
  traces_sample_rate: float | None = None
16
- environment: str | None = None
17
20
  max_breadcrumbs: int = 15
18
21
  max_value_length: int = 16384
19
22
  attach_stacktrace: bool = True
@@ -21,15 +24,15 @@ class SentryInstrument(BaseInstrument):
21
24
  additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
22
25
  tags: dict[str, str] | None = None
23
26
 
24
- def is_ready(self) -> bool:
27
+ def is_ready(self, _: ServiceConfig) -> bool:
25
28
  return bool(self.dsn)
26
29
 
27
- def bootstrap(self) -> None:
30
+ def bootstrap(self, service_config: ServiceConfig, _: ApplicationT | None = None) -> None:
28
31
  sentry_sdk.init(
29
32
  dsn=self.dsn,
30
33
  sample_rate=self.sample_rate,
31
34
  traces_sample_rate=self.traces_sample_rate,
32
- environment=self.environment,
35
+ environment=service_config.service_environment,
33
36
  max_breadcrumbs=self.max_breadcrumbs,
34
37
  max_value_length=self.max_value_length,
35
38
  attach_stacktrace=self.attach_stacktrace,
@@ -39,4 +42,4 @@ class SentryInstrument(BaseInstrument):
39
42
  tags: dict[str, str] = self.tags or {}
40
43
  sentry_sdk.set_tags(tags)
41
44
 
42
- def teardown(self) -> None: ...
45
+ def teardown(self, application: ApplicationT | None = None) -> None: ...
@@ -0,0 +1,9 @@
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
9
+ service_debug: bool = True
@@ -0,0 +1,4 @@
1
+ import typing
2
+
3
+
4
+ ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any)
@@ -43,12 +43,18 @@ otl = [
43
43
  "opentelemetry-exporter-otlp",
44
44
  "opentelemetry-instrumentation",
45
45
  ]
46
+ logging = [
47
+ "structlog",
48
+ ]
46
49
  fastapi = [
47
50
  "fastapi",
48
51
  ]
49
52
  fastapi-otl = [
50
53
  "opentelemetry-instrumentation-fastapi",
51
54
  ]
55
+ fastapi-all = [
56
+ "lite-bootstrap[sentry,otl,logging,fastapi,fastapi-otl]"
57
+ ]
52
58
 
53
59
  [dependency-groups]
54
60
  dev = [
@@ -0,0 +1,36 @@
1
+ import typing
2
+ from unittest.mock import Mock
3
+
4
+ import pytest
5
+ from fastapi import FastAPI
6
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
7
+
8
+ from lite_bootstrap.service_config import ServiceConfig
9
+
10
+
11
+ class CustomInstrumentor(BaseInstrumentor): # type: ignore[misc]
12
+ def instrumentation_dependencies(self) -> typing.Collection[str]:
13
+ return []
14
+
15
+ def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None:
16
+ pass
17
+
18
+
19
+ @pytest.fixture
20
+ def fastapi_app() -> FastAPI:
21
+ return FastAPI()
22
+
23
+
24
+ @pytest.fixture
25
+ def service_config() -> ServiceConfig:
26
+ return ServiceConfig(
27
+ service_name="microservice",
28
+ service_version="2.0.0",
29
+ service_environment="test",
30
+ service_debug=False,
31
+ )
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def mock_sentry_init(monkeypatch: pytest.MonkeyPatch) -> None:
36
+ monkeypatch.setattr("sentry_sdk.init", Mock)
File without changes
@@ -0,0 +1,83 @@
1
+ import logging
2
+ from io import StringIO
3
+
4
+ import structlog
5
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter
6
+ from opentelemetry.trace import get_tracer
7
+
8
+ from lite_bootstrap.instruments.logging_instrument import LoggingInstrument, MemoryLoggerFactory
9
+ from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument
10
+ from lite_bootstrap.service_config import ServiceConfig
11
+
12
+
13
+ logger = structlog.getLogger(__name__)
14
+
15
+
16
+ def test_logging_instrument(service_config: ServiceConfig) -> None:
17
+ logging_instrument = LoggingInstrument(logging_unset_handlers=["uvicorn"], logging_buffer_capacity=0)
18
+ try:
19
+ logging_instrument.bootstrap(service_config)
20
+ logger.info("testing logging", key="value")
21
+ finally:
22
+ logging_instrument.teardown()
23
+
24
+
25
+ def test_logging_instrument_tracer_injection(service_config: ServiceConfig) -> None:
26
+ logging_instrument = LoggingInstrument(logging_unset_handlers=["uvicorn"], logging_buffer_capacity=0)
27
+ opentelemetry_instrument = OpenTelemetryInstrument(
28
+ endpoint="otl",
29
+ span_exporter=ConsoleSpanExporter(),
30
+ )
31
+ try:
32
+ logging_instrument.bootstrap(service_config)
33
+ opentelemetry_instrument.bootstrap(service_config)
34
+ tracer = get_tracer(__name__)
35
+ logger.info("testing tracer injection without spans")
36
+ with tracer.start_as_current_span("my_fake_span") as span:
37
+ logger.info("testing tracer injection without span attributes")
38
+ span.set_attribute("example_attribute", "value")
39
+ span.add_event("example_event", {"event_attr": 1})
40
+ logger.info("testing tracer injection with span attributes")
41
+ finally:
42
+ logging_instrument.teardown()
43
+ opentelemetry_instrument.teardown()
44
+
45
+
46
+ def test_memory_logger_factory_info() -> None:
47
+ test_capacity = 10
48
+ test_flush_level = logging.ERROR
49
+ test_stream = StringIO()
50
+
51
+ logger_factory = MemoryLoggerFactory(
52
+ logging_buffer_capacity=test_capacity,
53
+ logging_flush_level=test_flush_level,
54
+ logging_log_level=logging.INFO,
55
+ log_stream=test_stream,
56
+ )
57
+ test_logger = logger_factory()
58
+ test_message = "test message"
59
+
60
+ for current_log_index in range(test_capacity):
61
+ test_logger.info(test_message)
62
+ log_contents = test_stream.getvalue()
63
+ if current_log_index == test_capacity - 1:
64
+ assert test_message in log_contents
65
+ else:
66
+ assert not log_contents
67
+
68
+
69
+ def test_memory_logger_factory_error() -> None:
70
+ test_capacity = 10
71
+ test_flush_level = logging.ERROR
72
+ test_stream = StringIO()
73
+
74
+ logger_factory = MemoryLoggerFactory(
75
+ logging_buffer_capacity=test_capacity,
76
+ logging_flush_level=test_flush_level,
77
+ logging_log_level=logging.INFO,
78
+ log_stream=test_stream,
79
+ )
80
+ test_logger = logger_factory()
81
+ error_message = "error message"
82
+ test_logger.error(error_message)
83
+ assert error_message in test_stream.getvalue()
@@ -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_instrument = 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_instrument.bootstrap(service_config)
19
+ finally:
20
+ opentelemetry_instrument.teardown()
21
+
22
+
23
+ def test_opentelemetry_instrument_empty_instruments(service_config: ServiceConfig) -> None:
24
+ opentelemetry_instrument = OpenTelemetryInstrument(
25
+ endpoint="otl",
26
+ span_exporter=ConsoleSpanExporter(),
27
+ )
28
+ try:
29
+ opentelemetry_instrument.bootstrap(service_config)
30
+ finally:
31
+ opentelemetry_instrument.teardown()
@@ -0,0 +1,10 @@
1
+ from lite_bootstrap.instruments.sentry_instrument import SentryInstrument
2
+ from lite_bootstrap.service_config import ServiceConfig
3
+
4
+
5
+ def test_sentry_instrument(service_config: ServiceConfig) -> None:
6
+ SentryInstrument(dsn="https://testdsn@localhost/1", tags={"tag": "value"}).bootstrap(service_config)
7
+
8
+
9
+ def test_sentry_instrument_empty_dsn(service_config: ServiceConfig) -> None:
10
+ SentryInstrument(dsn="").bootstrap(service_config)
@@ -0,0 +1,48 @@
1
+ import structlog
2
+ from fastapi import FastAPI
3
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter
4
+ from starlette import status
5
+ from starlette.testclient import TestClient
6
+
7
+ from lite_bootstrap.bootstraps.fastapi_bootstrap import (
8
+ FastAPIBootstrap,
9
+ FastAPIHealthChecksInstrument,
10
+ FastAPILoggingInstrument,
11
+ FastAPIOpenTelemetryInstrument,
12
+ FastAPISentryInstrument,
13
+ )
14
+ from lite_bootstrap.service_config import ServiceConfig
15
+ from tests.conftest import CustomInstrumentor
16
+
17
+
18
+ logger = structlog.getLogger(__name__)
19
+
20
+
21
+ def test_fastapi_bootstrap(fastapi_app: FastAPI, service_config: ServiceConfig) -> None:
22
+ fastapi_bootstrap = FastAPIBootstrap(
23
+ application=fastapi_app,
24
+ service_config=service_config,
25
+ instruments=[
26
+ FastAPIOpenTelemetryInstrument(
27
+ endpoint="otl",
28
+ instrumentors=[CustomInstrumentor()],
29
+ span_exporter=ConsoleSpanExporter(),
30
+ ),
31
+ FastAPISentryInstrument(
32
+ dsn="https://testdsn@localhost/1",
33
+ ),
34
+ FastAPIHealthChecksInstrument(
35
+ path="/health/",
36
+ ),
37
+ FastAPILoggingInstrument(logging_buffer_capacity=0),
38
+ ],
39
+ )
40
+ fastapi_bootstrap.bootstrap()
41
+ logger.info("testing logging", key="value")
42
+
43
+ try:
44
+ response = TestClient(fastapi_app).get("/health/")
45
+ assert response.status_code == status.HTTP_200_OK
46
+ assert response.json() == {"health_status": True, "service_name": "microservice", "service_version": "2.0.0"}
47
+ finally:
48
+ fastapi_bootstrap.teardown()
@@ -1,18 +0,0 @@
1
- import abc
2
- import typing
3
-
4
- from lite_bootstrap.instruments.base import BaseInstrument
5
-
6
-
7
- class BaseBootstrap(abc.ABC):
8
- instruments: typing.Sequence[BaseInstrument]
9
-
10
- def bootstrap(self) -> None:
11
- for one_instrument in self.instruments:
12
- if one_instrument.is_ready():
13
- one_instrument.bootstrap()
14
-
15
- def teardown(self) -> None:
16
- for one_instrument in self.instruments:
17
- if one_instrument.is_ready():
18
- one_instrument.teardown()
@@ -1,51 +0,0 @@
1
- import dataclasses
2
- import typing
3
-
4
- import fastapi
5
- from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
6
- from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
7
-
8
- from lite_bootstrap.bootstraps.base import BaseBootstrap
9
- from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrument
10
- from lite_bootstrap.instruments.sentry_instrument import SentryInstrument
11
-
12
-
13
- @dataclasses.dataclass(kw_only=True)
14
- class FastAPIOpenTelemetryInstrument(OpenTelemetryInstrument):
15
- excluded_urls: list[str] = dataclasses.field(default_factory=list)
16
- app: fastapi.FastAPI = dataclasses.field(init=False)
17
-
18
- def bootstrap(self) -> None:
19
- super().bootstrap()
20
- FastAPIInstrumentor.instrument_app(
21
- app=self.app,
22
- tracer_provider=self.tracer_provider,
23
- excluded_urls=",".join(self.excluded_urls),
24
- )
25
-
26
- def teardown(self) -> None:
27
- FastAPIInstrumentor.uninstrument_app(self.app)
28
- super().teardown()
29
-
30
-
31
- @dataclasses.dataclass(kw_only=True)
32
- class FastAPISentryInstrument(SentryInstrument):
33
- app: fastapi.FastAPI = dataclasses.field(init=False)
34
-
35
- def bootstrap(self) -> None:
36
- super().bootstrap()
37
- self.app.add_middleware(SentryAsgiMiddleware) # type: ignore[arg-type]
38
-
39
- def teardown(self) -> None:
40
- FastAPIInstrumentor.uninstrument_app(self.app)
41
- super().teardown()
42
-
43
-
44
- @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
45
- class FastAPIBootstrap(BaseBootstrap):
46
- app: fastapi.FastAPI
47
- instruments: typing.Sequence[FastAPIOpenTelemetryInstrument | FastAPISentryInstrument]
48
-
49
- def __post_init__(self) -> None:
50
- for one_instrument in self.instruments:
51
- one_instrument.app = self.app
@@ -1,12 +0,0 @@
1
- import abc
2
-
3
-
4
- class BaseInstrument(abc.ABC):
5
- @abc.abstractmethod
6
- def bootstrap(self) -> None: ...
7
-
8
- @abc.abstractmethod
9
- def teardown(self) -> None: ...
10
-
11
- @abc.abstractmethod
12
- def is_ready(self) -> bool: ...
@@ -1,27 +0,0 @@
1
- import typing
2
-
3
- import pytest
4
- from fastapi import APIRouter, FastAPI
5
- from fastapi.responses import JSONResponse
6
- from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined]
7
-
8
-
9
- class CustomInstrumentor(BaseInstrumentor): # type: ignore[misc]
10
- def instrumentation_dependencies(self) -> typing.Collection[str]:
11
- return []
12
-
13
- def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None:
14
- pass
15
-
16
-
17
- @pytest.fixture
18
- def fastapi_app() -> FastAPI:
19
- app: typing.Final = FastAPI()
20
- router: typing.Final = APIRouter()
21
-
22
- @router.get("/test")
23
- async def for_test_endpoint() -> JSONResponse:
24
- return JSONResponse(content={"key": "value"})
25
-
26
- app.include_router(router)
27
- return app
@@ -1,33 +0,0 @@
1
- from fastapi import FastAPI
2
- from starlette import status
3
- from starlette.testclient import TestClient
4
-
5
- from lite_bootstrap.bootstraps.fastapi_bootstrap import (
6
- FastAPIBootstrap,
7
- FastAPIOpenTelemetryInstrument,
8
- FastAPISentryInstrument,
9
- )
10
- from tests.conftest import CustomInstrumentor
11
-
12
-
13
- def test_fastapi_bootstrap(fastapi_app: FastAPI) -> None:
14
- fastapi_bootstrap = FastAPIBootstrap(
15
- app=fastapi_app,
16
- instruments=[
17
- FastAPIOpenTelemetryInstrument(
18
- endpoint="localhost",
19
- service_name="test_service",
20
- instrumentors=[CustomInstrumentor()],
21
- ),
22
- FastAPISentryInstrument(
23
- dsn="https://testdsn@test.sentry.com/1",
24
- ),
25
- ],
26
- )
27
- fastapi_bootstrap.bootstrap()
28
- fastapi_bootstrap.teardown()
29
-
30
- response = TestClient(fastapi_app).get("/test")
31
- assert response.status_code == status.HTTP_200_OK
32
-
33
- fastapi_bootstrap.teardown()
@@ -1,24 +0,0 @@
1
- from lite_bootstrap.instruments.opentelemetry_instrument import InstrumentorWithParams, OpenTelemetryInstrument
2
- from tests.conftest import CustomInstrumentor
3
-
4
-
5
- def test_bootstrap_opentelemetry() -> None:
6
- opentelemetry = OpenTelemetryInstrument(
7
- endpoint="localhost",
8
- service_name="test_service",
9
- instrumentors=[
10
- InstrumentorWithParams(instrumentor=CustomInstrumentor(), additional_params={"key": "value"}),
11
- CustomInstrumentor(),
12
- ],
13
- )
14
- opentelemetry.bootstrap()
15
- opentelemetry.teardown()
16
-
17
-
18
- def test_bootstrap_opentelemetry_empty_instruments() -> None:
19
- opentelemetry = OpenTelemetryInstrument(
20
- endpoint="localhost",
21
- service_name="test_service",
22
- )
23
- opentelemetry.bootstrap()
24
- opentelemetry.teardown()
@@ -1,9 +0,0 @@
1
- from lite_bootstrap.instruments.sentry_instrument import SentryInstrument
2
-
3
-
4
- def test_sentry_bootstrap() -> None:
5
- SentryInstrument(dsn="https://testdsn@test.sentry.com/1", tags={"tag": "value"}).bootstrap()
6
-
7
-
8
- def test_sentry_bootstrap_empty_dsn() -> None:
9
- SentryInstrument(dsn="").bootstrap()
File without changes
File without changes