Skip to content

Add Pyroscope instrument (continuous profiling) #92

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 20 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7aa909d
Add Pyroscope instrumentation and example app
vrslev Apr 18, 2025
30e1dd8
Add Pyroscope instrument configuration test and ensure server address…
vrslev Apr 18, 2025
f5b211f
Add OpentelemetryConfig and PyroscopeSpanProcessor to test_pyroscope.py
vrslev Apr 18, 2025
adb79fb
Adjust hello_world to be asynchronous and update create_app with pyro…
vrslev Apr 18, 2025
f44c5b1
Organize imports and streamline server creation in t.py
vrslev Apr 18, 2025
b15ad7c
Adding Litestar example application and updating pyproject.toml for i…
vrslev Apr 18, 2025
20d6a04
Add Settings class and use it in Litestar app configuration
vrslev Apr 18, 2025
acdfe55
Adjust import comment and remove per-file ignore for INP001
vrslev Apr 18, 2025
34b9612
Adjust import style and fix ruff ignore for examples directory
vrslev Apr 18, 2025
cf634bb
Adding PyroscopeInstrument to FastApi and FastStream bootstrappers
vrslev Apr 18, 2025
27317f5
Add future import for type annotations in litestar_app.py
vrslev Apr 18, 2025
85841b6
Remove example application script t.py
vrslev Apr 18, 2025
f993158
Adjust imports and add checks for Pyroscope availability on non-Windo…
vrslev Apr 18, 2025
ed86de5
Update comment to clarify pyroscope import unavailability on Windows
vrslev Apr 18, 2025
ce235b2
Add Pyroscope support and update instrument documentation
vrslev Apr 18, 2025
e9c9ee2
Updating OpenTelemetry section to include debug mode option
vrslev Apr 18, 2025
4b68c17
Ensure instruments are ready before tearing them down in ApplicationB…
vrslev Apr 18, 2025
e27bcea
Adjust ready_condition description for PyroscopeInstrument to specify…
vrslev Apr 18, 2025
6bb6b2d
Add MockSentryTransport for testing Sentry configurations
vrslev Apr 18, 2025
07dfb4d
Adjusting import error handling comments for coverage and clarity
vrslev Apr 18, 2025
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
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,14 @@ At present, the following instruments are supported for bootstrapping:
- `sentry`
- `prometheus`
- `opentelemetry`
- `pyroscope`
- `logging`
- `cors`
- `swagger`

Let's clarify the process required to bootstrap these instruments.

### Sentry
### [Sentry](https://sentry.io/)

To bootstrap Sentry, you must provide at least the `sentry_dsn`.
Additional parameters can also be supplied through the settings object.
Expand All @@ -233,7 +234,7 @@ class YourSettings(BaseServiceSettings):

