1085 lines
37 KiB
Python
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)
|