diff --git a/examples/images/tornado.png b/examples/images/tornado.png new file mode 100644 index 0000000..8f6fc05 Binary files /dev/null and b/examples/images/tornado.png differ diff --git a/examples/tornado/README.md b/examples/tornado/README.md new file mode 100644 index 0000000..e8e799b --- /dev/null +++ b/examples/tornado/README.md @@ -0,0 +1,17 @@ +# Tornado example +This example demonstrates the integration with the [Tornado](https://www.tornadoweb.org/). + +## Running the example +1. Start an instance of SBA (Spring Boot Admin): + ```sh + docker run --rm -p 8080:8080 michayaak/spring-boot-admin:2.2.3-1 + ``` +2. Once Spring Boot Admin is running, you can run the examples as follow: + ```sh + cd examples/tornado + poetry install + poetry run python -m tornado_example_app + ``` + +![tornado Example](../images/tornado.png) + diff --git a/examples/tornado/pyproject.toml b/examples/tornado/pyproject.toml new file mode 100644 index 0000000..9e3acb6 --- /dev/null +++ b/examples/tornado/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "tornado-pyctuator-example" +version = "1.0.0" +description = "Example of using Pyctuator" +authors = [ + "Desmond Stonie ", +] + +[tool.poetry.dependencies] +python = "^3.7" +psutil = { version = "^5.6" } +tornado = { version = "^6.0.4" } +pyctuator = { version = "^0.13" } + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" + diff --git a/examples/tornado/tornado_example_app.py b/examples/tornado/tornado_example_app.py new file mode 100644 index 0000000..5a4c2d8 --- /dev/null +++ b/examples/tornado/tornado_example_app.py @@ -0,0 +1,37 @@ +import datetime +import logging +import random + +from tornado import ioloop +from tornado.web import Application, RequestHandler +from tornado.httpserver import HTTPServer + +from pyctuator.pyctuator import Pyctuator + +my_logger = logging.getLogger("example") + +class HomeHandler(RequestHandler): + def get(self): + my_logger.debug(f"{datetime.datetime.now()} - {str(random.randint(0, 100))}") + print("Printing to STDOUT") + self.write("Hello World!") + +app = Application([ + (r"/", HomeHandler) +], debug=False) + +example_app_address = "host.docker.internal" +example_sba_address = "localhost" + +Pyctuator( + app, + "Tornado Pyctuator", + app_url=f"http://{example_app_address}:5000", + pyctuator_endpoint_url=f"http://{example_app_address}:5000/pyctuator", + registration_url=f"http://{example_sba_address}:8080/instances", + app_description="Demonstrate Spring Boot Admin Integration with Tornado", +) + +http_server = HTTPServer(app, decompress_request=True) +http_server.listen(5000) +ioloop.IOLoop.current().start() diff --git a/pyctuator/impl/tornado_pyctuator.py b/pyctuator/impl/tornado_pyctuator.py new file mode 100644 index 0000000..08625ce --- /dev/null +++ b/pyctuator/impl/tornado_pyctuator.py @@ -0,0 +1,113 @@ +import dataclasses +import json +from collections import defaultdict +from datetime import datetime +from functools import partial +from typing import Any, Callable, List, Mapping + +from tornado.web import Application, RequestHandler + +from pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse +from pyctuator.impl import SBA_V2_CONTENT_TYPE +from pyctuator.impl.pyctuator_impl import PyctuatorImpl +from pyctuator.impl.pyctuator_router import PyctuatorRouter + +class AbstractPyctuatorHandler(RequestHandler): + pyctuator = None + dumps = None + def initialize(self): + self.pyctuator = self.application.settings.get('pyctuator') + self.dumps = self.application.settings.get('custom_dumps') + +class PyctuatorHandler(AbstractPyctuatorHandler): + def get(self): + resp = self.pyctuator.get_endpoints_data() + self.write(self.dumps(resp)) + +# GET /env +class EnvHandler(AbstractPyctuatorHandler): + def options(self): + self.write('') + def get(self): + resp = self.pyctuator.pyctuator_impl.get_environment() + self.write(self.dumps(resp)) + +# GET /info +class InfoHandler(AbstractPyctuatorHandler): + def options(self): + self.write('') + def get(self): + resp = self.pyctuator.pyctuator_impl.app_info + self.write(self.dumps(resp)) + +# GET /health +class HealthHandler(AbstractPyctuatorHandler): + def options(self): + self.write('') + def get(self): + resp = self.pyctuator.pyctuator_impl.get_health() + self.write(self.dumps(resp)) + +# GET /metrics +class MetricsHandler(AbstractPyctuatorHandler): + def options(self): + self.write('') + def get(self): + resp = self.pyctuator.pyctuator_impl.get_metric_names() + self.write(self.dumps(resp)) + +# GET "/metrics/{metric_name}" +class MetricsNameHandler(AbstractPyctuatorHandler): + def get(self, metric_name): + resp = self.pyctuator.pyctuator_impl.get_metric_measurement(metric_name) + self.write(self.dumps(resp)) + +# GET /loggers +class LoggersHandler(AbstractPyctuatorHandler): + def options(self): + self.write('') + def get(self): + resp = self.pyctuator.pyctuator_impl.logging.get_loggers() + self.write(self.dumps(resp)) + +# GET /loggers/{logger_name} +# POST /loggers/{logger_name} +class LoggersNameHandler(AbstractPyctuatorHandler): + def get(self, logger_name): + resp = self.pyctuator.pyctuator_impl.logging.get_logger(logger_name) + self.write(self.dumps(resp)) + def post(self, logger_name): + body = self.request.body.decode('utf-8') + body = json.loads(body) + self.pyctuator.pyctuator_impl.logging.set_logger_level(logger_name, body.get('configuredLevel', None)) + self.write('') + +# pylint: disable=too-many-locals,unused-argument +class TornadoHttpPyctuator(PyctuatorRouter): + def __init__(self, app: Application, pyctuator_impl: PyctuatorImpl) -> None: + super().__init__(app, pyctuator_impl) + + custom_dumps = partial( + json.dumps, default=self._custom_json_serializer + ) + + app.settings.setdefault("pyctuator", self) + app.settings.setdefault("custom_dumps", custom_dumps) + app.add_handlers(".*$", [ + (r"/pyctuator", PyctuatorHandler), + (r"/pyctuator/env", EnvHandler), + (r"/pyctuator/info", InfoHandler), + (r"/pyctuator/health", HealthHandler), + (r"/pyctuator/metrics", MetricsHandler), + (r"/pyctuator/metrics/(?P.*$)", MetricsNameHandler), + (r"/pyctuator/loggers", LoggersHandler), + (r"/pyctuator/loggers/(?P.*$)", LoggersNameHandler), + ]) + + def _custom_json_serializer(self, value: Any) -> Any: + if dataclasses.is_dataclass(value): + return dataclasses.asdict(value) + + if isinstance(value, datetime): + return str(value) + return None diff --git a/pyctuator/pyctuator.py b/pyctuator/pyctuator.py index c692d2a..47de625 100644 --- a/pyctuator/pyctuator.py +++ b/pyctuator/pyctuator.py @@ -53,6 +53,8 @@ def __init__( * aiohttp - `app` is an instance of `aiohttp.web.Application` + * Tornado - `app` is an instance of `tornado.web.Application` + :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 :param app_description: a description that will be presented in the "Info" section in boot-admin @@ -101,6 +103,7 @@ def __init__( "flask": self._integrate_flask, "fastapi": self._integrate_fastapi, "aiohttp": self._integrate_aiohttp, + "tornado": self._integrate_tornado } for framework_name, framework_integration_function in framework_integrations.items(): if self._is_framework_installed(framework_name): @@ -195,3 +198,16 @@ def _integrate_aiohttp(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool: AioHttpPyctuator(app, pyctuator_impl) return True return False + + def _integrate_tornado(self, app: Any, pyctuator_impl: PyctuatorImpl) -> bool: + """ + This method should only be called if we detected that tornado is installed. + It will then check whether the given app is a tornado app, and if so - it will add the Pyctuator + endpoints to it. + """ + from tornado.web import Application + if isinstance(app, Application): + from pyctuator.impl.tornado_pyctuator import TornadoHttpPyctuator + TornadoHttpPyctuator(app, pyctuator_impl) + return True + return False diff --git a/pyproject.toml b/pyproject.toml index 4dca14b..e7dba9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ PyMySQL = {version = "^0.9.3", optional = true} cryptography = {version = "^2.8", optional = true} redis = {version = "^3.3", optional = true} aiohttp = {version = "^3.6.2", optional = true} +tornado = {version = "^6.0.4", optional = true} [tool.poetry.dev-dependencies] requests = "^2.22" @@ -56,6 +57,7 @@ psutil = ["psutil"] fastapi = ["fastapi", "uvicorn"] flask = ["flask"] aiohttp = ["aiohttp"] +tornado = ["tornado"] db = ["sqlalchemy", "PyMySQL", "cryptography"] redis = ["redis"]