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

1085 lines
37 KiB
Python

import gzip
import time
import zlib
from pathlib import Path
from typing import Iterator
from unittest import mock
import pytest
import requests
from opentelemetry import trace as otel_trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
ExportTraceServiceResponse,
)
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, InstrumentationScope, KeyValue
from opentelemetry.proto.resource.v1.resource_pb2 import Resource
from opentelemetry.proto.trace.v1.trace_pb2 import ResourceSpans, ScopeSpans
from opentelemetry.proto.trace.v1.trace_pb2 import Span as OTelProtoSpan
from opentelemetry.sdk.resources import Resource as OTelSDKResource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor
from opentelemetry.util._once import Once
import mlflow
from mlflow.server import handlers
from mlflow.server.fastapi_app import app as mlflow_app
from mlflow.server.handlers import initialize_backend_stores
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.telemetry.client import TelemetryClient
from mlflow.telemetry.events import TraceSource, TracesReceivedByServerEvent
from mlflow.tracing.utils import encode_trace_id
from mlflow.tracing.utils.otlp import MLFLOW_EXPERIMENT_ID_HEADER
from mlflow.version import IS_TRACING_SDK_ONLY
from tests.helper_functions import get_safe_port
from tests.tracking.integration_test_utils import ServerThread
if IS_TRACING_SDK_ONLY:
pytest.skip("OTel endpoint tests require full MLflow server", allow_module_level=True)
@pytest.fixture
def mlflow_server(tmp_path: Path, db_uri: str) -> Iterator[str]:
artifact_root = tmp_path.as_uri()
handlers._tracking_store = None
handlers._model_registry_store = None
initialize_backend_stores(db_uri, default_artifact_root=artifact_root)
# Start the FastAPI app in a background thread and yield its URL.
with ServerThread(mlflow_app, get_safe_port()) as url:
yield url
# Drain any pending async trace exports while the server is still up.
# The autouse `enable_async_trace_logging` fixture in tests/tracing/conftest.py
# also calls flush on teardown, but by that time this ServerThread has
# already exited, causing the worker to retry against a dead server.
mlflow.flush_trace_async_logging()
def test_otel_client_sends_spans_to_mlflow_database(mlflow_server: str, monkeypatch):
"""
Test end-to-end: OpenTelemetry client sends spans via experiment ID header to MLflow.
Note: This test verifies that spans are successfully accepted by the server.
Without artifact upload, traces won't be retrievable via search_traces.
"""
# Enable synchronous trace logging to ensure traces are immediately available
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING", "false")
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-test-experiment")
experiment_id = experiment.experiment_id
resource = OTelSDKResource.create({"service.name": "test-service-e2e"})
tracer_provider = TracerProvider(resource=resource)
# First, verify the endpoint is reachable
test_response = requests.get(f"{mlflow_server}/health", timeout=5)
assert test_response.status_code == 200, (
f"Server health check failed: {test_response.status_code}"
)
exporter = OTLPSpanExporter(
endpoint=f"{mlflow_server}/v1/traces",
headers={MLFLOW_EXPERIMENT_ID_HEADER: experiment_id},
timeout=10, # Explicit timeout
)
# Use SimpleSpanProcessor for immediate span export in tests
# This ensures spans are sent immediately rather than batched
span_processor = SimpleSpanProcessor(exporter)
tracer_provider.add_span_processor(span_processor)
# Reset the global tracer provider to avoid conflicts with other tests.
# This is necessary because OpenTelemetry doesn't allow overriding an already-set provider.
#
# NOTE: We're using internal APIs here (_TRACER_PROVIDER_SET_ONCE and _TRACER_PROVIDER)
# because OpenTelemetry doesn't provide a public API to reset the global tracer provider.
# The library is designed to set the provider once at application startup, which doesn't
# work well for testing scenarios where different tests need different configurations.
# This pattern is also used in tests/semantic_kernel/conftest.py for the same reason.
otel_trace._TRACER_PROVIDER_SET_ONCE = Once()
otel_trace._TRACER_PROVIDER = None
# Set the tracer provider
otel_trace.set_tracer_provider(tracer_provider)
tracer = otel_trace.get_tracer(__name__)
with tracer.start_as_current_span("otel-e2e-test-span") as span:
span.set_attribute("test.e2e.attribute", "e2e-test-value")
# Capture the OTel trace ID to verify it matches the MLflow trace ID
otel_trace_id = span.get_span_context().trace_id
# Verify the span was actually created and has valid context
assert span.get_span_context().is_valid, "Span context is not valid"
assert otel_trace_id != 0, "Trace ID is zero"
# Add a small delay to ensure the server has processed the spans
time.sleep(0.5)
# Wait up to 30 seconds for search_traces() to return a trace
traces = []
for _ in range(30):
traces = mlflow.search_traces(
locations=[experiment_id], include_spans=False, return_type="list"
)
if traces:
break
time.sleep(1)
assert len(traces) > 0, "No traces found in the database after sending spans"
# Verify the trace ID matches the expected format based on the OTel span
expected_trace_id = f"tr-{encode_trace_id(otel_trace_id)}"
actual_trace_id = traces[0].info.trace_id
assert actual_trace_id == expected_trace_id, (
f"Trace ID mismatch: expected {expected_trace_id}, got {actual_trace_id}"
)
def test_otel_endpoint_requires_experiment_id_header(mlflow_server: str):
"""
Test that the OTel endpoint requires experiment ID header.
"""
# Create protobuf request
span = OTelProtoSpan()
span.trace_id = bytes.fromhex("0000000000000002" + "0" * 16)
span.span_id = bytes.fromhex("00000002" + "0" * 8)
span.name = "test-span-no-header"
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={"Content-Type": "application/x-protobuf"},
timeout=10,
)
assert response.status_code == 422
def test_invalid_otel_span_format_returns_400(mlflow_server: str):
"""
Test that invalid OpenTelemetry protobuf format returns HTTP 400.
"""
# Send completely invalid protobuf data
invalid_protobuf_data = b"this is not valid protobuf data at all"
response = requests.post(
f"{mlflow_server}/v1/traces",
data=invalid_protobuf_data,
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: "test-experiment",
},
timeout=10,
)
assert response.status_code == 400, f"Expected 400, got {response.status_code}"
def test_missing_required_span_fields_returns_422(mlflow_server: str):
"""
Test that spans that fail MLflow conversion return HTTP 422.
"""
# Create protobuf request with missing span_id (this should cause MLflow conversion to fail)
span = OTelProtoSpan()
span.trace_id = bytes.fromhex("0000000000000001" + "0" * 16)
# Don't set span_id - this should cause from_otel_proto to fail
span.name = "incomplete-span"
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: "0",
},
timeout=10,
)
assert response.status_code == 422
def test_missing_experiment_id_header_returns_422(mlflow_server: str):
"""
Test that missing experiment ID header returns HTTP 422 (FastAPI validation error).
"""
# Create valid protobuf request
span = OTelProtoSpan()
span.trace_id = bytes.fromhex("0000000000000003" + "0" * 16)
span.span_id = bytes.fromhex("00000003" + "0" * 8)
span.name = "test-span"
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={"Content-Type": "application/x-protobuf"},
timeout=10,
)
assert response.status_code == 422
def test_invalid_content_type_returns_400(mlflow_server: str):
"""
Test that invalid Content-Type header returns HTTP 400.
"""
# Create a valid OTLP request
span = OTelProtoSpan()
span.trace_id = b"1234567890123456"
span.span_id = b"12345678"
span.name = "test-span"
span.start_time_unix_nano = 1000000000
span.end_time_unix_nano = 2000000000
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
# Send request with unsupported Content-Type
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "text/plain",
MLFLOW_EXPERIMENT_ID_HEADER: "test-experiment",
},
timeout=10,
)
assert response.status_code == 400
assert "Invalid Content-Type" in response.text
def test_empty_resource_spans_returns_400(mlflow_server: str):
request = ExportTraceServiceRequest()
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: "test-experiment",
},
timeout=10,
)
assert response.status_code == 400
assert "no spans found" in response.text
def test_batch_span_processor_with_multiple_traces(mlflow_server: str):
"""
Test that BatchSpanProcessor can send spans from multiple traces in a single request.
This verifies the server-side grouping by trace_id functionality.
"""
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-batch-test-experiment")
experiment_id = experiment.experiment_id
resource = OTelSDKResource.create({"service.name": "test-batch-service"})
tracer_provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=f"{mlflow_server}/v1/traces",
headers={MLFLOW_EXPERIMENT_ID_HEADER: experiment_id},
timeout=10,
)
# Use BatchSpanProcessor to batch spans from multiple traces
span_processor = BatchSpanProcessor(exporter)
tracer_provider.add_span_processor(span_processor)
# Reset the global tracer provider
otel_trace._TRACER_PROVIDER_SET_ONCE = Once()
otel_trace._TRACER_PROVIDER = None
otel_trace.set_tracer_provider(tracer_provider)
tracer = otel_trace.get_tracer(__name__)
# Create multiple traces with spans
trace_ids = []
for i in range(3):
with tracer.start_as_current_span(f"batch-test-span-{i}") as span:
span.set_attribute("test.batch.index", i)
otel_trace_id = span.get_span_context().trace_id
trace_ids.append(otel_trace_id)
assert otel_trace_id != 0
# Force flush to send all batched spans
span_processor.force_flush()
traces = mlflow.search_traces(
locations=[experiment_id], include_spans=False, return_type="list"
)
assert len(traces) == 3
# Verify all expected trace IDs are present
expected_trace_ids = {f"tr-{encode_trace_id(tid)}" for tid in trace_ids}
actual_trace_ids = {trace.info.trace_id for trace in traces}
assert expected_trace_ids == actual_trace_ids
def test_multiple_traces_in_single_request(mlflow_server: str):
"""
Test that a single request containing spans from multiple traces is handled correctly.
This simulates what BatchSpanProcessor does internally.
"""
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-multi-trace-test")
experiment_id = experiment.experiment_id
# Create protobuf request with spans from 3 different traces
request = ExportTraceServiceRequest()
for trace_num in range(3):
# Create a span with unique trace_id
span = OTelProtoSpan()
trace_id_hex = f"{trace_num:016x}" + "0" * 16
span.trace_id = bytes.fromhex(trace_id_hex)
span.span_id = bytes.fromhex(f"{trace_num:08x}" + "0" * 8)
span.name = f"multi-trace-span-{trace_num}"
span.start_time_unix_nano = 1000000000 + trace_num * 1000
span.end_time_unix_nano = 2000000000 + trace_num * 1000
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request.resource_spans.append(resource_spans)
# Send the request with multiple traces
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
traces = mlflow.search_traces(
locations=[experiment_id], include_spans=False, return_type="list"
)
assert len(traces) == 3
def test_logging_many_traces_in_single_request(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-many-traces-test")
experiment_id = experiment.experiment_id
# Create a request with 15 different traces (exceeds the 10 thread pool limit)
request = ExportTraceServiceRequest()
num_traces = 15
for trace_num in range(num_traces):
span = OTelProtoSpan()
trace_id_hex = f"{trace_num + 1000:016x}" + "0" * 16
span.trace_id = bytes.fromhex(trace_id_hex)
span.span_id = bytes.fromhex(f"{trace_num + 1000:08x}" + "0" * 8)
span.name = f"many-traces-test-span-{trace_num}"
span.start_time_unix_nano = 1000000000 + trace_num * 1000
span.end_time_unix_nano = 2000000000 + trace_num * 1000
scope = InstrumentationScope()
scope.name = "many-traces-test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request.resource_spans.append(resource_spans)
# Send the request and measure response time
requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
traces = mlflow.search_traces(
locations=[experiment_id], include_spans=False, return_type="list"
)
assert len(traces) == num_traces
def test_mixed_trace_spans_in_single_request(mlflow_server: str):
"""
Test that multiple spans from the same trace, mixed with spans from other traces,
are grouped and logged correctly.
"""
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-mixed-test")
experiment_id = experiment.experiment_id
request = ExportTraceServiceRequest()
# Create 2 spans for trace A, 1 span for trace B, 2 spans for trace C
trace_configs = [
("A", 0, 2), # trace A with 2 spans
("B", 1, 1), # trace B with 1 span
("C", 2, 2), # trace C with 2 spans
]
for trace_name, trace_id_num, span_count in trace_configs:
trace_id_hex = f"{trace_id_num + 2000:016x}" + "0" * 16
for span_num in range(span_count):
span = OTelProtoSpan(
trace_id=bytes.fromhex(trace_id_hex),
span_id=bytes.fromhex(f"{trace_id_num * 100 + span_num:08x}" + "0" * 8),
name=f"mixed-span-{trace_name}-{span_num}",
start_time_unix_nano=1000000000 + span_num * 1000,
end_time_unix_nano=2000000000 + span_num * 1000,
)
scope = InstrumentationScope(
name="mixed-test-scope",
)
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request.resource_spans.append(resource_spans)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
traces = mlflow.search_traces(locations=[experiment_id], include_spans=True, return_type="list")
assert len(traces) == 3
span_counts = [len(trace.data.spans) for trace in traces]
assert span_counts == [2, 1, 2]
def test_error_logging_spans(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-error-test")
experiment_id = experiment.experiment_id
resource = OTelSDKResource.create({"service.name": "test-batch-service"})
tracer_provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=f"{mlflow_server}/v1/traces",
headers={MLFLOW_EXPERIMENT_ID_HEADER: experiment_id},
timeout=10,
)
# Use BatchSpanProcessor to batch spans from multiple traces
span_processor = BatchSpanProcessor(exporter)
tracer_provider.add_span_processor(span_processor)
# Reset the global tracer provider
otel_trace._TRACER_PROVIDER_SET_ONCE = Once()
otel_trace._TRACER_PROVIDER = None
otel_trace.set_tracer_provider(tracer_provider)
tracer = otel_trace.get_tracer(__name__)
original_log_spans = SqlAlchemyStore.log_spans
call_count = {"count": 0}
def mock_log_spans(self, *args, **kwargs):
if call_count["count"] == 0:
call_count["count"] += 1
raise Exception("test_error")
else:
return original_log_spans(self, *args, **kwargs)
with (
mock.patch.object(SqlAlchemyStore, "log_spans", mock_log_spans),
mock.patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter._logger.error"
) as mock_error,
):
for _ in range(2):
with tracer.start_as_current_span("batch-test-span-0"):
pass
span_processor.force_flush()
assert any("Failed to export" in error[0][0] for error in mock_error.call_args_list)
traces = mlflow.search_traces(
locations=[experiment_id], include_spans=False, return_type="list"
)
# The OTLP endpoint now calls log_spans once for all spans in the batch.
# If that call fails, all spans in the batch are dropped together (HTTP 422 is
# non-retryable for the OTel OTLP exporter). Previously, per-trace calls meant
# the second trace could still succeed. With the unified log_spans call, the
# result is 0 stored traces.
assert len(traces) == 0
def test_otel_trace_received_telemetry_from_mlflow_client(mlflow_server: str):
"""
Test TraceReceivedByServerEvent telemetry shows source=MLFLOW_PYTHON_CLIENT for standard client.
Uses @mlflow.trace with standard MLflow client configuration, which automatically sends
User-Agent and X-MLflow-Client-Version headers to identify traces from MLflow client.
"""
mlflow.set_tracking_uri(mlflow_server)
mlflow.set_experiment("otel-telemetry-mlflow-client-test")
with mock.patch("mlflow.telemetry.track.get_telemetry_client") as mock_get_client:
mock_client = mock.MagicMock(spec=TelemetryClient)
mock_get_client.return_value = mock_client
@mlflow.trace
def test_function():
return "test result"
result = test_function()
assert result == "test result"
time.sleep(1)
if mock_client.add_record.called:
record = mock_client.add_record.call_args[0][0]
assert record.event_name == TracesReceivedByServerEvent.name
assert record.params["source"] == TraceSource.MLFLOW_PYTHON_CLIENT.value
assert record.params["count"] == 1
def test_otel_trace_received_telemetry_from_external_client(mlflow_server: str):
"""
Test TracesReceivedByServerEvent telemetry shows source=UNKNOWN for external clients.
Sends a direct protobuf request without MLflow client headers to simulate an external
OpenTelemetry client (not MLflow client). Tests with 2 traces to verify count field.
"""
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-telemetry-external-client-test")
experiment_id = experiment.experiment_id
trace_id_1 = bytes.fromhex("0000000000000100" + "0" * 16)
trace_id_2 = bytes.fromhex("0000000000000200" + "0" * 16)
request = ExportTraceServiceRequest()
# First trace with root span and child spans
request.resource_spans.append(
ResourceSpans(
scope_spans=[
ScopeSpans(
scope=InstrumentationScope(name="telemetry-test-scope"),
spans=[
OTelProtoSpan(
trace_id=trace_id_1,
span_id=bytes.fromhex("00000001" + "0" * 8),
name="root-span-1",
start_time_unix_nano=1000000000,
end_time_unix_nano=2000000000,
),
OTelProtoSpan(
trace_id=trace_id_1,
span_id=bytes.fromhex("00000002" + "0" * 8),
parent_span_id=bytes.fromhex("00000001" + "0" * 8),
name="child-span-1",
start_time_unix_nano=1100000000,
end_time_unix_nano=1500000000,
),
],
)
]
)
)
# Second trace with root span
request.resource_spans.append(
ResourceSpans(
scope_spans=[
ScopeSpans(
scope=InstrumentationScope(name="telemetry-test-scope"),
spans=[
OTelProtoSpan(
trace_id=trace_id_2,
span_id=bytes.fromhex("00000003" + "0" * 8),
name="root-span-2",
start_time_unix_nano=1600000000,
end_time_unix_nano=1900000000,
),
],
)
]
)
)
with mock.patch("mlflow.telemetry.track.get_telemetry_client") as mock_get_client:
mock_client = mock.MagicMock(spec=TelemetryClient)
mock_client.config = None # Ensure telemetry is not disabled for any event
mock_get_client.return_value = mock_client
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
mock_client.add_record.assert_called_once()
record = mock_client.add_record.call_args[0][0]
assert record.event_name == TracesReceivedByServerEvent.name
assert record.status.value == "success"
assert record.params["source"] == TraceSource.UNKNOWN.value
assert record.params["count"] == 2
@pytest.mark.parametrize(
("service_name", "expected_source", "expected_service_names"),
[
("codex_cli_rs", TraceSource.EXTERNAL_OTEL_CLIENT, ["codex_cli_rs"]),
("gemini-cli", TraceSource.EXTERNAL_OTEL_CLIENT, ["gemini-cli"]),
("qwen-code", TraceSource.EXTERNAL_OTEL_CLIENT, ["qwen-code"]),
# Unknown service names are not on the allowlist — source falls back to UNKNOWN
("my-custom-app", TraceSource.UNKNOWN, None),
],
)
def test_otel_trace_received_telemetry_from_external_otel_client_with_service_name(
mlflow_server: str,
service_name: str,
expected_source: TraceSource,
expected_service_names: list[str] | None,
):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-telemetry-service-name-test")
experiment_id = experiment.experiment_id
trace_id = bytes.fromhex("0000000000000500" + "0" * 16)
request = ExportTraceServiceRequest()
request.resource_spans.append(
ResourceSpans(
resource=Resource(
attributes=[
KeyValue(key="service.name", value=AnyValue(string_value=service_name)),
]
),
scope_spans=[
ScopeSpans(
scope=InstrumentationScope(name="test-scope"),
spans=[
OTelProtoSpan(
trace_id=trace_id,
span_id=bytes.fromhex("00000005" + "0" * 8),
name="root-span",
start_time_unix_nano=1000000000,
end_time_unix_nano=2000000000,
),
],
)
],
)
)
with mock.patch("mlflow.telemetry.track.get_telemetry_client") as mock_get_client:
mock_client = mock.MagicMock(spec=TelemetryClient)
mock_client.config = None
mock_get_client.return_value = mock_client
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
mock_client.add_record.assert_called_once()
record = mock_client.add_record.call_args[0][0]
assert record.event_name == TracesReceivedByServerEvent.name
assert record.params["source"] == expected_source.value
assert record.params["count"] == 1
if expected_service_names is not None:
assert record.params["service_names"] == expected_service_names
else:
assert "service_names" not in record.params
def test_service_name_propagated_to_root_span(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-service-name-on-span-test")
experiment_id = experiment.experiment_id
trace_id = bytes.fromhex("0000000000000600" + "0" * 16)
root_span_id = bytes.fromhex("00000006" + "0" * 8)
child_span_id = bytes.fromhex("00000007" + "0" * 8)
request = ExportTraceServiceRequest()
request.resource_spans.append(
ResourceSpans(
resource=Resource(
attributes=[
KeyValue(key="service.name", value=AnyValue(string_value="gemini-cli")),
]
),
scope_spans=[
ScopeSpans(
scope=InstrumentationScope(name="test-scope"),
spans=[
OTelProtoSpan(
trace_id=trace_id,
span_id=root_span_id,
name="root-span",
start_time_unix_nano=1000000000,
end_time_unix_nano=2000000000,
),
OTelProtoSpan(
trace_id=trace_id,
span_id=child_span_id,
parent_span_id=root_span_id,
name="child-span",
start_time_unix_nano=1100000000,
end_time_unix_nano=1500000000,
),
],
)
],
)
)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
traces = mlflow.search_traces(locations=[experiment_id], include_spans=True, return_type="list")
assert len(traces) == 1
root_span = next(s for s in traces[0].data.spans if s.parent_id is None)
child_span = next(s for s in traces[0].data.spans if s.parent_id is not None)
# service.name should be on the root span only
assert root_span.get_attribute("service.name") == "gemini-cli"
assert child_span.get_attribute("service.name") is None
def test_otlp_json_encoding(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-json-encoding-test")
experiment_id = experiment.experiment_id
trace_id_hex = "61181d5c16b3b25d966891c3fc595af9"
span_id_hex = "508755a859b294e3"
payload = {
"resourceSpans": [
{
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "gemini-cli"}},
]
},
"scopeSpans": [
{
"scope": {"name": "gemini-cli"},
"spans": [
{
"traceId": trace_id_hex,
"spanId": span_id_hex,
"name": "user_prompt",
"startTimeUnixNano": str(int(time.time() * 1e9)),
"endTimeUnixNano": str(int(time.time() * 1e9) + 1000000000),
"attributes": [
{
"key": "gen_ai.operation.name",
"value": {"stringValue": "user_prompt"},
},
],
}
],
}
],
}
]
}
response = requests.post(
f"{mlflow_server}/v1/traces",
json=payload,
headers={
"Content-Type": "application/json",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
traces = mlflow.search_traces(locations=[experiment_id], include_spans=True, return_type="list")
assert len(traces) == 1
root_span = traces[0].data.spans[0]
assert root_span.name == "user_prompt"
assert root_span.get_attribute("service.name") == "gemini-cli"
def test_response_is_protobuf_format(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-protobuf-response-test")
experiment_id = experiment.experiment_id
# Create a valid OTLP request
span = OTelProtoSpan()
span.trace_id = bytes.fromhex("0000000000000400" + "0" * 16)
span.span_id = bytes.fromhex("00000004" + "0" * 8)
span.name = "protobuf-test-span"
span.start_time_unix_nano = 1000000000
span.end_time_unix_nano = 2000000000
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=request.SerializeToString(),
headers={
"Content-Type": "application/x-protobuf",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
# Verify response status and content-type
assert response.status_code == 200
assert response.headers.get("content-type") == "application/x-protobuf"
# Verify the response can be parsed as a valid ExportTraceServiceResponse
response_message = ExportTraceServiceResponse()
response_message.ParseFromString(response.content)
def _build_valid_otlp_request() -> str:
"""Helper: Build a valid OTLP ExportTraceServiceRequest protobuf."""
span = OTelProtoSpan()
span.trace_id = bytes.fromhex("0000000000000400" + "0" * 16)
span.span_id = bytes.fromhex("00000004" + "0" * 8)
span.name = "test-span"
span.start_time_unix_nano = 1000000000
span.end_time_unix_nano = 2000000000
scope = InstrumentationScope()
scope.name = "test-scope"
scope_spans = ScopeSpans()
scope_spans.scope.CopyFrom(scope)
scope_spans.spans.append(span)
resource = Resource()
resource_spans = ResourceSpans()
resource_spans.resource.CopyFrom(resource)
resource_spans.scope_spans.append(scope_spans)
request = ExportTraceServiceRequest()
request.resource_spans.append(resource_spans)
return request.SerializeToString()
def test_otlp_traces_no_compression(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-identity-test")
experiment_id = experiment.experiment_id
data = _build_valid_otlp_request()
response = requests.post(
f"{mlflow_server}/v1/traces",
data=data,
headers={
"Content-Type": "application/x-protobuf",
# No Content-Encoding -> no compression
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
assert response.headers.get("content-type") == "application/x-protobuf"
msg = ExportTraceServiceResponse()
msg.ParseFromString(response.content)
def test_otlp_traces_gzip_compression(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-gzip-test")
experiment_id = experiment.experiment_id
raw = _build_valid_otlp_request()
compressed = gzip.compress(raw)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=compressed,
headers={
"Content-Type": "application/x-protobuf",
"Content-Encoding": "gzip",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
assert response.headers.get("content-type") == "application/x-protobuf"
msg = ExportTraceServiceResponse()
msg.ParseFromString(response.content)
def test_otlp_traces_deflate_compression(mlflow_server: str):
mlflow.set_tracking_uri(mlflow_server)
experiment = mlflow.set_experiment("otel-deflate-test")
experiment_id = experiment.experiment_id
raw = _build_valid_otlp_request()
compressed = zlib.compress(raw)
response = requests.post(
f"{mlflow_server}/v1/traces",
data=compressed,
headers={
"Content-Type": "application/x-protobuf",
"Content-Encoding": "deflate",
MLFLOW_EXPERIMENT_ID_HEADER: experiment_id,
},
timeout=10,
)
assert response.status_code == 200
assert response.headers.get("content-type") == "application/x-protobuf"
msg = ExportTraceServiceResponse()
msg.ParseFromString(response.content)