Skip to content
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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ 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 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_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`), for FastMCP with `OTEL_PYTHON_STARLETTE_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`; 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.
Expand Down Expand Up @@ -494,6 +494,49 @@ class YourSettings(FastStreamSettings):
...
```

#### FastMCP

`FastMcpSettings` include all OpenTelemetry settings, so tracing is enabled with `opentelemetry_endpoint` alone:

```python
from fastmcp import FastMCP
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

from microbootstrap import FastMcpSettings
from microbootstrap.bootstrappers.fastmcp import FastMcpBootstrapper
from microbootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrumentor


class YourSettings(FastMcpSettings):
opentelemetry_endpoint: str | None = "otel-collector:4317"
opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = [OpenTelemetryInstrumentor(HTTPXClientInstrumentor())]


application: FastMCP = FastMcpBootstrapper(YourSettings()).bootstrap()
http_application = application.http_app(path="/mcp")
```

FastMCP creates its ASGI application only when `http_app()` is called (directly or by `application.run(transport="http")`),
so `SERVER` spans are added to every application returned by `http_app()`. Each request to it is wrapped in
[`OpenTelemetryMiddleware`](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html)
and produces a span named like `POST /mcp` or `GET /health/` with the `http.route` attribute and the response status code.
Requests to unknown paths produce spans named after the HTTP method only, without `http.route`.

- `opentelemetry_endpoint` - OTLP endpoint for exported traces.
- `opentelemetry_instrumentors` - extra instrumentors, e.g. for HTTP clients used by your tools.
- `opentelemetry_exclude_urls` - urls without spans, `["/metrics"]` by default. Combined with `OTEL_PYTHON_STARLETTE_EXCLUDED_URLS`.
- `opentelemetry_generate_health_check_spans` - set to `False` to skip spans for `health_checks_path`.
- The status code attribute name depends on `OTEL_SEMCONV_STABILITY_OPT_IN`: `http.status_code` when unset,
`http.response.status_code` for `http`, both for `http/dup`.

Applications already instrumented by `StarletteInstrumentor` are left as is, so the request is never traced twice.
To post-process every created ASGI application yourself, use `add_http_application_postprocessor` on the bootstrapped application:

```python
application = FastMcpBootstrapper(settings).bootstrap()
application.add_http_application_postprocessor(lambda http_application: http_application)
```

### [Pyroscope](https://pyroscope.io)

To integrate Pyroscope, specify the `pyroscope_endpoint`.
Expand Down
75 changes: 72 additions & 3 deletions microbootstrap/bootstrappers/fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
import prometheus_client
import typing_extensions
from fastmcp import FastMCP
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.util.http import ExcludeList, get_excluded_urls
from starlette.applications import Starlette
from starlette.responses import JSONResponse, Response
from starlette.routing import Match, Mount, Route

from microbootstrap.bootstrappers.base import ApplicationBootstrapper
from microbootstrap.config.fastmcp import FastMcpConfig
from microbootstrap.instruments import opentelemetry_instrument
from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict
from microbootstrap.instruments.logging_instrument import LoggingInstrument
from microbootstrap.instruments.prometheus_instrument import FastMcpPrometheusConfig, PrometheusInstrument
Expand All @@ -18,16 +23,46 @@


if typing.TYPE_CHECKING:
from fastmcp.server.http import StarletteWithLifespan
from starlette.requests import Request
from starlette.types import Scope


StarletteT = typing.TypeVar("StarletteT", bound=Starlette)


class KwargsFastMCP(FastMCP[typing.Any]):
def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401
super().__init__(**kwargs)
self.http_application_postprocessors: list[typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]] = []

def add_http_application_postprocessor(
self, postprocessor: typing.Callable[[StarletteWithLifespan], StarletteWithLifespan]
) -> None:
self.http_application_postprocessors.append(postprocessor)

