Skip to content

Commit b4bfc1c

Browse files
committed
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
1 parent 4b8917e commit b4bfc1c

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed

dash/dash.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,10 @@ class Dash(ObsoleteChecker):
416416
:param use_async: When True, the app will create async endpoints, as a dev,
417417
they will be responsible for installing the `flask[async]` dependency.
418418
:type use_async: boolean
419+
420+
:param health_endpoint: Path for the health check endpoint. Set to None to
421+
disable the health endpoint. Default is "health".
422+
:type health_endpoint: string or None
419423
"""
420424

421425
_plotlyjs_url: str
@@ -466,6 +470,7 @@ def __init__( # pylint: disable=too-many-statements
466470
description: Optional[str] = None,
467471
on_error: Optional[Callable[[Exception], Any]] = None,
468472
use_async: Optional[bool] = None,
473+
health_endpoint: Optional[str] = "health",
469474
**obsolete,
470475
):
471476

@@ -537,6 +542,7 @@ def __init__( # pylint: disable=too-many-statements
537542
update_title=update_title,
538543
include_pages_meta=include_pages_meta,
539544
description=description,
545+
health_endpoint=health_endpoint,
540546
)
541547
self.config.set_read_only(
542548
[
@@ -767,6 +773,8 @@ def _setup_routes(self):
767773
self._add_url("_dash-update-component", self.dispatch, ["POST"])
768774
self._add_url("_reload-hash", self.serve_reload_hash)
769775
self._add_url("_favicon.ico", self._serve_default_favicon)
776+
if self.config.health_endpoint is not None:
777+
self._add_url(self.config.health_endpoint, self.serve_health)
770778
self._add_url("", self.index)
771779

772780
if jupyter_dash.active:
@@ -975,6 +983,70 @@ def serve_reload_hash(self):
975983
}
976984
)
977985

986+
def serve_health(self):
987+
"""
988+
Health check endpoint for monitoring Dash server status.
989+
990+
Returns a JSON response indicating the server is running and healthy.
991+
This endpoint can be used by load balancers, monitoring systems,
992+
and other platforms to check if the Dash server is operational.
993+
994+
:return: JSON response with status information
995+
"""
996+
import datetime
997+
import platform
998+
import psutil
999+
import sys
1000+
1001+
# Basic health information
1002+
health_data = {
1003+
"status": "healthy",
1004+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
1005+
"dash_version": __version__,
1006+
"python_version": sys.version,
1007+
"platform": platform.platform(),
1008+
}
1009+
1010+
# Add server information if available
1011+
try:
1012+
health_data.update({
1013+
"server_name": self.server.name,
1014+
"debug_mode": self.server.debug,
1015+
"host": getattr(self.server, 'host', 'unknown'),
1016+
"port": getattr(self.server, 'port', 'unknown'),
1017+
})
1018+
except Exception:
1019+
pass
1020+
1021+
# Add system resource information if psutil is available
1022+
try:
1023+
health_data.update({
1024+
"system": {
1025+
"cpu_percent": psutil.cpu_percent(interval=0.1),
1026+
"memory_percent": psutil.virtual_memory().percent,
1027+
"disk_percent": psutil.disk_usage('/').percent if os.name != 'nt' else psutil.disk_usage('C:').percent,
1028+
}
1029+
})
1030+
except ImportError:
1031+
# psutil not available, skip system metrics
1032+
pass
1033+
except Exception:
1034+
# Error getting system metrics, skip them
1035+
pass
1036+
1037+
# Add callback information
1038+
try:
1039+
health_data.update({
1040+
"callbacks": {
1041+
"total_callbacks": len(self.callback_map),
1042+
"background_callbacks": len(getattr(self, '_background_callback_map', {})),
1043+
}
1044+
})
1045+
except Exception:
1046+
pass
1047+
1048+
return flask.jsonify(health_data)
1049+
9781050
def get_dist(self, libraries: Sequence[str]) -> list:
9791051
dists = []
9801052
for dist_type in ("_js_dist", "_css_dist"):
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import json
2+
import requests
3+
import pytest
4+
from dash import Dash, html
5+
6+
7+
def test_health001_basic_health_check(dash_duo):
8+
"""Test basic health endpoint functionality."""
9+
app = Dash(__name__)
10+
app.layout = html.Div("Test Health Endpoint")
11+
12+
dash_duo.start_server(app)
13+
14+
# Test health endpoint
15+
response = requests.get(f"{dash_duo.server_url}/health")
16+
17+
assert response.status_code == 200
18+
data = response.json()
19+
20+
# Verify required fields
21+
assert data["status"] == "healthy"
22+
assert "timestamp" in data
23+
assert "dash_version" in data
24+
assert "python_version" in data
25+
assert "platform" in data
26+
assert "server_name" in data
27+
assert "debug_mode" in data
28+
29+
# Verify callbacks information
30+
assert "callbacks" in data
31+
assert "total_callbacks" in data["callbacks"]
32+
assert "background_callbacks" in data["callbacks"]
33+
34+
35+
def test_health002_health_with_callbacks(dash_duo):
36+
"""Test health endpoint with callbacks."""
37+
from dash import Input, Output
38+
39+
app = Dash(__name__)
40+
app.layout = html.Div([
41+
html.Button("Click me", id="btn"),
42+
html.Div(id="output")
43+
])
44+
45+
@app.callback(Output("output", "children"), Input("btn", "n_clicks"))
46+
def update_output(n_clicks):
47+
return f"Clicked {n_clicks or 0} times"
48+
49+
dash_duo.start_server(app)
50+
51+
response = requests.get(f"{dash_duo.server_url}/health")
52+
assert response.status_code == 200
53+
54+
data = response.json()
55+
assert data["callbacks"]["total_callbacks"] == 1
56+
assert data["callbacks"]["background_callbacks"] == 0
57+
58+
59+
def test_health003_health_with_background_callbacks(dash_duo):
60+
"""Test health endpoint with background callbacks."""
61+
from dash import Input, Output
62+
from dash.long_callback import DiskcacheManager
63+
64+
app = Dash(__name__)
65+
66+
# Add background callback manager
67+
cache = DiskcacheManager()
68+
app.long_callback_manager = cache
69+
70+
app.layout = html.Div([
71+
html.Button("Click me", id="btn"),
72+
html.Div(id="output")
73+
])
74+
75+
@app.long_callback(
76+
Output("output", "children"),
77+
Input("btn", "n_clicks"),
78+
running=[(Output("output", "children"), "Running...", None)],
79+
prevent_initial_call=True,
80+
)
81+
def long_callback(n_clicks):
82+
import time
83+
time.sleep(1) # Simulate long running task
84+
return f"Completed {n_clicks or 0} times"
85+
86+
dash_duo.start_server(app)
87+
88+
response = requests.get(f"{dash_duo.server_url}/health")
89+
assert response.status_code == 200
90+
91+
data = response.json()
92+
assert data["callbacks"]["background_callbacks"] >= 0 # May be 0 or 1 depending on setup
93+
94+
95+
def test_health004_health_without_psutil(dash_duo, monkeypatch):
96+
"""Test health endpoint when psutil is not available."""
97+
import sys
98+
99+
# Mock psutil import to raise ImportError
100+
original_import = __builtins__.__import__
101+
102+
def mock_import(name, *args, **kwargs):
103+
if name == 'psutil':
104+
raise ImportError("No module named 'psutil'")
105+
return original_import(name, *args, **kwargs)
106+
107+
monkeypatch.setattr(__builtins__, '__import__', mock_import)
108+
109+
app = Dash(__name__)
110+
app.layout = html.Div("Test Health Without Psutil")
111+
112+
dash_duo.start_server(app)
113+
114+
response = requests.get(f"{dash_duo.server_url}/health")
115+
assert response.status_code == 200
116+
117+
data = response.json()
118+
assert data["status"] == "healthy"
119+
# System metrics should not be present when psutil is not available
120+
assert "system" not in data
121+
122+
123+
def test_health005_health_json_format(dash_duo):
124+
"""Test that health endpoint returns valid JSON."""
125+
app = Dash(__name__)
126+
app.layout = html.Div("Test Health JSON Format")
127+
128+
dash_duo.start_server(app)
129+
130+
response = requests.get(f"{dash_duo.server_url}/health")
131+
assert response.status_code == 200
132+
133+
# Verify content type
134+
assert response.headers['content-type'].startswith('application/json')
135+
136+
# Verify valid JSON
137+
try:
138+
data = response.json()
139+
assert isinstance(data, dict)
140+
except json.JSONDecodeError:
141+
pytest.fail("Health endpoint did not return valid JSON")
142+
143+
144+
def test_health006_health_with_custom_server_name(dash_duo):
145+
"""Test health endpoint with custom server name."""
146+
app = Dash(__name__, name="custom_health_app")
147+
app.layout = html.Div("Test Custom Server Name")
148+
149+
dash_duo.start_server(app)
150+
151+
response = requests.get(f"{dash_duo.server_url}/health")
152+
assert response.status_code == 200
153+
154+
data = response.json()
155+
assert data["server_name"] == "custom_health_app"
156+
157+
158+
def test_health007_health_endpoint_accessibility(dash_duo):
159+
"""Test that health endpoint is accessible without authentication."""
160+
app = Dash(__name__)
161+
app.layout = html.Div("Test Health Accessibility")
162+
163+
dash_duo.start_server(app)
164+
165+
# Test multiple requests to ensure consistency
166+
for _ in range(3):
167+
response = requests.get(f"{dash_duo.server_url}/health")
168+
assert response.status_code == 200
169+
data = response.json()
170+
assert data["status"] == "healthy"
171+
172+
173+
def test_health008_health_timestamp_format(dash_duo):
174+
"""Test that health endpoint returns valid ISO timestamp."""
175+
import datetime
176+
177+
app = Dash(__name__)
178+
app.layout = html.Div("Test Health Timestamp")
179+
180+
dash_duo.start_server(app)
181+
182+
response = requests.get(f"{dash_duo.server_url}/health")
183+
assert response.status_code == 200
184+
185+
data = response.json()
186+
timestamp = data["timestamp"]
187+
188+
# Verify timestamp format (ISO 8601 with Z suffix)
189+
assert timestamp.endswith('Z')
190+
assert 'T' in timestamp
191+
192+
# Verify it's a valid datetime
193+
try:
194+
parsed_time = datetime.datetime.fromisoformat(timestamp[:-1] + '+00:00')
195+
assert isinstance(parsed_time, datetime.datetime)
196+
except ValueError:
197+
pytest.fail(f"Invalid timestamp format: {timestamp}")
198+
199+
200+
def test_health009_health_with_routes_pathname_prefix(dash_duo):
201+
"""Test health endpoint with custom routes_pathname_prefix."""
202+
app = Dash(__name__, routes_pathname_prefix="/app/")
203+
app.layout = html.Div("Test Health With Prefix")
204+
205+
dash_duo.start_server(app)
206+
207+
# Health endpoint should be available at /app/health
208+
response = requests.get(f"{dash_duo.server_url}/app/health")
209+
assert response.status_code == 200
210+
211+
data = response.json()
212+
assert data["status"] == "healthy"
213+
214+
215+
def test_health010_health_performance(dash_duo):
216+
"""Test that health endpoint responds quickly."""
217+
import time
218+
219+
app = Dash(__name__)
220+
app.layout = html.Div("Test Health Performance")
221+
222+
dash_duo.start_server(app)
223+
224+
start_time = time.time()
225+
response = requests.get(f"{dash_duo.server_url}/health")
226+
end_time = time.time()
227+
228+
assert response.status_code == 200
229+
assert (end_time - start_time) < 1.0 # Should respond within 1 second
230+
231+
data = response.json()
232+
assert data["status"] == "healthy"

0 commit comments

Comments
 (0)