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"\(\.\.\) 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"\(\.\.\) 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()