Files
2026-07-13 13:17:40 +08:00

1510 lines
52 KiB
Python

import io
import json
import logging
import os
import re
import string
import sys
import time
from collections import Counter
from contextlib import redirect_stderr
from pathlib import Path
from typing import List, Tuple
from unittest.mock import patch
import grpc
import httpx
import pytest
import starlette
from fastapi import FastAPI
from starlette.responses import PlainTextResponse
import ray
from ray import serve
from ray._common.formatters import JSONFormatter
from ray._common.test_utils import wait_for_condition
from ray.serve._private.common import DeploymentID, ReplicaID, ServeComponentType
from ray.serve._private.constants import SERVE_LOG_EXTRA_FIELDS, SERVE_LOGGER_NAME
from ray.serve._private.logging_utils import (
ServeComponentFilter,
ServeFormatter,
StreamToLogger,
configure_component_logger,
configure_default_serve_logger,
get_serve_logs_dir,
redirected_print,
)
from ray.serve._private.test_utils import get_application_url, ping_grpc_call_method
from ray.serve._private.utils import get_component_file_name
from ray.serve.config import gRPCOptions
from ray.serve.context import _get_global_client
from ray.serve.schema import EncodingType, LoggingConfig
from ray.serve.tests.test_config_files.grpc_deployment import g
from ray.util.state import list_actors, list_nodes
class FakeLogger:
def __init__(self):
self._logs: List[Tuple[int, str]] = []
def log(self, level: int, message: str, stacklevel: int = 1):
self._logs.append((level, message))
def get_logs(self):
return self._logs
class FakeStdOut:
def __init__(self):
self.encoding = "utf-8"
@pytest.fixture
def serve_and_ray_shutdown():
serve.shutdown()
ray.shutdown()
yield
def set_logging_config(monkeypatch, max_bytes, backup_count):
monkeypatch.setenv("RAY_ROTATION_MAX_BYTES", str(max_bytes))
monkeypatch.setenv("RAY_ROTATION_BACKUP_COUNT", str(backup_count))
def _get_expected_replica_log_content(replica_id: ReplicaID):
app_name = replica_id.deployment_id.app_name
deployment_name = replica_id.deployment_id.name
return f"{app_name}_{deployment_name} {replica_id.unique_id}"
def test_log_rotation_config(monkeypatch, ray_shutdown):
# This test should be executed before any test that uses
# the shared serve_instance, as environment variables
# for log rotation need to be set before ray.init
logger = logging.getLogger("ray.serve")
max_bytes = 100
backup_count = 3
set_logging_config(monkeypatch, max_bytes, backup_count)
ray.init(num_cpus=1)
@serve.deployment
class Handle:
def __call__(self):
handlers = logger.handlers
res = {}
for handler in handlers:
if isinstance(handler, logging.handlers.MemoryHandler):
target = handler.target
assert isinstance(target, logging.handlers.RotatingFileHandler)
res["max_bytes"] = target.maxBytes
res["backup_count"] = target.backupCount
return res
handle = serve.run(Handle.bind())
rotation_config = handle.remote().result()
assert rotation_config["max_bytes"] == max_bytes
assert rotation_config["backup_count"] == backup_count
@pytest.mark.parametrize("log_format", ["TEXT", "JSON"])
def test_http_access_log_in_stderr(serve_instance, log_format):
if log_format == "JSON":
# TODO (SERVE-908|harshit): This test is flaky in premerge.
pytest.skip("The test for JSON log format is flaky, skipping for now.")
name = "deployment_name"
fastapi_app = FastAPI()
@serve.deployment(name=name)
@serve.ingress(fastapi_app)
class Handler:
def __init__(self):
self._replica_unique_id = serve.get_replica_context().replica_id.unique_id
@fastapi_app.get("/")
def get_root(self):
return PlainTextResponse(self._replica_unique_id)
@fastapi_app.post("/")
def post_root(self):
return PlainTextResponse(self._replica_unique_id)
@fastapi_app.get("/{status}")
def template(self, status: str):
return PlainTextResponse(self._replica_unique_id, status_code=int(status))
@fastapi_app.put("/fail")
def fail(self):
raise RuntimeError("OOPS!")
serve.run(Handler.bind(), logging_config={"encoding": log_format})
f = io.StringIO()
with redirect_stderr(f):
def check_log(
replica_id: ReplicaID,
method: str,
route: str,
status_code: str,
fail: bool = False,
):
s = f.getvalue()
return all(
[
name in s,
_get_expected_replica_log_content(replica_id) in s,
f"{method} {route} {status_code}" in s,
"ms" in s,
("OOPS!" in s and "RuntimeError" in s)
if fail
else True, # Check for stacktrace.
]
)
url = get_application_url(use_localhost=True)
r = httpx.get(url)
assert r.status_code == 200
replica_id = ReplicaID(unique_id=r.text, deployment_id=DeploymentID(name=name))
wait_for_condition(
check_log,
replica_id=replica_id,
method="GET",
route="/",
status_code="200",
timeout=20,
)
r = httpx.post(url)
assert r.status_code == 200
wait_for_condition(
check_log,
replica_id=replica_id,
method="POST",
route="/",
status_code="200",
timeout=20,
)
r = httpx.get(f"{url}/350")
assert r.status_code == 350
wait_for_condition(
check_log,
replica_id=replica_id,
method="GET",
route="/{status}",
status_code="350",
timeout=20,
)
r = httpx.put(f"{url}/fail")
assert r.status_code == 500
wait_for_condition(
check_log,
replica_id=replica_id,
method="PUT",
route="/fail",
status_code="500",
fail=True,
timeout=20,
)
@pytest.mark.parametrize("log_format", ["TEXT", "JSON"])
def test_http_access_log_in_logs_file(serve_instance, log_format):
name = "deployment_name"
fastapi_app = FastAPI()
@serve.deployment(name=name)
@serve.ingress(fastapi_app)
class Handler:
def __init__(self):
self._replica_unique_id = serve.get_replica_context().replica_id.unique_id
def _get_context_info(self):
"""Get context information for matching with logs"""
request_context = ray.serve.context._get_serve_request_context()
return {
"replica": self._replica_unique_id,
"request_id": request_context.request_id,
"worker_id": ray.get_runtime_context().get_worker_id(),
"node_id": ray.get_runtime_context().get_node_id(),
"actor_id": ray.get_runtime_context().get_actor_id(),
}
@fastapi_app.get("/")
def get_root(self):
return self._get_context_info()
@fastapi_app.post("/")
def post_root(self):
return self._get_context_info()
@fastapi_app.get("/{status}")
def template(self, status: str):
content_info = {"context": self._get_context_info()}
return PlainTextResponse(
content=json.dumps(content_info),
status_code=int(status),
media_type="application/json",
)
@fastapi_app.put("/fail")
def fail(self):
error_response = {"error": "OOPS!", "context": self._get_context_info()}
return PlainTextResponse(
content=json.dumps(error_response),
status_code=500,
media_type="application/json",
)
serve.run(Handler.bind(), logging_config={"encoding": log_format})
# Get log file information
client = _get_global_client()
serve_log_dir = get_serve_logs_dir()
replicas = ray.get(
client._controller.get_deployment_details.remote("default", name)
).replicas
replica_id = replicas[0].replica_id
replica_log_file_name = f"replica_default_{name}_{replica_id}.log"
log_file_path = os.path.join(serve_log_dir, replica_log_file_name)
url = get_application_url(use_localhost=True)
# Define the HTTP calls to make
http_calls = [
{
"method": "GET",
"url": url,
"expected_status": 200,
"expected_route": "/",
},
{
"method": "POST",
"url": url,
"expected_status": 200,
"expected_route": "/",
},
{
"method": "GET",
"url": f"{url}/350",
"expected_status": 350,
"expected_route": "/{status}",
},
{
"method": "PUT",
"url": f"{url}/fail",
"expected_status": 500,
"expected_route": "/fail",
},
]
def get_file_end_position(file_path):
"""Get the current end position of the file"""
try:
with open(file_path, "r") as f:
f.seek(0, 2) # Seek to end of file
return f.tell()
except FileNotFoundError:
return 0
def verify_http_response_in_logs(
response, new_log_lines, call_info, log_format, context_info=None
):
"""Verify that the HTTP response matches the new log entries"""
if not new_log_lines:
print("No new log lines found")
return False
if log_format == "JSON":
for line in new_log_lines:
if line.strip():
try:
log_data = json.loads(line.strip())
message = log_data.get("message", "")
if all(
[
f"default_{name}" == log_data.get("deployment"),
f"{call_info['method']} {call_info['expected_route']} {call_info['expected_status']}"
in message,
"ms" in message,
(
context_info is not None
and log_data.get("request_id")
== context_info["request_id"]
and log_data.get("worker_id")
== context_info["worker_id"]
and log_data.get("node_id")
== context_info["node_id"]
and log_data.get("replica")
== context_info["replica"]
),
]
):
return True
except json.JSONDecodeError:
continue
else:
for line in new_log_lines:
if all(
[
name in line,
f"default_{name} {replica_id}" in line,
f"{call_info['method']} {call_info['expected_route']} {call_info['expected_status']}"
in line,
"ms" in line,
]
):
return True
return False
# Process each HTTP call individually
for i, call_info in enumerate(http_calls):
# Step 1: Get current file end position
start_position = get_file_end_position(log_file_path)
# Step 2: Make HTTP call
if call_info["method"] == "GET":
response = httpx.get(call_info["url"])
elif call_info["method"] == "POST":
response = httpx.post(call_info["url"])
elif call_info["method"] == "PUT":
response = httpx.put(call_info["url"])
else:
raise ValueError(f"Unsupported HTTP method: {call_info['method']}")
# Verify response status
assert (
response.status_code == call_info["expected_status"]
), f"Expected status {call_info['expected_status']}, got {response.status_code}"
# Extract context information from response
context_info = None
response_data = response.json()
# For all routes apart from `/` endpoint, context info is nested under "context" key
if call_info["expected_route"] == "/":
context_info = response_data
elif "context" in response_data:
context_info = response_data["context"]
else:
raise ValueError(
f"Could not extract context info from response: {response.text}"
)
# Step 3: Verify HTTP response matches new log lines
def verify_log_lines(
file_path, start_pos, response, call_info, log_format, context_info
):
new_log_lines = []
try:
with open(file_path, "r") as f:
f.seek(start_pos)
new_content = f.read()
lines = new_content.splitlines() if new_content else []
new_log_lines = lines
except FileNotFoundError:
new_log_lines = []
return verify_http_response_in_logs(
response, new_log_lines, call_info, log_format, context_info
)
wait_for_condition(
verify_log_lines,
timeout=20,
retry_interval_ms=100,
file_path=log_file_path,
start_pos=start_position,
response=response,
call_info=call_info,
log_format=log_format,
context_info=context_info,
)
def test_handle_access_log(serve_instance):
name = "handler"
@serve.deployment(name=name)
class Handler:
def other_method(self, *args):
return serve.get_replica_context().replica_id
def __call__(self, *args):
return serve.get_replica_context().replica_id
def throw(self, *args):
raise RuntimeError("blah blah blah")
h = serve.run(Handler.bind())
f = io.StringIO()
with redirect_stderr(f):
def check_log(replica_id: ReplicaID, method_name: str, fail: bool = False):
s = f.getvalue()
return all(
[
name in s,
_get_expected_replica_log_content(replica_id) in s,
method_name in s,
("ERROR" if fail else "OK") in s,
"ms" in s,
("blah blah blah" in s and "RuntimeError" in s)
if fail
else True, # Check for stacktrace.
]
)
replica_id = h.remote().result()
wait_for_condition(check_log, replica_id=replica_id, method_name="__call__")
h.other_method.remote().result()
wait_for_condition(check_log, replica_id=replica_id, method_name="other_method")
with pytest.raises(RuntimeError, match="blah blah blah"):
h.throw.remote().result()
wait_for_condition(
check_log, replica_id=replica_id, method_name="throw", fail=True
)
def test_user_logs(serve_instance):
logger = logging.getLogger("ray.serve")
stderr_msg = "user log message"
log_file_msg = "in file only"
name = "user_fn"
@serve.deployment(name=name)
def fn(*args):
logger.info(stderr_msg)
logger.info(log_file_msg, extra={"log_to_stderr": False})
return (
serve.get_replica_context().replica_id,
logger.handlers[1].target.baseFilename,
)
handle = serve.run(fn.bind())
f = io.StringIO()
with redirect_stderr(f):
replica_id, log_file_name = handle.remote().result()
def check_stderr_log(replica_id: ReplicaID):
s = f.getvalue()
return all(
[
name in s,
_get_expected_replica_log_content(replica_id) in s,
stderr_msg in s,
log_file_msg not in s,
]
)
# Only the stderr_msg should be logged to stderr.
wait_for_condition(check_stderr_log, replica_id=replica_id)
def check_log_file(replica_id: str):
with open(log_file_name, "r") as f:
s = f.read()
return all(
[
name in s,
_get_expected_replica_log_content(replica_id) in s,
stderr_msg in s,
log_file_msg in s,
]
)
# Both messages should be logged to the file.
wait_for_condition(check_log_file, replica_id=replica_id)
def test_disable_access_log(serve_instance):
logger = logging.getLogger("ray.serve")
@serve.deployment
class A:
def __init__(self):
logger.setLevel(logging.ERROR)
def __call__(self, *args):
return serve.get_replica_context().replica_id
handle = serve.run(A.bind())
f = io.StringIO()
with redirect_stderr(f):
replica_id = handle.remote().result()
for _ in range(10):
time.sleep(0.1)
assert _get_expected_replica_log_content(replica_id) not in f.getvalue()
def test_log_filenames_contain_only_posix_characters(serve_instance):
"""Assert that all log filenames only consist of POSIX-compliant characters.
See: https://github.com/ray-project/ray/issues/41615
"""
@serve.deployment
class A:
def __call__(self, *args) -> str:
return "hi"
serve.run(A.bind())
url = get_application_url(use_localhost=True)
r = httpx.get(url)
r.raise_for_status()
assert r.text == "hi"
acceptable_chars = string.ascii_letters + string.digits + "_" + "."
for filename in os.listdir(get_serve_logs_dir()):
assert all(char in acceptable_chars for char in filename)
@pytest.mark.parametrize("json_log_format", [False, True])
def test_context_information_in_logging(serve_and_ray_shutdown, json_log_format):
"""Make sure all context information exist in the log message"""
logger = logging.getLogger("ray.serve")
@serve.deployment(
logging_config={"encoding": "JSON" if json_log_format else "TEXT"}
)
def fn(*args):
logger.info("user func")
request_context = ray.serve.context._get_serve_request_context()
return {
"request_id": request_context.request_id,
"route": request_context.route,
"app_name": request_context.app_name,
"log_file": logger.handlers[1].target.baseFilename,
"replica": serve.get_replica_context().replica_id.unique_id,
"actor_id": ray.get_runtime_context().get_actor_id(),
"worker_id": ray.get_runtime_context().get_worker_id(),
"node_id": ray.get_runtime_context().get_node_id(),
"task_name": ray.get_runtime_context().get_task_name(),
"task_func_name": ray.get_runtime_context().get_task_function_name(),
"actor_name": ray.get_runtime_context().get_actor_name(),
}
@serve.deployment(
logging_config={"encoding": "JSON" if json_log_format else "TEXT"}
)
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("user log message from class method")
request_context = ray.serve.context._get_serve_request_context()
return {
"request_id": request_context.request_id,
"route": request_context.route,
"app_name": request_context.app_name,
"log_file": logger.handlers[1].target.baseFilename,
"replica": serve.get_replica_context().replica_id.unique_id,
"actor_id": ray.get_runtime_context().get_actor_id(),
"worker_id": ray.get_runtime_context().get_worker_id(),
"node_id": ray.get_runtime_context().get_node_id(),
"task_name": ray.get_runtime_context().get_task_name(),
"task_func_name": ray.get_runtime_context().get_task_function_name(),
"actor_name": ray.get_runtime_context().get_actor_name(),
}
serve.run(fn.bind(), name="app1", route_prefix="/fn")
serve.run(Model.bind(), name="app2", route_prefix="/class_method")
url = get_application_url(app_name="app1", use_localhost=True)
url2 = get_application_url(app_name="app2", use_localhost=True)
f = io.StringIO()
with redirect_stderr(f):
resp = httpx.get(url).json()
resp2 = httpx.get(url2).json()
# Check the component log
expected_log_infos = [
f"{resp['request_id']} -- ",
f"{resp2['request_id']} -- ",
]
# Check User log
user_log_regexes = [
f".*{resp['request_id']} -- user func.*",
f".*{resp2['request_id']} -- user log.*message from class method.*",
]
def check_log():
logs_content = f.getvalue()
for expected_log_info in expected_log_infos:
assert expected_log_info in logs_content
for regex in user_log_regexes:
assert re.findall(regex, logs_content) != []
return True
# Check stream log
wait_for_condition(
check_log,
timeout=25,
retry_interval_ms=100,
)
# Check user log file
method_replica_id = resp["replica"].split("#")[-1]
class_method_replica_id = resp2["replica"].split("#")[-1]
if json_log_format:
user_method_log_regex = (
'.*"message": "user func".*'
f'"route": "{resp["route"]}", '
f'"request_id": "{resp["request_id"]}", '
f'"application": "{resp["app_name"]}", '
f'"worker_id": "{resp["worker_id"]}", '
f'"node_id": "{resp["node_id"]}", '
f'"actor_id": "{resp["actor_id"]}", '
f'"task_name": "{resp["task_name"]}", '
f'"task_func_name": "{resp["task_func_name"]}", '
f'"actor_name": "{resp["actor_name"]}", '
f'"deployment": "{resp["app_name"]}_fn", '
f'"replica": "{method_replica_id}", '
f'"component_name": "replica", '
rf'"timestamp_ns": \d+}}.*'
)
user_class_method_log_regex = (
'.*"message": "user log message from class method".*'
f'"route": "{resp2["route"]}", '
f'"request_id": "{resp2["request_id"]}", '
f'"application": "{resp2["app_name"]}", '
f'"worker_id": "{resp2["worker_id"]}", '
f'"node_id": "{resp2["node_id"]}", '
f'"actor_id": "{resp2["actor_id"]}", '
f'"task_name": "{resp2["task_name"]}", '
f'"task_func_name": "{resp2["task_func_name"]}", '
f'"actor_name": "{resp2["actor_name"]}", '
f'"deployment": "{resp2["app_name"]}_Model", '
f'"replica": "{class_method_replica_id}", '
f'"component_name": "replica", '
rf'"timestamp_ns": \d+}}.*'
)
else:
user_method_log_regex = f".*{resp['request_id']} -- user func.*"
user_class_method_log_regex = (
f".*{resp2['request_id']} -- .*user log message from class method.*"
)
def check_log_file(log_file: str, expected_regex: list):
with open(log_file, "r") as f:
s = f.read()
assert re.findall(expected_regex, s) != []
check_log_file(resp["log_file"], user_method_log_regex)
check_log_file(resp2["log_file"], user_class_method_log_regex)
@pytest.mark.parametrize("raise_error", [True, False])
def test_extra_field(serve_and_ray_shutdown, raise_error):
"""Test ray serve extra logging"""
logger = logging.getLogger("ray.serve")
@serve.deployment(logging_config={"encoding": "JSON"})
def fn(*args):
if raise_error:
logger.info("user_func", extra={SERVE_LOG_EXTRA_FIELDS: [123]})
else:
logger.info(
"user_func",
extra={"k1": "my_v1", SERVE_LOG_EXTRA_FIELDS: {"k2": "my_v2"}},
)
return {
"log_file": logger.handlers[1].target.baseFilename,
}
serve.run(fn.bind(), name="app1", route_prefix="/fn")
url = get_application_url(app_name="app1", use_localhost=True)
resp = httpx.get(url)
if raise_error:
resp.status_code == 500
else:
resp = resp.json()
with open(resp["log_file"], "r") as f:
s = f.read()
assert re.findall(".*my_v1.*", s) != []
assert re.findall('.*"k2": "my_v2".*', s) != []
def check_log_file(log_file: str, expected_regex: list, check_contains: bool = True):
with open(log_file, "r") as f:
s = f.read()
for regex in expected_regex:
if check_contains:
assert re.findall(regex, s) != []
else:
assert re.findall(regex, s) == []
class TestLoggingAPI:
def test_start_serve_with_logging_config(self, serve_and_ray_shutdown):
serve.start(logging_config={"log_level": "DEBUG", "encoding": "JSON"})
serve_log_dir = get_serve_logs_dir()
# Check controller log
actors = list_actors()
expected_log_regex = [".*logger with logging config.*"]
for actor in actors:
print(actor["name"])
if "SERVE_CONTROLLER_ACTOR" == actor["name"]:
controller_pid = actor["pid"]
controller_log_file_name = get_component_file_name(
"controller", controller_pid, component_type=None, suffix=".log"
)
controller_log_path = os.path.join(serve_log_dir, controller_log_file_name)
check_log_file(controller_log_path, expected_log_regex)
# Check proxy log
nodes = list_nodes()
node_ip_address = nodes[0].node_ip
proxy_log_file_name = get_component_file_name(
"proxy", node_ip_address, component_type=None, suffix=".log"
)
proxy_log_path = os.path.join(serve_log_dir, proxy_log_file_name)
check_log_file(proxy_log_path, expected_log_regex)
@pytest.mark.parametrize("encoding_type", ["TEXT", "JSON"])
def test_encoding(self, serve_and_ray_shutdown, encoding_type):
"""Test serve.run logging API"""
logging_config = {"encoding": encoding_type}
logger = logging.getLogger("ray.serve")
@serve.deployment(logging_config=logging_config)
class Model:
def __call__(self, req: starlette.requests.Request):
return {
"log_file": logger.handlers[1].target.baseFilename,
"replica": serve.get_replica_context().replica_id.unique_id,
}
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
replica_id = resp["replica"].split("#")[-1]
if encoding_type == "JSON":
expected_log_regex = [f'"replica": "{replica_id}", ']
else:
expected_log_regex = [f".*{replica_id}.*"]
check_log_file(resp["log_file"], expected_log_regex)
def test_log_level(self, serve_and_ray_shutdown):
logger = logging.getLogger("ray.serve")
@serve.deployment
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("model_info_level")
logger.debug("model_debug_level")
return {
"log_file": logger.handlers[1].target.baseFilename,
}
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
expected_log_regex = [".*model_info_level.*"]
check_log_file(resp["log_file"], expected_log_regex)
# Make sure 'model_debug_level' log content does not exist
with pytest.raises(AssertionError):
check_log_file(resp["log_file"], [".*model_debug_level.*"])
serve.run(Model.options(logging_config={"log_level": "DEBUG"}).bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
expected_log_regex = [".*model_info_level.*", ".*model_debug_level.*"]
check_log_file(resp["log_file"], expected_log_regex)
def test_logs_dir(self, serve_and_ray_shutdown):
logger = logging.getLogger("ray.serve")
@serve.deployment
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("model_info_level")
for handler in logger.handlers:
if isinstance(handler, logging.handlers.MemoryHandler):
target = handler.target
assert isinstance(target, logging.handlers.RotatingFileHandler)
return {
"logs_path": target.baseFilename,
}
raise AssertionError("No memory handler found")
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
paths = resp["logs_path"].split("/")
paths[-1] = "new_dir"
new_log_dir = "/".join(paths)
serve.run(
Model.options(
logging_config={
"logs_dir": new_log_dir,
"additional_log_standard_attrs": ["name"],
}
).bind()
)
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
assert "new_dir" in resp["logs_path"]
check_log_file(resp["logs_path"], [".*model_info_level.*"])
check_log_file(resp["logs_path"], ["ray.serve"], check_contains=True)
@pytest.mark.parametrize("enable_access_log", [True, False])
@pytest.mark.parametrize("encoding_type", ["TEXT", "JSON"])
def test_access_log(self, serve_and_ray_shutdown, encoding_type, enable_access_log):
logger = logging.getLogger("ray.serve")
logging_config = {
"enable_access_log": enable_access_log,
"encoding": encoding_type,
}
@serve.deployment(logging_config=logging_config)
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("model_info_level")
logger.info("model_not_show", extra={"serve_access_log": True})
return {
"logs_path": logger.handlers[1].target.baseFilename,
}
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url)
assert resp.status_code == 200
resp = resp.json()
check_log_file(resp["logs_path"], [".*model_info_level.*"])
if enable_access_log:
check_log_file(resp["logs_path"], [".*model_not_show.*"])
check_log_file(
resp["logs_path"], ["serve_access_log"], check_contains=False
)
else:
with pytest.raises(AssertionError):
check_log_file(resp["logs_path"], [".*model_not_show.*"])
@pytest.mark.parametrize("enable_access_log", [True, False])
def test_access_log_in_stderr(self, serve_and_ray_shutdown, enable_access_log):
"""Test that access logs in stderr respect the enable_access_log setting.
Regression test: the log_access_log_filter was previously only applied
to the file handler (memory_handler), not the stream handler (stderr).
Since RAY_SERVE_LOG_TO_STDERR defaults to True, access logs appeared in
stderr even when enable_access_log=False.
"""
logger = logging.getLogger("ray.serve")
@serve.deployment(
logging_config={"enable_access_log": enable_access_log},
)
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("user_log_should_appear")
return "ok"
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
f = io.StringIO()
with redirect_stderr(f):
for _ in range(5):
resp = httpx.get(url)
assert resp.status_code == 200
# Give logs time to flush.
time.sleep(2)
stderr_output = f.getvalue()
# Normal user logs should still appear in stderr regardless
# of the enable_access_log setting.
assert "user_log_should_appear" in stderr_output
if enable_access_log:
# Access logs should appear in stderr when enabled.
# HTTP requests produce "GET / 200" format (not "CALL __call__ OK"
# which only appears for DeploymentHandle calls).
assert "GET / 200" in stderr_output
else:
# Access logs should NOT appear in stderr when disabled.
assert "GET / 200" not in stderr_output
@pytest.mark.parametrize("encoding_type", ["TEXT", "JSON"])
def test_additional_log_standard_attrs(self, serve_and_ray_shutdown, encoding_type):
"""Test additional log standard attrs"""
logger = logging.getLogger("ray.serve")
logging_config = {
"enable_access_log": True,
"encoding": encoding_type,
"additional_log_standard_attrs": ["name"],
}
@serve.deployment(logging_config=logging_config)
class Model:
def __call__(self, req: starlette.requests.Request):
logger.info("model_info_level")
logger.info("model_not_show", extra={"serve_access_log": True})
return {
"logs_path": logger.handlers[1].target.baseFilename,
}
serve.run(Model.bind())
url = get_application_url(use_localhost=True)
resp = httpx.get(url)
assert resp.status_code == 200
resp = resp.json()
if encoding_type == "JSON":
check_log_file(resp["logs_path"], ["name"], check_contains=True)
else:
check_log_file(resp["logs_path"], ["ray.serve"], check_contains=True)
def test_application_logging_overwrite(self, serve_and_ray_shutdown):
@serve.deployment
class Model:
def __call__(self, req: starlette.requests.Request):
logger = logging.getLogger("ray.serve")
logger.info("model_info_level")
logger.debug("model_debug_level")
return {
"log_file": logger.handlers[1].target.baseFilename,
}
serve.run(Model.bind(), logging_config={"log_level": "DEBUG"})
url = get_application_url(use_localhost=True)
resp = httpx.get(url).json()
expected_log_regex = [".*model_info_level.*", ".*model_debug_level.*"]
check_log_file(resp["log_file"], expected_log_regex)
# Setting logging config in the deployment level, application
# config can't override it.
@serve.deployment(logging_config={"log_level": "INFO"})
class Model2:
def __call__(self, req: starlette.requests.Request):
logger = logging.getLogger("ray.serve")
logger.info("model_info_level")
logger.debug("model_debug_level")
return {
"log_file": logger.handlers[1].target.baseFilename,
}
serve.run(
Model2.bind(),
logging_config={"log_level": "DEBUG"},
name="app2",
route_prefix="/app2",
)
url = get_application_url(app_name="app2", use_localhost=True)
resp = httpx.get(url).json()
check_log_file(resp["log_file"], [".*model_info_level.*"])
# Make sure 'model_debug_level' log content does not exist.
with pytest.raises(AssertionError):
check_log_file(resp["log_file"], [".*model_debug_level.*"])
@pytest.mark.parametrize("is_replica_type_component", [False, True])
def test_serve_component_filter(is_replica_type_component):
"""Test Serve component filter"""
if is_replica_type_component:
component_type = ServeComponentType.REPLICA
filter = ServeComponentFilter("component", "component_id", component_type)
else:
filter = ServeComponentFilter("component", "component_id")
init_kwargs = {
"name": "test_log",
"level": logging.DEBUG,
"pathname": "my_path",
"lineno": 1,
"msg": "my_message",
"args": (),
"exc_info": None,
}
record = logging.LogRecord(**init_kwargs)
def format_and_verify_json_output(record, expected_record: dict):
filter.filter(record)
formatted_record_dict = record.__dict__
for key in expected_record:
assert key in formatted_record_dict
assert formatted_record_dict[key] == expected_record[key]
expected_json = {}
if is_replica_type_component:
expected_json["deployment"] = "component"
expected_json["replica"] = "component_id"
expected_json["component_name"] = "replica"
else:
expected_json["component_name"] = "component"
expected_json["component_id"] = "component_id"
# Ensure message exists in the output.
# Note that there is no "message" key in the record dict until it has been
# formatted. This check should go before other fields are set and checked.
expected_json["msg"] = "my_message"
format_and_verify_json_output(record, expected_json)
# Set request id
record.request_id = "request_id"
expected_json["request_id"] = "request_id"
format_and_verify_json_output(record, expected_json)
# Set route
record.route = "route"
expected_json["route"] = "route"
format_and_verify_json_output(record, expected_json)
# set application
record.application = "application"
expected_json["application"] = "application"
format_and_verify_json_output(record, expected_json)
@pytest.mark.parametrize(
"log_encoding",
[
[None, None, "TEXT"],
[None, "TEXT", "TEXT"],
[None, "JSON", "JSON"],
["TEXT", None, "TEXT"],
["TEXT", "TEXT", "TEXT"],
["TEXT", "JSON", "JSON"],
["JSON", None, "JSON"],
["JSON", "TEXT", "TEXT"],
["JSON", "JSON", "JSON"],
["FOOBAR", None, "TEXT"],
["FOOBAR", "TEXT", "TEXT"],
["FOOBAR", "JSON", "JSON"],
],
)
def test_configure_component_logger_with_log_encoding_env_text(log_encoding):
"""Test the configure_component_logger function with different log encoding env.
When the log encoding env is not set, set to "TEXT" or set to unknon values,
the ServeFormatter should be used. When the log encoding env is set to "JSON",
the JSONFormatter should be used. Also, the log config should take the
precedence it's set.
"""
env_encoding, log_config_encoding, expected_encoding = log_encoding
with patch("ray.serve.schema.RAY_SERVE_LOG_ENCODING", env_encoding):
# Clean up logger handlers
logger = logging.getLogger(SERVE_LOGGER_NAME)
logger.handlers.clear()
# Ensure there is no logger handlers before calling configure_component_logger
assert logger.handlers == []
if log_config_encoding is None:
logging_config = LoggingConfig(logs_dir="/tmp/fake_logs_dir")
else:
logging_config = LoggingConfig(
encoding=log_config_encoding, logs_dir="/tmp/fake_logs_dir"
)
configure_component_logger(
component_name="fake_component_name",
component_id="fake_component_id",
logging_config=logging_config,
component_type=ServeComponentType.REPLICA,
max_bytes=100,
backup_count=3,
)
for handler in logger.handlers:
if isinstance(handler, logging.handlers.MemoryHandler):
if expected_encoding == EncodingType.JSON:
assert isinstance(handler.target.formatter, JSONFormatter)
else:
assert isinstance(handler.target.formatter, ServeFormatter)
# Clean up logger handlers
logger.handlers.clear()
@pytest.mark.parametrize(
"ray_instance, expect_client_ip",
[
({"RAY_SERVE_LOG_CLIENT_ADDRESS": "1"}, True),
({"RAY_SERVE_LOG_CLIENT_ADDRESS": "0"}, False),
],
indirect=["ray_instance"],
)
def test_http_access_log_client_address(
serve_and_ray_shutdown, ray_instance, expect_client_ip
):
"""Test that client IP:port in access logs is controlled by the feature flag."""
fastapi_app = FastAPI()
@serve.deployment(name="deployment_name")
@serve.ingress(fastapi_app)
class Handler:
@fastapi_app.get("/")
def get_root(self):
return PlainTextResponse("ok")
serve.run(Handler.bind())
f = io.StringIO()
with redirect_stderr(f):
url = get_application_url(use_localhost=True)
r = httpx.get(url)
assert r.status_code == 200
def check_log():
s = f.getvalue()
if "GET / 200" not in s:
return False
has_client_ip = bool(re.search(r"127\.0\.0\.1:\d+", s))
assert has_client_ip == expect_client_ip, (
f"Expected client IP {'present' if expect_client_ip else 'absent'} "
f"in log, but got: {s}"
)
return True
wait_for_condition(check_log, timeout=20)
@pytest.mark.parametrize(
"ray_instance",
[
{"RAY_SERVE_LOG_CLIENT_ADDRESS": "1"},
],
indirect=True,
)
def test_grpc_access_log_client_address(serve_and_ray_shutdown, ray_instance):
"""Test that gRPC access logs include client address when feature flag is on."""
grpc_port = 9000
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc."
"add_UserDefinedServiceServicer_to_server",
]
serve.start(
grpc_options=gRPCOptions(
port=grpc_port,
grpc_servicer_functions=grpc_servicer_functions,
),
)
serve.run(g)
channel = grpc.insecure_channel("localhost:9000")
f = io.StringIO()
with redirect_stderr(f):
ping_grpc_call_method(channel, "default")
def check_log():
s = f.getvalue()
# The replica access log uses "CALL" as the method for gRPC __call__.
if "CALL" not in s or "OK" not in s:
return False
# gRPC may connect over IPv4 (127.0.0.1) or IPv6 ([::1]).
has_client_ip = bool(re.search(r"(127\.0\.0\.1|\[::1\]):\d+", s))
assert has_client_ip, f"Expected client IP in gRPC access log, but got: {s}"
return True
wait_for_condition(check_log, timeout=20)
@pytest.mark.skipif(sys.platform == "win32", reason="Fail to create temp dir.")
@pytest.mark.parametrize(
"ray_instance",
[
{"RAY_SERVE_LOG_TO_STDERR": "0"},
],
indirect=True,
)
def test_logging_disable_stdout(serve_and_ray_shutdown, ray_instance, tmp_dir):
"""Test logging when RAY_SERVE_LOG_TO_STDERR is set.
When RAY_SERVE_LOG_TO_STDERR=0 is set, serve should redirect stdout and stderr to
serve logger.
"""
logs_dir = Path(tmp_dir)
logging_config = LoggingConfig(encoding="JSON", logs_dir=str(logs_dir))
serve_logger = logging.getLogger("ray.serve")
@serve.deployment(logging_config=logging_config)
def disable_stdout():
serve_logger.info("from_serve_logger")
print("from_print")
sys.stdout.write("direct_from_stdout\n")
sys.stderr.write("direct_from_stderr\n")
print("this\nis\nmultiline\nlog\n")
raise RuntimeError("from_error")
app = disable_stdout.bind()
serve.run(app)
url = get_application_url(use_localhost=True)
httpx.get(url, timeout=None)
# Check if each of the logs exist in Serve's log files.
def _all_expected_logs_exist():
from_serve_logger_check = False
from_print_check = False
from_error_check = False
direct_from_stdout = False
direct_from_stderr = False
multiline_log = False
for log_file in os.listdir(logs_dir):
if log_file.startswith("replica_default_disable_stdout"):
with open(logs_dir / log_file) as f:
for line in f:
structured_log = json.loads(line)
message = structured_log["message"]
exc_text = structured_log.get("exc_text", "")
if "from_serve_logger" in message:
from_serve_logger_check = True
elif "from_print" in message:
from_print_check = True
elif "from_error" in exc_text:
from_error_check = True
elif "direct_from_stdout" in message:
direct_from_stdout = True
elif "direct_from_stderr" in message:
direct_from_stderr = True
elif "this\nis\nmultiline\nlog\n" in message:
multiline_log = True
assert from_serve_logger_check
assert from_print_check
assert from_error_check
assert direct_from_stdout
assert direct_from_stderr
assert multiline_log
return True
wait_for_condition(_all_expected_logs_exist)
@pytest.mark.skipif(sys.platform == "win32", reason="Fail to look for temp dir.")
def test_serve_logging_file_names(serve_and_ray_shutdown, ray_instance):
"""Test to ensure the log file names are correct."""
logs_dir = Path("/tmp/ray/session_latest/logs/serve")
logging_config = LoggingConfig(encoding="JSON")
@serve.deployment
def app():
return "foo"
app = app.bind()
serve.run(app, logging_config=logging_config)
url = get_application_url(use_localhost=True)
r = httpx.get(url)
assert r.status_code == 200
# Construct serve log file names.
client = _get_global_client()
controller_id = ray.get(client._controller.get_pid.remote())
proxy_id = ray.util.get_node_ip_address()
replicas = ray.get(
client._controller.get_deployment_details.remote("default", "app")
).replicas
replica_id = replicas[0].replica_id
controller_log_file_name = f"controller_{controller_id}.log"
proxy_log_file_name = f"proxy_{proxy_id}.log"
replica_log_file_name = f"replica_default_app_{replica_id}.log"
# Check if each of the log files exist.
controller_log_file_name_correct = False
proxy_log_file_name_correct = False
replica_log_file_name_correct = False
for log_file in os.listdir(logs_dir):
if log_file == controller_log_file_name:
controller_log_file_name_correct = True
elif log_file == proxy_log_file_name:
proxy_log_file_name_correct = True
elif log_file == replica_log_file_name:
replica_log_file_name_correct = True
assert controller_log_file_name_correct
assert proxy_log_file_name_correct
assert replica_log_file_name_correct
def test_stream_to_logger():
"""Test calling methods on StreamToLogger."""
logger = FakeLogger()
stdout_object = FakeStdOut()
stream_to_logger = StreamToLogger(logger, logging.INFO, stdout_object)
assert logger.get_logs() == []
# Calling isatty() should return True.
assert stream_to_logger.isatty() is True
# Logs are buffered and not flushed to logger.
stream_to_logger.write("foo")
assert logger.get_logs() == []
# Logs are flushed when the message ends with newline "\n".
stream_to_logger.write("bar\n")
assert logger.get_logs() == [(20, "foobar")]
# Calling flush directly can also flush the message to the logger.
stream_to_logger.write("baz")
assert logger.get_logs() == [(20, "foobar")]
stream_to_logger.flush()
assert logger.get_logs() == [(20, "foobar"), (20, "baz")]
# Calling the attribute on the StreamToLogger should return the attribute on
# the stdout object.
assert stream_to_logger.encoding == stdout_object.encoding
# Calling non-existing attribute on the StreamToLogger should still raise error.
with pytest.raises(AttributeError):
_ = stream_to_logger.i_dont_exist
@pytest.mark.skipif(sys.platform == "win32", reason="Fail to create temp dir.")
@pytest.mark.parametrize(
"ray_instance",
[
{"RAY_SERVE_LOG_TO_STDERR": "0"},
],
indirect=True,
)
def test_json_logging_with_unpickleable_exc_info(
serve_and_ray_shutdown, ray_instance, tmp_dir
):
"""Test the json logging with unpickleable exc_info.
exc_info field is often used to log the exception stack trace. However, we had issue
where deepcopy is applied to traceback object from exc_info which is not pickleable
and caused logging error.
See: https://github.com/ray-project/ray/issues/45912
"""
logs_dir = Path(tmp_dir)
logging_config = LoggingConfig(encoding="JSON", logs_dir=str(logs_dir))
logger = logging.getLogger("ray.serve")
@serve.deployment(logging_config=logging_config)
class App:
def __call__(self):
try:
raise Exception("fake_exception")
except Exception as e:
logger.info("log message", exc_info=e)
return "foo"
serve.run(App.bind())
url = get_application_url(use_localhost=True)
r = httpx.get(f"{url}")
assert r.status_code == 200
for log_file in os.listdir(logs_dir):
with open(logs_dir / log_file) as f:
assert "Logging error" not in f.read()
assert "cannot pickle" not in f.read()
@pytest.mark.skipif(sys.platform == "win32", reason="Fail to create temp dir.")
@pytest.mark.parametrize(
"ray_instance",
[
{"RAY_SERVE_LOG_TO_STDERR": "0"},
],
indirect=True,
)
def test_configure_default_serve_logger_with_stderr_redirect(
serve_and_ray_shutdown, ray_instance, tmp_dir
):
"""Test configuring default serve logger with stderr redirect.
Default serve logger should only be configured with one StreamToLogger handler, and
print, stdout, and stderr should NOT be overridden and redirected to the logger.
"""
configure_default_serve_logger()
serve_logger = logging.getLogger("ray.serve")
assert len(serve_logger.handlers) == 1
assert isinstance(serve_logger.handlers[0], logging.StreamHandler)
assert print != redirected_print
assert not isinstance(sys.stdout, StreamToLogger)
assert not isinstance(sys.stderr, StreamToLogger)
@pytest.mark.parametrize(
"ray_instance",
[
{"RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE": "1"},
{"RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE": "100"},
],
indirect=True,
)
def test_request_id_uniqueness_with_buffering(serve_and_ray_shutdown, ray_instance):
"""Test request IDs are unique when buffering is enabled."""
logger = logging.getLogger("ray.serve")
@serve.deployment(logging_config={"encoding": "JSON"})
class TestApp:
async def __call__(self):
logger.info("Processing request")
logger.info("Additional log entry")
return "OK"
serve.run(TestApp.bind())
for _ in range(200):
httpx.get("http://127.0.0.1:8000/")
logs_dir = get_serve_logs_dir()
def check_logs():
for log_file in os.listdir(logs_dir):
if log_file.startswith("replica"):
with open(os.path.join(logs_dir, log_file)) as f:
log_request_ids = []
for line in f:
log_entry = json.loads(line)
request_id = log_entry.get("request_id", None)
message = log_entry.get("message", None)
if request_id:
# Append the (request_id, message) pairs to the list
log_request_ids.append((request_id, message))
# Check that there are no duplicate (request_id, message) pairs
request_id_counts = Counter(log_request_ids)
for _, count in request_id_counts.items():
assert count == 1, "Request ID duplicates when buffering"
return True
wait_for_condition(check_logs)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))