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

1073 lines
37 KiB
Python

import random
import threading
from concurrent.futures import ThreadPoolExecutor
from unittest import mock
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
import mlflow
from mlflow.entities.trace_location import (
MlflowExperimentLocation,
UCSchemaLocation,
UnityCatalog,
)
from mlflow.environment_variables import (
MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT,
MLFLOW_TRACE_SAMPLING_RATIO,
MLFLOW_USE_DEFAULT_TRACER_PROVIDER,
)
from mlflow.exceptions import MlflowException, MlflowTracingException
from mlflow.tracing.destination import Databricks, MlflowExperiment
from mlflow.tracing.export.inference_table import (
_TRACE_BUFFER,
InferenceTableSpanExporter,
)
from mlflow.tracing.export.mlflow_v3 import MlflowV3SpanExporter
from mlflow.tracing.export.uc_table import DatabricksUCTableSpanExporter
from mlflow.tracing.fluent import start_span_no_context
from mlflow.tracing.processor.inference_table import InferenceTableSpanProcessor
from mlflow.tracing.processor.mlflow_v3 import MlflowV3SpanProcessor
from mlflow.tracing.processor.otel import OtelSpanProcessor
from mlflow.tracing.processor.uc_table import DatabricksUCTableSpanProcessor
from mlflow.tracing.provider import (
_get_tracer,
_initialize_tracer_provider,
_IsolatedRandomIdGenerator,
is_tracing_enabled,
start_span_in_context,
trace_disabled,
)
from mlflow.tracing.provider import (
provider as _provider_wrapper,
)
from mlflow.tracing.utils import get_active_spans_table_name
from mlflow.utils.mlflow_tags import (
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_DESTINATION_PATH,
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_LOG_STORAGE_TABLE,
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_SPAN_STORAGE_TABLE,
)
from tests.tracing.helper import get_traces, purge_traces, skip_when_testing_trace_sdk
@pytest.fixture
def mock_setup_tracer_provider():
# To count the number of times _initialize_tracer_provider is called
with mock.patch(
"mlflow.tracing.provider._initialize_tracer_provider",
side_effect=_initialize_tracer_provider,
) as setup_mock:
yield setup_mock
def test_tracer_provider_initialized_once(mock_setup_tracer_provider):
assert mock_setup_tracer_provider.call_count == 0
start_span_in_context("test1")
assert mock_setup_tracer_provider.call_count == 1
start_span_in_context("test_2")
start_span_in_context("test_3")
assert mock_setup_tracer_provider.call_count == 1
# Thread safety
with ThreadPoolExecutor(max_workers=2, thread_name_prefix="test-tracing-provider") as executor:
executor.map(start_span_in_context, ["test_4", "test_5"])
assert mock_setup_tracer_provider.call_count == 1
def test_reset_tracer_setup(mock_setup_tracer_provider):
assert mock_setup_tracer_provider.call_count == 0
start_span_in_context("test1")
assert mock_setup_tracer_provider.call_count == 1
mlflow.tracing.reset()
assert mock_setup_tracer_provider.call_count == 2
start_span_in_context("test2")
assert mock_setup_tracer_provider.call_count == 3
assert mock_setup_tracer_provider.mock_calls == ([
mock.call(),
mock.call(disabled=True),
mock.call(),
])
def test_span_processor_and_exporter_model_serving(mock_databricks_serving_with_tracing_env):
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], InferenceTableSpanProcessor)
assert isinstance(processors[0].span_exporter, InferenceTableSpanExporter)
def test_set_destination_mlflow_experiment(monkeypatch):
mlflow.tracing.set_destination(destination=MlflowExperimentLocation(experiment_id="123"))
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert isinstance(processors[0], MlflowV3SpanProcessor)
assert isinstance(processors[0].span_exporter, MlflowV3SpanExporter)
def test_set_destination_databricks(monkeypatch):
monkeypatch.setenv("MLFLOW_TRACKING_URI", "databricks")
mlflow.tracing.set_destination(destination=Databricks(experiment_id="123"))
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], MlflowV3SpanProcessor)
assert isinstance(processors[0].span_exporter, MlflowV3SpanExporter)
def test_set_destination_databricks_uc():
with mock.patch("mlflow.tracing.provider._logger.warning") as mock_warning:
mlflow.tracing.set_destination(
destination=UCSchemaLocation(
catalog_name="catalog",
schema_name="schema",
)
)
mock_warning.assert_called_once()
assert "Passing `UCSchemaLocation` to `mlflow.tracing.set_destination` is deprecated" in str(
mock_warning.call_args.args[0]
)
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], DatabricksUCTableSpanProcessor)
assert isinstance(processors[0].span_exporter, DatabricksUCTableSpanExporter)
assert get_active_spans_table_name() == "catalog.schema.mlflow_experiment_trace_otel_spans"
def test_set_destination_databricks_unity_catalog_rejected(monkeypatch):
with pytest.raises(
MlflowException,
match=r"UnityCatalog table-prefix destinations are not supported by "
r"`mlflow\.tracing\.set_destination`",
):
mlflow.tracing.set_destination(
destination=UnityCatalog(
catalog_name="catalog",
schema_name="schema",
table_prefix="prefix",
)
)
def test_set_destination_databricks_uc_with_oltp_env_no_dual_export(monkeypatch):
# set_destination is called but OLTP is also set w/o dual export mode enabled
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "false")
with (
mock.patch("mlflow.tracing.provider.should_use_otlp_exporter", return_value=True),
mock.patch("mlflow.tracing.provider.get_otlp_exporter") as mock_get_exporter,
):
mock_get_exporter.return_value = mock.MagicMock()
mlflow.tracing.reset()
mlflow.tracing.set_destination(
destination=UCSchemaLocation(
catalog_name="catalog",
schema_name="schema",
)
)
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], DatabricksUCTableSpanProcessor)
assert isinstance(processors[0].span_exporter, DatabricksUCTableSpanExporter)
assert get_active_spans_table_name() == "catalog.schema.mlflow_experiment_trace_otel_spans"
def test_set_destination_databricks_uc_with_oltp_env_with_dual_export(monkeypatch):
# set_destination is called but OLTP is also set w/ dual export mode enabled
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "true")
with (
mock.patch("mlflow.tracing.provider.should_use_otlp_exporter", return_value=True),
mock.patch("mlflow.tracing.provider.get_otlp_exporter") as mock_get_exporter,
):
mock_get_exporter.return_value = mock.MagicMock()
mlflow.tracing.reset()
mlflow.tracing.set_destination(
destination=UCSchemaLocation(
catalog_name="catalog",
schema_name="schema",
)
)
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 2
assert isinstance(processors[0], DatabricksUCTableSpanProcessor)
assert isinstance(processors[0].span_exporter, DatabricksUCTableSpanExporter)
# OTLP processor needs to be there for dual export mode
assert isinstance(processors[1], OtelSpanProcessor)
assert get_active_spans_table_name() == "catalog.schema.mlflow_experiment_trace_otel_spans"
def test_set_destination_from_env_var_mlflow_experiment(monkeypatch):
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "123")
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], MlflowV3SpanProcessor)
assert isinstance(processors[0].span_exporter, MlflowV3SpanExporter)
def test_set_destination_from_env_var_databricks_uc(monkeypatch):
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "catalog.schema")
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], DatabricksUCTableSpanProcessor)
assert isinstance(processors[0].span_exporter, DatabricksUCTableSpanExporter)
assert get_active_spans_table_name() == "catalog.schema.mlflow_experiment_trace_otel_spans"
def test_set_destination_in_model_serving(mock_databricks_serving_with_tracing_env, monkeypatch):
monkeypatch.setenv("MLFLOW_TRACKING_URI", "databricks")
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "catalog.schema")
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], DatabricksUCTableSpanProcessor)
assert isinstance(processors[0].span_exporter, DatabricksUCTableSpanExporter)
assert get_active_spans_table_name() == "catalog.schema.mlflow_experiment_trace_otel_spans"
def test_set_destination_deprecated_classes():
from mlflow.tracing.provider import _MLFLOW_TRACE_USER_DESTINATION
with pytest.warns(FutureWarning, match="`mlflow.tracing.destination.MlflowExperiment``"):
mlflow.tracing.set_destination(destination=MlflowExperiment(experiment_id="123"))
destination = _MLFLOW_TRACE_USER_DESTINATION.get()
assert isinstance(destination, MlflowExperimentLocation)
assert destination.experiment_id == "123"
with pytest.warns(FutureWarning, match="`mlflow.tracing.destination.Databricks`"):
mlflow.tracing.set_destination(destination=Databricks(experiment_id="123"))
destination = _MLFLOW_TRACE_USER_DESTINATION.get()
assert isinstance(destination, MlflowExperimentLocation)
assert destination.experiment_id == "123"
def test_disable_enable_tracing():
@mlflow.trace
def test_fn():
pass
test_fn()
assert len(get_traces()) == 1
assert isinstance(_get_tracer(__name__), trace.Tracer)
purge_traces()
mlflow.tracing.disable()
test_fn()
assert len(get_traces()) == 0
assert isinstance(_get_tracer(__name__), trace.NoOpTracer)
mlflow.tracing.enable()
test_fn()
assert len(get_traces()) == 1
assert isinstance(_get_tracer(__name__), trace.Tracer)
# enable() / disable() should only raise MlflowTracingException
with mock.patch(
"mlflow.tracing.provider.is_tracing_enabled", side_effect=ValueError("error")
) as is_enabled_mock:
with pytest.raises(MlflowTracingException, match="error"):
mlflow.tracing.disable()
assert is_enabled_mock.call_count == 1
with pytest.raises(MlflowTracingException, match="error"):
mlflow.tracing.enable()
assert is_enabled_mock.call_count == 2
@pytest.mark.parametrize("enabled_initially", [True, False])
def test_trace_disabled_decorator(enabled_initially):
if not enabled_initially:
mlflow.tracing.disable()
assert is_tracing_enabled() == enabled_initially
call_count = 0
@trace_disabled
def test_fn():
with mlflow.start_span(name="test_span") as span:
span.set_attribute("key", "value")
nonlocal call_count
call_count += 1
return 0
test_fn()
assert len(get_traces()) == 0
assert call_count == 1
# Recover the initial state
assert is_tracing_enabled() == enabled_initially
# Tracing should be enabled back even if the function raises an exception
@trace_disabled
def test_fn_raise():
nonlocal call_count
call_count += 1
raise ValueError("error")
with pytest.raises(ValueError, match="error"):
test_fn_raise()
assert call_count == 2
assert len(get_traces()) == 0
assert is_tracing_enabled() == enabled_initially
# @trace_disabled should not block the decorated function even if the
# tracing machinery errors while checking/toggling the tracing state.
with mock.patch(
"mlflow.tracing.provider.is_tracing_enabled",
side_effect=MlflowTracingException("error"),
) as is_enabled_mock:
assert test_fn() == 0
assert call_count == 3
assert is_enabled_mock.call_count == 1
@pytest.mark.parametrize("enabled_initially", [True, False])
def test_trace_disabled_with_mlflow_trace_raising(enabled_initially):
if not enabled_initially:
mlflow.tracing.disable()
@mlflow.trace
def predict_fn(query):
raise ValueError("Uhoh!")
@trace_disabled
def validate():
predict_fn(query="What is MLflow?")
with pytest.raises(ValueError, match="Uhoh!"):
validate()
assert is_tracing_enabled() == enabled_initially
assert len(get_traces()) == 0
def test_disable_enable_tracing_not_mutate_otel_provider(monkeypatch):
monkeypatch.setenv(MLFLOW_USE_DEFAULT_TRACER_PROVIDER.name, "true")
# This test validates that disable/enable MLflow tracing does not mutate the OpenTelemetry's
# global tracer provider instance.
otel_tracer_provider = trace.get_tracer_provider()
mlflow.tracing.disable()
assert trace.get_tracer_provider() is otel_tracer_provider
mlflow.tracing.enable()
assert trace.get_tracer_provider() is otel_tracer_provider
@trace_disabled
def test_fn():
assert trace.get_tracer_provider() is otel_tracer_provider
test_fn()
assert trace.get_tracer_provider() is otel_tracer_provider
def _count_batch_processor_threads() -> int:
return sum("OtelBatchSpanRecordProcessor" in t.name for t in threading.enumerate())
@pytest.fixture
def batch_span_processor(monkeypatch):
# Force the async BatchSpanProcessor path (which owns the leaked thread).
# The backend is supplied by the autouse conftest fixtures, so don't override
# the tracking URI here (sqlite:// breaks the SDK-only job, which has no store).
monkeypatch.setenv("MLFLOW_USE_BATCH_SPAN_PROCESSOR", "true")
monkeypatch.setenv("MLFLOW_ENABLE_ASYNC_TRACE_LOGGING", "true")
def test_disable_enable_does_not_leak_batch_processor_threads(batch_span_processor):
@mlflow.trace
def f():
return 0
# Prime a real BatchSpanProcessor (and its daemon thread).
f()
baseline = _count_batch_processor_threads()
# Guard against a vacuous pass: the batch path must actually be active.
assert baseline >= 1
# Each enable() used to build a fresh provider + BatchSpanProcessor without
# shutting down the old one, leaking one thread per cycle (issue #24209).
for _ in range(10):
mlflow.tracing.disable()
mlflow.tracing.enable()
assert _count_batch_processor_threads() <= baseline
def test_trace_disabled_does_not_leak_batch_processor_threads(batch_span_processor):
@mlflow.trace
def f():
return 0
@trace_disabled
def wrapped():
return 0
f()
baseline = _count_batch_processor_threads()
assert baseline >= 1
# trace_disabled wraps load_model/log_model; it must not create or destroy
# the BatchSpanProcessor thread per call.
for _ in range(20):
wrapped()
assert _count_batch_processor_threads() <= baseline
# Tracing is fully restored and still records after the decorated call.
purge_traces()
f()
assert len(get_traces()) == 1
def test_disable_enable_no_data_loss_on_retire(batch_span_processor):
# A span emitted just before enable() rebuilds the provider must still be
# exported: retiring the outgoing processor force_flushes before shutdown.
@mlflow.trace
def f():
return 0
f()
purge_traces()
f()
# Rebuild the provider; the pending span must survive the retire (flush-then-shutdown).
mlflow.tracing.disable()
mlflow.tracing.enable()
assert len(get_traces()) == 1
def test_nested_trace_disabled_restores_tracing(batch_span_processor):
@mlflow.trace
def f():
return 0
@trace_disabled
def inner():
return is_tracing_enabled()
@trace_disabled
def outer():
assert not is_tracing_enabled()
inner_state = inner()
# The inner frame must NOT restore tracing on its own exit: we are still
# inside the outer frame, so tracing must remain disabled.
assert not is_tracing_enabled()
return inner_state
f()
baseline = _count_batch_processor_threads()
assert outer() is False
# Only the outermost exit restores tracing.
assert is_tracing_enabled()
assert _count_batch_processor_threads() <= baseline
purge_traces()
f()
assert len(get_traces()) == 1
def test_trace_disabled_under_concurrency_smoke(batch_span_processor):
# Smoke check that trace_disabled is safe under concurrent use: after many
# overlapping calls tracing is still enabled and no BSP thread leaked. This
# asserts the happy end state; it does not deterministically force the rare
# swap/restore interleaving the depth guard defends against.
@mlflow.trace
def f():
return 0
@trace_disabled
def wrapped():
return 0
f()
baseline = _count_batch_processor_threads()
with ThreadPoolExecutor(max_workers=8, thread_name_prefix="trace-disabled-test") as executor:
futures = [executor.submit(wrapped) for _ in range(200)]
for future in futures:
future.result()
assert is_tracing_enabled()
assert _count_batch_processor_threads() <= baseline
purge_traces()
f()
assert len(get_traces()) == 1
def test_otlp_span_processor_is_retired_on_provider_replace(monkeypatch):
# OtelSpanProcessor subclasses OTel's BatchSpanProcessor directly (not a
# BaseMlflowSpanProcessor), so it must be retired on provider replace too.
from mlflow.tracing.provider import _get_tracer
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "false")
with (
mock.patch("mlflow.tracing.provider.should_use_otlp_exporter", return_value=True),
mock.patch("mlflow.tracing.provider.get_otlp_exporter"),
):
mlflow.tracing.reset()
tracer = _get_tracer("test")
(otel_processor,) = tracer.span_processor._span_processors
assert isinstance(otel_processor, OtelSpanProcessor)
with (
mock.patch.object(otel_processor, "force_flush") as force_flush,
mock.patch.object(otel_processor, "shutdown") as shutdown,
):
# Replacing the provider must flush before it shuts the processor down.
mlflow.tracing.disable()
force_flush.assert_called_once()
shutdown.assert_called_once()
def test_set_experiment_survives_tracing_state_error():
# is_tracing_enabled() is raise_as_trace_exception-wrapped; a tracing error
# must not break set_experiment (issue #24209 review).
mlflow.set_experiment("first")
@mlflow.trace
def f():
return 0
f() # ensure provider.once._done so the preserve-disabled branch runs
with mock.patch(
"mlflow.tracing.provider.is_tracing_enabled",
side_effect=MlflowTracingException("boom"),
):
# Should not raise despite the tracing-state check failing.
mlflow.set_experiment("second")
def test_set_experiment_preserves_explicit_disable():
mlflow.set_experiment("first")
mlflow.tracing.disable()
assert not is_tracing_enabled()
# set_experiment resets the provider to re-derive the destination; it must
# not silently re-enable tracing the user explicitly turned off (issue #24209).
mlflow.set_experiment("second")
assert not is_tracing_enabled()
mlflow.tracing.enable()
assert is_tracing_enabled()
def test_is_tracing_enabled():
# Before doing anything -> tracing is considered as "on"
assert is_tracing_enabled()
# Generate a trace -> tracing is still "on"
@mlflow.trace
def foo():
pass
foo()
assert is_tracing_enabled()
# Disable tracing
mlflow.tracing.disable()
assert is_tracing_enabled() is False
# Try to generate a trace -> tracing is still "off"
foo()
assert is_tracing_enabled() is False
# Re-enable tracing
mlflow.tracing.enable()
assert is_tracing_enabled() is True
# is_tracing_enabled() should only raise MlflowTracingException
with mock.patch(
"mlflow.tracing.provider._get_tracer", side_effect=ValueError("error")
) as get_tracer_mock:
with pytest.raises(MlflowTracingException, match="error"):
assert is_tracing_enabled() is False
assert get_tracer_mock.call_count == 1
@pytest.mark.parametrize("enable_mlflow_tracing", [True, False, None])
def test_enable_mlflow_tracing_switch_in_serving_fluent(monkeypatch, enable_mlflow_tracing):
if enable_mlflow_tracing is None:
monkeypatch.delenv("ENABLE_MLFLOW_TRACING", raising=False)
else:
monkeypatch.setenv("ENABLE_MLFLOW_TRACING", str(enable_mlflow_tracing).lower())
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
@mlflow.trace
def foo():
return 1
request_ids = ["id1", "id2", "id3"]
with mock.patch(
"mlflow.tracing.processor.inference_table.maybe_get_request_id", side_effect=request_ids
):
for _ in range(3):
foo()
if enable_mlflow_tracing:
assert sorted(_TRACE_BUFFER) == request_ids
else:
assert len(_TRACE_BUFFER) == 0
@pytest.mark.parametrize("enable_mlflow_tracing", [True, False])
def test_enable_mlflow_tracing_switch_in_serving_client(monkeypatch, enable_mlflow_tracing):
monkeypatch.setenv("ENABLE_MLFLOW_TRACING", str(enable_mlflow_tracing).lower())
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
def foo():
return bar()
@mlflow.trace
def bar():
return 1
request_ids = ["123", "234"]
with mock.patch(
"mlflow.tracing.processor.inference_table.maybe_get_request_id", side_effect=request_ids
):
span = start_span_no_context("root")
foo()
if enable_mlflow_tracing:
span.end()
if enable_mlflow_tracing:
assert sorted(_TRACE_BUFFER) == request_ids
else:
assert len(_TRACE_BUFFER) == 0
def test_sampling_ratio(monkeypatch):
@mlflow.trace
def test_function():
return "test"
# Test with 100% sampling (default)
for _ in range(10):
test_function()
traces = get_traces()
assert len(traces) == 10
purge_traces()
# Test with 0% sampling
monkeypatch.setenv(MLFLOW_TRACE_SAMPLING_RATIO.name, "0.0")
mlflow.tracing.reset()
for _ in range(10):
test_function()
traces = get_traces()
assert len(traces) == 0
purge_traces()
# With 50% sampling and 100 runs, we expect around 50 traces
# but due to randomness, we check for a reasonable range
monkeypatch.setenv(MLFLOW_TRACE_SAMPLING_RATIO.name, "0.5")
mlflow.tracing.reset()
for _ in range(100):
test_function()
traces = get_traces()
assert 30 <= len(traces) <= 70, (
f"Expected around 50 traces with 0.5 sampling, got {len(traces)}"
)
def test_otlp_exclusive_vs_dual_export_with_no_set_location(monkeypatch):
from mlflow.environment_variables import MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT
from mlflow.tracing.processor.otel import OtelSpanProcessor
from mlflow.tracing.provider import _get_tracer
# Test 1: OTLP exclusive mode (dual export = false, default)
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "false")
with (
mock.patch("mlflow.tracing.provider.should_use_otlp_exporter", return_value=True),
mock.patch("mlflow.tracing.provider.get_otlp_exporter") as mock_get_exporter,
):
mock_get_exporter.return_value = mock.MagicMock()
mlflow.tracing.reset()
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
# Should have only OTLP processor as primary
assert len(processors) == 1
assert isinstance(processors[0], OtelSpanProcessor)
# Test 2: Dual export mode (both MLflow and OTLP)
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "true")
with (
mock.patch("mlflow.tracing.provider.should_use_otlp_exporter", return_value=True),
mock.patch("mlflow.tracing.provider.get_otlp_exporter") as mock_get_exporter,
):
mock_get_exporter.return_value = mock.MagicMock()
mlflow.tracing.reset()
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
# Should have both processors
assert len(processors) == 2
assert isinstance(processors[0], OtelSpanProcessor)
assert isinstance(processors[1], MlflowV3SpanProcessor)
@skip_when_testing_trace_sdk
@pytest.mark.parametrize("dual_export", [False, True])
def test_metrics_export_with_otlp_trace_export(monkeypatch, dual_export):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:4317")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://localhost:9090")
if dual_export:
monkeypatch.setenv(MLFLOW_TRACE_ENABLE_OTLP_DUAL_EXPORT.name, "true")
mlflow.tracing.reset()
tracer = _get_tracer("test")
if dual_export:
processors = tracer.span_processor._span_processors
assert len(processors) == 2
assert isinstance(processors[0], OtelSpanProcessor)
assert isinstance(processors[1], MlflowV3SpanProcessor)
# In dual export, MLflow processor exports metrics, OTLP doesn't
assert processors[0]._export_metrics is False
assert processors[1]._export_metrics is True
else:
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], OtelSpanProcessor)
assert processors[0]._export_metrics is True
@skip_when_testing_trace_sdk
def test_metrics_export_without_otlp_trace_export(monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://localhost:9090")
# No OTLP tracing endpoints set
monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
mlflow.tracing.reset()
tracer = _get_tracer("test")
processors = tracer.span_processor._span_processors
assert len(processors) == 1
assert isinstance(processors[0], MlflowV3SpanProcessor)
assert processors[0]._export_metrics is True
def test_otel_resource_attributes(monkeypatch):
def resource_attributes(tracer):
# opentelemetry-sdk 1.43.0+ auto-injects a random `service.instance.id` when it builds
# the resource from env vars. It is not deterministic, so drop it before comparing.
attributes = dict(tracer.resource.attributes)
attributes.pop("service.instance.id", None)
return attributes
tracer = _get_tracer("test")
# By default, only MLflow's SDK attributes are set on an empty resource
assert resource_attributes(tracer) == {
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "mlflow",
"telemetry.sdk.version": mlflow.__version__,
}
mlflow.tracing.reset()
# When otel attributes are set explicitly, they are merged into the resource
# alongside MLflow's SDK attributes.
monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "favorite.fruit=apple,color=red")
tracer = _get_tracer("test")
assert resource_attributes(tracer) == {
"favorite.fruit": "apple",
"color": "red",
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "mlflow",
"telemetry.sdk.version": mlflow.__version__,
"service.name": "unknown_service",
}
# Service name should be propagated from the env var
mlflow.tracing.reset()
monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service")
monkeypatch.delenv("OTEL_RESOURCE_ATTRIBUTES", raising=False)
tracer = _get_tracer("test")
assert resource_attributes(tracer) == {
"service.name": "test-service",
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "mlflow",
"telemetry.sdk.version": mlflow.__version__,
}
# Invalid env var should be ignored and does not block the tracer provider initialization
mlflow.tracing.reset()
monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "invalid")
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
tracer = _get_tracer("test")
assert resource_attributes(tracer) == {
"service.name": "unknown_service",
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "mlflow",
"telemetry.sdk.version": mlflow.__version__,
}
def test_isolated_random_id_generator_not_affected_by_random_seed(monkeypatch):
monkeypatch.setenv("MLFLOW_TRACE_USE_ISOLATED_RANDOM_ID_GENERATOR", "true")
mlflow.tracing.reset()
_initialize_tracer_provider()
rng_state = random.getstate()
try:
random.seed(42)
span1 = start_span_in_context("test1")
trace_id_1 = span1.get_span_context().trace_id
span_id_1 = span1.get_span_context().span_id
span1.end()
# Re-seeding with the same value would make RandomIdGenerator replay the exact same
# ID sequence. _IsolatedRandomIdGenerator must be immune to this.
random.seed(42)
span2 = start_span_in_context("test2")
trace_id_2 = span2.get_span_context().trace_id
span_id_2 = span2.get_span_context().span_id
span2.end()
finally:
random.setstate(rng_state)
assert trace_id_1 != trace_id_2
assert span_id_1 != span_id_2
def test_tracer_provider_uses_isolated_random_id_generator_when_env_var_set(monkeypatch):
# Ensure env var is unset so the default id generator is used
monkeypatch.delenv("MLFLOW_TRACE_USE_ISOLATED_RANDOM_ID_GENERATOR", raising=False)
# Default: OTel's RandomIdGenerator is used
mlflow.tracing.reset()
_initialize_tracer_provider()
tracer_provider = _provider_wrapper.get()
assert isinstance(tracer_provider.id_generator, RandomIdGenerator)
# Opt-in: _IsolatedRandomIdGenerator is used when the env var is set
monkeypatch.setenv("MLFLOW_TRACE_USE_ISOLATED_RANDOM_ID_GENERATOR", "true")
mlflow.tracing.reset()
_initialize_tracer_provider()
tracer_provider = _provider_wrapper.get()
assert isinstance(tracer_provider.id_generator, _IsolatedRandomIdGenerator)
def test_set_destination_from_env_var_databricks_uc_with_table_prefix_rejected(monkeypatch):
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "catalog.schema.prefix")
from mlflow.tracing.provider import _MLFLOW_TRACE_USER_DESTINATION
with pytest.raises(
MlflowException,
match=r"Unity Catalog table-prefix destinations "
r"\(<catalog_name>\.<schema_name>\.<table_prefix>\) are not supported in "
r"MLFLOW_TRACING_DESTINATION.*Use `mlflow\.set_experiment",
):
_MLFLOW_TRACE_USER_DESTINATION.get()
def test_set_destination_from_env_var_databricks_uc_with_table_prefix_rejected_on_init(
monkeypatch,
):
mlflow.tracing.reset()
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "catalog.schema.prefix")
with pytest.raises(
MlflowException,
match=r"Unity Catalog table-prefix destinations "
r"\(<catalog_name>\.<schema_name>\.<table_prefix>\) are not supported in "
r"MLFLOW_TRACING_DESTINATION.*Use `mlflow\.set_experiment",
):
_get_tracer("test")
def test_destination_resolution_precedence(monkeypatch):
from mlflow.tracing.provider import _MLFLOW_TRACE_USER_DESTINATION
_MLFLOW_TRACE_USER_DESTINATION.reset()
monkeypatch.setenv("MLFLOW_TRACING_DESTINATION", "catalog.schema")
# Env fallback is lowest priority.
destination = _MLFLOW_TRACE_USER_DESTINATION.get()
assert isinstance(destination, UCSchemaLocation)
# Global slot wins over env.
global_destination = UnityCatalog("catalog", "schema", table_prefix="global")
_MLFLOW_TRACE_USER_DESTINATION.set(global_destination)
assert _MLFLOW_TRACE_USER_DESTINATION.get().table_prefix == "global"
# Context-local wins over global.
local_destination = UnityCatalog("catalog", "schema", table_prefix="local")
_MLFLOW_TRACE_USER_DESTINATION.set(local_destination, context_local=True)
assert _MLFLOW_TRACE_USER_DESTINATION.get().table_prefix == "local"
_MLFLOW_TRACE_USER_DESTINATION.reset()
def _experiment(tags=None):
from mlflow.entities import Experiment
from mlflow.entities.experiment_tag import ExperimentTag
tag_entities = [ExperimentTag(k, v) for k, v in (tags or {}).items()] if tags else []
return Experiment(
experiment_id="123",
name="test",
artifact_location="file:/tmp",
lifecycle_stage="active",
tags=tag_entities,
)
def test_resolve_uc_location_from_experiment_tag():
from mlflow.tracing.provider import _resolve_experiment_uc_location
mlflow.tracing.reset()
with (
mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="databricks",
),
mock.patch("mlflow.tracking.fluent._get_experiment_id", return_value="123"),
mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store_fn,
):
mock_store_fn.return_value.get_experiment.return_value = _experiment(
tags={MLFLOW_EXPERIMENT_DATABRICKS_TRACE_DESTINATION_PATH: "cat.sch.pfx"}
)
result = _resolve_experiment_uc_location()
assert result == UnityCatalog("cat", "sch", table_prefix="pfx")
mlflow.tracing.reset()
def test_resolve_uc_location_includes_table_names_from_tags():
from mlflow.tracing.provider import _resolve_experiment_uc_location
mlflow.tracing.reset()
with (
mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="databricks",
),
mock.patch("mlflow.tracking.fluent._get_experiment_id", return_value="123"),
mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store_fn,
):
mock_store_fn.return_value.get_experiment.return_value = _experiment(
tags={
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_DESTINATION_PATH: "cat.sch.pfx",
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_SPAN_STORAGE_TABLE: "cat.sch.pfx_otel_spans",
MLFLOW_EXPERIMENT_DATABRICKS_TRACE_LOG_STORAGE_TABLE: "cat.sch.pfx_otel_logs",
}
)
result = _resolve_experiment_uc_location()
assert result == UnityCatalog("cat", "sch", table_prefix="pfx")
assert result._otel_spans_table_name == "cat.sch.pfx_otel_spans"
assert result._otel_logs_table_name == "cat.sch.pfx_otel_logs"
mlflow.tracing.reset()
def test_resolve_uc_location_returns_none_for_2_part_path():
from mlflow.tracing.provider import _resolve_experiment_uc_location
mlflow.tracing.reset()
with (
mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="databricks",
),
mock.patch("mlflow.tracking.fluent._get_experiment_id", return_value="123"),
mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store_fn,
):
mock_store_fn.return_value.get_experiment.return_value = _experiment(
tags={MLFLOW_EXPERIMENT_DATABRICKS_TRACE_DESTINATION_PATH: "cat.sch"}
)
assert _resolve_experiment_uc_location() is None
mlflow.tracing.reset()
def test_resolve_uc_location_returns_none_when_no_tag():
from mlflow.tracing.provider import _resolve_experiment_uc_location
mlflow.tracing.reset()
with (
mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="databricks",
),
mock.patch("mlflow.tracking.fluent._get_experiment_id", return_value="123"),
mock.patch("mlflow.tracking._tracking_service.utils._get_store") as mock_store_fn,
):
mock_store_fn.return_value.get_experiment.return_value = _experiment()
assert _resolve_experiment_uc_location() is None
mlflow.tracing.reset()
def test_resolve_uc_location_returns_none_for_non_databricks():
from mlflow.tracing.provider import _resolve_experiment_uc_location
mlflow.tracing.reset()
with mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="http://local",
):
assert _resolve_experiment_uc_location() is None
mlflow.tracing.reset()
def test_get_tracer_does_not_fail_when_experiment_id_resolution_fails():
mlflow.tracing.reset()
with (
mock.patch(
"mlflow.tracing.provider.mlflow.get_tracking_uri",
return_value="databricks",
),
mock.patch("mlflow.tracking.fluent._get_experiment_id", side_effect=RuntimeError("boom")),
):
tracer = _get_tracer("test")
assert tracer is not None
mlflow.tracing.reset()