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

260 lines
11 KiB
Python

"""
OpenTelemetry REST API endpoints for MLflow FastAPI server.
This module implements the OpenTelemetry Protocol (OTLP) REST API for ingesting spans
according to the OTel specification:
https://opentelemetry.io/docs/specs/otlp/#otlphttp
Note: This is a minimal implementation that serves as a placeholder for the OTel endpoint.
The actual span ingestion logic would need to properly convert incoming OTel format spans
to MLflow spans, which requires more complex conversion logic.
"""
import base64
import json
import logging
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from google.protobuf.json_format import Error as ProtoJsonError
from google.protobuf.json_format import Parse as ParseJsonProto
from google.protobuf.message import DecodeError
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
ExportTraceServiceResponse,
)
from mlflow.entities.span import Span
from mlflow.exceptions import MlflowException
from mlflow.server.handlers import _get_tracking_store
from mlflow.telemetry.events import TraceSource, TracesReceivedByServerEvent
from mlflow.telemetry.track import _record_event
from mlflow.tracing.utils import dump_span_attribute_value
from mlflow.tracing.utils.otlp import (
MLFLOW_EXPERIMENT_ID_HEADER,
MLFLOW_RUN_ID_HEADER,
OTLP_TRACES_PATH,
_decode_otel_proto_anyvalue,
decompress_otlp_body,
)
from mlflow.tracking.request_header.default_request_header_provider import (
_MLFLOW_PYTHON_CLIENT_USER_AGENT_PREFIX,
_USER_AGENT,
)
_logger = logging.getLogger(__name__)
# Allowlist of known OTEL client service names.
# Only service names on this list are stored and propagated to root spans.
# This prevents storing arbitrary free-form text from untrusted clients.
_KNOWN_SERVICE_NAMES = frozenset({
# Claude Code
"claude-code",
# Codex CLI (Rust)
"codex_cli_rs",
# Codex VS Code extension
"codex_vscode",
# Gemini CLI
"gemini-cli",
# Qwen Code
"qwen-code",
})
# Span ID fields that need hex→base64 conversion in OTLP JSON payloads.
# OTLP JSON uses lowercase hex for these fields, but protobuf's JSON mapping
# (google.protobuf.json_format.Parse) expects base64 for `bytes` fields.
_OTLP_HEX_ID_FIELDS = ("traceId", "spanId", "parentSpanId")
def _convert_otlp_json_ids_to_base64(body: bytes) -> bytes:
"""Convert hex-encoded trace/span IDs to base64 in an OTLP JSON payload.
The OTLP spec encodes trace_id and span_id as hex strings in JSON:
https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding
But protobuf's canonical JSON mapping uses base64 for ``bytes`` fields:
https://protobuf.dev/programming-guides/proto3/#json
``google.protobuf.json_format.Parse`` follows the protobuf convention,
so we must convert the hex IDs to base64 before parsing.
"""
data = json.loads(body)
for resource_span in data.get("resourceSpans", []):
for scope_span in resource_span.get("scopeSpans", []):
for span in scope_span.get("spans", []):
for field in _OTLP_HEX_ID_FIELDS:
if hex_val := span.get(field):
span[field] = base64.b64encode(bytes.fromhex(hex_val)).decode("ascii")
return json.dumps(data).encode("utf-8")
# Create FastAPI router for OTel endpoints
otel_router = APIRouter(prefix=OTLP_TRACES_PATH, tags=["OpenTelemetry"])
@otel_router.post("", status_code=200)
async def export_traces(
request: Request,
x_mlflow_experiment_id: str = Header(..., alias=MLFLOW_EXPERIMENT_ID_HEADER),
x_mlflow_run_id: str | None = Header(default=None, alias=MLFLOW_RUN_ID_HEADER),
content_type: str | None = Header(default=None),
content_encoding: str | None = Header(default=None),
user_agent: str | None = Header(None, alias=_USER_AGENT),
) -> Response:
"""
Export trace spans to MLflow via the OpenTelemetry protocol.
This endpoint accepts OTLP/HTTP protobuf trace export requests.
Protobuf format reference: https://opentelemetry.io/docs/specs/otlp/#binary-protobuf-encoding
Note: All spans in the batch are persisted in a single log_spans() call. If that
call fails, the entire batch is rejected (all-or-nothing). Partial-success is not
supported; clients that need per-trace error isolation should batch by trace.
Args:
request: OTel ExportTraceServiceRequest in protobuf format
x_mlflow_experiment_id: Required header containing the experiment ID
x_mlflow_run_id: Optional header containing the run ID to associate with ingested traces
content_type: Content-Type header from the request
content_encoding: Content-Encoding header from the request
user_agent: User-Agent header (used to identify MLflow Python client)
Returns:
FastAPI Response with ExportTraceServiceResponse in protobuf format
Raises:
HTTPException: If the request is invalid or span logging fails
"""
# Validate Content-Type header. Normalize by stripping parameters like
# charset (e.g., "application/json; charset=utf-8" → "application/json").
media_type = content_type.split(";")[0].strip() if content_type else None
if media_type not in ("application/x-protobuf", "application/json"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid Content-Type: {content_type}. "
"Expected: application/x-protobuf or application/json",
)
# Read & decompress request body
body = await request.body()
if content_encoding:
body = decompress_otlp_body(body, content_encoding.lower())
# Parse payload — supports both protobuf and JSON encoding per the OTLP spec:
# https://opentelemetry.io/docs/specs/otlp/#otlphttp
parsed_request = ExportTraceServiceRequest()
try:
if media_type == "application/json":
# OTLP JSON encodes trace_id/span_id as hex strings, but protobuf's
# JSON mapping expects base64 for `bytes` fields (per proto3 spec).
# We must convert hex→base64 before calling Parse(), otherwise the
# IDs are decoded incorrectly and overflow downstream int conversions.
body = _convert_otlp_json_ids_to_base64(body)
ParseJsonProto(body, parsed_request, ignore_unknown_fields=True)
else:
# In Python protobuf library 5.x, ParseFromString may not raise
# DecodeError on invalid data
parsed_request.ParseFromString(body)
if not parsed_request.resource_spans:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid OpenTelemetry format - no spans found",
)
except (DecodeError, ProtoJsonError, json.JSONDecodeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid OpenTelemetry format",
)
all_spans = []
completed_trace_ids = set()
service_names = set()
for resource_span in parsed_request.resource_spans:
# Extract service.name from resource attributes for telemetry and root span propagation
resource_service_name = None
for attr in resource_span.resource.attributes:
if attr.key == "service.name":
value = _decode_otel_proto_anyvalue(attr.value)
if value is not None and str(value) in _KNOWN_SERVICE_NAMES:
resource_service_name = str(value)
service_names.add(resource_service_name)
break
resource = resource_span.resource
for scope_span in resource_span.scope_spans:
for otel_proto_span in scope_span.spans:
try:
mlflow_span = Span.from_otel_proto(otel_proto_span, resource=resource)
# Propagate service.name onto root spans so it's visible
# in the UI. Per the OTel resource spec, resource attrs
# describe the entity producing telemetry:
# https://opentelemetry.io/docs/specs/otel/resource/sdk/
if mlflow_span.parent_id is None:
completed_trace_ids.add(mlflow_span.trace_id)
if resource_service_name:
mlflow_span._span._attributes["service.name"] = (
dump_span_attribute_value(resource_service_name)
)
all_spans.append(mlflow_span)
except Exception:
raise HTTPException(
status_code=422,
detail="Cannot convert OpenTelemetry span to MLflow span",
)
if all_spans:
store = _get_tracking_store()
try:
store.log_spans(x_mlflow_experiment_id, all_spans)
except NotImplementedError:
store_name = store.__class__.__name__
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=f"REST OTLP span logging is not supported by {store_name}",
)
except MlflowException as e:
return JSONResponse(
status_code=e.get_http_status_code(),
content=json.loads(e.serialize_as_json()),
)
except Exception as e:
raise HTTPException(status_code=422, detail="Failed to log OpenTelemetry spans") from e
if x_mlflow_run_id and completed_trace_ids:
try:
store.link_traces_to_run(list(completed_trace_ids), x_mlflow_run_id)
except Exception:
_logger.exception("Failed to link OpenTelemetry traces to MLflow run")
if completed_trace_ids:
if user_agent and user_agent.startswith(_MLFLOW_PYTHON_CLIENT_USER_AGENT_PREFIX):
trace_source = TraceSource.MLFLOW_PYTHON_CLIENT
elif service_names:
trace_source = TraceSource.EXTERNAL_OTEL_CLIENT
else:
trace_source = TraceSource.UNKNOWN
event_params: dict[str, object] = {
"source": trace_source,
"count": len(completed_trace_ids),
}
if service_names:
event_params["service_names"] = sorted(service_names)
_record_event(TracesReceivedByServerEvent, event_params)
# Return protobuf response as per OTLP specification
response_message = ExportTraceServiceResponse()
response_bytes = response_message.SerializeToString()
return Response(
content=response_bytes,
media_type="application/x-protobuf",
status_code=200,
)