Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ class YourSettings(BaseServiceSettings):
opentelemetry_namespace: str | None = None
opentelemetry_insecure: bool = True
opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = []
opentelemetry_exclude_urls: list[str] = []
opentelemetry_exclude_urls: list[str] = ["/metrics"]
opentelemetry_baggage_span_attributes: dict[str, str] = {}

... # Other settings here
Expand All @@ -456,9 +456,9 @@ Parameters description:
- `opentelemetry_insecure` - is opentelemetry connection secure.
- `opentelemetry_container_name` - will be passed to the `Resource`.
- `opentelemetry_instrumentors` - a list of extra instrumentors.
- `opentelemetry_exclude_urls` - list of ignored urls.
- `opentelemetry_exclude_urls` - list of url regexes that produce no server spans (`["/metrics"]` by default). For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`).
- `opentelemetry_log_traces` - traces will be logged to stdout.
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`; if `False`, `health_checks_path` is added to the excluded urls.
- `opentelemetry_baggage_span_attributes` - maps allowed baggage keys to attributes added to local server and consumer spans.

These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration.
Expand Down
28 changes: 14 additions & 14 deletions microbootstrap/bootstrappers/litestar.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@
from litestar.types.asgi_types import ASGIApp, Scope
from litestar_offline_docs import generate_static_files_config
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.util.http import get_excluded_urls
from opentelemetry.util.http import ExcludeList, get_excluded_urls
from sentry_sdk.integrations.litestar import LitestarIntegration

from microbootstrap.bootstrappers.base import ApplicationBootstrapper
from microbootstrap.config.litestar import LitestarConfig
from microbootstrap.instruments import opentelemetry_instrument
from microbootstrap.instruments.cors_instrument import CorsInstrument
from microbootstrap.instruments.health_checks_instrument import (
HealthChecksInstrument,
HealthCheckTypedDict,
)
from microbootstrap.instruments.logging_instrument import LoggingInstrument
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
from microbootstrap.instruments.prometheus_instrument import (
LitestarPrometheusConfig,
PrometheusInstrument,
Expand Down Expand Up @@ -122,12 +122,6 @@ def bootstrap_before(self) -> dict[str, typing.Any]:
LitestarBootstrapper.use_instrument()(PyroscopeInstrument)


def build_span_name(method: str, route: str) -> str:
if not route:
return method
return f"{method} {route}"


def build_litestar_route_details_from_scope(
scope: Scope,
) -> tuple[str, dict[str, str]]:
Expand All @@ -144,26 +138,31 @@ def build_litestar_route_details_from_scope(
method: typing.Final = str(scope.get("method", "HTTP")).strip()
if path_template is not None:
path_template_stripped: typing.Final = path_template.strip()
return build_span_name(method, path_template_stripped), {"http.route": path_template_stripped}
span_name: typing.Final = opentelemetry_instrument.build_span_name(method, path_template_stripped)
return span_name, {"http.route": path_template_stripped}

path: typing.Final = scope.get("path")
if path is not None:
path_stripped: typing.Final = path.strip()
return build_span_name(method, path_stripped), {"http.route": path_stripped}
return opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped}
return method, {}


class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
def __init__(self, config: OpenTelemetryConfig) -> None:
def __init__(self, config: OpenTelemetryConfig, exclude_urls: typing.Sequence[str] = ()) -> None:
self.config = config
self.excluded_urls = opentelemetry_instrument.CombinedExcludeList(
ExcludeList(exclude_urls),
get_excluded_urls(self.config.exclude_urls_env_key),
)

def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddleware:
return OpenTelemetryMiddleware(
app=app,
client_request_hook=self.config.client_request_hook_handler,
client_response_hook=self.config.client_response_hook_handler,
default_span_details=build_litestar_route_details_from_scope,
excluded_urls=get_excluded_urls(self.config.exclude_urls_env_key),
excluded_urls=self.excluded_urls,
meter=self.config.meter,
meter_provider=self.config.meter_provider,
server_request_hook=self.config.server_request_hook_handler,
Expand All @@ -175,15 +174,16 @@ async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASG


@LitestarBootstrapper.use_instrument()
class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument):
def bootstrap_before(self) -> dict[str, typing.Any]:
return {
"middleware": [
LitestarOpenTelemetryInstrumentationMiddleware(
LitestarOpentelemetryConfig(
tracer_provider=self.tracer_provider,
middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type]
)
),
exclude_urls=self.define_exclude_urls(),
)
]
}
Expand Down
36 changes: 26 additions & 10 deletions microbootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.trace import SpanKind, format_span_id, get_current_span, set_tracer_provider
from opentelemetry.util._importlib_metadata import entry_points
from opentelemetry.util.http import ExcludeList