def http_app(self, *args: typing.Any, **kwargs: typing.Any) -> StarletteWithLifespan: # noqa: ANN401
# ASGI application is created by the user after bootstrap, so instruments subscribe to its creation
http_application = super().http_app(*args, **kwargs)
for postprocessor in self.http_application_postprocessors:
http_application = postprocessor(http_application)
return http_application


def build_fastmcp_route_details_from_scope(
scope: Scope,
routes: typing.Iterable[typing.Any],
) -> tuple[str, dict[str, str]]:
method: typing.Final = str(scope.get("method", "HTTP")).strip()
for route in routes:
if isinstance(route, (Route, Mount)) and route.matches(scope)[0] == Match.FULL:
return opentelemetry_instrument.build_span_name(method, route.path), {"http.route": route.path}
# Unmatched paths get no `http.route` to keep its cardinality low
return method, {}


class FastMcpBootstrapper(
ApplicationBootstrapper[FastMcpSettings, FastMCP[typing.Any], FastMcpConfig],
ApplicationBootstrapper[FastMcpSettings, KwargsFastMCP, FastMcpConfig],
):
application_config = FastMcpConfig()
application_type = KwargsFastMCP
Expand All @@ -41,8 +76,8 @@ def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:

def bootstrap_before_instruments_after_app_created(
self,
application: FastMCP[typing.Any],
) -> FastMCP[typing.Any]:
application: KwargsFastMCP,
) -> KwargsFastMCP:
self.console_writer.print_bootstrap_table()
return application

Expand All @@ -51,6 +86,40 @@ def bootstrap_before_instruments_after_app_created(
FastMcpBootstrapper.use_instrument()(PyroscopeInstrument)


@FastMcpBootstrapper.use_instrument()
class FastMcpOpentelemetryInstrument(
opentelemetry_instrument.BaseOpentelemetryInstrument[opentelemetry_instrument.OpentelemetryConfig]
):
def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override]
if isinstance(application, KwargsFastMCP):
application.add_http_application_postprocessor(self.__instrument_http_app)
return application

def __instrument_http_app(self, http_application: StarletteT) -> StarletteT:
# `StarletteInstrumentor` marks applications the same way, so each application is instrumented once
if getattr(http_application, "_is_instrumented_by_opentelemetry", False):
return http_application

def build_route_details(scope: Scope) -> tuple[str, dict[str, str]]:
return build_fastmcp_route_details_from_scope(scope, http_application.routes)

http_application.add_middleware(
OpenTelemetryMiddleware,
tracer_provider=self.tracer_provider,
default_span_details=build_route_details,
excluded_urls=opentelemetry_instrument.CombinedExcludeList(
ExcludeList(self.define_exclude_urls()),
get_excluded_urls("STARLETTE"),
),
)
http_application._is_instrumented_by_opentelemetry = True # type: ignore[attr-defined] # noqa: SLF001
return http_application

@classmethod
def get_config_type(cls) -> type[opentelemetry_instrument.OpentelemetryConfig]:
return opentelemetry_instrument.OpentelemetryConfig


@FastMcpBootstrapper.use_instrument()
class FastMcpLoggingInstrument(LoggingInstrument):
def bootstrap_after(self, application: FastMCP[typing.Any]) -> FastMCP[typing.Any]: # type: ignore[override]
Expand Down
1 change: 1 addition & 0 deletions microbootstrap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class FastMcpSettings( # type: ignore[misc]
BaseServiceSettings,
ServerConfig,
LoggingConfig,
OpentelemetryConfig,
SentryConfig,
FastMcpPrometheusConfig,
HealthChecksConfig,
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ dev = [
"anyio>=4.8.0",
"httpx>=0.28.1",
"mypy>=1.14.1",
"opentelemetry-instrumentation-starlette>=0.54b1",
"pre-commit>=4.0.1",
"pytest>=8.3.4",
"pytest-cov>=6.0.0",
Expand Down
Loading
Loading