-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat: Add /health endpoint for server monitoring #3460
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
base: dev
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
ndrezn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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 | ||
ndrezn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
return flask.jsonify(health_data) | ||
|
||
|
||
def get_dist(self, libraries: Sequence[str]) -> list: | ||
dists = [] | ||
for dist_type in ("_js_dist", "_css_dist"): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
Uh oh!
There was an error while loading. Please reload this page.