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

247 lines
8.1 KiB
Python

import json
import pytest
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace import SpanContext, SpanKind, TraceFlags
from opentelemetry.trace.status import Status, StatusCode
from mlflow.tracing.constant import GenAiSemconvKey, SpanAttributeKey
from mlflow.tracing.export.genai_semconv.translator import (
_translate_universal_attributes,
translate_span_to_genai,
)
def _make_span(
name="test_span",
attributes=None,
kind=SpanKind.INTERNAL,
start_time=1000000000,
end_time=2000000000,
):
context = SpanContext(
trace_id=0x000000000000000000000000DEADBEEF,
span_id=0x00000000DEADBEF0,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
)
return ReadableSpan(
name=name,
context=context,
kind=kind,
attributes=attributes or {},
start_time=start_time,
end_time=end_time,
status=Status(StatusCode.OK),
)
# --- _translate_universal_attributes ---
@pytest.mark.parametrize(
("span_type", "expected_operation"),
[
("CHAT_MODEL", "chat"),
("LLM", "generate_content"),
("EMBEDDING", "embeddings"),
("TOOL", "execute_tool"),
("AGENT", "invoke_agent"),
],
)
def test_translate_span_type_to_operation(span_type, expected_operation):
span = _make_span(attributes={SpanAttributeKey.SPAN_TYPE: json.dumps(span_type)})
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.OPERATION_NAME] == expected_operation
@pytest.mark.parametrize(
"span_type",
["CHAIN", "WORKFLOW", "PARSER", "MEMORY", "GUARDRAIL", "EVALUATOR", "RETRIEVER", "RERANKER"],
)
def test_translate_unmapped_span_type_passes_through_value(span_type):
span = _make_span(attributes={SpanAttributeKey.SPAN_TYPE: json.dumps(span_type)})
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.OPERATION_NAME] == span_type
def test_translate_model_name():
span = _make_span(
attributes={
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL"),
SpanAttributeKey.MODEL: json.dumps("gpt-4o"),
}
)
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.REQUEST_MODEL] == "gpt-4o"
def test_translate_provider():
span = _make_span(
attributes={
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL"),
SpanAttributeKey.MODEL_PROVIDER: json.dumps("openai"),
}
)
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.PROVIDER_NAME] == "openai"
def test_translate_token_usage():
usage = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
span = _make_span(
attributes={
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL"),
SpanAttributeKey.CHAT_USAGE: json.dumps(usage),
}
)
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.USAGE_INPUT_TOKENS] == 100
assert result[GenAiSemconvKey.USAGE_OUTPUT_TOKENS] == 50
def test_translate_tool_span_with_inputs_outputs():
tool_input = {"query": "what is MLflow?"}
tool_output = {"result": "MLflow is a platform..."}
span = _make_span(
attributes={
SpanAttributeKey.SPAN_TYPE: json.dumps("TOOL"),
SpanAttributeKey.INPUTS: json.dumps(tool_input),
SpanAttributeKey.OUTPUTS: json.dumps(tool_output),
}
)
result = _translate_universal_attributes(span)
assert result[GenAiSemconvKey.OPERATION_NAME] == "execute_tool"
assert json.loads(result[GenAiSemconvKey.TOOL_CALL_ARGUMENTS]) == tool_input
assert json.loads(result[GenAiSemconvKey.TOOL_CALL_RESULT]) == tool_output
def test_translate_missing_attributes():
span = _make_span(attributes={})
result = _translate_universal_attributes(span)
assert result == {}
def test_translate_malformed_json_attributes():
span = _make_span(
attributes={
SpanAttributeKey.SPAN_TYPE: "not valid json {",
SpanAttributeKey.MODEL: "also not valid {",
}
)
result = _translate_universal_attributes(span)
assert GenAiSemconvKey.OPERATION_NAME not in result
# --- _build_genai_span_name / _get_genai_span_kind (tested via translate_span_to_genai) ---
@pytest.mark.parametrize(
("operation", "model", "expected_name", "expected_kind"),
[
("chat", "gpt-4o", "chat gpt-4o", SpanKind.CLIENT),
(
"embeddings",
"text-embedding-3-small",
"embeddings text-embedding-3-small",
SpanKind.CLIENT,
),
("generate_content", "gemini-pro", "generate_content gemini-pro", SpanKind.CLIENT),
("execute_tool", None, "execute_tool", SpanKind.INTERNAL),
("invoke_agent", None, "invoke_agent", SpanKind.INTERNAL),
],
)
def test_span_name_and_kind(operation, model, expected_name, expected_kind):
operation_to_type = {
"chat": "CHAT_MODEL",
"generate_content": "LLM",
"embeddings": "EMBEDDING",
"execute_tool": "TOOL",
"invoke_agent": "AGENT",
}
attrs = {SpanAttributeKey.SPAN_TYPE: json.dumps(operation_to_type[operation])}
if model:
attrs[SpanAttributeKey.MODEL] = json.dumps(model)
span = _make_span(name="original", attributes=attrs)
result = translate_span_to_genai(span)
assert result.name == expected_name
assert result.kind == expected_kind
def test_span_name_unmapped_type_uses_span_type():
span = _make_span(name="my_chain", attributes={SpanAttributeKey.SPAN_TYPE: json.dumps("CHAIN")})
result = translate_span_to_genai(span)
assert result.name == "CHAIN"
# --- translate_span_to_genai (end-to-end) ---
def test_full_chat_span():
attrs = {
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL"),
SpanAttributeKey.MODEL: json.dumps("gpt-4o"),
SpanAttributeKey.MODEL_PROVIDER: json.dumps("openai"),
SpanAttributeKey.CHAT_USAGE: json.dumps({
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
}),
}
span = _make_span(name="ChatCompletion.create", attributes=attrs)
result = translate_span_to_genai(span)
assert result.name == "chat gpt-4o"
assert result.kind == SpanKind.CLIENT
assert result.attributes[GenAiSemconvKey.OPERATION_NAME] == "chat"
assert result.attributes[GenAiSemconvKey.REQUEST_MODEL] == "gpt-4o"
assert result.attributes[GenAiSemconvKey.PROVIDER_NAME] == "openai"
assert result.attributes[GenAiSemconvKey.USAGE_INPUT_TOKENS] == 100
assert result.attributes[GenAiSemconvKey.USAGE_OUTPUT_TOKENS] == 50
assert not any(k.startswith("mlflow.") for k in result.attributes)
def test_unmapped_span_type_passes_through():
attrs = {
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAIN"),
"custom.attribute": "preserved",
}
span = _make_span(name="my_chain", attributes=attrs)
result = translate_span_to_genai(span)
assert result.attributes[GenAiSemconvKey.OPERATION_NAME] == "CHAIN"
assert result.attributes["custom.attribute"] == "preserved"
assert not any(k.startswith("mlflow.") for k in result.attributes)
assert result.name == "CHAIN"
def test_non_mlflow_attributes_preserved():
attrs = {
SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL"),
"http.method": "POST",
"http.url": "https://api.openai.com/v1/chat/completions",
}
span = _make_span(attributes=attrs)
result = translate_span_to_genai(span)
assert result.attributes["http.method"] == "POST"
assert result.attributes["http.url"] == "https://api.openai.com/v1/chat/completions"
def test_span_context_preserved():
attrs = {SpanAttributeKey.SPAN_TYPE: json.dumps("CHAT_MODEL")}
span = _make_span(attributes=attrs)
result = translate_span_to_genai(span)
assert result.context == span.context
assert result.start_time == span.start_time
assert result.end_time == span.end_time
assert result.status == span.status
def test_empty_attributes():
span = _make_span(attributes={})
result = translate_span_to_genai(span)
assert result.attributes == {}