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

1255 lines
45 KiB
Python

import json
from datetime import datetime
from unittest import mock
import opentelemetry.trace as trace_api
import pytest
from opentelemetry.proto.resource.v1.resource_pb2 import Resource as OTelProtoResource
from opentelemetry.proto.trace.v1.trace_pb2 import Span as OTelProtoSpan
from opentelemetry.proto.trace.v1.trace_pb2 import Status as OTelProtoStatus
from opentelemetry.sdk.resources import Resource as OTelResource
from opentelemetry.sdk.trace import Event as OTelEvent
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
from opentelemetry.trace import Status as OTelStatus
from opentelemetry.trace import StatusCode as OTelStatusCode
import mlflow
from mlflow.entities import LiveSpan, Span, SpanEvent, SpanStatus, SpanStatusCode, SpanType
from mlflow.entities.span import (
NoOpSpan,
create_mlflow_span,
)
from mlflow.exceptions import MlflowException
from mlflow.tracing.constant import TRACE_ID_V4_PREFIX
from mlflow.tracing.provider import _get_tracer, trace_disabled
from mlflow.tracing.utils import build_otel_context, encode_span_id, encode_trace_id
from mlflow.tracing.utils.otlp import (
_decode_otel_proto_anyvalue,
_set_otel_proto_anyvalue,
resource_to_otel_proto,
)
def test_create_live_span():
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("parent") as parent_span:
span = create_mlflow_span(parent_span, trace_id=trace_id, span_type=SpanType.LLM)
assert isinstance(span, LiveSpan)
assert span.trace_id == trace_id
assert span._trace_id == encode_trace_id(parent_span.context.trace_id)
assert span.span_id == encode_span_id(parent_span.context.span_id)
assert span.name == "parent"
assert span.start_time_ns == parent_span.start_time
assert span.end_time_ns is None
assert span.parent_id is None
span.set_inputs({"input": 1})
span.set_outputs(2)
assert span.inputs == {"input": 1}
assert span.outputs == 2
span.set_attribute("key", 3)
assert span.get_attribute("key") == 3
# non-serializable value should be stored as string
non_serializable = datetime.now()
span.set_attribute("non_serializable", non_serializable)
assert span.get_attribute("non_serializable") == str(non_serializable)
# NB: `mlflow.spanLogLevel` is intentionally absent here — the live
# span hasn't ended yet, and log-level resolution happens in `end()`.
# Persisted spans always carry the attribute (covered elsewhere).
assert parent_span._attributes == {
"mlflow.traceRequestId": json.dumps(trace_id),
"mlflow.spanInputs": '{"input": 1}',
"mlflow.spanOutputs": "2",
"mlflow.spanType": '"LLM"',
"key": "3",
"non_serializable": json.dumps(str(non_serializable)),
}
span.set_status("OK")
assert span.status == SpanStatus(SpanStatusCode.OK)
span.add_event(SpanEvent("test_event", timestamp=99999, attributes={"foo": "bar"}))
assert len(span.events) == 1
assert span.events[0].name == "test_event"
assert span.events[0].timestamp == 99999
assert span.events[0].attributes == {"foo": "bar"}
# Test child span
with tracer.start_as_current_span("child") as child_span:
span = create_mlflow_span(child_span, trace_id=trace_id)
assert isinstance(span, LiveSpan)
assert span.name == "child"
assert span.parent_id == encode_span_id(parent_span.context.span_id)
def test_create_non_live_span():
trace_id = "tr-12345"
parent_span_context = trace_api.SpanContext(
trace_id=12345, span_id=111, is_remote=False, trace_flags=trace_api.TraceFlags(1)
)
readable_span = OTelReadableSpan(
name="test",
context=trace_api.SpanContext(
trace_id=12345, span_id=222, is_remote=False, trace_flags=trace_api.TraceFlags(1)
),
parent=parent_span_context,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id),
"mlflow.spanInputs": '{"input": 1, "nested": {"foo": "bar"}}',
"mlflow.spanOutputs": "2",
"key": "3",
},
start_time=99999,
end_time=100000,
)
span = create_mlflow_span(readable_span, trace_id)
assert isinstance(span, Span)
assert not isinstance(span, LiveSpan)
assert not isinstance(span, NoOpSpan)
assert span.trace_id == trace_id
assert span._trace_id == encode_trace_id(12345)
assert span.span_id == encode_span_id(222)
assert span.name == "test"
assert span.start_time_ns == 99999
assert span.end_time_ns == 100000
assert span.parent_id == encode_span_id(111)
assert span.inputs == {"input": 1, "nested": {"foo": "bar"}}
assert span.outputs == 2
assert span.status == SpanStatus(SpanStatusCode.UNSET, description="")
assert span.get_attribute("key") == 3
# Non-live span should not implement setter methods
with pytest.raises(AttributeError, match="set_inputs"):
span.set_inputs({"input": 1})
def test_create_noop_span():
trace_id = "tr-12345"
@trace_disabled
def f():
tracer = _get_tracer("test")
with tracer.start_as_current_span("span") as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id)
assert isinstance(span, NoOpSpan)
# create from None
span = create_mlflow_span(None, trace_id=trace_id)
assert isinstance(span, NoOpSpan)
def test_create_raise_for_invalid_otel_span():
with pytest.raises(MlflowException, match=r"The `otel_span` argument must be"):
create_mlflow_span(otel_span=123, trace_id="tr-12345")
@pytest.mark.parametrize(
"status",
[SpanStatus("OK"), SpanStatus(SpanStatusCode.ERROR, "Error!"), "OK", "ERROR"],
)
def test_set_status(status):
with mlflow.start_span("test_span") as span:
span.set_status(status)
assert isinstance(span.status, SpanStatus)
def test_set_status_raise_for_invalid_value():
with mlflow.start_span("test_span") as span:
with pytest.raises(MlflowException, match=r"INVALID is not a valid SpanStatusCode value."):
span.set_status("INVALID")
def test_dict_conversion():
with mlflow.start_span("parent"):
with mlflow.start_span("child", span_type=SpanType.LLM) as span:
span.set_inputs({"input": 1})
span.set_outputs(2)
span.set_attribute("key", 3)
span.set_status("OK")
span.add_event(SpanEvent("test_event", timestamp=0, attributes={"foo": "bar"}))
span_dict = span.to_dict()
recovered_span = Span.from_dict(span_dict)
assert span.trace_id == recovered_span.trace_id
assert span._trace_id == recovered_span._trace_id
assert span.span_id == recovered_span.span_id
assert span.name == recovered_span.name
assert span.span_type == recovered_span.span_type
assert span.start_time_ns == recovered_span.start_time_ns
assert span.end_time_ns == recovered_span.end_time_ns
assert span.parent_id == recovered_span.parent_id
assert span.status == recovered_span.status
assert span.inputs == recovered_span.inputs
assert span.outputs == recovered_span.outputs
assert span.attributes == recovered_span.attributes
assert span.events == recovered_span.events
# Loaded span should not implement setter methods
with pytest.raises(AttributeError, match="set_status"):
recovered_span.set_status("OK")
def test_dict_conversion_with_exception_event():
with pytest.raises(ValueError, match="Test exception"):
with mlflow.start_span("test") as span:
raise ValueError("Test exception")
span_dict = span.to_dict()
recovered_span = Span.from_dict(span_dict)
assert span.request_id == recovered_span.request_id
assert span._trace_id == recovered_span._trace_id
assert span.span_id == recovered_span.span_id
assert span.name == recovered_span.name
assert span.span_type == recovered_span.span_type
assert span.start_time_ns == recovered_span.start_time_ns
assert span.end_time_ns == recovered_span.end_time_ns
assert span.parent_id == recovered_span.parent_id
assert span.status == recovered_span.status
assert span.inputs == recovered_span.inputs
assert span.outputs == recovered_span.outputs
assert span.attributes == recovered_span.attributes
assert span.events == recovered_span.events
# Loaded span should not implement setter methods
with pytest.raises(AttributeError, match="set_status"):
recovered_span.set_status("OK")
def test_from_v2_dict():
span = Span.from_dict({
"name": "test",
"context": {
"span_id": "8a90fc46e65ea5a4",
"trace_id": "0125978dc5c5a9456d7ca9ef1f7cf4af",
},
"parent_id": None,
"start_time": 1738662897576578992,
"end_time": 1738662899068969049,
"status_code": "OK",
"status_message": "",
"attributes": {
"mlflow.traceRequestId": '"tr-123"',
"mlflow.spanType": '"LLM"',
"mlflow.spanInputs": '{"input": 1}',
"mlflow.spanOutputs": "2",
"key": "3",
},
"events": [],
})
assert span.request_id == "tr-123"
assert span.name == "test"
assert span.span_type == SpanType.LLM
assert span.parent_id is None
assert span.status == SpanStatus(SpanStatusCode.OK, description="")
assert span.inputs == {"input": 1}
assert span.outputs == 2
assert span.events == []
def test_to_immutable_span():
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("parent") as parent_span:
live_span = LiveSpan(parent_span, trace_id=trace_id, span_type=SpanType.LLM)
live_span.set_inputs({"input": 1})
live_span.set_outputs(2)
live_span.set_attribute("key", 3)
live_span.set_status("OK")
live_span.add_event(SpanEvent("test_event", timestamp=0, attributes={"foo": "bar"}))
span = live_span.to_immutable_span()
assert isinstance(span, Span)
assert span.trace_id == trace_id
assert span._trace_id == encode_trace_id(parent_span.context.trace_id)
assert span.span_id == encode_span_id(parent_span.context.span_id)
assert span.name == "parent"
assert span.start_time_ns == parent_span.start_time
assert span.end_time_ns is not None
assert span.parent_id is None
assert span.inputs == {"input": 1}
assert span.outputs == 2
assert span.get_attribute("key") == 3
assert span.status == SpanStatus(SpanStatusCode.OK, description="")
assert span.events == [SpanEvent("test_event", timestamp=0, attributes={"foo": "bar"})]
with pytest.raises(AttributeError, match="set_attribute"):
span.set_attribute("OK")
def test_from_dict_raises_when_trace_id_is_empty():
with pytest.raises(MlflowException, match=r"Failed to create a Span object from "):
Span.from_dict({
"name": "predict",
"context": {
"trace_id": "12345",
"span_id": "12345",
},
"parent_id": None,
"start_time": 0,
"end_time": 1,
"status_code": "OK",
"status_message": "",
"attributes": {
"mlflow.traceRequestId": None,
},
"events": [],
})
def test_set_attribute_directly_to_otel_span():
with mlflow.start_span("test") as span:
span._span.set_attribute("int", 1)
span._span.set_attribute("str", "a")
assert span.get_attribute("int") == 1
assert span.get_attribute("str") == "a"
@pytest.fixture
def sample_otel_span_for_conversion():
"""Create a sample OTelReadableSpan for testing."""
return OTelReadableSpan(
name="test_span",
context=build_otel_context(
trace_id=0x12345678901234567890123456789012,
span_id=0x1234567890123456,
),
parent=build_otel_context(
trace_id=0x12345678901234567890123456789012,
span_id=0x0987654321098765,
),
start_time=1000000000,
end_time=2000000000,
attributes={
"mlflow.traceRequestId": "tr-12345678901234567890123456789012",
"mlflow.spanType": "LLM",
"mlflow.spanInputs": '{"prompt": "Hello"}',
"mlflow.spanOutputs": '{"response": "Hi"}',
"custom_attr": '{"key": "value"}',
},
status=OTelStatus(OTelStatusCode.OK, "Success"),
events=[
OTelEvent(
name="event1",
timestamp=1500000000,
attributes={"event_key": "event_value"},
)
],
)
@pytest.mark.parametrize(
"attributes",
[
# Empty attributes
{},
# String attributes
{"str_key": "str_value"},
# Numeric attributes
{"int_key": 42, "float_key": 3.14},
# Boolean attributes
{"bool_key": True, "false_key": False},
# Bytes attributes
{"bytes_key": b"binary_data"},
# Empty list and dict attributes
{"empty_list": [], "empty_dict": {}},
# List attributes
{"list_str": ["a", "b", "c"], "list_int": [1, 2, 3], "list_float": [1.1, 2.2]},
# Dict attributes
{"dict_key": {"nested": "value", "number": 123}},
# Mixed complex attributes
{
"complex": {
"nested_list": [1, "two", 3.0],
"nested_dict": {"deep": {"deeper": "value"}},
},
"simple": "string",
},
],
)
def test_otel_attribute_conversion(attributes):
from opentelemetry.proto.common.v1.common_pb2 import KeyValue
from mlflow.tracing.utils.otlp import _decode_otel_proto_anyvalue, _set_otel_proto_anyvalue
# Convert attributes to proto format
proto_attrs = []
for key, value in attributes.items():
kv = KeyValue()
kv.key = key
_set_otel_proto_anyvalue(kv.value, value)
proto_attrs.append(kv)
# Decode back and verify
decoded = {}
for kv in proto_attrs:
decoded[kv.key] = _decode_otel_proto_anyvalue(kv.value)
assert decoded == attributes
def test_span_to_otel_proto_conversion(sample_otel_span_for_conversion):
# Create MLflow span from OTel span
mlflow_span = Span(sample_otel_span_for_conversion)
# Convert to OTel proto
otel_proto = mlflow_span.to_otel_proto()
# Verify basic fields
assert otel_proto.name == "test_span"
assert otel_proto.start_time_unix_nano == 1000000000
assert otel_proto.end_time_unix_nano == 2000000000
# Verify IDs (should be in bytes format)
assert len(otel_proto.trace_id) == 16 # 128-bit trace ID
assert len(otel_proto.span_id) == 8 # 64-bit span ID
assert len(otel_proto.parent_span_id) == 8
# Verify status
assert otel_proto.status.code == OTelProtoStatus.STATUS_CODE_OK
# OTel SDK clears description for non-ERROR statuses
assert otel_proto.status.message == ""
# Verify attributes exist
assert len(otel_proto.attributes) == 5
attr_keys = {attr.key for attr in otel_proto.attributes}
assert "mlflow.spanType" in attr_keys
assert "custom_attr" in attr_keys
# Verify events
assert len(otel_proto.events) == 1
assert otel_proto.events[0].name == "event1"
assert otel_proto.events[0].time_unix_nano == 1500000000
def test_span_from_otel_proto_conversion():
# Create OTel proto span
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.parent_span_id = bytes.fromhex("0987654321098765")
otel_proto.name = "proto_span"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
# Add status
otel_proto.status.code = OTelProtoStatus.STATUS_CODE_ERROR
otel_proto.status.message = "Error occurred"
# Add attributes
from mlflow.tracing.utils.otlp import _set_otel_proto_anyvalue
attr2 = otel_proto.attributes.add()
attr2.key = "mlflow.spanType"
_set_otel_proto_anyvalue(attr2.value, "CHAIN")
attr3 = otel_proto.attributes.add()
attr3.key = "custom"
_set_otel_proto_anyvalue(attr3.value, {"nested": {"value": 123}})
# Add event
event = otel_proto.events.add()
event.name = "test_event"
event.time_unix_nano = 1500000000
event_attr = event.attributes.add()
event_attr.key = "event_data"
_set_otel_proto_anyvalue(event_attr.value, "event_value")
# Add link
proto_link = otel_proto.links.add()
proto_link.trace_id = bytes.fromhex("aabbccddeeff00112233445566778899")
proto_link.span_id = bytes.fromhex("1122334455667788")
link_attr = proto_link.attributes.add()
link_attr.key = "rel"
_set_otel_proto_anyvalue(link_attr.value, "causal")
# Convert to MLflow span
mlflow_span = Span.from_otel_proto(otel_proto)
# Verify basic fields
assert mlflow_span.name == "proto_span"
assert mlflow_span.start_time_ns == 1000000000
assert mlflow_span.end_time_ns == 2000000000
# Verify IDs
assert mlflow_span.trace_id == "tr-12345678901234567890123456789012"
assert mlflow_span.span_id == "1234567890123456"
assert mlflow_span.parent_id == "0987654321098765"
# Verify status
assert mlflow_span.status.status_code == SpanStatusCode.ERROR
assert mlflow_span.status.description == "Error occurred"
# Verify attributes
assert mlflow_span.span_type == "CHAIN"
assert mlflow_span.get_attribute("custom") == {"nested": {"value": 123}}
# Verify events
assert len(mlflow_span.events) == 1
assert mlflow_span.events[0].name == "test_event"
assert mlflow_span.events[0].timestamp == 1500000000
assert mlflow_span.events[0].attributes["event_data"] == "event_value"
# Verify resource is empty when none is provided
assert len(mlflow_span._span.resource.attributes) == 0
# Verify links use v3 format when no location_id
assert len(mlflow_span.links) == 1
assert mlflow_span.links[0].trace_id == "tr-aabbccddeeff00112233445566778899"
assert mlflow_span.links[0].span_id == "1122334455667788"
assert mlflow_span.links[0].attributes == {"rel": "causal"}
def test_span_from_otel_proto_with_resource():
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.name = "span_with_resource"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
# Build a proto Resource with typical OTel resource attributes
resource = OTelProtoResource()
for key, value in [
("service.name", "my-app"),
("telemetry.sdk.language", "java"),
("telemetry.sdk.name", "opentelemetry"),
("telemetry.sdk.version", "1.40.0"),
]:
attr = resource.attributes.add()
attr.key = key
_set_otel_proto_anyvalue(attr.value, value)
mlflow_span = Span.from_otel_proto(otel_proto, resource=resource)
assert mlflow_span.name == "span_with_resource"
res_attrs = dict(mlflow_span._span.resource.attributes)
assert res_attrs["service.name"] == "my-app"
assert res_attrs["telemetry.sdk.language"] == "java"
assert res_attrs["telemetry.sdk.name"] == "opentelemetry"
assert res_attrs["telemetry.sdk.version"] == "1.40.0"
def test_span_from_otel_proto_with_empty_resource():
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.name = "span_empty_resource"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
# An empty proto Resource (no attributes)
resource = OTelProtoResource()
mlflow_span = Span.from_otel_proto(otel_proto, resource=resource)
assert len(mlflow_span._span.resource.attributes) == 0
def test_span_from_otel_proto_with_location():
# Create OTel proto span
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.parent_span_id = bytes.fromhex("0987654321098765")
otel_proto.name = "proto_span_v4"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
# Add status
otel_proto.status.code = OTelProtoStatus.STATUS_CODE_OK
otel_proto.status.message = ""
# Add attributes
attr1 = otel_proto.attributes.add()
attr1.key = "mlflow.spanType"
_set_otel_proto_anyvalue(attr1.value, "LLM")
# Add link
proto_link = otel_proto.links.add()
proto_link.trace_id = bytes.fromhex("aabbccddeeff00112233445566778899")
proto_link.span_id = bytes.fromhex("1122334455667788")
# Convert to MLflow span with location
location = "catalog.schema"
mlflow_span = Span.from_otel_proto(otel_proto, location_id=location)
# Verify basic fields
assert mlflow_span.name == "proto_span_v4"
assert mlflow_span.start_time_ns == 1000000000
assert mlflow_span.end_time_ns == 2000000000
# Verify IDs
assert mlflow_span.span_id == "1234567890123456"
assert mlflow_span.parent_id == "0987654321098765"
# Verify trace_id is in v4 format with location
expected_trace_id = f"{TRACE_ID_V4_PREFIX}{location}/12345678901234567890123456789012"
assert mlflow_span.trace_id == expected_trace_id
# Verify the REQUEST_ID attribute also uses v4 format
request_id = mlflow_span.get_attribute("mlflow.traceRequestId")
assert request_id == expected_trace_id
# Links are skipped for v4 UC traces
assert len(mlflow_span.links) == 0
def test_span_from_otel_proto_with_pre_encoded_request_id():
"""
Regression test: when the MLflow SDK (OtelSpanProcessor.on_start) runs before OTLP export,
it JSON-encodes mlflow.traceRequestId into the live OTel span via LiveSpan.__init__.
The OTLP proto then carries the already-encoded value (e.g. '"tr-abc"' with inner quotes).
from_otel_proto() must not double-encode it — the server-computed ID must always win.
"""
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.name = "langchain_span"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
otel_proto.status.code = OTelProtoStatus.STATUS_CODE_OK
# Simulate what OtelSpanProcessor.on_start + LiveSpan.__init__ produce:
# _SpanAttributesRegistry.set() calls dump_span_attribute_value("tr-abc") = '"tr-abc"'
# so the OTel span attribute stored in memory is the JSON-encoded string '"tr-abc"'.
expected_trace_id = "tr-12345678901234567890123456789012"
already_encoded_id = json.dumps(expected_trace_id) # '"tr-12345678901234567890123456789012"'
attr = otel_proto.attributes.add()
attr.key = "mlflow.traceRequestId"
_set_otel_proto_anyvalue(attr.value, already_encoded_id)
mlflow_span = Span.from_otel_proto(otel_proto)
# Server-computed ID must win — must start with 'tr-', not with '"'
assert mlflow_span.trace_id == expected_trace_id
assert mlflow_span.trace_id.startswith("tr-")
def test_span_from_otel_proto_can_preserve_request_id_for_round_trip():
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.name = "archived_span"
otel_proto.start_time_unix_nano = 1000000000
otel_proto.end_time_unix_nano = 2000000000
otel_proto.status.code = OTelProtoStatus.STATUS_CODE_OK
attr = otel_proto.attributes.add()
attr.key = "mlflow.traceRequestId"
_set_otel_proto_anyvalue(attr.value, "tr-abc123")
mlflow_span = Span.from_otel_proto(otel_proto, preserve_request_id=True)
assert mlflow_span.trace_id == "tr-abc123"
def test_otel_roundtrip_conversion(sample_otel_span_for_conversion):
# Start with OTel span -> MLflow span
mlflow_span = Span(sample_otel_span_for_conversion)
# Convert to OTel proto
otel_proto = mlflow_span.to_otel_proto()
# Convert back to MLflow span
roundtrip_span = Span.from_otel_proto(otel_proto)
# Verify key fields are preserved
assert roundtrip_span.name == mlflow_span.name
assert roundtrip_span.span_id == mlflow_span.span_id
assert roundtrip_span.parent_id == mlflow_span.parent_id
assert roundtrip_span.start_time_ns == mlflow_span.start_time_ns
assert roundtrip_span.end_time_ns == mlflow_span.end_time_ns
assert roundtrip_span.status.status_code == mlflow_span.status.status_code
assert roundtrip_span.status.description == mlflow_span.status.description
# Verify span attributes are preserved
assert roundtrip_span.span_type == mlflow_span.span_type
assert roundtrip_span.inputs == mlflow_span.inputs
assert roundtrip_span.outputs == mlflow_span.outputs
# Verify ALL attributes are preserved by iterating through them
# Get all attribute keys from both spans
original_attributes = mlflow_span.attributes
roundtrip_attributes = roundtrip_span.attributes
# Check we have the same number of attributes
assert len(original_attributes) == len(roundtrip_attributes)
# Check each attribute is preserved correctly
for attr_key in original_attributes:
assert attr_key in roundtrip_attributes, f"Attribute {attr_key} missing after roundtrip"
original_value = original_attributes[attr_key]
roundtrip_value = roundtrip_attributes[attr_key]
assert original_value == roundtrip_value, (
f"Attribute {attr_key} changed: {original_value} != {roundtrip_value}"
)
# Also explicitly verify specific important attributes
# The original span has a custom_attr that should be preserved
original_custom_attr = mlflow_span.get_attribute("custom_attr")
roundtrip_custom_attr = roundtrip_span.get_attribute("custom_attr")
assert original_custom_attr == roundtrip_custom_attr
assert original_custom_attr == {"key": "value"}
# Verify the trace request ID is preserved
assert roundtrip_span.request_id == mlflow_span.request_id
assert roundtrip_span.request_id == "tr-12345678901234567890123456789012"
# Verify events
assert len(roundtrip_span.events) == len(mlflow_span.events)
for orig_event, rt_event in zip(mlflow_span.events, roundtrip_span.events):
assert rt_event.name == orig_event.name
assert rt_event.timestamp == orig_event.timestamp
assert rt_event.attributes == orig_event.attributes
def test_resource_to_otel_proto():
resource = OTelResource.create({
"service.name": "test-service",
"service.version": "1.0.0",
"custom.int": 42,
"custom.bool": True,
})
resource_proto = resource_to_otel_proto(resource)
# Convert proto attributes to dict for easier verification
attrs = {}
for attr in resource_proto.attributes:
attrs[attr.key] = _decode_otel_proto_anyvalue(attr.value)
assert attrs["service.name"] == "test-service"
assert attrs["service.version"] == "1.0.0"
assert attrs["custom.int"] == 42
assert attrs["custom.bool"] is True
def test_resource_to_otel_proto_none():
resource_proto = resource_to_otel_proto(None)
assert len(resource_proto.attributes) == 0
def test_span_from_dict_old_format():
span_dict = {
"trace_id": "6ST7JNq8BC4JRp0HA/vD6Q==",
"span_id": "Sd/l0Zs4M3g=",
"parent_span_id": None,
"name": "test_span",
"start_time_unix_nano": 1000000000,
"end_time_unix_nano": 2000000000,
"status": {"code": "STATUS_CODE_ERROR", "message": "Error occurred"},
"attributes": {
"mlflow.spanInputs": '{"query": "test"}',
"mlflow.spanOutputs": '{"result": "success"}',
"custom": "value",
"mlflow.traceRequestId": '"tr-e924fb24dabc042e09469d0703fbc3e9"',
},
"events": [],
}
# Deserialize it
recovered_span = Span.from_dict(span_dict)
# Verify all fields are recovered correctly
assert recovered_span.trace_id == "tr-e924fb24dabc042e09469d0703fbc3e9"
assert recovered_span.span_id == "49dfe5d19b383378"
assert recovered_span.parent_id is None
assert recovered_span.name == span_dict["name"]
assert recovered_span.start_time_ns == span_dict["start_time_unix_nano"]
assert recovered_span.end_time_ns == span_dict["end_time_unix_nano"]
assert recovered_span.status.status_code.value == "ERROR"
assert recovered_span.inputs == {"query": "test"}
assert recovered_span.outputs == {"result": "success"}
assert recovered_span.get_attribute("custom") == "value"
def test_span_dict_v4_with_no_parent():
with mlflow.start_span("root_span") as span:
span.set_inputs({"x": 1})
span.set_outputs({"y": 2})
span_dict = span.to_dict()
# Root span should have None for parent_span_id
assert span_dict["parent_span_id"] is None
# Deserialize and verify
recovered = Span.from_dict(span_dict)
assert recovered.parent_id is None
assert recovered.name == "root_span"
assert recovered.inputs == {"x": 1}
assert recovered.outputs == {"y": 2}
def test_span_from_dict_supports_both_status_code_formats():
with mlflow.start_span("test") as span:
span.set_status("OK")
span_dict = span.to_dict()
# Current code serializes as protobuf enum name
assert span_dict["status"]["code"] == "STATUS_CODE_OK"
# Verify we can deserialize protobuf enum name format (backward compatibility)
span_dict["status"]["code"] = "STATUS_CODE_ERROR"
recovered = Span.from_dict(span_dict)
assert recovered.status.status_code == SpanStatusCode.ERROR
# Verify we can also deserialize enum value format
# (forward compatibility with older serialized data)
span_dict["status"]["code"] = "OK"
recovered = Span.from_dict(span_dict)
assert recovered.status.status_code == SpanStatusCode.OK
span_dict["status"]["code"] = "UNSET"
recovered = Span.from_dict(span_dict)
assert recovered.status.status_code == SpanStatusCode.UNSET
span_dict["status"]["code"] = "ERROR"
recovered = Span.from_dict(span_dict)
assert recovered.status.status_code == SpanStatusCode.ERROR
def test_load_from_old_span_dict():
span_dict = {
"trace_id": "ZqqBulxlq2cwRCfKHxmDVA==",
"span_id": "/mCYZRxbTqw=",
"trace_state": "",
"parent_span_id": "f6qlKYqTw2E=",
"name": "custom",
"start_time_unix_nano": 1761103703884225000,
"end_time_unix_nano": 1761103703884454000,
"attributes": {
"mlflow.spanOutputs": "4",
"mlflow.spanType": '"LLM"',
"mlflow.spanInputs": '{"z": 3}',
"mlflow.traceRequestId": '"tr-66aa81ba5c65ab67304427ca1f198354"',
"delta": "1",
"mlflow.spanFunctionName": '"add_one"',
},
"status": {"message": "", "code": "STATUS_CODE_OK"},
"events": [
{
"time_unix_nano": 1761105506649041,
"name": "agent_action",
"attributes": {
"tool": "search_web",
"tool_input": '"What is MLflow?"',
"log": "test",
},
}
],
}
span = Span.from_dict(span_dict)
assert span.trace_id == "tr-66aa81ba5c65ab67304427ca1f198354"
assert span.span_id == "fe6098651c5b4eac"
assert span.parent_id == "7faaa5298a93c361"
assert span.name == "custom"
assert span.start_time_ns == 1761103703884225000
assert span.end_time_ns == 1761103703884454000
assert span.status == SpanStatus(SpanStatusCode.OK, description="")
assert span.inputs == {"z": 3}
assert span.outputs == 4
assert len(span.events) == 1
assert span.events[0].name == "agent_action"
assert span.events[0].timestamp == 1761105506649041
assert span.events[0].attributes == {
"tool": "search_web",
"tool_input": '"What is MLflow?"',
"log": "test",
}
def test_load_from_3_5_0_span_dict():
span_dict = {
"trace_id": "tr-66aa81ba5c65ab67304427ca1f198354",
"span_id": "fe6098651c5b4eac",
"trace_state": "",
"parent_span_id": "7faaa5298a93c361",
"name": "custom",
"start_time_unix_nano": 1761103703884225000,
"end_time_unix_nano": 1761103703884454000,
"attributes": {
"mlflow.spanOutputs": "4",
"mlflow.spanType": '"LLM"',
"mlflow.spanInputs": '{"z": 3}',
"mlflow.traceRequestId": '"tr-66aa81ba5c65ab67304427ca1f198354"',
"delta": "1",
"mlflow.spanFunctionName": '"add_one"',
},
"status": {"message": "", "code": "OK"},
"events": [
{
"time_unix_nano": 1761105506649041,
"name": "agent_action",
"attributes": {
"tool": "search_web",
"tool_input": '"What is MLflow?"',
"log": "test",
},
}
],
}
span = Span.from_dict(span_dict)
assert span.trace_id == "tr-66aa81ba5c65ab67304427ca1f198354"
assert span.span_id == "fe6098651c5b4eac"
assert span.parent_id == "7faaa5298a93c361"
assert span.name == "custom"
assert span.start_time_ns == 1761103703884225000
assert span.end_time_ns == 1761103703884454000
assert span.status == SpanStatus(SpanStatusCode.OK, description="")
assert span.inputs == {"z": 3}
assert span.outputs == 4
assert len(span.events) == 1
assert span.events[0].name == "agent_action"
assert span.events[0].timestamp == 1761105506649041
assert span.events[0].attributes == {
"tool": "search_web",
"tool_input": '"What is MLflow?"',
"log": "test",
}
def test_live_span_add_link():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id)
link = Link(
trace_id="tr-abc123",
span_id="aabbccddeeff0011",
attributes={"relationship": "triggered_by"},
)
span.add_link(link)
assert len(span.links) == 1
assert span.links[0].trace_id == "tr-abc123"
assert span.links[0].span_id == "aabbccddeeff0011"
assert span.links[0].attributes == {"relationship": "triggered_by"}
# Verify the link was forwarded to the underlying OTel span
assert len(otel_span.links) == 1
assert otel_span.links[0].context.trace_id == 0xABC123
assert otel_span.links[0].context.span_id == 0xAABBCCDDEEFF0011
def test_add_link_after_end_does_not_store_link():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
otel_span = tracer.start_span("test_span")
span = create_mlflow_span(otel_span, trace_id=trace_id)
span.end()
with mock.patch("mlflow.entities.span._logger.debug") as mock_debug:
span.add_link(
Link(
trace_id="tr-abc123",
span_id="aabbccddeeff0011",
attributes={"relationship": "triggered_by"},
)
)
assert len(span.links) == 0
assert len(otel_span.links) == 0
mock_debug.assert_called_once_with(
"Skipping link addition because the span is no longer recording."
)
def test_add_link_rejects_invalid_ids():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id)
with pytest.raises(MlflowException, match="Invalid link"):
span.add_link(Link(trace_id="tr-abc123", span_id="not-hex"))
with pytest.raises(MlflowException, match="Invalid link"):
span.add_link(Link(trace_id="bad-format", span_id="aabbccddeeff0011"))
with pytest.raises(MlflowException, match="Invalid link"):
span.add_link(Link(trace_id="tr-abc123", span_id="aabbccddeeff0011aabbccddeeff0011"))
assert len(span.links) == 0
def test_span_seeds_links_from_otel_span():
trace_id = "tr-12345"
otel_link = trace_api.Link(
context=trace_api.SpanContext(
trace_id=0xAABBCCDDEEFF00112233445566778899,
span_id=0xAABBCCDDEEFF0011,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
),
attributes={"type": "causal"},
)
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span", links=[otel_link]) as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id)
immutable_span = span.to_immutable_span()
assert len(immutable_span.links) == 1
assert immutable_span.links[0].trace_id == "tr-aabbccddeeff00112233445566778899"
assert immutable_span.links[0].span_id == "aabbccddeeff0011"
assert immutable_span.links[0].attributes == {"type": "causal"}
def test_span_to_dict_with_links():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
live_span = create_mlflow_span(otel_span, trace_id=trace_id)
link = Link(trace_id="tr-abc123", span_id="aabbccddeeff0011", attributes={"type": "test"})
live_span.add_link(link)
immutable_span = live_span.to_immutable_span()
span_dict = immutable_span.to_dict()
assert "links" in span_dict
assert len(span_dict["links"]) == 1
assert span_dict["links"][0]["trace_id"] == "tr-abc123"
assert span_dict["links"][0]["span_id"] == "aabbccddeeff0011"
assert span_dict["links"][0]["attributes"] == {"type": "test"}
def test_span_from_dict_with_links():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
live_span = create_mlflow_span(otel_span, trace_id=trace_id)
link = Link(trace_id="tr-abc123", span_id="aabbccddeeff0011", attributes={"type": "test"})
live_span.add_link(link)
immutable_span = live_span.to_immutable_span()
span_dict = immutable_span.to_dict()
reconstructed_span = Span.from_dict(span_dict)
assert len(reconstructed_span.links) == 1
assert reconstructed_span.links[0].trace_id == "tr-abc123"
assert reconstructed_span.links[0].span_id == "aabbccddeeff0011"
assert reconstructed_span.links[0].attributes == {"type": "test"}
def test_span_from_dict_without_links():
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id)
immutable_span = span.to_immutable_span()
span_dict = immutable_span.to_dict()
assert span_dict["links"] == []
reconstructed_span = Span.from_dict(span_dict)
assert len(reconstructed_span.links) == 0
def test_span_links_otel_proto_roundtrip():
otel_proto = OTelProtoSpan()
otel_proto.trace_id = bytes.fromhex("12345678901234567890123456789012")
otel_proto.span_id = bytes.fromhex("1234567890123456")
otel_proto.name = "test"
link_trace_bytes = bytes.fromhex("aabbccddeeff00112233445566778899")
link_span_bytes = bytes.fromhex("aabbccddeeff0011")
proto_link = otel_proto.links.add()
proto_link.trace_id = link_trace_bytes
proto_link.span_id = link_span_bytes
link_attr = proto_link.attributes.add()
link_attr.key = "type"
_set_otel_proto_anyvalue(link_attr.value, "causal")
mlflow_span = Span.from_otel_proto(otel_proto)
assert len(mlflow_span.links) == 1
assert mlflow_span.links[0].attributes == {"type": "causal"}
rt = mlflow_span.to_otel_proto()
assert rt.links[0].trace_id == link_trace_bytes
assert rt.links[0].span_id == link_span_bytes
def test_span_skips_links_for_v4_traces():
otel_link = trace_api.Link(
context=trace_api.SpanContext(
trace_id=0xAABBCCDDEEFF00112233445566778899,
span_id=0xAABBCCDDEEFF0011,
is_remote=False,
trace_flags=trace_api.TraceFlags(1),
),
attributes={"type": "causal"},
)
v4_trace_id = f"{TRACE_ID_V4_PREFIX}catalog.schema/12345678901234567890123456789012"
otel_span = OTelReadableSpan(
name="test_v4",
context=build_otel_context(0x12345678901234567890123456789012, 0x1234567890123456),
attributes={"mlflow.traceRequestId": json.dumps(v4_trace_id)},
links=[otel_link],
resource=OTelResource.get_empty(),
)
span = Span(otel_span)
assert span.trace_id == v4_trace_id
assert len(span.links) == 0
def test_span_links_property_returns_deep_copy():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
live_span = create_mlflow_span(otel_span, trace_id=trace_id)
live_span.add_link(
Link(trace_id="tr-abc", span_id="aabbccddeeff0011", attributes={"k": "v"})
)
immutable_span = live_span.to_immutable_span()
links = immutable_span.links
links[0].attributes["k"] = "mutated"
assert immutable_span.links[0].attributes["k"] == "v"
def test_add_link_clones_on_insert():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
live_span = create_mlflow_span(otel_span, trace_id=trace_id)
link = Link(trace_id="tr-abc", span_id="aabbccddeeff0011", attributes={"k": "v"})
live_span.add_link(link)
link.attributes["k"] = "mutated"
assert live_span.links[0].attributes["k"] == "v"
def test_to_immutable_span_deep_copies_links():
from mlflow.entities.link import Link
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("test_span") as otel_span:
live_span = create_mlflow_span(otel_span, trace_id=trace_id)
live_span.add_link(
Link(trace_id="tr-abc", span_id="aabbccddeeff0011", attributes={"k": "v"})
)
immutable_span = live_span.to_immutable_span()
live_span._links[0].attributes["k"] = "mutated"
assert immutable_span.links[0].attributes["k"] == "v"
def test_lazy_span_to_dict_does_not_materialize():
from mlflow.entities.span import LazySpan
with mlflow.start_span("parent"):
with mlflow.start_span("child", span_type=SpanType.LLM) as span:
span.set_inputs({"input": 1})
span.set_outputs(2)
span_dict = span.to_dict()
lazy = LazySpan(span_dict)
assert isinstance(lazy, Span)
assert not isinstance(lazy, LiveSpan)
assert lazy.to_dict() is span_dict
assert lazy.__dict__["_materialized"] is False
def test_lazy_span_materializes_on_property_access():
from mlflow.entities.span import LazySpan
with mlflow.start_span("parent"):
with mlflow.start_span("child", span_type=SpanType.LLM) as span:
span.set_inputs({"input": 1})
span.set_outputs(2)
span.set_status("OK")
span_dict = span.to_dict()
lazy = LazySpan(span_dict)
assert lazy.name == "child"
assert lazy.__dict__["_materialized"] is True
assert lazy.inputs == {"input": 1}
assert lazy.outputs == 2
assert lazy.span_type == SpanType.LLM
assert lazy.to_dict() == span_dict
def test_lazy_span_round_trips_through_trace_data_to_dict():
from mlflow.entities.span import LazySpan
from mlflow.entities.trace_data import TraceData
with mlflow.start_span("parent"):
with mlflow.start_span("child", span_type=SpanType.LLM) as span:
span.set_inputs({"input": 1})
lazy = LazySpan(span.to_dict())
trace_data = TraceData(spans=[lazy])
dumped = trace_data.to_dict()
assert dumped["spans"][0]["name"] == "child"
assert lazy.__dict__["_materialized"] is False
def test_lazy_span_matches_eager_span_after_materialization():
from mlflow.entities.link import Link
from mlflow.entities.span import LazySpan
from mlflow.tracing.otel.translation import translate_loaded_span
trace_id = "tr-12345"
tracer = _get_tracer("test")
with tracer.start_as_current_span("parent"):
with tracer.start_as_current_span("child") as otel_span:
span = create_mlflow_span(otel_span, trace_id=trace_id, span_type=SpanType.LLM)
span.set_inputs({"input": 1})
span.set_outputs({"output": 2})
span.set_status("OK")
span.add_event(SpanEvent("test_event", timestamp=0, attributes={"foo": "bar"}))
span.add_link(
Link(trace_id="tr-abc123", span_id="aabbccddeeff0011", attributes={"type": "test"})
)
span_dict = translate_loaded_span(span.to_dict())
eager = Span.from_dict(span_dict)
lazy = LazySpan(span_dict)
assert lazy.to_dict() == eager.to_dict()
assert lazy.to_otel_proto().SerializeToString() == eager.to_otel_proto().SerializeToString()
_ = lazy.name
assert lazy.__dict__["_materialized"] is True
assert lazy.to_dict() == eager.to_dict()
assert lazy.attributes == eager.attributes
assert lazy.links == eager.links
assert lazy.status == eager.status
assert lazy.events == eager.events
assert lazy.to_otel_proto().SerializeToString() == eager.to_otel_proto().SerializeToString()