These settings are subsequently passed to the [sentry-sdk](https://pypi.org/project/sentry-sdk/) package, finalizing your Sentry integration.

### Prometheus
### [Prometheus](https://prometheus.io/)

Prometheus integration presents a challenge because the underlying libraries for `FastAPI`, `Litestar` and `FastStream` differ significantly, making it impossible to unify them under a single interface. As a result, the Prometheus settings for `FastAPI`, `Litestar` and `FastStream` must be configured separately.

Expand Down Expand Up @@ -315,15 +316,9 @@ Parameters description:
- `prometheus_metrics_path` - path to metrics handler.
- `prometheus_middleware_cls` - Prometheus middleware for your broker.

### Opentelemetry
### [OpenTelemetry](https://opentelemetry.io/)

To bootstrap Opentelemetry, you must provide several parameters:

- `service_name`
- `service_version`
- `opentelemetry_endpoint`
- `opentelemetry_namespace`
- `opentelemetry_container_name`
To bootstrap OpenTelemetry, you must provide `opentelemetry_endpoint` or set `service_debug` to `True`. In debug mode traces are sent to the console.

However, additional parameters can also be supplied if needed.

Expand Down Expand Up @@ -376,6 +371,14 @@ class YourSettings(FastStreamSettings):
...
```

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

To integrate Pyroscope, specify the `pyroscope_endpoint`. The `service_name` will be used as the application name. You can also set `pyroscope_sample_rate` (default is 100).

When both Pyroscope and OpenTelemetry are enabled, profile span IDs will be included in traces using [`pyroscope-otel`](https://github.com/grafana/otel-profiling-python) for correlation.

Note that Pyroscope integration is not supported on Windows.

### Logging

<b>microbootstrap</b> provides in-memory JSON logging through the use of [structlog](https://pypi.org/project/structlog/).
Expand Down
29 changes: 29 additions & 0 deletions examples/litestar_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from __future__ import annotations

import litestar

from microbootstrap import LitestarSettings
from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
from microbootstrap.config.litestar import LitestarConfig
from microbootstrap.granian_server import create_granian_server


class Settings(LitestarSettings): ...


settings = Settings()


@litestar.get("/")
async def hello_world() -> dict[str, str]:
return {"hello": "world"}


def create_app() -> litestar.Litestar:
return (
LitestarBootstrapper(settings).configure_application(LitestarConfig(route_handlers=[hello_world])).bootstrap()
)


if __name__ == "__main__":
create_granian_server("examples.litestar_app:create_app", settings, factory=True).serve()
3 changes: 2 additions & 1 deletion microbootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,5 @@ def bootstrap_after(self, application: ApplicationT) -> ApplicationT:

def teardown(self) -> None:
for instrument in self.instrument_box.instruments:
instrument.teardown()
if instrument.is_ready():
instrument.teardown()
4 changes: 4 additions & 0 deletions microbootstrap/bootstrappers/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from microbootstrap.instruments.logging_instrument import LoggingInstrument
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument
from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
from microbootstrap.instruments.sentry_instrument import SentryInstrument
from microbootstrap.instruments.swagger_instrument import SwaggerInstrument
from microbootstrap.middlewares.fastapi import build_fastapi_logging_middleware
Expand Down Expand Up @@ -85,6 +86,9 @@ def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
return application


FastApiBootstrapper.use_instrument()(PyroscopeInstrument)


@FastApiBootstrapper.use_instrument()
class FastApiOpentelemetryInstrument(OpentelemetryInstrument):
def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
Expand Down
2 changes: 2 additions & 0 deletions microbootstrap/bootstrappers/faststream.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
FastStreamOpentelemetryConfig,
)
from microbootstrap.instruments.prometheus_instrument import FastStreamPrometheusConfig, PrometheusInstrument
from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
from microbootstrap.instruments.sentry_instrument import SentryInstrument
from microbootstrap.settings import FastStreamSettings

Expand All @@ -43,6 +44,7 @@ def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:


FastStreamBootstrapper.use_instrument()(SentryInstrument)
FastStreamBootstrapper.use_instrument()(PyroscopeInstrument)


@FastStreamBootstrapper.use_instrument()
Expand Down
4 changes: 4 additions & 0 deletions microbootstrap/bootstrappers/litestar.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from microbootstrap.instruments.logging_instrument import LoggingInstrument
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
from microbootstrap.instruments.prometheus_instrument import LitestarPrometheusConfig, PrometheusInstrument
from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
from microbootstrap.instruments.sentry_instrument import SentryInstrument
from microbootstrap.instruments.swagger_instrument import SwaggerInstrument
from microbootstrap.middlewares.litestar import build_litestar_logging_middleware
Expand Down Expand Up @@ -102,6 +103,9 @@ def bootstrap_before(self) -> dict[str, typing.Any]:
}


LitestarBootstrapper.use_instrument()(PyroscopeInstrument)


@LitestarBootstrapper.use_instrument()
class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
def bootstrap_before(self) -> dict[str, typing.Any]:
Expand Down
14 changes: 12 additions & 2 deletions microbootstrap/instruments/opentelemetry_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.trace import set_tracer_provider

from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument


try:
from pyroscope.otel import PyroscopeSpanProcessor # type: ignore[import-untyped]
except ImportError: # pragma: no cover
PyroscopeSpanProcessor = None


if typing.TYPE_CHECKING:
import faststream
from opentelemetry.metrics import Meter, MeterProvider
from opentelemetry.trace import TracerProvider

from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument


OpentelemetryConfigT = typing.TypeVar("OpentelemetryConfigT", bound="OpentelemetryConfig")

Expand All @@ -35,6 +41,7 @@ class OpentelemetryConfig(BaseInstrumentConfig):
service_name: str = "micro-service"
service_version: str = "1.0.0"
health_checks_path: str = "/health/"
pyroscope_endpoint: pydantic.HttpUrl | None = None

opentelemetry_service_name: str | None = None
opentelemetry_container_name: str | None = None
Expand Down Expand Up @@ -90,6 +97,9 @@ def bootstrap(self) -> None:
resource: typing.Final = resources.Resource.create(attributes=attributes)

self.tracer_provider = SdkTracerProvider(resource=resource)
if self.instrument_config.pyroscope_endpoint and PyroscopeSpanProcessor:
self.tracer_provider.add_span_processor(PyroscopeSpanProcessor())

if self.instrument_config.service_debug:
self.tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span)))
if self.instrument_config.opentelemetry_endpoint:
Expand Down
40 changes: 40 additions & 0 deletions microbootstrap/instruments/pyroscope_instrument.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import pydantic # noqa: TC002

from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument


try:
import pyroscope # type: ignore[import-untyped]
except ImportError: # pragma: no cover
pyroscope = None # Not supported on Windows


class PyroscopeConfig(BaseInstrumentConfig):
service_name: str = "micro-service"

pyroscope_endpoint: pydantic.HttpUrl | None = None
pyroscope_sample_rate: int = 100


class PyroscopeInstrument(Instrument[PyroscopeConfig]):
instrument_name = "Pyroscope"
ready_condition = "Provide pyroscope_endpoint"

def is_ready(self) -> bool:
return all([self.instrument_config.pyroscope_endpoint, pyroscope])

def teardown(self) -> None:
pyroscope.shutdown()

def bootstrap(self) -> None:
pyroscope.configure(
application_name=self.instrument_config.service_name,
server_address=str(self.instrument_config.pyroscope_endpoint),
sample_rate=self.instrument_config.pyroscope_sample_rate,
)

@classmethod
def get_config_type(cls) -> type[PyroscopeConfig]:
return PyroscopeConfig
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ dependencies = [
"rich>=13",
"sentry-sdk>=2.7",
"structlog>=24",
"pyroscope-io; platform_system != 'Windows'",
"pyroscope-otel; platform_system != 'Windows'",
]
dynamic = ["version"]
authors = [{ name = "community-of-python" }]
Expand Down Expand Up @@ -125,6 +127,7 @@ lines-after-imports = 2

[tool.ruff.lint.extend-per-file-ignores]
"tests/*.py" = ["S101", "S311"]
"examples/*.py" = ["INP001"]

[tool.coverage.report]
exclude_also = ["if typing.TYPE_CHECKING:", 'class \w+\(typing.Protocol\):']
Expand Down
15 changes: 14 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import litestar
import pytest
from sentry_sdk.transport import Transport as SentryTransport

import microbootstrap.settings
from microbootstrap import (
Expand All @@ -22,6 +23,10 @@
from microbootstrap.settings import BaseServiceSettings, ServerConfig


if typing.TYPE_CHECKING:
from sentry_sdk.envelope import Envelope as SentryEnvelope


pytestmark = [pytest.mark.anyio]


Expand All @@ -35,9 +40,17 @@ def default_litestar_app() -> litestar.Litestar:
return litestar.Litestar()


class MockSentryTransport(SentryTransport):
def capture_envelope(self, envelope: SentryEnvelope) -> None: ...


@pytest.fixture
def minimal_sentry_config() -> SentryConfig:
return SentryConfig(sentry_dsn="https://examplePublicKey@o0.ingest.sentry.io/0", sentry_tags={"test": "test"})
return SentryConfig(
sentry_dsn="https://examplePublicKey@o0.ingest.sentry.io/0",
sentry_tags={"test": "test"},
sentry_additional_params={"transport": MockSentryTransport()},
)


@pytest.fixture
Expand Down
37 changes: 37 additions & 0 deletions tests/instruments/test_pyroscope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pydantic
import pytest

from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpentelemetryInstrument
from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument


try:
from pyroscope.otel import PyroscopeSpanProcessor # type: ignore[import-untyped]
except ImportError: # pragma: no cover
pytest.skip("pyroscope is not installed", allow_module_level=True)


class TestPyroscopeInstrument:
@pytest.fixture
def minimal_pyroscope_config(self) -> PyroscopeConfig:
return PyroscopeConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040"))

def test_ok(self, minimal_pyroscope_config: PyroscopeConfig) -> None:
instrument = PyroscopeInstrument(minimal_pyroscope_config)
assert instrument.is_ready()
instrument.bootstrap()
instrument.teardown()

def test_not_ready(self) -> None:
instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None))
assert not instrument.is_ready()

def test_opentelemetry_includes_pyroscope(self) -> None:
otel_instrument = OpentelemetryInstrument(
OpentelemetryConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040"))
)
otel_instrument.bootstrap()
assert PyroscopeSpanProcessor in {
type(one_span_processor)
for one_span_processor in otel_instrument.tracer_provider._active_span_processor._span_processors # noqa: SLF001
}