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

328 lines
12 KiB
Python

import gzip
import time
import zlib
from collections.abc import Callable
import pytest
from fastapi import HTTPException
import mlflow
from mlflow.entities.span import SpanType
from mlflow.environment_variables import MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT
from mlflow.tracing.processor.mlflow_v3 import MlflowV3SpanProcessor
from mlflow.tracing.processor.otel import OtelSpanProcessor
from mlflow.tracing.provider import _get_trace_exporter, _get_tracer
from mlflow.tracing.provider import provider as mlflow_provider
from mlflow.tracing.utils.otlp import _set_otel_proto_anyvalue
from mlflow.tracking import MlflowClient
from mlflow.utils.os import is_windows
from tests.tracing.helper import get_traces
# OTLP exporters are not installed in some CI jobs
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as GrpcExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HttpExporter,
)
from opentelemetry.proto.common.v1.common_pb2 import AnyValue
except ImportError:
pytest.skip("OTLP exporters are not installed", allow_module_level=True)
from mlflow.exceptions import MlflowException
from mlflow.tracing.utils.otlp import (
decompress_otlp_body,
get_otlp_exporter,
should_use_otlp_exporter,
)
_TEST_HTTP_OTLP_ENDPOINT = "http://127.0.0.1:4317/v1/traces"
_TEST_HTTPS_OTLP_ENDPOINT = "https://127.0.0.1:4317/v1/traces"
@pytest.mark.parametrize(
("traces_endpoint", "otlp_endpoint", "mlflow_enable", "expected"),
[
# No endpoints configured
(None, None, None, False), # Default behavior - no export without endpoint
(None, None, "true", False), # Explicit enable but no endpoint
(None, None, "false", False), # Explicit disable and no endpoint
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT configured
(_TEST_HTTP_OTLP_ENDPOINT, None, None, True), # Default behavior - export enabled
(_TEST_HTTP_OTLP_ENDPOINT, None, "true", True), # Explicit enable
(_TEST_HTTP_OTLP_ENDPOINT, None, "false", False), # Explicit disable
# OTEL_EXPORTER_OTLP_ENDPOINT configured
(None, _TEST_HTTP_OTLP_ENDPOINT, None, True), # Default behavior - export enabled
(None, _TEST_HTTP_OTLP_ENDPOINT, "true", True), # Explicit enable
(None, _TEST_HTTP_OTLP_ENDPOINT, "false", False), # Explicit disable
# Both endpoints configured (traces endpoint takes precedence)
(_TEST_HTTP_OTLP_ENDPOINT, _TEST_HTTPS_OTLP_ENDPOINT, None, True),
(_TEST_HTTP_OTLP_ENDPOINT, _TEST_HTTPS_OTLP_ENDPOINT, "false", False),
],
)
def test_should_use_otlp_exporter(
traces_endpoint, otlp_endpoint, mlflow_enable, expected, monkeypatch
):
# Clear all relevant environment variables to ensure test isolation
monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.delenv("MLFLOW_ENABLE_OTLP_EXPORTER", raising=False)
# Set environment variables based on test parameters
if traces_endpoint is not None:
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", traces_endpoint)
if otlp_endpoint is not None:
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", otlp_endpoint)
if mlflow_enable is not None:
monkeypatch.setenv("MLFLOW_ENABLE_OTLP_EXPORTER", mlflow_enable)
assert should_use_otlp_exporter() is expected
@pytest.mark.parametrize(
("endpoint", "protocol", "expected_type"),
[
(_TEST_HTTP_OTLP_ENDPOINT, None, GrpcExporter),
(_TEST_HTTP_OTLP_ENDPOINT, "grpc", GrpcExporter),
(_TEST_HTTPS_OTLP_ENDPOINT, "grpc", GrpcExporter),
(_TEST_HTTP_OTLP_ENDPOINT, "http/protobuf", HttpExporter),
(_TEST_HTTPS_OTLP_ENDPOINT, "http/protobuf", HttpExporter),
],
)
def test_get_otlp_exporter_success(endpoint, protocol, expected_type, monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", endpoint)
if protocol:
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", protocol)
exporter = get_otlp_exporter()
assert isinstance(exporter, expected_type)
if isinstance(exporter, GrpcExporter):
assert exporter._endpoint == "127.0.0.1:4317"
else:
assert exporter._endpoint == endpoint
def test_get_otlp_exporter_invalid_protocol(monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", _TEST_HTTP_OTLP_ENDPOINT)
with pytest.raises(MlflowException, match="Unsupported OTLP protocol"):
get_otlp_exporter()
@pytest.mark.skipif(is_windows(), reason="Otel collector docker image does not support Windows")
@pytest.mark.parametrize("dual_export", [True, False, None], ids=["enable", "disable", "default"])
def test_export_to_otel_collector(otel_collector, monkeypatch, dual_export):
if dual_export:
monkeypatch.setenv("MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT", "true")
elif dual_export is False:
monkeypatch.setenv("MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT", "false")
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
_, _, port = otel_collector
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", f"http://127.0.0.1:{port}/v1/traces")
class TestModel:
@mlflow.trace()
def predict(self, x, y):
z = x + y
z = self.add_one(z)
z = mlflow.trace(self.square)(z)
return z # noqa: RET504
@mlflow.trace(
span_type=SpanType.LLM, name="add_one_with_custom_name", attributes={"delta": 1}
)
def add_one(self, z):
return z + 1
def square(self, t):
res = t**2
time.sleep(0.1)
return res
model = TestModel()
model.predict(2, 5)
# Tracer should be configured to export to OTLP.
# In dual-export mode, _get_trace_exporter() returns the MLflow exporter
# (since _get_span_processor prefers BaseMlflowSpanProcessor), so we find
# the OTLP exporter by looking for the OtelSpanProcessor directly.
if dual_export:
# In dual-export mode, _get_trace_exporter() returns the MLflow exporter
# (since _get_span_processor prefers BaseMlflowSpanProcessor). Find the
# OTLP exporter by looking up the OtelSpanProcessor from the tracer provider.
tp = mlflow_provider.get()
processors = tp._active_span_processor._span_processors
otel_processor = next(p for p in processors if isinstance(p, OtelSpanProcessor))
exporter = otel_processor.span_exporter
else:
exporter = _get_trace_exporter()
assert isinstance(exporter, OTLPSpanExporter)
assert exporter._endpoint == f"127.0.0.1:{port}"
mlflow_traces = get_traces()
if dual_export:
assert len(mlflow_traces) == 1
assert mlflow_traces[0].info.state == "OK"
assert len(mlflow_traces[0].data.spans) == 3
else:
assert len(mlflow_traces) == 0
# Wait for collector to receive spans, checking every second for up to 60 seconds
_, output_file, _ = otel_collector
spans_found = False
for _ in range(60):
time.sleep(1)
with open(output_file) as f:
collector_logs = f.read()
# Check if spans are in the logs - the debug exporter outputs span details
# The BatchSpanProcessor may send spans in multiple batches, so we check for any evidence
# that the collector is receiving spans from our test
if (
"predict" in collector_logs
and "add_one_with_custom_name" in collector_logs
and "square" in collector_logs
):
# We found evidence that spans are being exported to the collector
# The child spans may come in separate batches, but OTLP export works
spans_found = True
break
# Assert that spans were found in collector logs
assert spans_found, (
f"Expected spans not found in collector logs after 60 seconds. "
f"Logs: {collector_logs[:2000]}"
)
@pytest.mark.skipif(is_windows(), reason="Otel collector docker image does not support Windows")
def test_dual_export_to_mlflow_and_otel(otel_collector, monkeypatch):
"""
Test that dual export mode sends traces to both MLflow and OTLP collector.
"""
_, _, port = otel_collector
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "true")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", f"http://127.0.0.1:{port}/v1/traces")
experiment = mlflow.set_experiment("dual_export_test")
processors = _get_tracer("test").span_processor._span_processors
assert len(processors) == 2
assert isinstance(processors[0], OtelSpanProcessor)
assert isinstance(processors[1], MlflowV3SpanProcessor)
@mlflow.trace(name="parent_span")
def parent_function():
result = child_function("Hello", "World")
return f"Parent: {result}"
@mlflow.trace(name="child_span")
def child_function(arg1, arg2):
# Test that update_current_trace works in dual export mode
mlflow.update_current_trace({"env": "production", "version": "1.0"})
return f"{arg1} {arg2}"
result = parent_function()
assert result == "Parent: Hello World"
mlflow.flush_trace_async_logging()
client = MlflowClient()
traces = client.search_traces(locations=[experiment.experiment_id])
assert len(traces) == 1
trace = traces[0]
assert len(trace.data.spans) == 2
# Verify trace tags were set correctly
assert "env" in trace.info.tags
assert trace.info.tags["env"] == "production"
assert "version" in trace.info.tags
assert trace.info.tags["version"] == "1.0"
# Wait for collector to receive spans, checking every second for up to 60 seconds
_, output_file, _ = otel_collector
spans_found = False
for _ in range(60):
time.sleep(1)
with open(output_file) as f:
collector_logs = f.read()
# Check if spans are in the logs - the debug exporter outputs span details
# Look for evidence that spans were received
if "parent_span" in collector_logs or "child_span" in collector_logs:
# Evidence of traces being exported to OTLP
spans_found = True
break
# Assert that spans were found in collector logs
assert spans_found, (
f"Expected spans not found in collector logs after 60 seconds. "
f"Logs: {collector_logs[:2000]}"
)
@pytest.mark.parametrize(
("encoding", "compress_fn", "data"),
[
("gzip", gzip.compress, b"otlp-data-test"),
("deflate", zlib.compress, b"otlp-deflate-data"),
("deflate", lambda d: zlib.compress(d)[2:-4], b"raw-deflate-data"), # Raw deflate
],
ids=["gzip", "deflate-rfc", "deflate-raw"],
)
def test_decompress_otlp_body_valid(
encoding: str, compress_fn: Callable[[bytes], bytes], data: bytes
):
compressed = compress_fn(data)
output = decompress_otlp_body(compressed, encoding)
assert output == data
@pytest.mark.parametrize(
("encoding", "invalid_data", "expected_error"),
[
("gzip", b"not-gzip-data", r"Failed to decompress gzip payload"),
("deflate", b"not-deflate-data", r"Failed to decompress deflate payload"),
("unknown-encoding", b"xxx", r"Unsupported Content-Encoding"),
],
ids=["gzip-invalid", "deflate-invalid", "unknown-encoding"],
)
def test_decompress_otlp_body_invalid(encoding: str, invalid_data: bytes, expected_error: str):
with pytest.raises(HTTPException, match=expected_error, check=lambda e: e.status_code == 400):
decompress_otlp_body(invalid_data, encoding)
def test_set_otel_proto_anyvalue_sanitizes_lone_surrogate_string():
value = AnyValue()
_set_otel_proto_anyvalue(value, "x" * 149 + "\ud83e")
assert value.string_value == "x" * 149 + "?"
def test_set_otel_proto_anyvalue_sanitizes_lone_surrogate_fallback():
class BadStr:
def __str__(self):
return "bad\ud83e"
value = AnyValue()
_set_otel_proto_anyvalue(value, BadStr())
assert value.string_value == "bad?"
def test_set_otel_proto_anyvalue_sanitizes_nested_lone_surrogate():
value = AnyValue()
_set_otel_proto_anyvalue(value, {"items": ["ok", {"bad": "x\ud83e"}]})
nested = value.kvlist_value.values[0].value.array_value.values[1]
assert nested.kvlist_value.values[0].value.string_value == "x?"
def test_set_otel_proto_anyvalue_sanitizes_lone_surrogate_dict_key():
value = AnyValue()
_set_otel_proto_anyvalue(value, {"bad\ud83e": "ok"})
assert value.kvlist_value.values[0].key == "bad?"
assert value.kvlist_value.values[0].value.string_value == "ok"