Skip to content
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

Introduce Django #119

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Monitor Python web apps using
[Spring Boot Admin](https://github.com/codecentric/spring-boot-admin).

Pyctuator supports **[Flask](https://palletsprojects.com/p/flask/)**, **[FastAPI](https://fastapi.tiangolo.com/)**, **[aiohttp](https://docs.aiohttp.org/)** and **[Tornado](https://www.tornadoweb.org/)**. **Django** support is planned as well.
Pyctuator supports **[Flask](https://palletsprojects.com/p/flask/)**, **[FastAPI](https://fastapi.tiangolo.com/)**, **[aiohttp](https://docs.aiohttp.org/)** and **[Tornado](https://www.tornadoweb.org/)**. **Django** support is unstable.

The following video shows a FastAPI web app being monitored and controled using Spring Boot Admin.

Expand Down Expand Up @@ -135,6 +135,29 @@ Pyctuator(
Server(config=(Config(app=app, loop="asyncio"))).run()
```

### Django
Update your Django wsgi.py as is.

```python
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'actuator.settings')

application = get_wsgi_application()

# add this part
Pyctuator(
application,
"Django Pyctuator",
app_url="http://host.docker.internal:8000",
pyctuator_endpoint_url="http://host.docker.internal:8000/pyctuator",
registration_url="http://localhost:8080/register",
)

```

The application will automatically register with Spring Boot Admin upon start up.

Log in to the Spring Boot Admin UI at `http://localhost:8080` to interact with the application.
Expand Down
1,443 changes: 1,253 additions & 190 deletions poetry.lock

Large diffs are not rendered by default.

235 changes: 235 additions & 0 deletions pyctuator/impl/django_pyctuator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import dataclasses
from datetime import datetime
import json
import importlib
from http import HTTPStatus
from typing import Mapping, List, Any
from collections import defaultdict

from django.http.response import HttpResponse, JsonResponse
from django.http.request import HttpRequest
from pyctuator.impl.pyctuator_router import PyctuatorRouter
from pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse

from django.core.handlers.base import BaseHandler
from django.urls import path
from django.conf import settings
from django.core.wsgi import get_wsgi_application
from django.core.serializers.json import DjangoJSONEncoder
from pyctuator.impl import SBA_V2_CONTENT_TYPE

from pyctuator.impl.pyctuator_impl import PyctuatorImpl
from pyctuator.endpoints import Endpoints


class EnhancedJSONEncoder(DjangoJSONEncoder):
def default(self, o):
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return super().default(o)


def env(request):
return JsonResponse(
settings.PYCTUATOR.pyctuator_impl.get_environment(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def info(request):
return JsonResponse(
settings.PYCTUATOR.pyctuator_impl.get_app_info(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def httptrace(request):
return JsonResponse(
settings.PYCTUATOR.pyctuator_impl.http_tracer.get_httptrace(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def index(request):
return JsonResponse(
settings.PYCTUATOR.get_endpoints_data(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def health(request):
health = settings.PYCTUATOR.pyctuator_impl.get_health()
return JsonResponse(
health,
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
status=health.http_status(),
safe=False,
)


def metrics(request, metric_name=None):
data = (
settings.PYCTUATOR.pyctuator_impl.get_metric_measurement(metric_name)
if metric_name
else settings.PYCTUATOR.pyctuator_impl.get_metric_names()
)
return JsonResponse(
data,
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def thread_dump(request):
return JsonResponse(
settings.PYCTUATOR.pyctuator_impl.get_thread_dump(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def loggers(request, logger_name=None):
response_body = {}
if request.method == "POST":
# TODO: throw if empty logger_name
data = json.loads(request.body)
settings.PYCTUATOR.pyctuator_impl.logging.set_logger_level(
logger_name, data.get("configuredLevel", None)
)

else:
response_body = (
settings.PYCTUATOR.pyctuator_impl.logging.get_logger(logger_name)
if logger_name
else settings.PYCTUATOR.pyctuator_impl.logging.get_loggers()
)
return JsonResponse(
response_body,
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)


def logfile(request):
range_header = request.headers.get("range")
if not range_header:
return JsonResponse(
settings.PYCTUATOR.pyctuator_impl.logfile.log_messages.get_range(),
encoder=EnhancedJSONEncoder,
content_type=SBA_V2_CONTENT_TYPE,
safe=False,
)

str_res, start, end = settings.PYCTUATOR.pyctuator_impl.logfile.get_logfile(
range_header
)
response = HttpResponse(str_res, status=HTTPStatus.PARTIAL_CONTENT.value)
response["Content-Type"] = "text/html; charset=UTF-8"
response["Accept-Ranges"] = "bytes"
response["Content-Range"] = f"bytes {start}-{end}/{end}"

return response


class DjangoPyctuatorMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
request_time = datetime.now()
response = self.get_response(request)
response_time = datetime.now()
# Record the request and response
new_record = self._create_record(request, response, request_time, response_time)
settings.PYCTUATOR.pyctuator_impl.http_tracer.add_record(record=new_record)

return response

def _create_headers_dictionary(self, headers: Any) -> Mapping[str, List[str]]:
headers_dict: Mapping[str, List[str]] = defaultdict(list)
for key, value in headers.items():
headers_dict[key].append(value)
return headers_dict

def _create_record(
self,
request: HttpRequest,
response: HttpResponse,
request_time: datetime,
response_time: datetime,
) -> TraceRecord:
new_record: TraceRecord = TraceRecord(
request_time,
None,
None,
TraceRequest(
request.method or "GET",
request.build_absolute_uri(),
self._create_headers_dictionary(request.headers),
),
TraceResponse(
response.status_code, self._create_headers_dictionary(response.headers)
),
int((response_time.timestamp() - request_time.timestamp()) * 1000),
)
return new_record


class DjangoPyctuator(PyctuatorRouter):
def __init__(
self,
app: BaseHandler,
pyctuator_impl: PyctuatorImpl,
disabled_endpoints: Endpoints,
) -> None:
super().__init__(app, pyctuator_impl)
if not settings.configured:
self.app = get_wsgi_application()

settings.PYCTUATOR = self
self.inject_middleware()
self.urls_module = importlib.import_module(settings.ROOT_URLCONF)
self.inject_route("", index)
self.inject_route("/env", env)
self.inject_route("/info", info)
self.inject_route("/health", health)
self.inject_route("/metrics", metrics)
self.inject_route("/metrics/<metric_name>", metrics)
self.inject_route("/loggers", loggers)
self.inject_route("/loggers/<logger_name>", loggers)
self.inject_route("/dump", thread_dump)
self.inject_route("/threaddump", thread_dump)
self.inject_route("/logfile", logfile)
self.inject_route("/trace", httptrace)
self.inject_route("/httptrace", httptrace)

app.load_middleware()

def inject_route(self, uri, view):
new_route = path(
f"pyctuator{uri}",
view,
name=f"pyctuator:{uri}",
)
self.urls_module.urlpatterns.append(new_route)

def inject_middleware(self):
if (
"pyctuator.impl.django_pyctuator.DjangoPyctuatorMiddleware"
not in settings.MIDDLEWARE
):
settings.MIDDLEWARE.insert(
0, "pyctuator.impl.django_pyctuator.DjangoPyctuatorMiddleware"
)
26 changes: 25 additions & 1 deletion pyctuator/pyctuator.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def __init__(
* aiohttp - `app` is an instance of `aiohttp.web.Application`

* Tornado - `app` is an instance of `tornado.web.Application`

* Django - `app` is an instance of `django.core.handlers.base.BaseHandler`

:param app: an instance of a supported web-framework with which the pyctuator endpoints will be registered
:param app_name: the application's name that will be presented in the "Info" section in boot-admin
Expand Down Expand Up @@ -126,7 +128,8 @@ def __init__(
"flask": self._integrate_flask,
"fastapi": self._integrate_fastapi,
"aiohttp": self._integrate_aiohttp,
"tornado": self._integrate_tornado
"tornado": self._integrate_tornado,
"django": self._integrate_django
}
for framework_name, framework_integration_function in framework_integrations.items():
if self._is_framework_installed(framework_name):
Expand Down Expand Up @@ -267,3 +270,24 @@ def _integrate_tornado(
TornadoHttpPyctuator(app, pyctuator_impl, disabled_endpoints)
return True
return False

# pylint: disable=unused-argument
def _integrate_django(
self,
app: Any,
pyctuator_impl: PyctuatorImpl,
customizer: Optional[Callable],
disabled_endpoints: Endpoints,
) -> bool:
"""
This method should only be called if we detected that django is installed.
It will then check whether the given app is a django app, and if so - it will add the Pyctuator
endpoints to it.
"""
from django.core.handlers.base import BaseHandler
if isinstance(app, BaseHandler):
from pyctuator.impl.django_pyctuator import DjangoPyctuator
DjangoPyctuator(app, pyctuator_impl, disabled_endpoints)
return True
return False

6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ maintainers = [
readme = "README.md"
homepage = "https://github.com/SolarEdgeTech/pyctuator"
repository = "https://github.com/SolarEdgeTech/pyctuator"
keywords = ["spring boot admin", "actuator", "pyctuator", "fastapi", "flask", "aiohttp", "tornado"]
keywords = ["spring boot admin", "actuator", "pyctuator", "fastapi", "flask", "aiohttp", "tornado", "django"]

classifiers = [
"Development Status :: 4 - Beta",
Expand All @@ -34,7 +34,7 @@ classifiers = [
]

[tool.poetry.dependencies]
python = "^3.9"
python = "^3.10"
psutil = { version = "^5.6", optional = true }
flask = { version = "^2.3.0", optional = true }
fastapi = { version = "^0.100.1", optional = true }
Expand All @@ -45,6 +45,7 @@ cryptography = {version = ">=39.0.1,<40.0.0", optional = true}
redis = {version = "^4.3.4", optional = true}
aiohttp = {version = "^3.6.2", optional = true}
tornado = {version = "^6.0.4", optional = true}
django = {version = "^5.1.3", optional = true}

[tool.poetry.dev-dependencies]
requests = "^2.22"
Expand All @@ -62,6 +63,7 @@ aiohttp = ["aiohttp"]
tornado = ["tornado"]
db = ["sqlalchemy", "PyMySQL", "cryptography"]
redis = ["redis"]
django = ["django"]

[build-system]
requires = ["poetry>=1.1"]
Expand Down
Loading