Files
2026-07-13 13:22:34 +08:00

184 lines
6.5 KiB
Python

"""
The ``mlflow.otel`` module provides generic OTEL-to-MLflow span forwarding.
When enabled, every span produced by any OpenTelemetry-instrumented library
(e.g. Langfuse, OpenInference / Arize Phoenix) is automatically forwarded
to the MLflow backend via the OTLP endpoint.
.. code-block:: python
import mlflow.otel
mlflow.otel.autolog() # enable (batched by default)
mlflow.otel.autolog(batch=False) # enable (synchronous export)
mlflow.otel.autolog(disable=True) # disable
"""
import logging
from opentelemetry import trace as otel_trace_api
from opentelemetry.context import Context
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
from opentelemetry.sdk.trace import Span as OTelSpan
from opentelemetry.sdk.trace import SpanProcessor
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor
import mlflow
from mlflow.environment_variables import MLFLOW_ENABLE_ASYNC_TRACE_LOGGING
from mlflow.tracing.utils.otlp import OTLP_TRACES_PATH, build_otlp_headers
from mlflow.tracking.fluent import _get_experiment_id
from mlflow.utils.autologging_utils import autologging_integration
from mlflow.utils.autologging_utils.safety import _AUTOLOGGING_CLEANUP_CALLBACKS
_logger = logging.getLogger(__name__)
FLAVOR_NAME = "otel"
# Keep a reference so we can disable/re-enable it.
_active_processor: "_ToggleableSpanProcessor | None" = None
class _ToggleableSpanProcessor(SpanProcessor):
"""A span processor that can be enabled/disabled at runtime.
Wraps a standard OTEL ``SpanProcessor`` (Simple or Batch). Since there
is no public API to *remove* a processor from a TracerProvider, we gate
on_start/on_end behind a flag so that disabling autolog truly stops
span processing.
"""
def __init__(self, inner: SpanProcessor):
self._inner = inner
self._enabled = True
def on_start(self, span: OTelSpan, parent_context: Context | None = None):
if not self._enabled:
return
self._inner.on_start(span, parent_context)
def on_end(self, span: OTelReadableSpan) -> None:
if not self._enabled:
return
self._inner.on_end(span)
def shutdown(self) -> None:
self._inner.shutdown()
def force_flush(self, timeout_millis: int = 30000) -> bool:
return self._inner.force_flush(timeout_millis)
def enable(self):
self._enabled = True
def disable(self):
self._enabled = False
def setup_otel_processor(batch: bool | None = None) -> None:
"""Register an MLflow span processor on the global OTEL TracerProvider.
Spans are exported to the MLflow backend via the OTLP endpoint. The
server handles trace creation and attribute translation automatically.
Args:
batch: If ``True``, use ``BatchSpanProcessor`` for buffered export.
If ``False``, use ``SimpleSpanProcessor`` for synchronous export.
If ``None`` (default), follows the ``MLFLOW_ENABLE_ASYNC_TRACE_LOGGING``
environment variable (defaults to ``True``).
"""
global _active_processor
if _active_processor is not None:
_active_processor.enable()
_logger.debug("Re-enabled existing MLflow span processor.")
return
# Ensure a real (non-proxy) TracerProvider exists globally.
# Langfuse performs the same check in _init_tracer_provider(): it
# replaces a ProxyTracerProvider but reuses an existing SdkTracerProvider.
# This means either initialization order works — whichever runs first
# creates the SdkTracerProvider, and the other adds its processor to it.
provider = otel_trace_api.get_tracer_provider()
if isinstance(provider, otel_trace_api.ProxyTracerProvider):
provider = SdkTracerProvider()
otel_trace_api.set_tracer_provider(provider)
if not isinstance(provider, SdkTracerProvider):
_logger.warning(
"Global TracerProvider is %s, not an SDK TracerProvider. "
"Cannot register MLflow span processor.",
type(provider).__name__,
)
return
if batch is None:
batch = MLFLOW_ENABLE_ASYNC_TRACE_LOGGING.get()
tracking_uri = mlflow.get_tracking_uri().rstrip("/")
endpoint = f"{tracking_uri}{OTLP_TRACES_PATH}"
experiment_id = _get_experiment_id()
exporter = OTLPSpanExporter(
endpoint=endpoint,
headers=build_otlp_headers(experiment_id),
)
inner = BatchSpanProcessor(exporter) if batch else SimpleSpanProcessor(exporter)
processor = _ToggleableSpanProcessor(inner)
provider.add_span_processor(processor)
_active_processor = processor
# Register teardown so that ``revert_patches(flavor_name)`` — called by
# the ``@autologging_integration`` decorator on ``autolog(disable=True)``
# — disables the processor. The decorator short-circuits before our
# function body runs, so we cannot rely on the body itself.
_AUTOLOGGING_CLEANUP_CALLBACKS.setdefault(FLAVOR_NAME, []).append(teardown_otel_processor)
_logger.debug(
"Registered MLflow span processor on global TracerProvider "
"(endpoint=%s, experiment_id=%s, batch=%s).",
endpoint,
experiment_id,
batch,
)
def teardown_otel_processor() -> None:
"""Disable the MLflow span processor (best-effort)."""
if _active_processor is None:
return
_active_processor.disable()
_logger.debug("Disabled MLflow span processor.")
@autologging_integration(FLAVOR_NAME)
def autolog(
log_traces: bool = True,
disable: bool = False,
silent: bool = False,
batch: bool | None = None,
):
"""
Enables (or disables) generic OTEL-to-MLflow span forwarding.
Args:
log_traces: If ``True``, traces are logged to MLflow.
If ``False``, no MLflow traces are collected.
Default ``True``.
disable: If ``True``, disables the OTEL autologging
integration. Default ``False``.
silent: If ``True``, suppress all event logs and warnings from
MLflow during OTEL autologging. Default ``False``.
batch: If ``True``, use ``BatchSpanProcessor`` for buffered,
asynchronous export. If ``False``, use
``SimpleSpanProcessor`` for synchronous, immediate export.
If ``None`` (default), follows the
``MLFLOW_ENABLE_ASYNC_TRACE_LOGGING`` environment variable
(defaults to ``True``).
"""
setup_otel_processor(batch=batch)