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

1160 lines
39 KiB
Python

import json
import os
import re
import shutil
import sys
import uuid
from pathlib import Path
from threading import Thread
from typing import Set
from unittest.mock import patch
import grpc
import httpx
import pytest
import starlette
from starlette.requests import Request
from starlette.responses import StreamingResponse
import ray
from ray import serve
from ray._common.test_utils import SignalActor
from ray.serve._private.common import ServeComponentType
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_DIRECT_INGRESS,
RAY_SERVE_ENABLE_HA_PROXY,
)
from ray.serve._private.logging_utils import get_serve_logs_dir
from ray.serve._private.test_utils import (
get_application_url,
wait_for_haproxy_routing_to_replica,
)
from ray.serve._private.tracing_utils import (
DEFAULT_TRACING_EXPORTER_IMPORT_PATH,
TRACE_STACK,
_append_trace_stack,
_load_span_processors,
_validate_tracing_exporter,
_validate_tracing_exporter_processors,
set_trace_status,
setup_tracing,
)
from ray.serve.config import HTTPOptions, gRPCOptions
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.grpc_util import gRPCInputStream
from ray.serve.tests.conftest import * # noqa
from ray.serve.utils import get_trace_context
from ray.tests.conftest import * # noqa
try:
from opentelemetry import trace
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
from opentelemetry.trace.status import StatusCode
except ImportError:
raise ModuleNotFoundError(
"`opentelemetry` or `opentelemetry.sdk.trace.export` not found"
)
CUSTOM_EXPORTER_OUTPUT_FILENAME = "spans.txt"
@pytest.fixture
def use_custom_tracing_exporter():
yield "ray.serve.tests.test_tracing_utils:custom_tracing_exporter"
# Clean up output file produced by custom exporter
if os.path.exists(CUSTOM_EXPORTER_OUTPUT_FILENAME):
safe_remove(CUSTOM_EXPORTER_OUTPUT_FILENAME)
@pytest.fixture
def serve_and_ray_shutdown():
serve.shutdown()
ray.shutdown()
yield
serve.shutdown()
class FakeSpan:
def __init__(self):
self.status = None
def set_status(self, status):
self.status = status
def test_disable_tracing_exporter():
"""Test that setting `tracing_exporter_import_path`
to an empty string disables tracing.
"""
is_tracing_setup_successful = setup_tracing(
component_type=ServeComponentType.REPLICA,
component_name="component_name",
component_id="component_id",
tracing_exporter_import_path="",
tracing_sampling_ratio=1.0,
)
assert is_tracing_setup_successful is False
def test_validate_tracing_exporter_with_string():
"""Test exception message for invalid exporter type."""
invalid_exporters = [1, "string", []]
expected_exception = "Tracing exporter must be a function."
for invalid_exporter in invalid_exporters:
with pytest.raises(TypeError, match=expected_exception):
_validate_tracing_exporter(invalid_exporter)
def test_validate_tracing_exporter_with_args():
"""Test exception message for _validate_tracing_exporter.
if exporter contains an argument.
"""
def test_exporter(arg):
return arg
expected_exception = "Tracing exporter cannot take any arguments."
with pytest.raises(TypeError, match=expected_exception):
_validate_tracing_exporter(test_exporter)
def test_validate_tracing_exporter_processors_list():
"""Test exception message for _validate_tracing_exporter.
if exporter returns invalid return type.
"""
invalid_span_processors = [1, "string"]
for invalid_span_processor in invalid_span_processors:
expected_exception = re.escape(
"Output of tracing exporter needs to be of type "
"List[SpanProcessor], but received type "
f"{type(invalid_span_processor)}."
)
with pytest.raises(TypeError, match=expected_exception):
_validate_tracing_exporter_processors(invalid_span_processor)
def test_validate_tracing_exporter_processors_full_output():
"""Test exception message for _validate_tracing_exporter.
if exporter returns invalid return type.
"""
invalid_span_processors = [[1, 2], ["1", "2"]]
for invalid_span_processor in invalid_span_processors:
expected_exception = re.escape(
"Output of tracing exporter needs to be of "
"type List[SpanProcessor], "
f"but received type {type(invalid_span_processor[0])}."
)
with pytest.raises(TypeError, match=expected_exception):
_validate_tracing_exporter_processors(invalid_span_processor)
def test_missing_dependencies():
"""Test that setup_tracing raises an exception if
tracing module is not installed.
"""
expected_exception = (
"You must `pip install opentelemetry-api` and "
"`pip install opentelemetry-sdk` "
"to enable tracing on Ray Serve."
)
with patch("ray.serve._private.tracing_utils.trace", new=None):
with pytest.raises(ImportError, match=expected_exception):
setup_tracing(
component_type=ServeComponentType.REPLICA,
component_name="component_name",
component_id="component_id",
tracing_sampling_ratio=1.0,
)
def test_default_tracing_exporter(ray_start_cluster):
"""Test that the default tracing exporter returns
List[SimpleSpanProcessor].
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=1)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
span_processors = _load_span_processors(
DEFAULT_TRACING_EXPORTER_IMPORT_PATH, "mock_file.json"
)
assert isinstance(span_processors, list)
for span_processor in span_processors:
assert isinstance(span_processor, SimpleSpanProcessor)
def safe_remove(path):
"""Safely removes a file or directory, handling Windows file locking by using a retry method."""
if sys.platform != "win32":
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
return
provider = trace.get_tracer_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
def test_custom_tracing_exporter(use_custom_tracing_exporter):
"""Test setup_tracing with a custom tracing exporter."""
custom_tracing_exporter_path = use_custom_tracing_exporter
span_processors = _load_span_processors(
custom_tracing_exporter_path, "mock_file.json"
)
assert isinstance(span_processors, list)
for span_processor in span_processors:
assert isinstance(span_processor, SimpleSpanProcessor)
span_processor.shutdown()
is_tracing_setup_successful = setup_tracing(
"component_name",
"component_id",
ServeComponentType.REPLICA,
custom_tracing_exporter_path,
tracing_sampling_ratio=1.0,
)
# Validate that tracing is setup successfully
# and the tracing exporter created the output file
assert is_tracing_setup_successful
assert os.path.exists(CUSTOM_EXPORTER_OUTPUT_FILENAME)
def test_tracing_sampler(use_custom_tracing_exporter):
"""Test that tracing sampler is properly configured
through tracing_sampling_ratio argument.
"""
custom_tracing_exporter_path = use_custom_tracing_exporter
tracing_sampling_ratio = 1
is_tracing_setup_successful = setup_tracing(
"component_name",
"component_id",
ServeComponentType.REPLICA,
custom_tracing_exporter_path,
tracing_sampling_ratio,
)
# Validate that tracing is setup successfully
# and the tracing exporter created the output file
assert is_tracing_setup_successful
assert os.path.exists(CUSTOM_EXPORTER_OUTPUT_FILENAME)
tracer = trace.get_tracer(__name__)
tracer_data = tracer.__dict__
assert "sampler" in tracer_data
sampler = tracer_data["sampler"]
sampler_data = sampler.__dict__
# ParentBasedTraceIdRatio sampler contains other samplers as attributes
# The sampling ratio is stored in the underlying samplers
# Check that we have the expected sampler structure
assert "_local_parent_not_sampled" in sampler_data
assert "_local_parent_sampled" in sampler_data
assert "_remote_parent_sampled" in sampler_data
assert "_remote_parent_not_sampled" in sampler_data
assert isinstance(sampler, ParentBasedTraceIdRatio)
@pytest.mark.parametrize(
(
"serve_application",
"expected_proxy_spans_path",
"expected_replica_spans_path",
"expected_upstream_spans_path",
),
[
(
"basic",
"fixtures/basic_proxy_spans.json",
"fixtures/basic_replica_spans.json",
"fixtures/basic_upstream_spans.json",
),
(
"streaming",
"fixtures/streaming_proxy_spans.json",
"fixtures/streaming_replica_spans.json",
"fixtures/streaming_upstream_spans.json",
),
(
"grpc",
"fixtures/grpc_proxy_spans.json",
"fixtures/grpc_replica_spans.json",
"fixtures/grpc_upstream_spans.json",
),
],
)
def test_tracing_e2e(
serve_and_ray_shutdown,
serve_application,
expected_proxy_spans_path,
expected_replica_spans_path,
expected_upstream_spans_path,
):
"""Test tracing e2e."""
signal_actor = SignalActor.remote()
@serve.deployment
class BasicModel:
def __call__(self, req: starlette.requests.Request):
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
return "hello"
def hi_gen_sync():
for i in range(10):
yield f"hello_{i}"
# to avoid coalescing chunks
ray.get(signal_actor.wait.remote())
@serve.deployment
class StreamingModel:
def __call__(self, request: Request) -> StreamingResponse:
gen = hi_gen_sync()
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
return StreamingResponse(gen, media_type="text/plain")
@serve.deployment
class GrpcDeployment:
def __call__(self, user_message):
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
greeting = f"Hello {user_message.name} from {user_message.foo}"
num_x2 = user_message.num * 2
user_response = serve_pb2.UserDefinedResponse(
greeting=greeting,
num_x2=num_x2,
)
return user_response
if serve_application == "basic":
serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
)
)
serve.run(BasicModel.bind())
wait_for_haproxy_routing_to_replica()
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
url = get_application_url("HTTP")
r = httpx.post(f"{url}/", headers=headers)
assert r.text == "hello"
elif serve_application == "streaming":
serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
)
)
serve.run(StreamingModel.bind())
wait_for_haproxy_routing_to_replica()
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
url = get_application_url("HTTP")
with httpx.stream("GET", f"{url}/", headers=headers) as r:
r.raise_for_status()
for i, chunk in enumerate(r.iter_text()):
assert chunk == f"hello_{i}"
ray.get(signal_actor.send.remote())
elif serve_application == "grpc":
# TODO: Remove this once HAProxy supports gRPC
if RAY_SERVE_ENABLE_HA_PROXY:
return
grpc_port = 9000
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc"
".add_UserDefinedServiceServicer_to_server",
"ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server",
]
serve.start(
grpc_options=gRPCOptions(
port=grpc_port,
grpc_servicer_functions=grpc_servicer_functions,
),
)
g = GrpcDeployment.options(name="grpc-deployment").bind()
serve.run(g)
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
metadata = tuple(headers.items())
channel = grpc.insecure_channel(get_application_url("gRPC"))
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
response, call = stub.__call__.with_call(request=request, metadata=metadata)
assert call.code() == grpc.StatusCode.OK, call.code()
assert response.greeting == "Hello foo from bar", response.greeting
serve.shutdown()
serve_logs_dir = get_serve_logs_dir()
spans_dir = os.path.join(serve_logs_dir, "spans")
files = os.listdir(spans_dir)
# proxy, replica, and upstream span files. Under HAProxy the proxy file is
# written by the fallback ProxyActor on startup but holds no request spans
# under direct ingress.
assert len(files) == 3
replica_filename = None
proxy_filename = None
upstream_filename = None
for file in files:
if "replica" in file:
replica_filename = file
elif "proxy" in file:
proxy_filename = file
elif "upstream" in file:
upstream_filename = file
else:
assert False, f"Did not expect tracing file with name {file}"
assert replica_filename and proxy_filename and upstream_filename
upstream_spans = load_spans(os.path.join(spans_dir, upstream_filename))
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
proxy_spans = load_spans(os.path.join(spans_dir, proxy_filename))
else:
proxy_spans = []
replica_spans = load_spans(os.path.join(spans_dir, replica_filename))
entire_trace = replica_spans + proxy_spans + upstream_spans
validate_span_associations_in_trace(entire_trace)
expected_upstream_spans = load_json_fixture(expected_upstream_spans_path)
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
expected_proxy_spans = load_json_fixture(expected_proxy_spans_path)
else:
expected_proxy_spans = []
expected_replica_spans = load_json_fixture(expected_replica_spans_path)
sanitize_spans(upstream_spans)
sanitize_spans(proxy_spans)
sanitize_spans(replica_spans)
assert upstream_spans == expected_upstream_spans
assert proxy_spans == expected_proxy_spans
assert replica_spans == expected_replica_spans
safe_remove(spans_dir)
@pytest.mark.parametrize(
"protocol,expected_status_code,expected_span_status",
[
("http", 500, StatusCode.ERROR),
("streaming", 500, StatusCode.ERROR),
("grpc", grpc.StatusCode.INTERNAL.name, StatusCode.ERROR),
],
)
def test_tracing_e2e_with_errors(
serve_and_ray_shutdown, protocol, expected_status_code, expected_span_status
):
"""Test tracing with error responses across HTTP, streaming, and gRPC protocols."""
@serve.deployment
class HttpErrorModel:
def __call__(self, request: starlette.requests.Request):
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
raise RuntimeError("Internal server error")
@serve.deployment
class StreamingErrorModel:
def __call__(self, request: Request) -> StreamingResponse:
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
def error_generator():
raise RuntimeError("Streaming error occurred")
return StreamingResponse(error_generator(), media_type="text/plain")
@serve.deployment
class GrpcErrorModel:
def __call__(self, user_message):
replica_context = serve.get_replica_context()
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"application_span", context=get_trace_context()
) as span:
span.set_attribute("deployment", replica_context.deployment)
span.set_attribute("replica_id", replica_context.replica_id.unique_id)
# Raise error
raise RuntimeError("gRPC error occurred")
# Setup based on protocol
if protocol == "http":
serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
)
)
serve.run(HttpErrorModel.bind())
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
# Make HTTP request
url = get_application_url("HTTP")
response = httpx.post(f"{url}/", headers=headers)
assert response.status_code == expected_status_code
elif protocol == "streaming":
serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
)
)
serve.run(StreamingErrorModel.bind())
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
url = get_application_url("HTTP")
with httpx.stream("GET", f"{url}/", headers=headers) as r:
assert r.status_code == expected_status_code
elif protocol == "grpc":
# TODO: Remove this once HAProxy supports gRPC
if RAY_SERVE_ENABLE_HA_PROXY:
return
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(GrpcErrorModel.options(name="grpc-error-model").bind())
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_app"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
channel = grpc.insecure_channel("localhost:9000")
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="test", num=10, foo="bar")
with pytest.raises(grpc.RpcError) as exception_info:
_ = stub.__call__(request=request)
rpc_error = exception_info.value
print(rpc_error)
assert rpc_error.code().name == expected_status_code
else:
assert False, "Invalid protocol"
serve.shutdown()
# Verify the trace data
serve_logs_dir = get_serve_logs_dir()
spans_dir = os.path.join(serve_logs_dir, "spans")
files = os.listdir(spans_dir)
# proxy, replica, and upstream span files. Under HAProxy the proxy file is
# written by the fallback ProxyActor on startup but holds no request spans
# under direct ingress.
assert len(files) == 3
replica_filename = None
proxy_filename = None
upstream_filename = None
for file in files:
if "replica" in file:
replica_filename = file
elif "proxy" in file:
proxy_filename = file
elif "upstream" in file:
upstream_filename = file
else:
assert False, f"Did not expect tracing file with name {file}"
assert replica_filename and proxy_filename and upstream_filename
# Load and check spans
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
proxy_spans = load_spans(os.path.join(spans_dir, proxy_filename))
else:
proxy_spans = []
replica_spans = load_spans(os.path.join(spans_dir, replica_filename))
# Verify error status in spans
for span in replica_spans:
if "application_span" in span["name"]:
assert span["status"]["status_code"] == expected_span_status.name
# Check for error attributes based on protocol and error type
if protocol == "http":
assert "Internal server error" in str(span.get("events", []))
elif protocol == "streaming":
assert "Streaming error occurred" in str(span.get("events", []))
elif protocol == "grpc":
assert "gRPC error occurred" in str(span.get("events", []))
else:
assert False, "Invalid protocol"
# Verify status code in proxy spans
for span in proxy_spans:
if protocol == "http" or protocol == "streaming":
if "proxy_http_request" in span["name"]:
assert (
span["attributes"].get("http.status_code") == expected_status_code
)
assert span["status"]["status_code"] == "ERROR"
elif "route_to_replica" in span["name"]:
pass
else:
raise Exception("Invalid proxy span")
elif protocol == "grpc":
if "proxy_grpc_request" in span["name"]:
assert (
span["attributes"].get("rpc.grpc.status_code")
== expected_status_code
)
assert span["status"]["status_code"] == "ERROR"
elif "route_to_replica" in span["name"]:
pass
else:
assert False, "Invalid proxy span"
else:
assert False, "Invalid protocol"
# Clean up
safe_remove(spans_dir)
def custom_tracing_exporter():
"""Custom tracing exporter used for testing."""
out_file = open(CUSTOM_EXPORTER_OUTPUT_FILENAME, "a")
class FileConsoleSpanExporter(ConsoleSpanExporter):
def shutdown(self):
if not out_file.closed:
out_file.flush()
out_file.close()
return [SimpleSpanProcessor(FileConsoleSpanExporter(out=out_file))]
def load_json_fixture(file_path):
"""Load json from a fixture."""
with Path(__file__).parent.joinpath(file_path).open() as f:
return json.load(f)
def load_spans(file_path):
"""Load and parse spans from a `.span` file.
This requires special handling because ConsoleSpanExporter
does not write proper JSON since the data is streamed.
"""
if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
return []
with open(file_path, "r") as file:
file_contents = file.read()
raw_spans = re.split(r"}\s*\n\s*{", file_contents)
spans = []
for i, raw_span in enumerate(raw_spans):
if len(raw_spans) > 1:
if i == 0:
raw_span = raw_span + "}"
elif i == (len(raw_spans) - 1):
raw_span = "{" + raw_span
elif i != 0:
raw_span = "{" + raw_span + "}"
span_dict = json.loads(raw_span)
spans.append(span_dict)
spans.sort(reverse=True, key=lambda x: x["start_time"])
return spans
def sanitize_spans(spans):
"""Remove span attributes with ephemeral data."""
for span in spans:
del span["context"]
del span["parent_id"]
del span["start_time"]
del span["end_time"]
del span["resource"]
for k, _ in span["attributes"].items():
if "_id" in k:
span["attributes"][k] = ""
def validate_span_associations_in_trace(spans):
"""Validate that all spans in a
trace are correctly associated with each
other through the parent_id relationship.
"""
if len(spans) <= 1:
return
class Span:
def __init__(self, _span_id: str, _parent_id: str, _name: str):
self.span_id = _span_id
self.parent_id = _parent_id
self.name = _name
def __repr__(self):
return f"Span(span_id={self.span_id}, parent_id={self.parent_id}, name={self.name})"
span_nodes = {}
starting_span = None
for span in spans:
span_id = span["context"]["span_id"].lstrip("0x")
parent_id = span["parent_id"].lstrip("0x") if span["parent_id"] else None
name = span["name"]
new_span = Span(span_id, parent_id, name)
span_nodes[span_id] = new_span
# The starting span is always the application span.
if name == "application_span":
assert starting_span is None, "Multiple starting spans found in trace"
starting_span = new_span
current_span = starting_span
span_nodes.pop(current_span.span_id)
while current_span:
current_span = span_nodes.pop(current_span.parent_id, None)
# All spans should have been visited.
assert not span_nodes
def test_set_trace_status_empty_stack():
"""test calling set_trace_status with an empty trace stack.
When there is nothing in the trace stack, calling set_trace_status does nothing
and does not error.
"""
set_trace_status(is_error=True, description="error")
trace_stack = TRACE_STACK.get([])
assert trace_stack == []
def test_set_trace_status_error():
"""test calling set_trace_status with error status.
When there is a trace stack, calling set_trace_status with error updates
the correct status.
"""
error_message = "error"
TRACE_STACK.set([FakeSpan()])
set_trace_status(is_error=True, description=error_message)
trace_stack = TRACE_STACK.get([])
assert len(trace_stack) == 1
assert trace_stack[0].status.status_code == StatusCode.ERROR
assert trace_stack[0].status.description == error_message
def test_set_trace_status_ok(caplog):
"""test calling set_trace_status with ok status.
When there is a trace stack, calling set_trace_status with ok updates
the correct status.
"""
ok_message = "ok"
TRACE_STACK.set([FakeSpan()])
set_trace_status(is_error=False, description=ok_message)
trace_stack = TRACE_STACK.get([])
assert len(trace_stack) == 1
assert trace_stack[0].status.status_code == StatusCode.OK
# Note: when the status is OK, the description is not set.
assert trace_stack[0].status.description is None
# Ensure we don't attempt to set the description when the status is OK.
assert (
"description should only be set when status_code is set to StatusCode.ERROR"
not in caplog.text
)
def test_append_trace_stack_multithread():
"""test calling _append_trace_stack in multiple threads.
When multiple threads call _append_trace_stack, TRACE_STACK should only be updated
to contain the task name of the thread that called _append_trace_stack.
"""
number_of_tasks = 10
passing = set()
def try_append_trace_stack(_task_name: str, _passing: Set[str]):
"""
Helper to call `_append_trace_stack()` in a thread. It checks TRACE_STACK in
the current thread starts out empty and the correct task name is added to it
after `_append_trace_stack()` is called. If both checks out, the task name will
be added to the _passing.
"""
trace_stack_before = TRACE_STACK.get([])
assert trace_stack_before == []
_append_trace_stack(_task_name)
trace_stack_after = TRACE_STACK.get()
assert trace_stack_after == [_task_name]
passing.add(_task_name)
tasks = []
for i in range(number_of_tasks):
t = Thread(target=try_append_trace_stack, args=(f"task{i}", passing))
t.start()
tasks.append(t)
for task in tasks:
task.join()
assert len(passing) == number_of_tasks
def test_batched_span_attached_to_first_request_trace():
"""Ensure span created inside a batched method attaches to the first request's trace.
With 6 requests and max_batch_size=3, Serve should create 2 batches. Only the first request
in each batch should contribute the parent trace to the 'batched_span', yielding exactly
2 spans whose trace_ids match the traces of those first requests.
"""
@serve.deployment
class BatchedDeployment:
def __init__(self):
# We keep a counter so we can assert two unique batches producing two unique spans
self._batch_idx = 0
@serve.batch(
max_batch_size=3, batch_wait_timeout_s=1.0, max_concurrent_batches=2
)
async def handle_batch(self, reqs):
tracer = trace.get_tracer(__name__)
# whichever request is at index 0 is the one whose context is attached
first_req_id = None
try:
first_req_id = reqs[0].headers.get("req-id")
except Exception:
pass
batch_idx = self._batch_idx
self._batch_idx += 1
with tracer.start_as_current_span(
"batched_span", context=get_trace_context()
) as span:
if first_req_id is not None:
span.set_attribute("first-req-id", first_req_id)
span.set_attribute("batch-idx", batch_idx)
return ["ok" for _ in reqs]
async def __call__(self, request: Request):
return await self.handle_batch(request)
serve.start(http_options=HTTPOptions(host="0.0.0.0"))
serve.run(BatchedDeployment.bind())
setup_tracing(
component_name="upstream_app",
component_id="batching_test_upstream_multi",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing_batching_multi")
def do_request(span_name: str):
req_id = str(uuid.uuid4())
with tracer.start_as_current_span(span_name) as span:
# tag the upstream span so we can map req-id -> trace_id later
span.set_attribute("req-id", req_id)
# build headers from current OTEL context
ctx = get_trace_context()
headers = {"req-id": req_id}
TraceContextTextMapPropagator().inject(headers, ctx)
url = get_application_url("HTTP")
r = httpx.post(f"{url}/", headers=headers)
r.raise_for_status()
# Launch 6 requests -> expect 2 batches of 3 requests each
threads = [Thread(target=do_request, args=(f"upstream_{i}",)) for i in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
serve.shutdown()
# Load span files
serve_logs_dir = get_serve_logs_dir()
spans_dir = os.path.join(serve_logs_dir, "spans")
files = os.listdir(spans_dir)
assert files, "No span files found. Tracing may not be configured."
replica_filename = None
upstream_filename = None
for file in files:
if "replica" in file:
replica_filename = file
elif "upstream" in file:
upstream_filename = file
assert replica_filename and upstream_filename
upstream_spans = load_spans(os.path.join(spans_dir, upstream_filename))
replica_spans = load_spans(os.path.join(spans_dir, replica_filename))
id_to_trace = {}
for s in upstream_spans:
rid = s.get("attributes", {}).get("req-id")
if rid:
id_to_trace[rid] = s["context"]["trace_id"]
assert (
len(id_to_trace) == 6
), f"Expected 6 upstream request spans, saw {len(id_to_trace)}"
# Exactly two batch spans, one per each batch, are expected
batched_spans = [s for s in replica_spans if s["name"] == "batched_span"]
assert (
len(batched_spans) == 2
), f"Expected 2 batched spans, saw {len(batched_spans)}"
# For each batched span, its trace_id should equal the trace of the first request in the batch
batched_trace_ids = set()
batch_indices = set()
for bs in batched_spans:
first_req_id = bs.get("attributes", {}).get("first-req-id")
assert (
first_req_id in id_to_trace
), f"first-req-id {first_req_id} not found among upstream req-ids"
assert bs["context"]["trace_id"] == id_to_trace[first_req_id]
batched_trace_ids.add(bs["context"]["trace_id"])
batch_indices.add(bs.get("attributes", {}).get("batch-idx"))
# The two batched spans must correspond to two unique traces
assert (
len(batched_trace_ids) == 2
), "Batched spans did not map to two unique upstream traces"
# And they must be from distinct batches
assert (
len(batch_indices) == 2
), f"Expected two distinct batch indices, got {batch_indices}"
safe_remove(spans_dir)
@pytest.mark.parametrize(
"method_name",
["ClientStreaming", "BidiStreaming"],
)
def test_grpc_streaming_tracing_attributes(serve_and_ray_shutdown, method_name):
"""Test that tracing attributes are correctly set for gRPC streaming requests.
Verifies that for client streaming and bidirectional streaming methods,
the proxy gRPC span contains the expected tracing attributes including
request_id, deployment, app, request_type, and RPC-specific attributes.
"""
if RAY_SERVE_ENABLE_HA_PROXY:
pytest.skip()
grpc_port = 9000
serve.start(
grpc_options=gRPCOptions(
port=grpc_port,
grpc_servicer_functions=[
"ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server",
],
),
)
@serve.deployment
class StreamingDeployment:
async def ClientStreaming(self, request_stream: gRPCInputStream):
async for _ in request_stream:
pass
return serve_pb2.UserDefinedResponse(greeting="ok", num_x2=0)
async def BidiStreaming(self, request_stream: gRPCInputStream):
async for request in request_stream:
yield serve_pb2.UserDefinedResponse(
greeting=f"Hello {request.name}",
num_x2=request.num * 2,
)
serve.run(StreamingDeployment.options(name="streaming-deployment").bind())
setup_tracing(
component_name="upstream_app",
component_id="345",
tracing_sampling_ratio=1.0,
)
tracer = trace.get_tracer("test_tracing")
with tracer.start_as_current_span("upstream_streaming"):
ctx = get_trace_context()
headers = {}
TraceContextTextMapPropagator().inject(headers, ctx)
metadata = tuple(headers.items())
channel = grpc.insecure_channel("localhost:9000")
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
def request_generator():
for i in range(2):
yield serve_pb2.UserDefinedMessage(name=f"msg_{i}", num=i, foo="bar")
if method_name == "ClientStreaming":
stub.ClientStreaming(request_generator(), metadata=metadata)
else:
list(stub.BidiStreaming(request_generator(), metadata=metadata))
serve.shutdown()
# Find proxy spans
serve_logs_dir = get_serve_logs_dir()
spans_dir = os.path.join(serve_logs_dir, "spans")
files = os.listdir(spans_dir)
proxy_spans = None
for file in files:
if "proxy" in file:
proxy_spans = load_spans(os.path.join(spans_dir, file))
break
grpc_proxy_span = next(
(span for span in proxy_spans if "proxy_grpc_request" in span["name"]),
None,
)
assert grpc_proxy_span is not None
attrs = grpc_proxy_span["attributes"]
# Verify tracing attributes are set up correctly
assert "request_id" in attrs
assert attrs["deployment"] == "streaming-deployment"
assert "app" in attrs
assert attrs["request_type"] == "grpc"
assert attrs["rpc.system"] == "grpc"
assert f"/ray.serve.UserDefinedService/{method_name}" == attrs["rpc.method"]
assert attrs["rpc.grpc.status_code"] == "OK"
assert grpc_proxy_span["status"]["status_code"] == "OK"
safe_remove(spans_dir)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))