Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
):

Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there should be any code in the default handler, it should simply returns "OK" with response 200. The api should provide a way for the developer to add a function to handle the response and do it's health check properly by checking the needed services.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with @T4rk1n, I would expect the /health response to be as simple as

{"status":"ok"}

The remaining metadata is still useful if we return it via a different endpoint, eg. /info

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normal health check should return "OK", 200


def get_dist(self, libraries: Sequence[str]) -> list:
dists = []
for dist_type in ("_js_dist", "_css_dist"):
Expand Down
232 changes: 232 additions & 0 deletions tests/integration/test_health_endpoint.py
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"