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

611 lines
22 KiB
Python

import json
import uuid
from unittest.mock import Mock, patch
import pytest
from opentelemetry import trace as trace_api
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
import mlflow
from mlflow.entities.span import create_mlflow_span
from mlflow.entities.trace import Trace
from mlflow.entities.trace_data import TraceData
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_location import TraceLocation
from mlflow.entities.trace_state import TraceState
from mlflow.environment_variables import (
MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS,
MLFLOW_GET_TRACE_OTEL_MAX_RETRY_INTERVAL_SECONDS,
MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS,
MLFLOW_TRACING_SQL_WAREHOUSE_ID,
)
from mlflow.exceptions import MlflowException, MlflowNotImplementedException
from mlflow.store.tracking import SEARCH_TRACES_DEFAULT_MAX_RESULTS
from mlflow.tracing.analysis import TraceFilterCorrelationResult
from mlflow.tracing.client import TracingClient
from mlflow.tracing.constant import SpansLocation, TraceMetadataKey, TraceSizeStatsKey, TraceTagKey
from mlflow.tracing.utils import TraceJSONEncoder
from tests.tracing.helper import skip_when_testing_trace_sdk
def test_get_trace_v4():
mock_store = Mock()
mock_store.batch_get_traces.return_value = ["dummy_trace"]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
trace = client.get_trace("trace:/catalog.schema/1234567890")
assert trace == "dummy_trace"
mock_store.batch_get_traces.assert_called_once_with(
["trace:/catalog.schema/1234567890"], "catalog.schema"
)
def test_get_trace_v4_retry():
mock_store = Mock()
mock_store.batch_get_traces.side_effect = [[], ["dummy_trace"]]
with (
patch("mlflow.tracing.client._get_store", return_value=mock_store),
patch("mlflow.tracing.client.time.sleep"),
):
client = TracingClient()
trace = client.get_trace("trace:/catalog.schema/1234567890")
assert trace == "dummy_trace"
assert mock_store.batch_get_traces.call_count == 2
def test_get_trace_v4_retry_timeout_exhausted(monkeypatch):
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS.name, "0")
mock_store = Mock()
mock_store.batch_get_traces.return_value = []
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with pytest.raises(MlflowException, match="is not found"):
client.get_trace("trace:/catalog.schema/abc")
assert mock_store.batch_get_traces.call_count == 1
def test_get_trace_v4_retry_uses_initial_interval(monkeypatch):
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS.name, "0.25")
mock_store = Mock()
mock_store.batch_get_traces.side_effect = [[], ["dummy_trace"]]
with (
patch("mlflow.tracing.client._get_store", return_value=mock_store),
patch("mlflow.tracing.client.time.sleep") as mock_sleep,
):
client = TracingClient()
trace = client.get_trace("trace:/catalog.schema/abc")
assert trace == "dummy_trace"
mock_sleep.assert_called_once_with(0.25)
def test_get_trace_v4_retry_caps_max_interval(monkeypatch):
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS.name, "60")
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS.name, "1")
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_MAX_RETRY_INTERVAL_SECONDS.name, "3")
mock_store = Mock()
mock_store.batch_get_traces.side_effect = [[], [], [], [], ["dummy_trace"]]
with (
patch("mlflow.tracing.client._get_store", return_value=mock_store),
patch("mlflow.tracing.client.time.sleep") as mock_sleep,
):
client = TracingClient()
trace = client.get_trace("trace:/catalog.schema/abc")
assert trace == "dummy_trace"
assert [call.args[0] for call in mock_sleep.call_args_list] == [1, 2, 3, 3]
def test_get_trace_v4_retry_clamps_sleep_to_deadline(monkeypatch):
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS.name, "5")
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS.name, "10")
monkeypatch.setenv(MLFLOW_GET_TRACE_OTEL_MAX_RETRY_INTERVAL_SECONDS.name, "20")
mock_store = Mock()
mock_store.batch_get_traces.return_value = []
clock = [1000.0]
def fake_time():
return clock[0]
def fake_sleep(seconds):
clock[0] += seconds
with (
patch("mlflow.tracing.client._get_store", return_value=mock_store),
patch("mlflow.tracing.client.time.monotonic", side_effect=fake_time),
patch("mlflow.tracing.client.time.sleep", side_effect=fake_sleep) as mock_sleep,
):
client = TracingClient()
with pytest.raises(MlflowException, match="is not found"):
client.get_trace("trace:/catalog.schema/abc")
assert [call.args[0] for call in mock_sleep.call_args_list] == [5]
def test_batch_get_traces():
mock_store = Mock()
mock_store.batch_get_traces.return_value = ["trace1", "trace2"]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
traces = client.batch_get_traces(["id1", "id2"], location="catalog.schema")
assert traces == ["trace1", "trace2"]
mock_store.batch_get_traces.assert_called_once_with(["id1", "id2"], "catalog.schema")
def test_batch_get_traces_without_location():
mock_store = Mock()
trace_info = TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={TraceTagKey.SPANS_LOCATION: SpansLocation.TRACKING_STORE},
)
mock_store.batch_get_trace_infos.return_value = [trace_info]
mock_store.batch_get_traces.return_value = ["trace1"]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
traces = client.batch_get_traces(["id1"])
assert traces == ["trace1"]
mock_store.batch_get_trace_infos.assert_called_once_with(["id1"])
mock_store.batch_get_traces.assert_called_once_with(["id1"], None)
def test_batch_get_traces_without_location_for_archive_repo():
mock_store = Mock()
archived_trace = Trace(
info=TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={
TraceTagKey.SPANS_LOCATION: SpansLocation.ARCHIVE_REPO,
TraceTagKey.ARCHIVE_LOCATION: "s3://bucket/archive/id1",
},
),
data=TraceData(spans=[]),
)
mock_store.batch_get_trace_infos.return_value = [archived_trace.info]
mock_store.batch_get_traces.return_value = [archived_trace]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch.object(TracingClient, "_download_spans_from_artifact_repo") as mock_download:
traces = client.batch_get_traces(["id1"])
assert traces == [archived_trace]
mock_store.batch_get_trace_infos.assert_called_once_with(["id1"])
mock_store.batch_get_traces.assert_called_once_with(["id1"], None)
mock_download.assert_not_called()
def test_get_trace_for_archive_repo_uses_store_get_trace():
mock_store = Mock()
archived_trace = Trace(
info=TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={
TraceTagKey.SPANS_LOCATION: SpansLocation.ARCHIVE_REPO,
TraceTagKey.ARCHIVE_LOCATION: "s3://bucket/archive/id1",
},
),
data=TraceData(spans=[]),
)
mock_store.get_trace_info.return_value = archived_trace.info
mock_store.get_trace.return_value = archived_trace
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch.object(client, "_download_trace_data") as mock_download:
trace = client.get_trace("id1")
assert trace == archived_trace
mock_store.get_trace_info.assert_called_once_with("id1")
mock_store.get_trace.assert_called_once_with("id1")
mock_store.batch_get_traces.assert_not_called()
mock_download.assert_not_called()
def test_get_trace_for_archive_repo_falls_back_to_batch_get_traces():
mock_store = Mock()
archived_trace = Trace(
info=TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={
TraceTagKey.SPANS_LOCATION: SpansLocation.ARCHIVE_REPO,
TraceTagKey.ARCHIVE_LOCATION: "s3://bucket/archive/id1",
},
),
data=TraceData(spans=[]),
)
mock_store.get_trace_info.return_value = archived_trace.info
mock_store.get_trace.side_effect = MlflowNotImplementedException()
mock_store.batch_get_traces.return_value = [archived_trace]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch.object(client, "_download_trace_data") as mock_download:
trace = client.get_trace("id1")
assert trace == archived_trace
mock_store.get_trace_info.assert_called_once_with("id1")
mock_store.get_trace.assert_called_once_with("id1")
mock_store.batch_get_traces.assert_called_once_with(["id1"])
mock_download.assert_not_called()
def test_get_trace_for_artifact_repo_downloads_client_side():
mock_store = Mock()
trace_info = TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={
TraceTagKey.SPANS_LOCATION: SpansLocation.ARTIFACT_REPO,
},
)
artifact_trace_data = TraceData(spans=[])
mock_store.get_trace_info.return_value = trace_info
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch.object(
client, "_download_trace_data", return_value=artifact_trace_data
) as mock_download:
trace = client.get_trace("id1")
assert trace == Trace(trace_info, artifact_trace_data)
mock_store.batch_get_traces.assert_not_called()
mock_store.get_trace.assert_not_called()
mock_download.assert_called_once_with(trace_info)
def test_batch_get_traces_skips_traces_with_missing_location_metadata():
mock_store = Mock()
trace_info = TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={},
)
mock_store.batch_get_trace_infos.return_value = [trace_info]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch("mlflow.tracing.client._logger") as mock_logger:
mock_logger.isEnabledFor.return_value = False
traces = client.batch_get_traces(["id1"])
assert traces == []
mock_store.batch_get_traces.assert_not_called()
mock_logger.warning.assert_called_once()
def test_batch_get_traces_for_archive_repo_does_not_require_archive_location_tag():
mock_store = Mock()
archived_trace = Trace(
info=TraceInfo(
trace_id="id1",
trace_location=TraceLocation.from_experiment_id("0"),
request_time=1000,
state=TraceState.OK,
tags={TraceTagKey.SPANS_LOCATION: SpansLocation.ARCHIVE_REPO},
),
data=TraceData(spans=[]),
)
mock_store.batch_get_trace_infos.return_value = [archived_trace.info]
mock_store.batch_get_traces.return_value = [archived_trace]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
with patch.object(TracingClient, "_download_spans_from_artifact_repo") as mock_download:
traces = client.batch_get_traces(["id1"])
assert traces == [archived_trace]
mock_store.batch_get_trace_infos.assert_called_once_with(["id1"])
mock_store.batch_get_traces.assert_called_once_with(["id1"], None)
mock_download.assert_not_called()
def test_batch_get_traces_empty():
mock_store = Mock()
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
traces = client.batch_get_traces([])
assert traces == []
mock_store.batch_get_trace_infos.assert_not_called()
@skip_when_testing_trace_sdk
def test_batch_get_traces_with_artifact_repo_traces():
# Create a trace with spans in the tracking store
with mlflow.start_span("tracking_span"):
tracking_trace_id = mlflow.get_active_trace_id()
# Create a trace without spans (artifact-repo case)
experiment_id = mlflow.tracking.fluent._get_experiment_id()
artifact_trace_id = f"tr-{uuid.uuid4().hex}"
store = mlflow.tracking.MlflowClient()._tracking_client.store
store.start_trace(
TraceInfo(
trace_id=artifact_trace_id,
trace_location=TraceLocation.from_experiment_id(experiment_id),
request_time=1000,
execution_duration=500,
state=TraceState.OK,
)
)
# Mock _download_spans_from_artifact_repo since there's no real artifact data
artifact_trace = Trace(
info=store.get_trace_info(artifact_trace_id),
data=TraceData(spans=[]),
)
mlflow.flush_trace_async_logging()
with patch.object(
TracingClient, "_download_spans_from_artifact_repo", return_value=artifact_trace
) as mock_download:
client = TracingClient()
traces = client.batch_get_traces([tracking_trace_id, artifact_trace_id])
assert len(traces) == 2
trace_ids = {t.info.trace_id for t in traces}
assert tracking_trace_id in trace_ids
assert artifact_trace_id in trace_ids
mock_download.assert_called_once()
def test_batch_get_traces_fallback_when_batch_get_trace_infos_not_implemented():
mock_store = Mock()
mock_store.batch_get_trace_infos.side_effect = MlflowNotImplementedException()
mock_store.batch_get_traces.return_value = ["trace1"]
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
traces = client.batch_get_traces(["id1"])
assert traces == ["trace1"]
mock_store.batch_get_traces.assert_called_once_with(["id1"], None)
@skip_when_testing_trace_sdk
def test_tracing_client_link_prompt_versions_to_trace():
with mlflow.start_run():
# Register a prompt
prompt_version = mlflow.register_prompt(name="test_prompt", template="Hello, {{name}}!")
# Create a trace
with mlflow.start_span("test_span"):
trace_id = mlflow.get_active_trace_id()
# Link prompts to trace
client = TracingClient()
client.link_prompt_versions_to_trace(trace_id, [prompt_version])
# Verify the linked prompts tag was set
trace = mlflow.get_trace(trace_id, flush=True)
assert "mlflow.linkedPrompts" in trace.info.tags
# Parse and verify the linked prompts
linked_prompts = json.loads(trace.info.tags["mlflow.linkedPrompts"])
assert len(linked_prompts) == 1
assert linked_prompts[0]["name"] == "test_prompt"
assert linked_prompts[0]["version"] == "1"
def test_tracing_client_calculate_trace_filter_correlation():
mock_store = Mock()
expected_result = TraceFilterCorrelationResult(
npmi=0.456,
npmi_smoothed=0.445,
filter1_count=100,
filter2_count=80,
joint_count=50,
total_count=200,
)
mock_store.calculate_trace_filter_correlation.return_value = expected_result
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
result = client.calculate_trace_filter_correlation(
experiment_ids=["123", "456"],
filter_string1="span.type = 'LLM'",
filter_string2="feedback.quality > 0.8",
base_filter="request_time > 1000",
)
mock_store.calculate_trace_filter_correlation.assert_called_once_with(
experiment_ids=["123", "456"],
filter_string1="span.type = 'LLM'",
filter_string2="feedback.quality > 0.8",
base_filter="request_time > 1000",
)
assert result == expected_result
assert result.npmi == 0.456
assert result.npmi_smoothed == 0.445
assert result.filter1_count == 100
assert result.filter2_count == 80
assert result.joint_count == 50
assert result.total_count == 200
def test_tracing_client_calculate_trace_filter_correlation_without_base_filter():
mock_store = Mock()
expected_result = TraceFilterCorrelationResult(
npmi=float("nan"),
npmi_smoothed=None,
filter1_count=0,
filter2_count=0,
joint_count=0,
total_count=100,
)
mock_store.calculate_trace_filter_correlation.return_value = expected_result
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
result = client.calculate_trace_filter_correlation(
experiment_ids=["789"],
filter_string1="error = true",
filter_string2="duration > 5000",
)
mock_store.calculate_trace_filter_correlation.assert_called_once_with(
experiment_ids=["789"],
filter_string1="error = true",
filter_string2="duration > 5000",
base_filter=None,
)
assert result == expected_result
assert result.filter1_count == 0
assert result.filter2_count == 0
assert result.joint_count == 0
assert result.total_count == 100
@pytest.mark.parametrize("sql_warehouse_id", [None, "some-warehouse-id"])
def test_tracing_client_search_traces_with_model_id(monkeypatch, sql_warehouse_id: str | None):
if sql_warehouse_id:
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, sql_warehouse_id)
else:
monkeypatch.delenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, raising=False)
mock_store = Mock()
mock_store.search_traces.return_value = ([], None)
with patch("mlflow.tracing.client._get_store", return_value=mock_store):
client = TracingClient()
client.search_traces(model_id="model_id")
mock_store.search_traces.assert_called_once_with(
experiment_ids=None,
filter_string="request_metadata.`mlflow.modelId` = 'model_id'"
if sql_warehouse_id is None
else None,
max_results=SEARCH_TRACES_DEFAULT_MAX_RESULTS,
order_by=None,
page_token=None,
model_id="model_id" if sql_warehouse_id else None,
locations=None,
)
@skip_when_testing_trace_sdk
def test_tracing_client_get_trace_with_database_stored_spans():
client = TracingClient()
experiment_id = mlflow.create_experiment("test")
trace_id = f"tr-{uuid.uuid4().hex}"
store = client.store
otel_span = OTelReadableSpan(
name="test_span",
context=trace_api.SpanContext(
trace_id=12345,
span_id=111,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
),
parent=None,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id, cls=TraceJSONEncoder),
"llm.model_name": "test-model",
"custom.attribute": "test-value",
},
start_time=1_000_000_000,
end_time=2_000_000_000,
resource=None,
)
span = create_mlflow_span(otel_span, trace_id, "LLM")
store.log_spans(experiment_id, [span])
trace = client.get_trace(trace_id)
assert trace.info.trace_id == trace_id
assert trace.info.tags.get(TraceTagKey.SPANS_LOCATION) == SpansLocation.TRACKING_STORE
assert len(trace.data.spans) == 1
loaded_span = trace.data.spans[0]
assert loaded_span.name == "test_span"
assert loaded_span.trace_id == trace_id
assert loaded_span.start_time_ns == 1_000_000_000
assert loaded_span.end_time_ns == 2_000_000_000
assert loaded_span.attributes.get("llm.model_name") == "test-model"
assert loaded_span.attributes.get("custom.attribute") == "test-value"
@skip_when_testing_trace_sdk
def test_tracing_client_get_trace_error_handling():
client = TracingClient()
experiment_id = mlflow.create_experiment("test")
trace_id = f"tr-{uuid.uuid4().hex}"
store = client.store
otel_span = OTelReadableSpan(
name="test_span",
context=trace_api.SpanContext(
trace_id=12345,
span_id=111,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
),
parent=None,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id, cls=TraceJSONEncoder),
"llm.model_name": "test-model",
"custom.attribute": "test-value",
},
start_time=1_000_000_000,
end_time=2_000_000_000,
resource=None,
)
span = create_mlflow_span(otel_span, trace_id, "LLM")
store.log_spans(experiment_id, [span])
trace = client.get_trace(trace_id)
trace_info = trace.info
trace_info.trace_metadata[TraceMetadataKey.SIZE_STATS] = json.dumps({
TraceSizeStatsKey.NUM_SPANS: 2
})
store.start_trace(trace_info)
with pytest.raises(
MlflowException, match=rf"Trace with ID {trace_id} is not fully exported yet"
):
client.get_trace(trace_id)