Files
ray-project--ray/python/ray/serve/tests/test_metrics_haproxy.py
T
2026-07-13 13:17:40 +08:00

1075 lines
36 KiB
Python

"""
HAProxy metrics tests for Ray Serve.
These tests verify that Ray Serve metrics work correctly when HAProxy is enabled
as a replacement for the default Serve HTTP proxy.
Key differences from the default Serve proxy:
1. When HAProxy is enabled, RAY_SERVE_ENABLE_DIRECT_INGRESS is automatically set.
2. HTTP proxy metrics (serve_num_http_requests, etc.) are emitted from replicas when
they receive direct ingress requests from HAProxy.
3. 404 errors for non-existent routes are handled by HAProxy itself (not forwarded to
replicas), so these won't generate Serve metrics. Tests that need to verify 404
metrics must deploy an application that returns 404s.
4. HAProxy has its own metrics exposed on a separate port (default 9101), but these
tests focus on Serve metrics exposed via the Ray metrics port (9999).
"""
import http
import json
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, Optional
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import PlainTextResponse
import ray
from ray import serve
from ray._common.network_utils import parse_address
from ray._common.test_utils import (
PrometheusTimeseries,
SignalActor,
fetch_prometheus_metrics,
wait_for_condition,
)
from ray._common.utils import reset_ray_address
from ray.serve import HTTPOptions
from ray.serve._private.long_poll import LongPollHost, UpdatedObject
from ray.serve._private.test_utils import (
expected_proxy_actors,
get_application_url,
get_metric_dictionaries,
)
from ray.serve._private.utils import block_until_http_ready
from ray.serve.tests.conftest import (
TEST_METRICS_EXPORT_PORT,
wait_for_metrics_endpoint,
wait_for_metrics_port_free,
)
from ray.util.state import list_actors
@pytest.fixture
def metrics_start_shutdown(request):
"""Fixture provides a fresh Ray cluster to prevent metrics state sharing."""
param = request.param if hasattr(request, "param") else None
request_timeout_s = param if param else None
wait_for_metrics_port_free()
ray.init(
_metrics_export_port=TEST_METRICS_EXPORT_PORT,
_system_config={
"metrics_report_interval_ms": 100,
"task_retry_delay_ms": 50,
},
)
try:
session_name = ray._private.worker._global_node.session_name
wait_for_metrics_endpoint(session_name)
yield serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
request_timeout_s=request_timeout_s,
),
)
finally:
serve.shutdown()
ray.shutdown()
reset_ray_address()
def extract_tags(line: str) -> Dict[str, str]:
"""Extracts any tags from the metrics line."""
try:
tags_string = line.replace("{", "}").split("}")[1]
except IndexError:
# No tags were found in this line.
return {}
detected_tags = {}
for tag_pair in tags_string.split(","):
sanitized_pair = tag_pair.replace('"', "")
tag, value = sanitized_pair.split("=")
detected_tags[tag] = value
return detected_tags
def contains_tags(line: str, expected_tags: Optional[Dict[str, str]] = None) -> bool:
"""Checks if the metrics line contains the expected tags.
Does nothing if expected_tags is None.
"""
if expected_tags is not None:
detected_tags = extract_tags(line)
# Check if expected_tags is a subset of detected_tags
return expected_tags.items() <= detected_tags.items()
else:
return True
def get_metric_float(
metric: str, expected_tags: Optional[Dict[str, str]] = None
) -> float:
"""Gets the float value of metric.
If tags is specified, searched for metric with matching tags.
Returns -1 if the metric isn't available.
"""
metrics = httpx.get("http://127.0.0.1:9999").text
metric_value = -1
for line in metrics.split("\n"):
if metric in line and contains_tags(line, expected_tags):
metric_value = line.split(" ")[-1]
return metric_value
def check_metric_float_eq(
metric: str, expected: float, expected_tags: Optional[Dict[str, str]] = None
) -> bool:
metric_value = get_metric_float(metric, expected_tags)
assert float(metric_value) == expected
return True
def check_sum_metric_eq(
metric_name: str,
expected: float,
tags: Optional[Dict[str, str]] = None,
) -> bool:
if tags is None:
tags = {}
metrics = fetch_prometheus_metrics(["localhost:9999"])
metrics = {k: v for k, v in metrics.items() if "ray_serve_" in k}
metric_samples = metrics.get(metric_name, None)
if metric_samples is None:
metric_sum = 0
else:
metric_samples = [
sample for sample in metric_samples if tags.items() <= sample.labels.items()
]
metric_sum = sum(sample.value for sample in metric_samples)
# Check the metrics sum to the expected number
assert float(metric_sum) == float(expected), (
f"The following metrics don't sum to {expected}: "
f"{json.dumps(metric_samples, indent=4)}\n."
f"All metrics: {json.dumps(metrics, indent=4)}"
)
# # For debugging
if metric_samples:
print(f"The following sum to {expected} for '{metric_name}' and tags {tags}:")
for sample in metric_samples:
print(sample)
return True
def test_serve_metrics_for_successful_connection(metrics_start_shutdown):
@serve.deployment(name="metrics")
async def f(request):
return "hello"
app_name = "app1"
handle = serve.run(target=f.bind(), name=app_name)
http_url = get_application_url(app_name=app_name)
# send 10 concurrent requests
ray.get([block_until_http_ready.remote(http_url) for _ in range(10)])
[handle.remote(http_url) for _ in range(10)]
def verify_metrics(do_assert=False):
try:
resp = httpx.get("http://127.0.0.1:9999").text
# Requests will fail if we are crashing the controller
except httpx.HTTPError:
return False
# NOTE: These metrics should be documented at
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
# Any updates to here should be reflected there too.
expected_metrics = [
# counter
"ray_serve_num_router_requests",
"ray_serve_num_http_requests",
"ray_serve_deployment_queued_queries",
"ray_serve_deployment_request_counter",
"ray_serve_deployment_replica_starts",
# histogram
"ray_serve_deployment_processing_latency_ms_bucket",
"ray_serve_deployment_processing_latency_ms_count",
"ray_serve_deployment_processing_latency_ms_sum",
"ray_serve_deployment_processing_latency_ms",
# gauge
"ray_serve_replica_processing_queries",
"ray_serve_deployment_replica_healthy",
# handle
"ray_serve_handle_request_counter",
]
for metric in expected_metrics:
# For the final error round
if do_assert:
assert metric in resp
# For the wait_for_condition
else:
if metric not in resp:
return False
return True
try:
wait_for_condition(verify_metrics, retry_interval_ms=500, timeout=40)
except RuntimeError:
verify_metrics(do_assert=True)
def test_http_replica_gauge_metrics(metrics_start_shutdown):
"""Test http replica gauge metrics"""
signal = SignalActor.remote()
@serve.deployment(graceful_shutdown_timeout_s=0.0001)
class A:
async def __call__(self):
await signal.wait.remote()
handle = serve.run(A.bind(), name="app1")
_ = handle.remote()
processing_requests = get_metric_dictionaries(
"ray_serve_replica_processing_queries", timeout=5
)
assert len(processing_requests) == 1
assert processing_requests[0]["deployment"] == "A"
assert processing_requests[0]["application"] == "app1"
print("ray_serve_replica_processing_queries exists.")
def ensure_request_processing():
resp = httpx.get("http://127.0.0.1:9999").text
resp = resp.split("\n")
for metrics in resp:
if "# HELP" in metrics or "# TYPE" in metrics:
continue
if "ray_serve_replica_processing_queries" in metrics:
assert "1.0" in metrics
return True
wait_for_condition(ensure_request_processing, timeout=5)
def test_proxy_metrics_not_found(metrics_start_shutdown):
# NOTE: When using HAProxy, 404 errors for non-existent routes are handled
# by HAProxy itself (not forwarded to replicas), so we need to deploy an
# application and test 404s within that application's context.
# These metrics should be documented at
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
# Any updates here should be reflected there too.
expected_metrics = [
"ray_serve_num_http_requests",
"ray_serve_num_http_error_requests_total",
"ray_serve_num_deployment_http_error_requests",
"ray_serve_http_request_latency_ms",
]
app = FastAPI()
@serve.deployment(name="A")
@serve.ingress(app)
class A:
@app.get("/existing-path") # Only this path is defined
async def handler(self, request: Request):
return {"message": "success"}
app_name = "app"
serve.run(A.bind(), name=app_name, route_prefix="/A")
def verify_metrics(_expected_metrics, do_assert=False):
try:
resp = httpx.get("http://127.0.0.1:9999").text
# Requests will fail if we are crashing the controller
except httpx.HTTPError:
return False
for metric in _expected_metrics:
if do_assert:
assert metric in resp
if metric not in resp:
return False
return True
# Trigger HTTP 404 error via the deployed application
httpx.get("http://127.0.0.1:8000/A/nonexistent")
httpx.get("http://127.0.0.1:8000/A/nonexistent")
# Ensure all expected metrics are present.
try:
wait_for_condition(
verify_metrics,
retry_interval_ms=1000,
timeout=10,
expected_metrics=expected_metrics,
)
except RuntimeError:
verify_metrics(expected_metrics, True)
def verify_error_count(do_assert=False):
resp = httpx.get("http://127.0.0.1:9999").text
resp = resp.split("\n")
http_error_count = 0
deployment_404_count = 0
for metrics in resp:
if "# HELP" in metrics or "# TYPE" in metrics:
continue
# Skip health check metrics
if "/-/healthz" in metrics:
continue
if (
"ray_serve_num_http_error_requests_total" in metrics
and 'route="/A"' in metrics
):
# Accumulate error counts from route "/A"
http_error_count += int(float(metrics.split(" ")[-1]))
elif (
"ray_serve_num_deployment_http_error_requests_total" in metrics
and 'route="/A"' in metrics
and 'error_code="404"' in metrics
):
# Count deployment 404 errors
deployment_404_count += int(float(metrics.split(" ")[-1]))
# We expect 2 requests total, both should be 404 errors from the deployment
if do_assert:
assert (
http_error_count == 2
), f"Expected at least 2 HTTP errors, got {http_error_count}"
assert (
deployment_404_count == 2
), f"Expected 2 deployment 404 errors, got {deployment_404_count}"
return http_error_count >= 2 and deployment_404_count == 2
# There is a latency in updating the counter
try:
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=20)
except RuntimeError:
verify_error_count(do_assert=True)
def test_proxy_metrics_internal_error(metrics_start_shutdown):
# NOTE: When using HAProxy, we need the replica to stay alive to emit metrics.
# Instead of crashing the actor (which prevents metric emission), we return
# a 500 error explicitly.
# These metrics should be documented at
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
# Any updates here should be reflected there too.
expected_metrics = [
"ray_serve_num_http_requests",
"ray_serve_num_http_error_requests_total",
"ray_serve_num_deployment_http_error_requests",
"ray_serve_http_request_latency_ms",
]
def verify_metrics(_expected_metrics, do_assert=False):
try:
resp = httpx.get("http://127.0.0.1:9999", timeout=None).text
# Requests will fail if we are crashing the controller
except httpx.HTTPError:
return False
for metric in _expected_metrics:
if do_assert:
assert metric in resp
if metric not in resp:
return False
return True
@serve.deployment(name="A")
class A:
async def __init__(self):
pass
async def __call__(self, request: Request):
# Return 500 Internal Server Error
return PlainTextResponse("Internal Server Error", status_code=500)
app_name = "app"
serve.run(A.bind(), name=app_name, route_prefix="/")
resp1 = httpx.get("http://localhost:8000/", timeout=None)
resp2 = httpx.get("http://localhost:8000/", timeout=None)
assert resp1.status_code == 500
assert resp2.status_code == 500
# Ensure all expected metrics are present.
try:
wait_for_condition(
verify_metrics,
retry_interval_ms=1000,
timeout=10,
expected_metrics=expected_metrics,
)
except RuntimeError:
verify_metrics(expected_metrics, True)
def verify_error_count(do_assert=False):
resp = httpx.get("http://127.0.0.1:9999", timeout=None).text
resp = resp.split("\n")
http_error_count = 0
deployment_error_count = 0
for metrics in resp:
if "# HELP" in metrics or "# TYPE" in metrics:
continue
if (
"ray_serve_num_http_error_requests_total" in metrics
and 'route="/"' in metrics
and 'error_code="500"' in metrics
):
http_error_count += int(float(metrics.split(" ")[-1]))
elif (
"ray_serve_num_deployment_http_error_requests" in metrics
and 'deployment="A"' in metrics
and 'error_code="500"' in metrics
):
deployment_error_count += int(float(metrics.split(" ")[-1]))
# We expect 2 requests total, both should be 500 errors
if do_assert:
assert (
http_error_count == 2
), f"Expected at least 2 HTTP 500 errors, got {http_error_count}"
assert (
deployment_error_count == 2
), f"Expected at least 2 deployment 500 errors, got {deployment_error_count}"
return http_error_count == 2 and deployment_error_count == 2
# There is a latency in updating the counter
try:
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=30)
except RuntimeError:
verify_error_count(do_assert=True)
def test_proxy_metrics_fields_not_found(metrics_start_shutdown):
"""Tests the proxy metrics' fields' behavior for not found.
Note: When using HAProxy, we need to deploy an application that returns 404,
as HAProxy handles non-existent route 404s itself without forwarding to replicas.
"""
# These metrics should be documented at
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
# Any updates here should be reflected there too.
expected_metrics = [
"ray_serve_num_http_requests",
"ray_serve_num_http_error_requests_total",
"ray_serve_num_deployment_http_error_requests",
"ray_serve_http_request_latency_ms",
]
app = FastAPI()
@serve.deployment(name="test_app")
@serve.ingress(app)
class NotFoundApp:
@app.get("/existing-path") # Only this path is defined
async def handler(self, request: Request):
return {"message": "success"}
app_name = "app"
serve.run(NotFoundApp.bind(), name=app_name, route_prefix="/test")
def verify_metrics(_expected_metrics, do_assert=False):
try:
resp = httpx.get("http://127.0.0.1:9999").text
# Requests will fail if we are crashing the controller
except httpx.HTTPError:
return False
for metric in _expected_metrics:
if do_assert:
assert metric in resp
if metric not in resp:
return False
return True
# Trigger HTTP 404 error via the deployed application
httpx.get("http://127.0.0.1:8000/test/nonexistent")
httpx.get("http://127.0.0.1:8000/test/nonexistent")
# Ensure all expected metrics are present.
try:
wait_for_condition(
verify_metrics,
retry_interval_ms=1000,
timeout=10,
expected_metrics=expected_metrics,
)
except RuntimeError:
verify_metrics(expected_metrics, True)
def verify_error_count(do_assert=False):
resp = httpx.get("http://127.0.0.1:9999").text
resp = resp.split("\n")
http_error_count = 0
deployment_404_count = 0
for metrics in resp:
if "# HELP" in metrics or "# TYPE" in metrics:
continue
# Skip health check metrics
if "/-/healthz" in metrics:
continue
if (
"ray_serve_num_http_error_requests_total" in metrics
and 'route="/test"' in metrics
):
# Accumulate error counts from route "/test"
http_error_count += int(float(metrics.split(" ")[-1]))
elif (
"ray_serve_num_deployment_http_error_requests_total" in metrics
and 'route="/test"' in metrics
and 'error_code="404"' in metrics
):
# Count deployment 404 errors
deployment_404_count += int(float(metrics.split(" ")[-1]))
# We expect 2 requests total, both should be 404 errors from the deployment
if do_assert:
assert (
http_error_count == 2
), f"Expected at least 2 HTTP errors, got {http_error_count}"
assert (
deployment_404_count == 2
), f"Expected 2 deployment 404 errors, got {deployment_404_count}"
return http_error_count >= 2 and deployment_404_count == 2
# There is a latency in updating the counter
try:
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=20)
except RuntimeError:
verify_error_count(do_assert=True)
@pytest.mark.parametrize(
"metrics_start_shutdown",
[
1,
],
indirect=True,
)
def test_proxy_timeout_metrics(metrics_start_shutdown):
"""Test that HTTP timeout metrics are reported correctly."""
signal = SignalActor.remote()
@serve.deployment
async def return_status_code_with_timeout(request: Request):
await signal.wait.remote()
return
serve.run(
return_status_code_with_timeout.bind(),
route_prefix="/status_code_timeout",
name="status_code_timeout",
)
http_url = get_application_url("HTTP", app_name="status_code_timeout")
r = httpx.get(http_url)
assert r.status_code == 408
ray.get(signal.send.remote(clear=True))
num_errors = get_metric_dictionaries("ray_serve_num_http_error_requests_total")
assert len(num_errors) == 1
assert num_errors[0]["route"] == "/status_code_timeout"
assert num_errors[0]["error_code"] == "408"
assert num_errors[0]["method"] == "GET"
assert num_errors[0]["application"] == "status_code_timeout"
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
def test_proxy_disconnect_http_metrics(metrics_start_shutdown):
"""Test that HTTP disconnect metrics are reported correctly."""
signal = SignalActor.remote()
@serve.deployment
class Disconnect:
async def __call__(self, request: Request):
await signal.wait.remote()
return
serve.run(
Disconnect.bind(),
route_prefix="/disconnect",
name="disconnect",
)
# Simulate an HTTP disconnect
http_url = get_application_url("HTTP", app_name="disconnect")
ip_port = http_url.replace("http://", "").split("/")[0] # remove the route prefix
ip, port = parse_address(ip_port)
conn = http.client.HTTPConnection(ip, int(port))
conn.request("GET", "/disconnect")
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 1, timeout=10
)
conn.close() # Forcefully close the connection
ray.get(signal.send.remote(clear=True))
num_errors = get_metric_dictionaries("ray_serve_num_http_error_requests_total")
assert len(num_errors) == 1
assert num_errors[0]["route"] == "/disconnect"
assert num_errors[0]["error_code"] == "499"
assert num_errors[0]["method"] == "GET"
assert num_errors[0]["application"] == "disconnect"
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
def test_no_499_misclassification_after_successful_response(metrics_start_shutdown):
"""Reproduce the race where response is sent (body without more_body) but
response_finished stays False, then disconnect arrives and we incorrectly log 499.
convert_object_to_asgi_messages omits more_body in the final body chunk (valid
per ASGI spec). The fix treats omitted more_body as final so we don't
misclassify successful responses as 499 when client disconnects after response.
"""
@serve.deployment
async def fast_return(request: Request):
return "ok"
serve.run(
fast_return.bind(),
route_prefix="/race_test",
name="race_test",
)
http_url = get_application_url("HTTP", app_name="race_test")
def _request_then_close_immediately():
"""Send request, read 1 byte of response, then close. This creates the race:
server has sent full response (body without more_body) but client closes
before request_task exits. Without the fix, response_finished stays False
and we incorrectly log 499."""
with httpx.Client() as client:
with client.stream("GET", http_url) as response:
next(
response.iter_bytes(1)
) # Read 1 byte, then exit - connection closes
# Run many times to hit the race (disconnect arrives before request_task exits)
num_requests = 500
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [
executor.submit(_request_then_close_immediately)
for _ in range(num_requests)
]
for f in as_completed(futures):
f.result()
# First assert all requests were processed
def check_request_count_and_no_499_errors():
check_sum_metric_eq(
"ray_serve_num_http_requests_total",
num_requests,
tags={"route": "/race_test"},
)
check_sum_metric_eq(
"ray_serve_num_http_error_requests_total",
0,
tags={"route": "/race_test", "error_code": "499"},
)
return True
wait_for_condition(check_request_count_and_no_499_errors, timeout=30)
def test_proxy_metrics_fields_internal_error(metrics_start_shutdown):
"""Tests the proxy metrics' fields' behavior for internal error."""
@serve.deployment()
def f(*args):
return 1 / 0
real_app_name = "app"
real_app_name2 = "app2"
serve.run(f.bind(), name=real_app_name, route_prefix="/real_route")
serve.run(f.bind(), name=real_app_name2, route_prefix="/real_route2")
# Deployment should generate divide-by-zero errors
correct_url = get_application_url("HTTP", real_app_name)
_ = httpx.get(correct_url).text
print("Sent requests to correct URL.")
num_deployment_errors = get_metric_dictionaries(
"ray_serve_num_deployment_http_error_requests_total"
)
assert len(num_deployment_errors) == 1
assert num_deployment_errors[0]["deployment"] == "f"
assert num_deployment_errors[0]["error_code"] == "500"
assert num_deployment_errors[0]["method"] == "GET"
assert num_deployment_errors[0]["application"] == "app"
print("ray_serve_num_deployment_http_error_requests working as expected.")
latency_metrics = get_metric_dictionaries("ray_serve_http_request_latency_ms_sum")
# Filter out health check metrics - HAProxy generates health checks to /-/healthz
latency_metrics = [m for m in latency_metrics if m["route"] != "/-/healthz"]
assert len(latency_metrics) == 1
assert latency_metrics[0]["method"] == "GET"
assert latency_metrics[0]["route"] == "/real_route"
assert latency_metrics[0]["application"] == "app"
assert latency_metrics[0]["status_code"] == "500"
print("ray_serve_http_request_latency_ms working as expected.")
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
def test_proxy_metrics_http_status_code_is_error(metrics_start_shutdown):
"""Verify that 2xx and 3xx status codes aren't errors, others are."""
def check_request_count_metrics(
expected_error_count: int,
expected_success_count: int,
):
resp = httpx.get("http://127.0.0.1:9999").text
error_count = 0
success_count = 0
for line in resp.split("\n"):
# Skip health check metrics
if "/-/healthz" in line:
continue
if line.startswith("ray_serve_num_http_error_requests_total"):
error_count += int(float(line.split(" ")[-1]))
if line.startswith("ray_serve_num_http_requests_total"):
success_count += int(float(line.split(" ")[-1]))
assert error_count == expected_error_count
assert success_count == expected_success_count
return True
@serve.deployment
async def return_status_code(request: Request):
code = int((await request.body()).decode("utf-8"))
return PlainTextResponse("", status_code=code)
serve.run(return_status_code.bind())
http_url = get_application_url("HTTP")
# 200 is not an error.
r = httpx.request("GET", http_url, content=b"200")
assert r.status_code == 200
wait_for_condition(
check_request_count_metrics,
expected_error_count=0,
expected_success_count=1,
)
# 2xx is not an error.
r = httpx.request("GET", http_url, content=b"250")
assert r.status_code == 250
wait_for_condition(
check_request_count_metrics,
expected_error_count=0,
expected_success_count=2,
)
# 3xx is not an error.
r = httpx.request("GET", http_url, content=b"300")
assert r.status_code == 300
wait_for_condition(
check_request_count_metrics,
expected_error_count=0,
expected_success_count=3,
)
# 4xx is an error.
r = httpx.request("GET", http_url, content=b"400")
assert r.status_code == 400
wait_for_condition(
check_request_count_metrics,
expected_error_count=1,
expected_success_count=4,
)
# 5xx is an error.
r = httpx.request("GET", http_url, content=b"500")
assert r.status_code == 500
wait_for_condition(
check_request_count_metrics,
expected_error_count=2,
expected_success_count=5,
)
def test_replica_metrics_fields(metrics_start_shutdown):
"""Test replica metrics fields"""
@serve.deployment
def f():
return "hello"
@serve.deployment
def g():
return "world"
serve.run(f.bind(), name="app1", route_prefix="/f")
serve.run(g.bind(), name="app2", route_prefix="/g")
url_f = get_application_url("HTTP", "app1")
url_g = get_application_url("HTTP", "app2")
assert "hello" == httpx.post(url_f).text
assert "world" == httpx.post(url_g).text
wait_for_condition(
lambda: len(
get_metric_dictionaries(
"ray_serve_deployment_request_counter_total", wait=False
)
)
== 2,
timeout=40,
)
metrics = get_metric_dictionaries("ray_serve_deployment_request_counter_total")
assert len(metrics) == 2
expected_output = {
("/f", "f", "app1"),
("/g", "g", "app2"),
}
assert {
(
metric["route"],
metric["deployment"],
metric["application"],
)
for metric in metrics
} == expected_output
start_metrics = get_metric_dictionaries("ray_serve_deployment_replica_starts_total")
assert len(start_metrics) == 2
expected_output = {("f", "app1"), ("g", "app2")}
assert {
(start_metric["deployment"], start_metric["application"])
for start_metric in start_metrics
} == expected_output
# Latency metrics
wait_for_condition(
lambda: len(
get_metric_dictionaries(
"ray_serve_deployment_processing_latency_ms_count", wait=False
)
)
== 2,
timeout=40,
)
for metric_name in [
"ray_serve_deployment_processing_latency_ms_count",
"ray_serve_deployment_processing_latency_ms_sum",
]:
latency_metrics = get_metric_dictionaries(metric_name)
print(f"checking metric {metric_name}, {latency_metrics}")
assert len(latency_metrics) == 2
expected_output = {("f", "app1"), ("g", "app2")}
assert {
(latency_metric["deployment"], latency_metric["application"])
for latency_metric in latency_metrics
} == expected_output
wait_for_condition(
lambda: len(
get_metric_dictionaries("ray_serve_replica_processing_queries", wait=False)
)
== 2
)
processing_queries = get_metric_dictionaries("ray_serve_replica_processing_queries")
expected_output = {("f", "app1"), ("g", "app2")}
assert {
(processing_query["deployment"], processing_query["application"])
for processing_query in processing_queries
} == expected_output
@serve.deployment
def h():
return 1 / 0
serve.run(h.bind(), name="app3", route_prefix="/h")
url_h = get_application_url("HTTP", "app3")
assert 500 == httpx.get(url_h).status_code
wait_for_condition(
lambda: len(
get_metric_dictionaries(
"ray_serve_deployment_error_counter_total", wait=False
)
)
== 1,
timeout=40,
)
err_requests = get_metric_dictionaries("ray_serve_deployment_error_counter_total")
assert len(err_requests) == 1
expected_output = ("/h", "h", "app3")
assert (
err_requests[0]["route"],
err_requests[0]["deployment"],
err_requests[0]["application"],
) == expected_output
expected_deployments = {("f", "app1"), ("g", "app2"), ("h", "app3")}
health_timeseries = PrometheusTimeseries()
def _check_replica_healthy():
metrics = get_metric_dictionaries(
"ray_serve_deployment_replica_healthy",
wait=False,
timeseries=health_timeseries,
)
return {
(m["deployment"], m["application"]) for m in metrics
} >= expected_deployments
wait_for_condition(_check_replica_healthy, timeout=40)
health_metrics = get_metric_dictionaries(
"ray_serve_deployment_replica_healthy", timeseries=health_timeseries
)
assert {
(health_metric["deployment"], health_metric["application"])
for health_metric in health_metrics
} >= expected_deployments
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
def test_multiplexed_metrics(metrics_start_shutdown):
"""Tests multiplexed API corresponding metrics."""
@serve.deployment
class Model:
@serve.multiplexed(max_num_models_per_replica=2)
async def get_model(self, model_id: str):
return model_id
async def __call__(self, model_id: str):
await self.get_model(model_id)
return
# Multiplexing is not supported on the ingress deployment when direct ingress /
# HAProxy is enabled, so keep the multiplexed deployment downstream of a plain
# ingress.
@serve.deployment
class Ingress:
def __init__(self, model):
self._model = model
async def __call__(self, model_id: str):
await self._model.remote(model_id)
handle = serve.run(Ingress.bind(Model.bind()), name="app", route_prefix="/app")
handle.remote("model1")
handle.remote("model2")
# Trigger model eviction.
handle.remote("model3")
expected_metrics = [
"ray_serve_multiplexed_model_load_latency_ms",
"ray_serve_multiplexed_model_unload_latency_ms",
"ray_serve_num_multiplexed_models",
"ray_serve_multiplexed_models_load_counter",
"ray_serve_multiplexed_models_unload_counter",
]
def verify_metrics():
try:
resp = httpx.get("http://127.0.0.1:9999").text
# Requests will fail if we are crashing the controller
except httpx.HTTPError:
return False
for metric in expected_metrics:
assert metric in resp
return True
wait_for_condition(
verify_metrics,
timeout=40,
retry_interval_ms=1000,
)
def test_long_poll_host_sends_counted(serve_instance):
"""Check that the transmissions by the long_poll are counted."""
host = ray.remote(LongPollHost).remote(
listen_for_change_request_timeout_s=(0.01, 0.01)
)
# Write a value.
ray.get(host.notify_changed.remote({"key_1": 999}))
object_ref = host.listen_for_change.remote({"key_1": -1})
# Check that the result's size is reported.
result_1: Dict[str, UpdatedObject] = ray.get(object_ref)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_host_transmission_counter",
expected=1,
expected_tags={"namespace_or_state": "key_1"},
)
# Write two new values.
ray.get(host.notify_changed.remote({"key_1": 1000}))
ray.get(host.notify_changed.remote({"key_2": 1000}))
object_ref = host.listen_for_change.remote(
{"key_1": result_1["key_1"].snapshot_id, "key_2": -1}
)
# Check that the new objects are transmitted.
result_2: Dict[str, UpdatedObject] = ray.get(object_ref)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_host_transmission_counter",
expected=1,
expected_tags={"namespace_or_state": "key_2"},
)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_host_transmission_counter",
expected=2,
expected_tags={"namespace_or_state": "key_1"},
)
# Check that a timeout result is counted.
object_ref = host.listen_for_change.remote({"key_2": result_2["key_2"].snapshot_id})
_ = ray.get(object_ref)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_host_transmission_counter",
expected=1,
expected_tags={"namespace_or_state": "TIMEOUT"},
)
def test_actor_summary(serve_instance):
@serve.deployment
def f():
pass
serve.run(f.bind(), name="app")
actors = list_actors(filters=[("state", "=", "ALIVE")])
class_names = {actor["class_name"] for actor in actors}
assert class_names.issuperset(
{"ServeController", *expected_proxy_actors(), "ServeReplica:app:f"}
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))