from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
from microbootstrap.instruments.sentry_instrument import snapshot_sentry_opentelemetry_baggage
Expand Down Expand Up @@ -70,6 +71,21 @@ def opentelemetry_baggage_scope(
context.detach(token)


def build_span_name(method: str, route: str) -> str:
if not route:
return method
return f"{method} {route}"


class CombinedExcludeList(ExcludeList):
def __init__(self, *exclude_lists: ExcludeList) -> None:
super().__init__([])
self.exclude_lists = exclude_lists

def url_disabled(self, url: str) -> bool:
return any(one_exclude_list.url_disabled(url) for one_exclude_list in self.exclude_lists)


@dataclasses.dataclass()
class OpenTelemetryInstrumentor:
instrumentor: BaseInstrumentor
Expand Down Expand Up @@ -167,6 +183,16 @@ def _load_instrumentors(self) -> None:
LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name)
raise

def define_exclude_urls(self) -> list[str]:
exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls]
if (
not self.instrument_config.opentelemetry_generate_health_check_spans
and self.instrument_config.health_checks_path
and self.instrument_config.health_checks_path not in exclude_urls
):
exclude_urls.append(self.instrument_config.health_checks_path)
return exclude_urls

def is_ready(self) -> bool:
return (
bool(self.instrument_config.opentelemetry_endpoint)
Expand Down Expand Up @@ -222,16 +248,6 @@ def bootstrap(self) -> None:


class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]):
def define_exclude_urls(self) -> list[str]:
exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls]
if (
not self.instrument_config.opentelemetry_generate_health_check_spans
and self.instrument_config.health_checks_path
and self.instrument_config.health_checks_path not in exclude_urls
):
exclude_urls.append(self.instrument_config.health_checks_path)
return exclude_urls

@classmethod
def get_config_type(cls) -> type[OpentelemetryConfig]:
return OpentelemetryConfig
Expand Down
56 changes: 56 additions & 0 deletions tests/bootstrappers/test_litestar_opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
from litestar.status_codes import HTTP_200_OK
from litestar.testing import TestClient
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import SpanKind

from microbootstrap import LitestarSettings
from microbootstrap.bootstrappers.litestar import (
Expand All @@ -15,6 +19,7 @@
build_litestar_route_details_from_scope,
)
from microbootstrap.config.litestar import LitestarConfig
from microbootstrap.instruments import opentelemetry_instrument
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig


Expand Down Expand Up @@ -182,3 +187,54 @@ def test_litestar_opentelemetry_middleware_initialization() -> None:
assert middleware.config == mock_config
otel_middleware = middleware.create_open_telemetry_middleware(mock_app)
assert otel_middleware is not None


def test_litestar_opentelemetry_excludes_urls_from_settings(
monkeypatch: pytest.MonkeyPatch,
minimal_opentelemetry_config: OpentelemetryConfig,
) -> None:
for environment_variable in ("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "OTEL_PYTHON_EXCLUDED_URLS"):
monkeypatch.delenv(environment_variable, raising=False)
span_exporter: typing.Final = InMemorySpanExporter()

def build_tracer_provider(*args: typing.Any, **kwargs: typing.Any) -> SdkTracerProvider: # noqa: ANN401
tracer_provider: typing.Final = SdkTracerProvider(*args, **kwargs)
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
return tracer_provider

monkeypatch.setattr(opentelemetry_instrument, "SdkTracerProvider", build_tracer_provider)
minimal_opentelemetry_config.opentelemetry_exclude_urls = ["/internal"]

@litestar.get("/internal")
async def internal() -> None: ...

@litestar.get("/public")
async def public() -> None: ...

application: typing.Final = (
LitestarBootstrapper(LitestarSettings())
.configure_instrument(minimal_opentelemetry_config)
.configure_application(LitestarConfig(route_handlers=[internal, public]))
.bootstrap()
)

with TestClient(app=application) as client:
client.get("/internal")
assert not span_exporter.get_finished_spans()

client.get("/public")
assert [span.name for span in span_exporter.get_finished_spans() if span.kind == SpanKind.SERVER] == ["GET /public"]


def test_litestar_opentelemetry_excludes_urls_from_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OTEL_PYTHON_LITESTAR_EXCLUDED_URLS", "/from-env")
middleware: typing.Final = LitestarOpenTelemetryInstrumentationMiddleware(
LitestarOpentelemetryConfig(),
exclude_urls=["/from-settings"],
)

assert middleware.excluded_urls.url_disabled("/from-env")
assert middleware.excluded_urls.url_disabled("/from-settings")
assert not middleware.excluded_urls.url_disabled("/other")