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

268 lines
9.3 KiB
Python

import logging
import pytest
import mlflow
from mlflow.entities import SpanLogLevel
from mlflow.entities.span import Span, SpanType
from mlflow.entities.span_event import SpanEvent
from mlflow.exceptions import MlflowException
from mlflow.tracing.constant import SpanAttributeKey
from mlflow.tracing.utils.default_log_level import default_log_level_for_span_type
from tests.tracing.helper import get_traces
@pytest.mark.parametrize(
("value", "expected"),
[
(SpanLogLevel.DEBUG, SpanLogLevel.DEBUG),
(SpanLogLevel.CRITICAL, SpanLogLevel.CRITICAL),
("DEBUG", SpanLogLevel.DEBUG),
("info", SpanLogLevel.INFO),
("Warning", SpanLogLevel.WARNING),
(" ERROR ", SpanLogLevel.ERROR),
],
)
def test_from_value_accepts_enum_and_string_forms(value, expected):
assert SpanLogLevel.from_value(value) is expected
@pytest.mark.parametrize("value", ["NOPE", "TRACE", "FATAL", "INFOO", "", "WARN", "warn"])
def test_from_value_rejects_invalid_string(value):
# "WARN" is not a valid alias; only the full names are accepted.
with pytest.raises(MlflowException, match="Invalid SpanLogLevel"):
SpanLogLevel.from_value(value)
@pytest.mark.parametrize("value", [0, 7, 10, 20, 30, 40, 50, 100, -1, logging.INFO])
def test_from_value_rejects_int(value):
# Raw integers — including the canonical 10/20/30/40/50 and `logging.*` —
# are rejected: the API surface is `SpanLogLevel | str` only. Use the enum
# member or its name string instead.
with pytest.raises(MlflowException, match="must be"):
SpanLogLevel.from_value(value)
@pytest.mark.parametrize("value", [None, 1.5, ["INFO"], object(), True, False])
def test_from_value_rejects_invalid_type(value):
with pytest.raises(MlflowException, match="must be"):
SpanLogLevel.from_value(value)
def test_log_level_constructor_default_for_unknown_span():
# No span_type provided -> defaults to UNKNOWN -> DEBUG via the constructor.
with mlflow.start_span("s"):
pass
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.DEBUG
assert persisted.attributes[SpanAttributeKey.LOG_LEVEL] == int(SpanLogLevel.DEBUG)
def test_log_level_constructor_default_for_info_span_type():
# CHAT_MODEL is in the INFO set -> constructor stamps INFO automatically.
with mlflow.start_span("s", span_type=SpanType.CHAT_MODEL):
pass
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.INFO
@pytest.mark.parametrize(
("set_value", "expected"),
[
(SpanLogLevel.WARNING, SpanLogLevel.WARNING),
(SpanLogLevel.ERROR, SpanLogLevel.ERROR),
("info", SpanLogLevel.INFO),
("CRITICAL", SpanLogLevel.CRITICAL),
],
)
def test_set_log_level_normalizes_input(set_value, expected):
with mlflow.start_span("s") as span:
span.set_log_level(set_value)
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is expected
# Stored as the raw int under the reserved attribute key for portability.
assert persisted.attributes[SpanAttributeKey.LOG_LEVEL] == int(expected)
def test_set_log_level_rejects_invalid_input():
with (
mlflow.start_span("s") as span,
pytest.raises(MlflowException, match="Invalid SpanLogLevel"),
):
span.set_log_level("NOPE")
def test_start_span_kwarg_overrides_constructor_default():
# CHAT_MODEL would default to INFO; explicit kwarg should win.
with mlflow.start_span("s", span_type=SpanType.CHAT_MODEL, log_level="WARNING"):
pass
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.WARNING
def test_start_span_no_context_kwarg():
span = mlflow.start_span_no_context("s", log_level=SpanLogLevel.ERROR)
span.end()
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.ERROR
def test_trace_decorator_kwarg_sync():
@mlflow.trace(log_level="DEBUG")
def fn(x):
return x + 1
fn(1)
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.DEBUG
@pytest.mark.asyncio
async def test_trace_decorator_kwarg_async():
@mlflow.trace(log_level=SpanLogLevel.INFO)
async def fn(x):
return x + 1
await fn(1)
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.INFO
def test_trace_decorator_kwarg_generator():
@mlflow.trace(log_level="WARNING")
def gen():
yield 1
yield 2
list(gen())
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.WARNING
def test_log_level_round_trips_through_to_dict_from_dict():
with mlflow.start_span("s", log_level=SpanLogLevel.ERROR):
pass
persisted = get_traces()[0].data.spans[0]
rebuilt = Span.from_dict(persisted.to_dict())
assert rebuilt.log_level is SpanLogLevel.ERROR
@pytest.mark.parametrize(
("span_type", "expected"),
[
# INFO set: user-visible semantic operations.
(SpanType.LLM, SpanLogLevel.INFO),
(SpanType.CHAT_MODEL, SpanLogLevel.INFO),
(SpanType.AGENT, SpanLogLevel.INFO),
(SpanType.TOOL, SpanLogLevel.INFO),
(SpanType.RETRIEVER, SpanLogLevel.INFO),
(SpanType.EMBEDDING, SpanLogLevel.INFO),
# DEBUG set: internal/glue work and unclassified types.
(SpanType.CHAIN, SpanLogLevel.DEBUG),
(SpanType.PARSER, SpanLogLevel.DEBUG),
(SpanType.RERANKER, SpanLogLevel.DEBUG),
(SpanType.MEMORY, SpanLogLevel.DEBUG),
(SpanType.WORKFLOW, SpanLogLevel.DEBUG),
(SpanType.TASK, SpanLogLevel.DEBUG),
(SpanType.GUARDRAIL, SpanLogLevel.DEBUG),
(SpanType.EVALUATOR, SpanLogLevel.DEBUG),
(SpanType.UNKNOWN, SpanLogLevel.DEBUG),
# Custom (non-built-in) span types fall through to DEBUG.
("MY_CUSTOM_TYPE", SpanLogLevel.DEBUG),
(None, SpanLogLevel.DEBUG),
],
)
def test_default_log_level_for_span_type_mapping(span_type, expected):
assert default_log_level_for_span_type(span_type) is expected
def test_constructor_stamps_default_for_info_span_type():
span = mlflow.start_span_no_context("s", span_type=SpanType.CHAT_MODEL)
span.end()
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.INFO
def test_constructor_stamps_default_for_debug_span_type():
span = mlflow.start_span_no_context("s", span_type=SpanType.PARSER)
span.end()
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.DEBUG
def test_multi_span_trace_carries_per_span_levels():
@mlflow.trace(log_level="DEBUG", name="inner")
def inner():
return 1
@mlflow.trace(log_level="WARNING", name="root")
def root():
return inner()
root()
spans = {s.name: s for s in get_traces()[0].data.spans}
assert spans["root"].log_level is SpanLogLevel.WARNING
assert spans["inner"].log_level is SpanLogLevel.DEBUG
# ---- Exception → ERROR bump --------------------------------------------------
def test_exception_event_bumps_debug_span_to_error():
# PARSER defaults to DEBUG via the constructor; an exception event should
# promote it to ERROR so users with the filter at INFO/WARNING still see it.
with mlflow.start_span("s", span_type=SpanType.PARSER) as span:
span.add_event(SpanEvent.from_exception(ValueError("boom")))
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.ERROR
def test_exception_event_bumps_info_span_to_error():
with mlflow.start_span("s", span_type=SpanType.CHAT_MODEL) as span:
span.add_event(SpanEvent.from_exception(RuntimeError("boom")))
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.ERROR
def test_exception_event_does_not_demote_critical():
# User-set CRITICAL must be preserved when an exception fires.
with mlflow.start_span("s", log_level=SpanLogLevel.CRITICAL) as span:
span.add_event(SpanEvent.from_exception(RuntimeError("boom")))
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.CRITICAL
def test_record_exception_bumps_to_error():
# record_exception() goes through add_event under the hood, so the bump
# should fire here too.
span = mlflow.start_span_no_context("s", span_type=SpanType.PARSER)
span.record_exception(ValueError("boom"))
span.end()
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.ERROR
def test_traced_function_that_raises_is_promoted_to_error():
# A plain @mlflow.trace function that throws records an exception event via
# the decorator's error-handling path, which should promote the span.
@mlflow.trace
def fn():
raise ValueError("boom")
with pytest.raises(ValueError, match="boom"):
fn()
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.ERROR
def test_non_exception_event_does_not_bump_log_level():
# Plain (non-exception) events must not move the level.
with mlflow.start_span("s", span_type=SpanType.CHAT_MODEL) as span:
span.add_event(SpanEvent(name="my_event", attributes={"k": "v"}))
persisted = get_traces()[0].data.spans[0]
assert persisted.log_level is SpanLogLevel.INFO