From b4bfc1c36b00eee0ad80f9f1e0c16a2929dfacd1 Mon Sep 17 00:00:00 2001 From: Luiz Felipe <61145881+lfelipediniz@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:57:23 -0300 Subject: [PATCH 1/2] feat: Add /health endpoint for server monitoring - Add configurable health endpoint to Dash core - Support custom endpoint paths and disable option - Include system metrics and callback information - Add comprehensive test suite with 10 test cases - Compatible with load balancers, Docker, Kubernetes Resolves: Add health check endpoint to Dash framework --- dash/dash.py | 72 +++++++ tests/integration/test_health_endpoint.py | 232 ++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 tests/integration/test_health_endpoint.py diff --git a/dash/dash.py b/dash/dash.py index 8430259c27..0a3ee5eb2d 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -416,6 +416,10 @@ class Dash(ObsoleteChecker): :param use_async: When True, the app will create async endpoints, as a dev, they will be responsible for installing the `flask[async]` dependency. :type use_async: boolean + + :param health_endpoint: Path for the health check endpoint. Set to None to + disable the health endpoint. Default is "health". + :type health_endpoint: string or None """ _plotlyjs_url: str @@ -466,6 +470,7 @@ def __init__( # pylint: disable=too-many-statements description: Optional[str] = None, on_error: Optional[Callable[[Exception], Any]] = None, use_async: Optional[bool] = None, + health_endpoint: Optional[str] = "health", **obsolete, ): @@ -537,6 +542,7 @@ def __init__( # pylint: disable=too-many-statements update_title=update_title, include_pages_meta=include_pages_meta, description=description, + health_endpoint=health_endpoint, ) self.config.set_read_only( [ @@ -767,6 +773,8 @@ def _setup_routes(self): self._add_url("_dash-update-component", self.dispatch, ["POST"]) self._add_url("_reload-hash", self.serve_reload_hash) self._add_url("_favicon.ico", self._serve_default_favicon) + if self.config.health_endpoint is not None: + self._add_url(self.config.health_endpoint, self.serve_health) self._add_url("", self.index) if jupyter_dash.active: @@ -975,6 +983,70 @@ def serve_reload_hash(self): } ) + def serve_health(self): + """ + Health check endpoint for monitoring Dash server status. + + Returns a JSON response indicating the server is running and healthy. + This endpoint can be used by load balancers, monitoring systems, + and other platforms to check if the Dash server is operational. + + :return: JSON response with status information + """ + import datetime + import platform + import psutil + import sys + + # Basic health information + health_data = { + "status": "healthy", + "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "dash_version": __version__, + "python_version": sys.version, + "platform": platform.platform(), + } + + # Add server information if available + try: + health_data.update({ + "server_name": self.server.name, + "debug_mode": self.server.debug, + "host": getattr(self.server, 'host', 'unknown'), + "port": getattr(self.server, 'port', 'unknown'), + }) + except Exception: + pass + + # Add system resource information if psutil is available + try: + health_data.update({ + "system": { + "cpu_percent": psutil.cpu_percent(interval=0.1), + "memory_percent": psutil.virtual_memory().percent, + "disk_percent": psutil.disk_usage('/').percent if os.name != 'nt' else psutil.disk_usage('C:').percent, + } + }) + except ImportError: + # psutil not available, skip system metrics + pass + except Exception: + # Error getting system metrics, skip them + pass + + # Add callback information + try: + health_data.update({ + "callbacks": { + "total_callbacks": len(self.callback_map), + "background_callbacks": len(getattr(self, '_background_callback_map', {})), + } + }) + except Exception: + pass + + return flask.jsonify(health_data) + def get_dist(self, libraries: Sequence[str]) -> list: dists = [] for dist_type in ("_js_dist", "_css_dist"): diff --git a/tests/integration/test_health_endpoint.py b/tests/integration/test_health_endpoint.py new file mode 100644 index 0000000000..5432c8d758 --- /dev/null +++ b/tests/integration/test_health_endpoint.py @@ -0,0 +1,232 @@ +import json +import requests +import pytest +from dash import Dash, html + + +def test_health001_basic_health_check(dash_duo): + """Test basic health endpoint functionality.""" + app = Dash(__name__) + app.layout = html.Div("Test Health Endpoint") + + dash_duo.start_server(app) + + # Test health endpoint + response = requests.get(f"{dash_duo.server_url}/health") + + assert response.status_code == 200 + data = response.json() + + # Verify required fields + assert data["status"] == "healthy" + assert "timestamp" in data + assert "dash_version" in data + assert "python_version" in data + assert "platform" in data + assert "server_name" in data + assert "debug_mode" in data + + # Verify callbacks information + assert "callbacks" in data + assert "total_callbacks" in data["callbacks"] + assert "background_callbacks" in data["callbacks"] + + +def test_health002_health_with_callbacks(dash_duo): + """Test health endpoint with callbacks.""" + from dash import Input, Output + + app = Dash(__name__) + app.layout = html.Div([ + html.Button("Click me", id="btn"), + html.Div(id="output") + ]) + + @app.callback(Output("output", "children"), Input("btn", "n_clicks")) + def update_output(n_clicks): + return f"Clicked {n_clicks or 0} times" + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + data = response.json() + assert data["callbacks"]["total_callbacks"] == 1 + assert data["callbacks"]["background_callbacks"] == 0 + + +def test_health003_health_with_background_callbacks(dash_duo): + """Test health endpoint with background callbacks.""" + from dash import Input, Output + from dash.long_callback import DiskcacheManager + + app = Dash(__name__) + + # Add background callback manager + cache = DiskcacheManager() + app.long_callback_manager = cache + + app.layout = html.Div([ + html.Button("Click me", id="btn"), + html.Div(id="output") + ]) + + @app.long_callback( + Output("output", "children"), + Input("btn", "n_clicks"), + running=[(Output("output", "children"), "Running...", None)], + prevent_initial_call=True, + ) + def long_callback(n_clicks): + import time + time.sleep(1) # Simulate long running task + return f"Completed {n_clicks or 0} times" + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + data = response.json() + assert data["callbacks"]["background_callbacks"] >= 0 # May be 0 or 1 depending on setup + + +def test_health004_health_without_psutil(dash_duo, monkeypatch): + """Test health endpoint when psutil is not available.""" + import sys + + # Mock psutil import to raise ImportError + original_import = __builtins__.__import__ + + def mock_import(name, *args, **kwargs): + if name == 'psutil': + raise ImportError("No module named 'psutil'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(__builtins__, '__import__', mock_import) + + app = Dash(__name__) + app.layout = html.Div("Test Health Without Psutil") + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + # System metrics should not be present when psutil is not available + assert "system" not in data + + +def test_health005_health_json_format(dash_duo): + """Test that health endpoint returns valid JSON.""" + app = Dash(__name__) + app.layout = html.Div("Test Health JSON Format") + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + # Verify content type + assert response.headers['content-type'].startswith('application/json') + + # Verify valid JSON + try: + data = response.json() + assert isinstance(data, dict) + except json.JSONDecodeError: + pytest.fail("Health endpoint did not return valid JSON") + + +def test_health006_health_with_custom_server_name(dash_duo): + """Test health endpoint with custom server name.""" + app = Dash(__name__, name="custom_health_app") + app.layout = html.Div("Test Custom Server Name") + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + data = response.json() + assert data["server_name"] == "custom_health_app" + + +def test_health007_health_endpoint_accessibility(dash_duo): + """Test that health endpoint is accessible without authentication.""" + app = Dash(__name__) + app.layout = html.Div("Test Health Accessibility") + + dash_duo.start_server(app) + + # Test multiple requests to ensure consistency + for _ in range(3): + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + +def test_health008_health_timestamp_format(dash_duo): + """Test that health endpoint returns valid ISO timestamp.""" + import datetime + + app = Dash(__name__) + app.layout = html.Div("Test Health Timestamp") + + dash_duo.start_server(app) + + response = requests.get(f"{dash_duo.server_url}/health") + assert response.status_code == 200 + + data = response.json() + timestamp = data["timestamp"] + + # Verify timestamp format (ISO 8601 with Z suffix) + assert timestamp.endswith('Z') + assert 'T' in timestamp + + # Verify it's a valid datetime + try: + parsed_time = datetime.datetime.fromisoformat(timestamp[:-1] + '+00:00') + assert isinstance(parsed_time, datetime.datetime) + except ValueError: + pytest.fail(f"Invalid timestamp format: {timestamp}") + + +def test_health009_health_with_routes_pathname_prefix(dash_duo): + """Test health endpoint with custom routes_pathname_prefix.""" + app = Dash(__name__, routes_pathname_prefix="/app/") + app.layout = html.Div("Test Health With Prefix") + + dash_duo.start_server(app) + + # Health endpoint should be available at /app/health + response = requests.get(f"{dash_duo.server_url}/app/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + + +def test_health010_health_performance(dash_duo): + """Test that health endpoint responds quickly.""" + import time + + app = Dash(__name__) + app.layout = html.Div("Test Health Performance") + + dash_duo.start_server(app) + + start_time = time.time() + response = requests.get(f"{dash_duo.server_url}/health") + end_time = time.time() + + assert response.status_code == 200 + assert (end_time - start_time) < 1.0 # Should respond within 1 second + + data = response.json() + assert data["status"] == "healthy" From 4101cdf038074ca69e7e5f57f615cdef5e9d73f4 Mon Sep 17 00:00:00 2001 From: Luiz Felipe <61145881+lfelipediniz@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:02:19 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20Add=20health=20endpoint=20featu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional health_endpoint parameter to Dash constructor (default: None) - Implement simple health check endpoint returning 'OK' with HTTP 200 - Health endpoint respects routes_pathname_prefix configuration - Add comprehensive unit tests for health endpoint functionality - Remove integration tests in favor of simpler unit tests - Apply code formatting with black --- dash/dash.py | 67 +------ tests/integration/test_health_endpoint.py | 232 ---------------------- tests/unit/test_health_endpoint_unit.py | 72 +++++++ 3 files changed, 77 insertions(+), 294 deletions(-) delete mode 100644 tests/integration/test_health_endpoint.py create mode 100644 tests/unit/test_health_endpoint_unit.py diff --git a/dash/dash.py b/dash/dash.py index 0a3ee5eb2d..9f9cd21240 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -417,8 +417,8 @@ class Dash(ObsoleteChecker): they will be responsible for installing the `flask[async]` dependency. :type use_async: boolean - :param health_endpoint: Path for the health check endpoint. Set to None to - disable the health endpoint. Default is "health". + :param health_endpoint: Path for the health check endpoint. Set to None to + disable the health endpoint. Default is None. :type health_endpoint: string or None """ @@ -470,7 +470,7 @@ def __init__( # pylint: disable=too-many-statements description: Optional[str] = None, on_error: Optional[Callable[[Exception], Any]] = None, use_async: Optional[bool] = None, - health_endpoint: Optional[str] = "health", + health_endpoint: Optional[str] = None, **obsolete, ): @@ -986,66 +986,9 @@ def serve_reload_hash(self): def serve_health(self): """ Health check endpoint for monitoring Dash server status. - - Returns a JSON response indicating the server is running and healthy. - This endpoint can be used by load balancers, monitoring systems, - and other platforms to check if the Dash server is operational. - - :return: JSON response with status information + Returns a simple "OK" response with HTTP 200 status. """ - import datetime - import platform - import psutil - import sys - - # Basic health information - health_data = { - "status": "healthy", - "timestamp": datetime.datetime.utcnow().isoformat() + "Z", - "dash_version": __version__, - "python_version": sys.version, - "platform": platform.platform(), - } - - # Add server information if available - try: - health_data.update({ - "server_name": self.server.name, - "debug_mode": self.server.debug, - "host": getattr(self.server, 'host', 'unknown'), - "port": getattr(self.server, 'port', 'unknown'), - }) - except Exception: - pass - - # Add system resource information if psutil is available - try: - health_data.update({ - "system": { - "cpu_percent": psutil.cpu_percent(interval=0.1), - "memory_percent": psutil.virtual_memory().percent, - "disk_percent": psutil.disk_usage('/').percent if os.name != 'nt' else psutil.disk_usage('C:').percent, - } - }) - except ImportError: - # psutil not available, skip system metrics - pass - except Exception: - # Error getting system metrics, skip them - pass - - # Add callback information - try: - health_data.update({ - "callbacks": { - "total_callbacks": len(self.callback_map), - "background_callbacks": len(getattr(self, '_background_callback_map', {})), - } - }) - except Exception: - pass - - return flask.jsonify(health_data) + return flask.Response("OK", status=200, mimetype="text/plain") def get_dist(self, libraries: Sequence[str]) -> list: dists = [] diff --git a/tests/integration/test_health_endpoint.py b/tests/integration/test_health_endpoint.py deleted file mode 100644 index 5432c8d758..0000000000 --- a/tests/integration/test_health_endpoint.py +++ /dev/null @@ -1,232 +0,0 @@ -import json -import requests -import pytest -from dash import Dash, html - - -def test_health001_basic_health_check(dash_duo): - """Test basic health endpoint functionality.""" - app = Dash(__name__) - app.layout = html.Div("Test Health Endpoint") - - dash_duo.start_server(app) - - # Test health endpoint - response = requests.get(f"{dash_duo.server_url}/health") - - assert response.status_code == 200 - data = response.json() - - # Verify required fields - assert data["status"] == "healthy" - assert "timestamp" in data - assert "dash_version" in data - assert "python_version" in data - assert "platform" in data - assert "server_name" in data - assert "debug_mode" in data - - # Verify callbacks information - assert "callbacks" in data - assert "total_callbacks" in data["callbacks"] - assert "background_callbacks" in data["callbacks"] - - -def test_health002_health_with_callbacks(dash_duo): - """Test health endpoint with callbacks.""" - from dash import Input, Output - - app = Dash(__name__) - app.layout = html.Div([ - html.Button("Click me", id="btn"), - html.Div(id="output") - ]) - - @app.callback(Output("output", "children"), Input("btn", "n_clicks")) - def update_output(n_clicks): - return f"Clicked {n_clicks or 0} times" - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - data = response.json() - assert data["callbacks"]["total_callbacks"] == 1 - assert data["callbacks"]["background_callbacks"] == 0 - - -def test_health003_health_with_background_callbacks(dash_duo): - """Test health endpoint with background callbacks.""" - from dash import Input, Output - from dash.long_callback import DiskcacheManager - - app = Dash(__name__) - - # Add background callback manager - cache = DiskcacheManager() - app.long_callback_manager = cache - - app.layout = html.Div([ - html.Button("Click me", id="btn"), - html.Div(id="output") - ]) - - @app.long_callback( - Output("output", "children"), - Input("btn", "n_clicks"), - running=[(Output("output", "children"), "Running...", None)], - prevent_initial_call=True, - ) - def long_callback(n_clicks): - import time - time.sleep(1) # Simulate long running task - return f"Completed {n_clicks or 0} times" - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - data = response.json() - assert data["callbacks"]["background_callbacks"] >= 0 # May be 0 or 1 depending on setup - - -def test_health004_health_without_psutil(dash_duo, monkeypatch): - """Test health endpoint when psutil is not available.""" - import sys - - # Mock psutil import to raise ImportError - original_import = __builtins__.__import__ - - def mock_import(name, *args, **kwargs): - if name == 'psutil': - raise ImportError("No module named 'psutil'") - return original_import(name, *args, **kwargs) - - monkeypatch.setattr(__builtins__, '__import__', mock_import) - - app = Dash(__name__) - app.layout = html.Div("Test Health Without Psutil") - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - data = response.json() - assert data["status"] == "healthy" - # System metrics should not be present when psutil is not available - assert "system" not in data - - -def test_health005_health_json_format(dash_duo): - """Test that health endpoint returns valid JSON.""" - app = Dash(__name__) - app.layout = html.Div("Test Health JSON Format") - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - # Verify content type - assert response.headers['content-type'].startswith('application/json') - - # Verify valid JSON - try: - data = response.json() - assert isinstance(data, dict) - except json.JSONDecodeError: - pytest.fail("Health endpoint did not return valid JSON") - - -def test_health006_health_with_custom_server_name(dash_duo): - """Test health endpoint with custom server name.""" - app = Dash(__name__, name="custom_health_app") - app.layout = html.Div("Test Custom Server Name") - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - data = response.json() - assert data["server_name"] == "custom_health_app" - - -def test_health007_health_endpoint_accessibility(dash_duo): - """Test that health endpoint is accessible without authentication.""" - app = Dash(__name__) - app.layout = html.Div("Test Health Accessibility") - - dash_duo.start_server(app) - - # Test multiple requests to ensure consistency - for _ in range(3): - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" - - -def test_health008_health_timestamp_format(dash_duo): - """Test that health endpoint returns valid ISO timestamp.""" - import datetime - - app = Dash(__name__) - app.layout = html.Div("Test Health Timestamp") - - dash_duo.start_server(app) - - response = requests.get(f"{dash_duo.server_url}/health") - assert response.status_code == 200 - - data = response.json() - timestamp = data["timestamp"] - - # Verify timestamp format (ISO 8601 with Z suffix) - assert timestamp.endswith('Z') - assert 'T' in timestamp - - # Verify it's a valid datetime - try: - parsed_time = datetime.datetime.fromisoformat(timestamp[:-1] + '+00:00') - assert isinstance(parsed_time, datetime.datetime) - except ValueError: - pytest.fail(f"Invalid timestamp format: {timestamp}") - - -def test_health009_health_with_routes_pathname_prefix(dash_duo): - """Test health endpoint with custom routes_pathname_prefix.""" - app = Dash(__name__, routes_pathname_prefix="/app/") - app.layout = html.Div("Test Health With Prefix") - - dash_duo.start_server(app) - - # Health endpoint should be available at /app/health - response = requests.get(f"{dash_duo.server_url}/app/health") - assert response.status_code == 200 - - data = response.json() - assert data["status"] == "healthy" - - -def test_health010_health_performance(dash_duo): - """Test that health endpoint responds quickly.""" - import time - - app = Dash(__name__) - app.layout = html.Div("Test Health Performance") - - dash_duo.start_server(app) - - start_time = time.time() - response = requests.get(f"{dash_duo.server_url}/health") - end_time = time.time() - - assert response.status_code == 200 - assert (end_time - start_time) < 1.0 # Should respond within 1 second - - data = response.json() - assert data["status"] == "healthy" diff --git a/tests/unit/test_health_endpoint_unit.py b/tests/unit/test_health_endpoint_unit.py new file mode 100644 index 0000000000..723591fba4 --- /dev/null +++ b/tests/unit/test_health_endpoint_unit.py @@ -0,0 +1,72 @@ +""" +Tests for the health endpoint. + +Covers: +- disabled by default +- enabled returns plain OK 200 +- respects routes_pathname_prefix +- custom nested path works +- HEAD allowed, POST not allowed +""" + +from dash import Dash, html + + +def test_health_disabled_by_default_returns_404(): + app = Dash(__name__) # health_endpoint=None by default + app.layout = html.Div("Test") + client = app.server.test_client() + r = client.get("/health") + # When health endpoint is disabled, it returns the main page (200) instead of 404 + # This is expected behavior - the health endpoint is not available + assert r.status_code == 200 + # Should return HTML content, not "OK" + assert b"OK" not in r.data + + +def test_health_enabled_returns_ok_200_plain_text(): + app = Dash(__name__, health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + r = client.get("/health") + assert r.status_code == 200 + assert r.data == b"OK" + # Flask automatically sets mimetype to text/plain for Response with mimetype + assert r.mimetype == "text/plain" + + +def test_health_respects_routes_pathname_prefix(): + app = Dash(__name__, routes_pathname_prefix="/x/", health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + ok = client.get("/x/health") + miss = client.get("/health") + + assert ok.status_code == 200 and ok.data == b"OK" + assert miss.status_code == 404 + + +def test_health_custom_nested_path(): + app = Dash(__name__, health_endpoint="api/v1/health") + app.layout = html.Div("Test") + client = app.server.test_client() + + r = client.get("/api/v1/health") + assert r.status_code == 200 + assert r.data == b"OK" + + +def test_health_head_allowed_and_post_405(): + app = Dash(__name__, health_endpoint="health") + app.layout = html.Div("Test") + client = app.server.test_client() + + head = client.head("/health") + assert head.status_code == 200 + # for HEAD the body can be empty, so we do not validate body + assert head.mimetype == "text/plain" + + post = client.post("/health") + assert post.status_code == 405