Skip to content

instruments: open_telemetry: port auto_instrumentation #110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions microbootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@
import typing

import pydantic
import structlog
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation import auto_instrumentation
from opentelemetry.instrumentation.dependencies import DependencyConflictError
from opentelemetry.instrumentation.environment_variables import OTEL_PYTHON_DISABLED_INSTRUMENTATIONS
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] # noqa: TC002
from opentelemetry.sdk import resources
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.trace import format_span_id, set_tracer_provider
from opentelemetry.util._importlib_metadata import entry_points

from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument


LOGGER_OBJ: typing.Final = structlog.get_logger(__name__)


try:
import pyroscope # type: ignore[import-untyped]
except ImportError: # pragma: no cover
Expand Down Expand Up @@ -54,6 +60,12 @@ class OpentelemetryConfig(BaseInstrumentConfig):
opentelemetry_insecure: bool = pydantic.Field(default=True)
opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = pydantic.Field(default_factory=list)
opentelemetry_exclude_urls: list[str] = pydantic.Field(default=["/metrics"])
opentelemetry_disabled_instrumentations: list[str] = pydantic.Field(
default=[
one_package_to_exclude.strip()
for one_package_to_exclude in os.environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, "").split(",")
]
)


@typing.runtime_checkable
Expand All @@ -80,6 +92,28 @@ class BaseOpentelemetryInstrument(Instrument[OpentelemetryConfigT]):
instrument_name = "Opentelemetry"
ready_condition = "Provide all necessary config parameters"

def _load_instrumentors(self) -> None:
for entry_point in entry_points(group="opentelemetry_instrumentor"):
if entry_point.name in self.instrument_config.opentelemetry_disabled_instrumentations:
LOGGER_OBJ.debug("Instrumentation skipped for library", entry_point_name=entry_point.name)
continue

try:
entry_point.load()().instrument(tracer_provider=self.tracer_provider)
LOGGER_OBJ.debug("Instrumented", entry_point_name=entry_point.name)
except DependencyConflictError as exc:
LOGGER_OBJ.debug("Skipping instrumentation", entry_point_name=entry_point.name, reason=exc.conflict)
continue
except ModuleNotFoundError as exc:
LOGGER_OBJ.debug("Skipping instrumentation", entry_point_name=entry_point.name, reason=exc.msg)
continue
except ImportError:
LOGGER_OBJ.debug("Importing failed, skipping it", entry_point_name=entry_point.name)
continue
except Exception:
LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name)
raise

def is_ready(self) -> bool:
return bool(self.instrument_config.opentelemetry_endpoint) or self.instrument_config.service_debug

Expand All @@ -88,8 +122,6 @@ def teardown(self) -> None:
instrumentor_with_params.instrumentor.uninstrument(**instrumentor_with_params.additional_params)

def bootstrap(self) -> None:
auto_instrumentation.initialize()

attributes = {
ResourceAttributes.SERVICE_NAME: self.instrument_config.opentelemetry_service_name
or self.instrument_config.service_name,
Expand Down Expand Up @@ -122,6 +154,7 @@ def bootstrap(self) -> None:
tracer_provider=self.tracer_provider,
**opentelemetry_instrumentor.additional_params,
)
self._load_instrumentors()
set_tracer_provider(self.tracer_provider)


Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,5 @@ def reset_reloaded_settings_module() -> typing.Iterator[None]:


@pytest.fixture(autouse=True)
def disable_auto_instrumentation(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(opentelemetry_instrument, "auto_instrumentation", MagicMock())
def patch_out_entry_points(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(opentelemetry_instrument, "entry_points", MagicMock(retrun_value=[]))
89 changes: 70 additions & 19 deletions tests/instruments/test_opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,53 @@
from fastapi.testclient import TestClient as FastAPITestClient
from litestar.middleware.base import DefineMiddleware
from litestar.testing import TestClient as LitestarTestClient
from opentelemetry.instrumentation.dependencies import DependencyConflictError

from microbootstrap import OpentelemetryConfig
from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument
from microbootstrap.bootstrappers.litestar import LitestarOpentelemetryInstrument
from microbootstrap.instruments import opentelemetry_instrument
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument


def test_opentelemetry_is_ready(
minimal_opentelemetry_config: OpentelemetryConfig,
) -> None:
opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert opentelemetry_instrument.is_ready()
test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert test_opentelemetry_instrument.is_ready()


def test_opentelemetry_bootstrap_is_not_ready(minimal_opentelemetry_config: OpentelemetryConfig) -> None:
minimal_opentelemetry_config.service_debug = False
minimal_opentelemetry_config.opentelemetry_endpoint = None
opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert not opentelemetry_instrument.is_ready()
test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert not test_opentelemetry_instrument.is_ready()


def test_opentelemetry_bootstrap_after(
default_litestar_app: litestar.Litestar,
minimal_opentelemetry_config: OpentelemetryConfig,
) -> None:
opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert opentelemetry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert test_opentelemetry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app


def test_opentelemetry_teardown(
minimal_opentelemetry_config: OpentelemetryConfig,
) -> None:
opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert opentelemetry_instrument.teardown() is None # type: ignore[func-returns-value]
test_opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
assert test_opentelemetry_instrument.teardown() is None # type: ignore[func-returns-value]


def test_litestar_opentelemetry_bootstrap(
minimal_opentelemetry_config: OpentelemetryConfig,
magic_mock: MagicMock,
) -> None:
minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock]
opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)

