From 0e5198d73e349bee5889805128174842cee03c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Zimmermann?= <101292599+ekneg54@users.noreply.github.com> Date: Mon, 29 Apr 2024 10:10:09 +0200 Subject: [PATCH] export metrics via uvicorn asgi app (#576) * extract ThreadingHTTPServer to separate module `logprep.util.http` * add security best practices * update changelog --------- Co-authored-by: dtrai2 <95028228+dtrai2@users.noreply.github.com> --- CHANGELOG.md | 4 + logprep/connector/http/input.py | 182 ++++-------------- logprep/metrics/exporter.py | 25 ++- logprep/util/configuration.py | 40 +++- logprep/util/http.py | 112 +++++++++++ .../exampledata/config/http_pipeline.yml | 10 + tests/acceptance/test_full_configuration.py | 6 +- tests/unit/connector/test_http_input.py | 32 ++- tests/unit/metrics/test_exporter.py | 6 +- tests/unit/util/test_configuration.py | 6 +- 10 files changed, 247 insertions(+), 176 deletions(-) create mode 100644 logprep/util/http.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62df9414c..26eeba3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### Features +* expose metrics via uvicorn webserver + * makes all uvicorn configuration options possible + * add security best practices to server configuration + ### Improvements ### Bugfix diff --git a/logprep/connector/http/input.py b/logprep/connector/http/input.py index 455b3004a..a08a722c3 100644 --- a/logprep/connector/http/input.py +++ b/logprep/connector/http/input.py @@ -77,12 +77,9 @@ * Responds with 405 """ -import inspect -import logging import multiprocessing as mp import queue import re -import threading from abc import ABC from base64 import b64encode from logging import Logger @@ -90,7 +87,6 @@ import falcon.asgi import msgspec -import uvicorn from attrs import define, field, validators from falcon import ( # pylint: disable=no-name-in-module HTTP_200, @@ -100,14 +96,9 @@ ) from logprep.abc.input import FatalInputError, Input -from logprep.util import defaults +from logprep.util import http from logprep.util.credentials import CredentialsFactory -uvicorn_parameter_keys = inspect.signature(uvicorn.Config).parameters.keys() -UVICORN_CONFIG_KEYS = [ - parameter for parameter in uvicorn_parameter_keys if parameter not in ["app", "log_level"] -] - def decorator_basic_auth(func: Callable): """Decorator to check basic authentication. @@ -272,113 +263,6 @@ async def __call__(self, req, resp, **kwargs): # pylint: disable=arguments-diff self.messages.put({**event, **metadata}, block=False) -class ThreadingHTTPServer: # pylint: disable=too-many-instance-attributes - """Singleton Wrapper Class around Uvicorn Thread that controls - lifecycle of Uvicorn HTTP Server. During Runtime this singleton object - is stateful and therefore we need to check for some attributes during - __init__ when multiple consecutive reconfigurations are happening. - - Parameters - ---------- - connector_config: Input.Config - Holds full connector config for config change checks - endpoints_config: dict - Endpoint paths as key and initiated endpoint objects as - value - log_level: str - Log level to be set for uvicorn server - """ - - _instance = None - _lock = threading.Lock() - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - with cls._lock: - if not cls._instance: - cls._instance = super(ThreadingHTTPServer, cls).__new__(cls) - return cls._instance - - def __init__( - self, - connector_config: Input.Config, - endpoints_config: dict, - log_level: str, - ) -> None: - """Creates object attributes with necessary configuration. - As this class creates a singleton object, the existing server - will be stopped and restarted on consecutively creations""" - super().__init__() - - self.connector_config = connector_config - self.endpoints_config = endpoints_config - self.log_level = log_level - - if hasattr(self, "thread"): - if self.thread.is_alive(): # pylint: disable=access-member-before-definition - self._stop() - self._start() - - def _start(self): - """Collect all configs, initiate application server and webserver - and run thread with uvicorn+falcon http server and wait - until it is up (started)""" - self.uvicorn_config = self.connector_config.uvicorn_config - self._init_web_application_server(self.endpoints_config) - log_config = self._init_log_config() - self.compiled_config = uvicorn.Config( - **self.uvicorn_config, - app=self.app, - log_level=self.log_level, - log_config=log_config, - ) - self.server = uvicorn.Server(self.compiled_config) - self._override_runtime_logging() - self.thread = threading.Thread(daemon=False, target=self.server.run) - self.thread.start() - while not self.server.started: - continue - - def _stop(self): - """Stop thread with uvicorn+falcon http server, wait for uvicorn - to exit gracefully and join the thread""" - if self.thread.is_alive(): - self.server.should_exit = True - while self.thread.is_alive(): - continue - self.thread.join() - - def _init_log_config(self) -> dict: - """Use for Uvicorn same log formatter like for Logprep""" - log_config = uvicorn.config.LOGGING_CONFIG - log_config["formatters"]["default"]["fmt"] = defaults.DEFAULT_LOG_FORMAT - log_config["formatters"]["access"]["fmt"] = defaults.DEFAULT_LOG_FORMAT - log_config["handlers"]["default"]["stream"] = "ext://sys.stdout" - return log_config - - def _override_runtime_logging(self): - """Uvicorn doesn't provide API to change name and handler beforehand - needs to be done during runtime""" - http_server_name = logging.getLogger("Logprep").name + " HTTPServer" - for logger_name in ["uvicorn", "uvicorn.access"]: - logging.getLogger(logger_name).removeHandler(logging.getLogger(logger_name).handlers[0]) - logging.getLogger(logger_name).addHandler( - logging.getLogger("Logprep").parent.handlers[0] - ) - logging.getLogger("uvicorn.access").name = http_server_name - logging.getLogger("uvicorn.error").name = http_server_name - - def _init_web_application_server(self, endpoints_config: dict) -> None: - "Init falcon application server and setting endpoint routes" - self.app = falcon.asgi.App() # pylint: disable=attribute-defined-outside-init - for endpoint_path, endpoint in endpoints_config.items(): - self.app.add_sink(endpoint, prefix=route_compile_helper(endpoint_path)) - - def shut_down(self): - """Shutdown method to trigger http server shutdown externally""" - self._stop() - - class HttpConnector(Input): """Connector to accept log messages as http post requests""" @@ -390,8 +274,8 @@ class Config(Input.Config): validator=[ validators.instance_of(dict), validators.deep_mapping( - key_validator=validators.in_(UVICORN_CONFIG_KEYS), - # lamba xyz tuple necessary because of input structure + key_validator=validators.in_(http.UVICORN_CONFIG_KEYS), + # lambda xyz tuple necessary because of input structure value_validator=lambda x, y, z: True, ), ] @@ -399,6 +283,23 @@ class Config(Input.Config): """Configure uvicorn server. For possible settings see `uvicorn settings page `_. + + .. security-best-practice:: + :title: Uvicorn Webserver Configuration + :location: uvicorn_config + :suggested-value: uvicorn_config.access_log: true, uvicorn_config.server_header: false, uvicorn_config.data_header: false + + Additionaly to the below it is recommended to configure `ssl on the metrics server endpoint + `_ + + .. code-block:: yaml + + uvicorn_config: + access_log: true + server_header: false + date_header: false + workers: 2 + """ endpoints: Mapping[str, str] = field( validator=[ @@ -457,15 +358,14 @@ class Config(Input.Config): def __init__(self, name: str, configuration: "HttpConnector.Config", logger: Logger) -> None: super().__init__(name, configuration, logger) - internal_uvicorn_config = { - "lifespan": "off", - "loop": "asyncio", - "timeout_graceful_shutdown": 5, - } - self._config.uvicorn_config.update(internal_uvicorn_config) - self.port = self._config.uvicorn_config["port"] - self.host = self._config.uvicorn_config["host"] - self.target = f"http://{self.host}:{self.port}" + port = self._config.uvicorn_config["port"] + host = self._config.uvicorn_config["host"] + ssl_options = any( + setting for setting in self._config.uvicorn_config if setting.startswith("ssl") + ) + schema = "https" if ssl_options else "http" + self.target = f"{schema}://{host}:{port}" + self.http_server = None def setup(self): """setup starts the actual functionality of this connector. @@ -501,11 +401,19 @@ def setup(self): self.messages, collect_meta, metafield_name, credentials ) - self.http_server = ThreadingHTTPServer( # pylint: disable=attribute-defined-outside-init - connector_config=self._config, - endpoints_config=endpoints_config, - log_level=self._logger.level, + app = self._get_asgi_app(endpoints_config) + self.http_server = http.ThreadingHTTPServer( + self._config.uvicorn_config, app, daemon=False, logger_name="Logprep HTTPServer" ) + self.http_server.start() + + @staticmethod + def _get_asgi_app(endpoints_config: dict) -> falcon.asgi.App: + """Init falcon application server and setting endpoint routes""" + app = falcon.asgi.App() + for endpoint_path, endpoint in endpoints_config.items(): + app.add_sink(endpoint, prefix=route_compile_helper(endpoint_path)) + return app def _get_event(self, timeout: float) -> Tuple: """Returns the first message from the queue""" @@ -516,16 +424,8 @@ def _get_event(self, timeout: float) -> Tuple: except queue.Empty: return None, None - def get_app_instance(self): - """Return app instance from webserver thread""" - return self.http_server.app - - def get_server_instance(self): - """Return server instance from webserver thread""" - return self.http_server.server - def shut_down(self): """Raises Uvicorn HTTP Server internal stop flag and waits to join""" - if not hasattr(self, "http_server"): + if self.http_server is None: return self.http_server.shut_down() diff --git a/logprep/metrics/exporter.py b/logprep/metrics/exporter.py index 086e492e4..67d2f6bab 100644 --- a/logprep/metrics/exporter.py +++ b/logprep/metrics/exporter.py @@ -4,20 +4,29 @@ import shutil from logging import getLogger -from prometheus_client import REGISTRY, multiprocess, start_http_server +from prometheus_client import REGISTRY, make_asgi_app, multiprocess +from logprep.util import http from logprep.util.configuration import MetricsConfig class PrometheusExporter: """Used to control the prometheus exporter and to manage the metrics""" - def __init__(self, status_logger_config: MetricsConfig): + def __init__(self, configuration: MetricsConfig): self.is_running = False - self._logger = getLogger("Prometheus Exporter") + logger_name = "Prometheus Exporter" + self._logger = getLogger(logger_name) self._logger.debug("Initializing Prometheus Exporter") - self.configuration = status_logger_config - self._port = status_logger_config.port + self.configuration = configuration + self._port = configuration.port + self._app = make_asgi_app(REGISTRY) + self._server = http.ThreadingHTTPServer( + configuration.uvicorn_config | {"port": self._port}, + self._app, + daemon=True, + logger_name=logger_name, + ) def _prepare_multiprocessing(self): """ @@ -36,7 +45,7 @@ def cleanup_prometheus_multiprocess_dir(self): os.remove(os.path.join(root, file)) for directory in dirs: shutil.rmtree(os.path.join(root, directory), ignore_errors=True) - self._logger.info("Cleaned up %s" % multiprocess_dir) + self._logger.info("Cleaned up %s", multiprocess_dir) def mark_process_dead(self, pid): """ @@ -53,6 +62,6 @@ def mark_process_dead(self, pid): def run(self): """Starts the default prometheus http endpoint""" self._prepare_multiprocessing() - start_http_server(self._port) - self._logger.info(f"Prometheus Exporter started on port {self._port}") + self._server.start() + self._logger.info("Prometheus Exporter started on port %s", self._port) self.is_running = True diff --git a/logprep/util/configuration.py b/logprep/util/configuration.py index b3a2288be..3ccb8fa4f 100644 --- a/logprep/util/configuration.py +++ b/logprep/util/configuration.py @@ -218,7 +218,7 @@ from logprep.factory import Factory from logprep.factory_error import FactoryError, InvalidConfigurationError from logprep.processor.base.exceptions import InvalidRuleDefinitionError -from logprep.util import getter +from logprep.util import getter, http from logprep.util.credentials import CredentialsEnvNotFoundError, CredentialsFactory from logprep.util.defaults import ( DEFAULT_CONFIG_LOCATION, @@ -306,6 +306,17 @@ class MetricsConfig: enabled: bool = field(validator=validators.instance_of(bool), default=False) port: int = field(validator=validators.instance_of(int), default=8000) + uvicorn_config: dict = field( + validator=[ + validators.instance_of(dict), + validators.deep_mapping( + key_validator=validators.in_(http.UVICORN_CONFIG_KEYS), + # lambda xyz tuple necessary because of input structure + value_validator=lambda x, y, z: True, + ), + ], + factory=dict, + ) @define(kw_only=True) @@ -397,7 +408,32 @@ class Configuration: converter=lambda x: MetricsConfig(**x) if isinstance(x, dict) else x, eq=False, ) - """Metrics configuration. Defaults to :code:`{"enabled": False, "port": 8000}`.""" + """Metrics configuration. Defaults to + :code:`{"enabled": False, "port": 8000, "uvicorn_config": {}}`. + + The key :code:`uvicorn_config` can be configured with any uvicorn config parameters. + For further information see the `uvicorn documentation `_. + + .. security-best-practice:: + :title: Metrics Configuration + :location: config.metrics.uvicorn_config + :suggested-value: metrics.uvicorn_config.access_log: true, metrics.uvicorn_config.server_header: false, metrics.uvicorn_config.data_header: false + + Additionaly to the below it is recommended to configure `ssl on the metrics server endpoint + `_ + + .. code-block:: yaml + + metrics: + enabled: true + port: 9000 + uvicorn_config: + access_log: true + server_header: false + date_header: false + workers: 1 + + """ profile_pipelines: bool = field(default=False, eq=False) """Start the profiler to profile the pipeline. Defaults to :code:`False`.""" print_auto_test_stack_trace: bool = field(default=False, eq=False) diff --git a/logprep/util/http.py b/logprep/util/http.py new file mode 100644 index 000000000..28b059fd9 --- /dev/null +++ b/logprep/util/http.py @@ -0,0 +1,112 @@ +"""logprep http utils""" + +import inspect +import logging +import threading + +import uvicorn + +from logprep.util import defaults + +uvicorn_parameter_keys = inspect.signature(uvicorn.Config).parameters.keys() +UVICORN_CONFIG_KEYS = [ + parameter for parameter in uvicorn_parameter_keys if parameter not in ["app", "log_level"] +] + + +class ThreadingHTTPServer: # pylint: disable=too-many-instance-attributes + """Singleton Wrapper Class around Uvicorn Thread that controls + lifecycle of Uvicorn HTTP Server. During Runtime this singleton object + is stateful and therefore we need to check for some attributes during + __init__ when multiple consecutive reconfigurations are happening. + """ + + _instance = None + _lock = threading.Lock() + + @property + def _log_config(self) -> dict: + """Use for Uvicorn same log formatter like for Logprep""" + log_config = uvicorn.config.LOGGING_CONFIG + log_config["formatters"]["default"]["fmt"] = defaults.DEFAULT_LOG_FORMAT + log_config["formatters"]["access"]["fmt"] = defaults.DEFAULT_LOG_FORMAT + log_config["handlers"]["default"]["stream"] = "ext://sys.stdout" + return log_config + + def __new__(cls, *args, **kwargs): + with cls._lock: + if not cls._instance: + cls._instance = super(ThreadingHTTPServer, cls).__new__(cls) + return cls._instance + + def __init__( + self, uvicorn_config: dict, app, daemon=True, logger_name="Logprep HTTPServer" + ) -> None: + """ + Creates object attributes with necessary configuration. + As this class creates a singleton object, the existing server + will be stopped and restarted on consecutively creations + + Parameters + ---------- + uvicorn_config: dict + Holds server config for config change checks + app: + The app instance that the server should provide + daemon: bool + Whether the server is in daemon mode or not + logger_name: str + Name of the logger instance + """ + + if ( + hasattr(self, "thread") + and self.thread.is_alive() # pylint: disable=access-member-before-definition + ): + self.shut_down() + internal_uvicorn_config = { + "lifespan": "off", + "loop": "asyncio", + "timeout_graceful_shutdown": 5, + } + uvicorn_config = {**internal_uvicorn_config, **uvicorn_config} + self._logger_name = logger_name + self._logger = logging.getLogger(self._logger_name) + uvicorn_config = uvicorn.Config(**uvicorn_config, app=app, log_config=self._log_config) + self.server = uvicorn.Server(uvicorn_config) + self._override_runtime_logging() + self.thread = threading.Thread(daemon=daemon, target=self.server.run) + + def start(self): + """Collect all configs, initiate application server and webserver + and run thread with uvicorn+falcon http server and wait + until it is up (started)""" + + self.thread.start() + while not self.server.started: + continue + + def shut_down(self): + """Stop thread with uvicorn+falcon http server, wait for uvicorn + to exit gracefully and join the thread""" + if not self.thread.is_alive(): + return + self.server.should_exit = True + while self.thread.is_alive(): + self._logger.debug("Wait for server to exit gracefully...") + continue + self.thread.join() + + def _override_runtime_logging(self): + """Uvicorn doesn't provide API to change name and handler beforehand + needs to be done during runtime""" + for logger_name in ["uvicorn", "uvicorn.access"]: + registered_handlers = logging.getLogger(logger_name).handlers + if not registered_handlers: + continue + logging.getLogger(logger_name).removeHandler(registered_handlers[0]) + logging.getLogger(logger_name).addHandler( + logging.getLogger("Logprep").parent.handlers[0] + ) + logging.getLogger("uvicorn.access").name = self._logger_name + logging.getLogger("uvicorn.error").name = self._logger_name diff --git a/quickstart/exampledata/config/http_pipeline.yml b/quickstart/exampledata/config/http_pipeline.yml index 007e6473b..dfc41fd10 100644 --- a/quickstart/exampledata/config/http_pipeline.yml +++ b/quickstart/exampledata/config/http_pipeline.yml @@ -7,6 +7,12 @@ logger: metrics: enabled: true port: 8003 + uvicorn_config: + host: 0.0.0.0 + access_log: true + server_header: false + date_header: false + workers: 1 input: httpinput: type: http_input @@ -16,6 +22,10 @@ input: uvicorn_config: host: 0.0.0.0 port: 9000 + workers: 2 + access_log: true + server_header: false + date_header: false endpoints: /auth-json: json /json: json diff --git a/tests/acceptance/test_full_configuration.py b/tests/acceptance/test_full_configuration.py index d7d892b8e..131141415 100644 --- a/tests/acceptance/test_full_configuration.py +++ b/tests/acceptance/test_full_configuration.py @@ -2,6 +2,7 @@ import os import re import tempfile +import time from pathlib import Path import requests @@ -162,9 +163,10 @@ def test_logprep_exposes_prometheus_metrics(tmp_path): assert "error" not in output.lower(), "error message" assert "critical" not in output.lower(), "error message" assert "exception" not in output.lower(), "error message" - if "Finished building pipeline" in output: + if "Startup complete" in output: break - response = requests.get("http://127.0.0.1:8003", timeout=5) + time.sleep(2) + response = requests.get("http://127.0.0.1:8003", timeout=7) response.raise_for_status() metrics = response.text expected_metrics = [ diff --git a/tests/unit/connector/test_http_input.py b/tests/unit/connector/test_http_input.py index c39fbdaec..1a21c2c44 100644 --- a/tests/unit/connector/test_http_input.py +++ b/tests/unit/connector/test_http_input.py @@ -2,6 +2,7 @@ # pylint: disable=protected-access # pylint: disable=attribute-defined-outside-init import multiprocessing +import re from copy import deepcopy from unittest import mock @@ -80,9 +81,6 @@ def teardown_method(self): def test_create_connector(self): assert isinstance(self.object, HttpConnector) - def test_has_falcon_asgi_app(self): - assert isinstance(self.object.get_app_instance(), falcon.asgi.App) - def test_no_pipeline_index(self): connector_config = deepcopy(self.CONFIG) connector = Factory.create({"test connector": connector_config}, logger=self.logger) @@ -97,7 +95,7 @@ def test_not_first_pipeline(self): connector = Factory.create({"test connector": connector_config}, logger=self.logger) connector.pipeline_index = 2 connector.setup() - assert not hasattr(connector, "http_server") + assert connector.http_server is None def test_get_method_returns_200(self): resp = requests.get(url=f"{self.target}/json", timeout=0.5) @@ -238,7 +236,7 @@ def test_get_next_returns_none_for_empty_queue(self): assert self.object.get_next(0.001)[0] is None def test_server_returns_uvicorn_server_instance(self): - assert isinstance(self.object.get_server_instance(), uvicorn.Server) + assert isinstance(self.object.http_server.server, uvicorn.Server) def test_server_starts_threaded_server(self): message = {"message": "my message"} @@ -260,7 +258,7 @@ def test_get_metadata(self): assert resp.status_code == 200 message = connector.messages.get(timeout=0.5) assert message["custom"]["url"] == target + "/json" - assert message["custom"]["remote_addr"] == connector.host + assert re.search(r"\d+\.\d+\.\d+\.\d+", message["custom"]["remote_addr"]) assert isinstance(message["custom"]["user_agent"], str) def test_server_multiple_config_changes(self): @@ -315,17 +313,6 @@ def test_get_next_with_hmac_of_raw_message(self): connector_next_msg, _ = connector.get_next(1) assert connector_next_msg == expected_event, "Output event with hmac is not as expected" - @pytest.mark.parametrize("endpoint", ["/auth-json-secret", "/.*/[A-Z]{2}/json$"]) - def test_endpoint_has_credentials(self, endpoint, credentials_file_path): - mock_env = {ENV_NAME_LOGPREP_CREDENTIALS_FILE: credentials_file_path} - with mock.patch.dict("os.environ", mock_env): - new_connector = Factory.create({"test connector": self.CONFIG}, logger=self.logger) - new_connector.pipeline_index = 1 - new_connector.setup() - endpoint_config = new_connector.http_server.endpoints_config.get(endpoint) - assert endpoint_config.credentials.username, endpoint - assert endpoint_config.credentials.password, endpoint - def test_endpoint_has_basic_auth(self, credentials_file_path): mock_env = {ENV_NAME_LOGPREP_CREDENTIALS_FILE: credentials_file_path} with mock.patch.dict("os.environ", mock_env): @@ -370,3 +357,14 @@ def test_all_endpoints_share_the_same_queue(self): """ requests.post(url=f"{self.target}/jsonl", data=data, timeout=0.5) assert self.object.messages.qsize() == 5 + + def test_sets_target_to_https_schema_if_ssl_options(self): + connector_config = deepcopy(self.CONFIG) + connector_config["uvicorn_config"]["ssl_keyfile"] = "path/to/keyfile" + connector = Factory.create({"test connector": connector_config}, logger=self.logger) + assert connector.target.startswith("https://") + + def test_sets_target_to_http_schema_if_no_ssl_options(self): + connector_config = deepcopy(self.CONFIG) + connector = Factory.create({"test connector": connector_config}, logger=self.logger) + assert connector.target.startswith("http://") diff --git a/tests/unit/metrics/test_exporter.py b/tests/unit/metrics/test_exporter.py index 0f6781268..3e6dff842 100644 --- a/tests/unit/metrics/test_exporter.py +++ b/tests/unit/metrics/test_exporter.py @@ -30,13 +30,13 @@ def test_default_port_if_missing_in_config(self): exporter = PrometheusExporter(metrics_config) assert exporter._port == 8000 - @mock.patch("logprep.metrics.exporter.start_http_server") - def test_run_starts_http_server(self, mock_http_server, caplog): + @mock.patch("logprep.util.http.ThreadingHTTPServer.start") + def test_run_starts_http_server(self, mock_http_server_start, caplog): with caplog.at_level(logging.INFO): exporter = PrometheusExporter(self.metrics_config) exporter.run() - mock_http_server.assert_has_calls([mock.call(exporter._port)]) + mock_http_server_start.assert_called() assert f"Prometheus Exporter started on port {exporter._port}" in caplog.text def test_cleanup_prometheus_multiprocess_dir_deletes_temp_dir_contents_but_not_the_dir_itself( diff --git a/tests/unit/util/test_configuration.py b/tests/unit/util/test_configuration.py index 56f25ba38..4b5d5cd75 100644 --- a/tests/unit/util/test_configuration.py +++ b/tests/unit/util/test_configuration.py @@ -81,13 +81,13 @@ def test_create_from_sources_adds_configs(self): ("logger", {"level": "INFO"}, {"level": "DEBUG"}), ( "metrics", - {"enabled": False, "port": 8000}, - {"enabled": True, "port": 9000}, + {"enabled": False, "port": 8000, "uvicorn_config": {"access_log": True}}, + {"enabled": True, "port": 9000, "uvicorn_config": {"access_log": False}}, ), ( "metrics", {"enabled": False, "port": 8000}, - {"enabled": True, "port": 9000}, + {"enabled": True, "port": 9000, "uvicorn_config": {}}, ), ], )