510 lines
16 KiB
Python
510 lines
16 KiB
Python
import inspect
|
|
import os
|
|
import threading
|
|
from contextvars import ContextVar, Token
|
|
from functools import wraps
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from ray._common.utils import import_attr
|
|
from ray.serve._private.constants import (
|
|
DEFAULT_TRACING_EXPORTER_IMPORT_PATH,
|
|
RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH,
|
|
RAY_SERVE_TRACING_SAMPLING_RATIO,
|
|
)
|
|
|
|
try:
|
|
from opentelemetry import trace
|
|
from opentelemetry.context import attach, detach, get_current
|
|
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
|
|
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio
|
|
from opentelemetry.semconv.trace import SpanAttributes
|
|
from opentelemetry.trace import SpanKind
|
|
from opentelemetry.trace.propagation import set_span_in_context
|
|
from opentelemetry.trace.propagation.tracecontext import (
|
|
TraceContextTextMapPropagator,
|
|
)
|
|
from opentelemetry.trace.status import Status, StatusCode
|
|
|
|
except ImportError:
|
|
SpanProcessor = None
|
|
ConsoleSpanExporter = None
|
|
SimpleSpanProcessor = None
|
|
trace = None
|
|
SpanKind = None
|
|
TracerProvider = None
|
|
TraceIdRatioBased = None
|
|
Status = None
|
|
StatusCode = None
|
|
set_span_in_context = None
|
|
TraceContextTextMapPropagator = None
|
|
get_current = None
|
|
attach = None
|
|
detach = None
|
|
SpanAttributes = None
|
|
ParentBasedTraceIdRatio = None
|
|
|
|
|
|
TRACE_STACK: ContextVar[List[Any]] = ContextVar(
|
|
"trace_stack"
|
|
) # Create tracer once at module level
|
|
|
|
_tracer = None
|
|
_tracer_lock = threading.Lock()
|
|
|
|
|
|
def get_tracer():
|
|
global _tracer
|
|
if _tracer is None:
|
|
with _tracer_lock:
|
|
if _tracer is None:
|
|
_tracer = trace.get_tracer(__name__)
|
|
return _tracer
|
|
|
|
|
|
# Default tracing exporter needs to map to DEFAULT_TRACING_EXPORTER_IMPORT_PATH
|
|
# defined in "python/ray/serve/_private/constants.py"
|
|
def default_tracing_exporter(tracing_file_name):
|
|
from ray.serve._private.logging_utils import get_serve_logs_dir
|
|
|
|
serve_logs_dir = get_serve_logs_dir()
|
|
spans_dir = os.path.join(serve_logs_dir, "spans")
|
|
os.makedirs(spans_dir, exist_ok=True)
|
|
spans_file = os.path.join(spans_dir, tracing_file_name)
|
|
|
|
out_file = open(spans_file, "a")
|
|
|
|
class FileConsoleSpanExporter(ConsoleSpanExporter):
|
|
def shutdown(self):
|
|
if not out_file.closed:
|
|
out_file.flush()
|
|
out_file.close()
|
|
|
|
return [SimpleSpanProcessor(FileConsoleSpanExporter(out=out_file))]
|
|
|
|
|
|
class TraceContextManager:
|
|
def __init__(
|
|
self, trace_name, span_kind=None, trace_context: Optional[Dict[str, str]] = None
|
|
):
|
|
self.span = None
|
|
self.trace_name = trace_name
|
|
self.span_kind = span_kind
|
|
self.trace_context = trace_context
|
|
|
|
self.is_tracing_enabled = is_tracing_enabled()
|
|
|
|
def __enter__(self):
|
|
if self.is_tracing_enabled:
|
|
self.span_kind = self.span_kind or SpanKind.SERVER
|
|
|
|
tracer = get_tracer()
|
|
ctx = self.trace_context if self.trace_context else get_trace_context()
|
|
|
|
self.span = tracer.start_span(
|
|
self.trace_name,
|
|
kind=self.span_kind,
|
|
context=ctx,
|
|
)
|
|
if not self.span.get_span_context().trace_flags.sampled:
|
|
return self
|
|
new_ctx = set_span_in_context(self.span)
|
|
set_trace_context(new_ctx)
|
|
_append_trace_stack(self.span)
|
|
set_span_name(self.trace_name)
|
|
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
if self.is_tracing_enabled and self.span is not None:
|
|
# if exc_type is not None, we have made a explicit decision
|
|
# to not set the span status to error. This is because
|
|
# errors are spans internal to Ray Serve and should not
|
|
# be reported as errors in the trace. They cause noise
|
|
# in the trace and are not meaningful to the user.
|
|
self.span.end()
|
|
_pop_trace_stack()
|
|
|
|
return False
|
|
|
|
|
|
class BatchTraceContextManager:
|
|
"""Attach/detach a tracing context around a block to scope the span of a batch."""
|
|
|
|
def __init__(self, trace_context: Optional[object]):
|
|
self._enabled = is_tracing_enabled() and trace_context is not None
|
|
self._trace_context = trace_context
|
|
self._token: Optional[Token] = None
|
|
|
|
def __enter__(self):
|
|
if self._enabled:
|
|
self._token = set_trace_context(self._trace_context)
|
|
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
if self._enabled and self._token is not None:
|
|
detach_trace_context(self._token)
|
|
return False
|
|
|
|
|
|
def tracing_decorator_factory(
|
|
trace_name: str, span_kind: Optional[Any] = None
|
|
) -> Callable:
|
|
"""
|
|
Factory function to create a tracing decorator for instrumenting functions/methods
|
|
with distributed tracing.
|
|
|
|
Args:
|
|
trace_name: The name of the trace.
|
|
span_kind: The kind of span to create
|
|
(e.g., SERVER, CLIENT). Defaults to trace.SpanKind.SERVER.
|
|
|
|
Returns:
|
|
Callable: A decorator function that can be used to wrap
|
|
functions/methods with distributed tracing.
|
|
|
|
Example Usage:
|
|
```python
|
|
@tracing_decorator_factory(
|
|
"my_trace",
|
|
span_kind=trace.SpanKind.CLIENT,
|
|
)
|
|
def my_function(obj):
|
|
# Function implementation
|
|
```
|
|
"""
|
|
|
|
def tracing_decorator(func):
|
|
if not is_tracing_enabled():
|
|
# if tracing is not enabled, we don't want to wrap the function
|
|
# with the tracing decorator.
|
|
return func
|
|
|
|
@wraps(func)
|
|
def synchronous_wrapper(*args, **kwargs):
|
|
with TraceContextManager(trace_name, span_kind):
|
|
result = func(*args, **kwargs)
|
|
|
|
return result
|
|
|
|
@wraps(func)
|
|
def generator_wrapper(*args, **kwargs):
|
|
with TraceContextManager(trace_name, span_kind):
|
|
for item in func(*args, **kwargs):
|
|
yield item
|
|
|
|
@wraps(func)
|
|
async def asynchronous_wrapper(*args, **kwargs):
|
|
with TraceContextManager(trace_name, span_kind):
|
|
result = await func(*args, **kwargs)
|
|
return result
|
|
|
|
@wraps(func)
|
|
async def asyc_generator_wrapper(*args, **kwargs):
|
|
with TraceContextManager(trace_name, span_kind):
|
|
async for item in func(*args, **kwargs):
|
|
yield item
|
|
|
|
is_generator = _is_generator_function(func)
|
|
is_async = _is_async_function(func)
|
|
if is_generator and is_async:
|
|
return asyc_generator_wrapper
|
|
elif is_async:
|
|
return asynchronous_wrapper
|
|
elif is_generator:
|
|
return generator_wrapper
|
|
else:
|
|
return synchronous_wrapper
|
|
|
|
return tracing_decorator
|
|
|
|
|
|
def setup_tracing(
|
|
component_name: str,
|
|
component_id: str,
|
|
component_type: Optional["ServeComponentType"] = None, # noqa: F821
|
|
tracing_exporter_import_path: Optional[
|
|
str
|
|
] = RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH,
|
|
tracing_sampling_ratio: Optional[float] = RAY_SERVE_TRACING_SAMPLING_RATIO,
|
|
) -> bool:
|
|
"""
|
|
Set up tracing for a specific Serve component.
|
|
|
|
Args:
|
|
component_name: The name of the component.
|
|
component_id: The unique identifier of the component.
|
|
component_type: The type of the component.
|
|
tracing_exporter_import_path: Path to tracing exporter function.
|
|
tracing_sampling_ratio: Sampling ratio for traces (0.0 to 1.0).
|
|
|
|
Returns:
|
|
bool: True if tracing setup is successful, False otherwise.
|
|
"""
|
|
if tracing_exporter_import_path == "":
|
|
return False
|
|
|
|
# Check dependencies
|
|
if not trace:
|
|
raise ImportError(
|
|
"You must `pip install opentelemetry-api` and "
|
|
"`pip install opentelemetry-sdk` "
|
|
"to enable tracing on Ray Serve."
|
|
)
|
|
|
|
from ray.serve._private.utils import get_component_file_name
|
|
|
|
tracing_file_name = get_component_file_name(
|
|
component_name=component_name,
|
|
component_id=component_id,
|
|
component_type=component_type,
|
|
suffix="_tracing.json",
|
|
)
|
|
|
|
span_processors = _load_span_processors(
|
|
tracing_exporter_import_path, tracing_file_name
|
|
)
|
|
|
|
# Intialize tracing
|
|
# Sets the tracer_provider. This is only allowed once~ per execution
|
|
# context and will log a warning if attempted multiple times.
|
|
# use ParentBasedTraceIdRatio to respect the parent span's sampling decision
|
|
# and sample probabilistically based on the tracing_sampling_ratio
|
|
sampler = ParentBasedTraceIdRatio(tracing_sampling_ratio)
|
|
|
|
trace.set_tracer_provider(TracerProvider(sampler=sampler))
|
|
|
|
for span_processor in span_processors:
|
|
trace.get_tracer_provider().add_span_processor(span_processor)
|
|
|
|
return True
|
|
|
|
|
|
def create_propagated_context() -> Dict[str, str]:
|
|
"""Create context that can be used across services and processes.
|
|
|
|
This function retrieves the current context and converts it
|
|
into a dictionary that can be used across actors and tasks since
|
|
it is serializable.
|
|
|
|
Returns:
|
|
- Trace Context Propagator (dict or None): A dictionary containing the propagated
|
|
trace context if available, otherwise None.
|
|
"""
|
|
trace_context = get_trace_context()
|
|
if trace_context and TraceContextTextMapPropagator:
|
|
ctx = {}
|
|
TraceContextTextMapPropagator().inject(ctx, trace_context)
|
|
return ctx
|
|
|
|
return None
|
|
|
|
|
|
def extract_propagated_context(
|
|
propagated_context: Optional[Dict[str, str]] = None
|
|
) -> Optional[Dict[str, str]]:
|
|
"""Extract the trace context from a Trace Context Propagator."""
|
|
if is_tracing_enabled() and propagated_context and TraceContextTextMapPropagator:
|
|
return TraceContextTextMapPropagator().extract(carrier=propagated_context)
|
|
|
|
return None
|
|
|
|
|
|
def set_trace_context(trace_context: Dict[str, str]) -> Optional[Token]:
|
|
"""Set the current trace context."""
|
|
if attach is None:
|
|
return
|
|
|
|
return attach(trace_context)
|
|
|
|
|
|
def detach_trace_context(token: Token):
|
|
"""Detach the current trace context corresponding to the token."""
|
|
if detach is None:
|
|
return
|
|
|
|
detach(token)
|
|
|
|
|
|
def get_trace_context() -> Optional[Dict[str, str]]:
|
|
"""Retrieve the current trace context."""
|
|
if get_current is None:
|
|
return None
|
|
|
|
context = get_current()
|
|
return context if context else None
|
|
|
|
|
|
def set_span_name(name: str):
|
|
"""Set the name for the current span in context."""
|
|
if TRACE_STACK:
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
trace_stack[-1].update_name(name)
|
|
# this is added specifically for Datadog tracing.
|
|
# See https://docs.datadoghq.com/tracing/guide/configuring-primary-operation/#opentracing
|
|
set_span_attributes({"resource.name": name})
|
|
|
|
|
|
def set_rpc_span_attributes(
|
|
system: str = "grpc",
|
|
method: Optional[str] = None,
|
|
status_code: Optional[str] = None,
|
|
service: Optional[str] = None,
|
|
):
|
|
"""
|
|
Use this function to set attributes for RPC spans.
|
|
Only include attributes that are in the OpenTelemetry
|
|
RPC span attributes spec https://opentelemetry.io/docs/specs/semconv/attributes-registry/rpc/.
|
|
"""
|
|
if not is_tracing_enabled():
|
|
return
|
|
attributes = {
|
|
SpanAttributes.RPC_SYSTEM: system,
|
|
SpanAttributes.RPC_METHOD: method,
|
|
SpanAttributes.RPC_GRPC_STATUS_CODE: status_code,
|
|
SpanAttributes.RPC_SERVICE: service,
|
|
}
|
|
set_span_attributes(attributes)
|
|
|
|
|
|
def set_http_span_attributes(
|
|
method: Optional[str] = None,
|
|
status_code: Optional[str] = None,
|
|
route: Optional[str] = None,
|
|
):
|
|
"""
|
|
Use this function to set attributes for HTTP spans.
|
|
Only include attributes that are in the OpenTelemetry
|
|
HTTP span attributes spec https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/.
|
|
"""
|
|
if not is_tracing_enabled():
|
|
return
|
|
attributes = {
|
|
SpanAttributes.HTTP_METHOD: method,
|
|
SpanAttributes.HTTP_STATUS_CODE: status_code,
|
|
SpanAttributes.HTTP_ROUTE: route,
|
|
}
|
|
set_span_attributes(attributes)
|
|
|
|
|
|
def set_span_attributes(attributes: Dict[str, Any]):
|
|
"""Set attributes for the current span in context."""
|
|
if TRACE_STACK:
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
# filter attribute values that are None, otherwise they
|
|
# will show up as warning logs on the console.
|
|
attributes = {k: v for k, v in attributes.items() if v is not None}
|
|
trace_stack[-1].set_attributes(attributes)
|
|
|
|
|
|
def set_trace_status(is_error: bool, description: str = ""):
|
|
"""Set the status for the current span in context."""
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
if is_error:
|
|
status_code = StatusCode.ERROR
|
|
else:
|
|
status_code = StatusCode.OK
|
|
description = None
|
|
trace_stack[-1].set_status(
|
|
Status(status_code=status_code, description=description)
|
|
)
|
|
|
|
|
|
def set_span_exception(exc: Exception, escaped: bool = False):
|
|
"""Set the exception for the current span in context."""
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
trace_stack[-1].record_exception(exc, escaped=escaped)
|
|
|
|
|
|
def is_tracing_enabled() -> bool:
|
|
return RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH != "" and trace is not None
|
|
|
|
|
|
def is_span_recording() -> bool:
|
|
if TRACE_STACK:
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _append_trace_stack(span):
|
|
"""Append span to global trace stack."""
|
|
trace_stack = TRACE_STACK.get([])
|
|
trace_stack.append(span)
|
|
TRACE_STACK.set(trace_stack)
|
|
|
|
|
|
def _pop_trace_stack():
|
|
"""Pop span to global trace stack."""
|
|
trace_stack = TRACE_STACK.get([])
|
|
if trace_stack:
|
|
trace_stack.pop()
|
|
TRACE_STACK.set(trace_stack)
|
|
|
|
|
|
def _validate_tracing_exporter(func: Callable) -> None:
|
|
"""Validate that the custom tracing exporter
|
|
is a function that takes no arguments.
|
|
"""
|
|
if inspect.isfunction(func) is False:
|
|
raise TypeError("Tracing exporter must be a function.")
|
|
|
|
signature = inspect.signature(func)
|
|
|
|
if len(signature.parameters) != 0:
|
|
raise TypeError("Tracing exporter cannot take any arguments.")
|
|
|
|
|
|
def _validate_tracing_exporter_processors(span_processors: List[Any]):
|
|
"""Validate that the output of a custom tracing exporter
|
|
returns type List[SpanProcessor].
|
|
"""
|
|
if not isinstance(span_processors, list):
|
|
raise TypeError(
|
|
"Output of tracing exporter needs to be of type "
|
|
f"List[SpanProcessor], but received type {type(span_processors)}."
|
|
)
|
|
|
|
for span_processor in span_processors:
|
|
if not isinstance(span_processor, SpanProcessor):
|
|
raise TypeError(
|
|
"Output of tracing exporter needs to be of "
|
|
"type List[SpanProcessor], "
|
|
f"but received type {type(span_processor)}."
|
|
)
|
|
|
|
|
|
def _load_span_processors(
|
|
tracing_exporter_import_path: str,
|
|
tracing_file_name: str,
|
|
):
|
|
"""Load span processors from a custome tracing
|
|
exporter function.
|
|
"""
|
|
tracing_exporter_def = import_attr(tracing_exporter_import_path)
|
|
|
|
if tracing_exporter_import_path == DEFAULT_TRACING_EXPORTER_IMPORT_PATH:
|
|
return tracing_exporter_def(tracing_file_name)
|
|
else:
|
|
# Validate tracing exporter function
|
|
_validate_tracing_exporter(tracing_exporter_def)
|
|
# Validate tracing exporter processors
|
|
span_processors = tracing_exporter_def()
|
|
_validate_tracing_exporter_processors(span_processors)
|
|
|
|
return span_processors
|
|
|
|
|
|
def _is_generator_function(func):
|
|
return inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func)
|
|
|
|
|
|
def _is_async_function(func):
|
|
return inspect.iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
|