opentelemetry_instrument.bootstrap()
opentelemetry_bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before()
test_opentelemetry_instrument.bootstrap()
opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before()

assert opentelemetry_bootstrap_result
assert "middleware" in opentelemetry_bootstrap_result
Expand All @@ -66,18 +68,18 @@ def test_litestar_opentelemetry_teardown(
magic_mock: MagicMock,
) -> None:
minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock]
opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)

opentelemetry_instrument.teardown()
test_opentelemetry_instrument.teardown()


def test_litestar_opentelemetry_bootstrap_working(
minimal_opentelemetry_config: OpentelemetryConfig,
async_mock: AsyncMock,
) -> None:
opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
opentelemetry_instrument.bootstrap()
opentelemetry_bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before()
test_opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
test_opentelemetry_instrument.bootstrap()
opentelemetry_bootstrap_result: typing.Final = test_opentelemetry_instrument.bootstrap_before()

opentelemetry_middleware = opentelemetry_bootstrap_result["middleware"][0]
assert isinstance(opentelemetry_middleware, DefineMiddleware)
Expand All @@ -104,9 +106,9 @@ def test_fastapi_opentelemetry_bootstrap_working(
) -> None:
monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock())

opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config)
opentelemetry_instrument.bootstrap()
fastapi_application: typing.Final = opentelemetry_instrument.bootstrap_after(fastapi.FastAPI())
test_opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config)
test_opentelemetry_instrument.bootstrap()
fastapi_application: typing.Final = test_opentelemetry_instrument.bootstrap_after(fastapi.FastAPI())

@fastapi_application.get("/test-handler")
async def test_handler() -> None:
Expand All @@ -115,3 +117,52 @@ async def test_handler() -> None:
with patch("opentelemetry.trace.use_span") as mock_capture_event:
FastAPITestClient(app=fastapi_application).get("/test-handler")
assert mock_capture_event.called


@pytest.mark.parametrize(
("instruments", "result"),
[
(
[
MagicMock(),
MagicMock(load=MagicMock(side_effect=ImportError)),
MagicMock(load=MagicMock(side_effect=DependencyConflictError("Hello"))),
MagicMock(load=MagicMock(side_effect=ModuleNotFoundError)),
],
"ok",
),
(
[
MagicMock(load=MagicMock(side_effect=ValueError)),
],
"raise",
),
(
[
MagicMock(load=MagicMock(side_effect=ValueError)),
],
"exclude",
),
],
)
def test_instrumentors_loader(
minimal_opentelemetry_config: OpentelemetryConfig,
instruments: list[MagicMock],
result: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
if result == "exclude":
minimal_opentelemetry_config.opentelemetry_disabled_instrumentations = ["exclude_this", "exclude_that"]
instruments[0].name = "exclude_this"
monkeypatch.setattr(
opentelemetry_instrument,
"entry_points",
MagicMock(return_value=[*instruments]),
)

if result != "raise":
opentelemetry_instrument.OpentelemetryInstrument(instrument_config=minimal_opentelemetry_config).bootstrap()
return

with pytest.raises(ValueError): # noqa: PT011
opentelemetry_instrument.OpentelemetryInstrument(instrument_config=minimal_opentelemetry_config).bootstrap()
1 change: 0 additions & 1 deletion tests/instruments/test_pyroscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ def test_opentelemetry_includes_pyroscope_2(
async def test_handler() -> None: ...

FastAPITestClient(app=fastapi_application).get("/test-handler")

assert (
add_thread_tag_mock.mock_calls
== remove_thread_tag_mock.mock_calls
Expand Down