1160 lines
39 KiB
Python
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__]))
|