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

286 lines
10 KiB
Python

import base64
import gzip
import os
import zlib
from typing import Any
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, ArrayValue, KeyValueList
from opentelemetry.proto.resource.v1.resource_pb2 import Resource as OTelProtoResource
from opentelemetry.sdk.resources import Resource as OTelResource
from opentelemetry.sdk.trace.export import SpanExporter
from mlflow.environment_variables import MLFLOW_ENABLE_OTLP_EXPORTER, MLFLOW_TRACKING_TOKEN
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST
from mlflow.utils.credentials import read_mlflow_creds
# Constants for OpenTelemetry integration
MLFLOW_EXPERIMENT_ID_HEADER = "x-mlflow-experiment-id"
MLFLOW_RUN_ID_HEADER = "x-mlflow-run-id"
OTLP_TRACES_PATH = "/v1/traces"
OTLP_METRICS_PATH = "/v1/metrics"
def build_otlp_headers(experiment_id: str) -> dict[str, str]:
"""Build OTLP exporter headers with experiment ID and auth credentials."""
headers: dict[str, str] = {MLFLOW_EXPERIMENT_ID_HEADER: experiment_id}
creds = read_mlflow_creds()
if creds.username and creds.password:
basic_auth_str = f"{creds.username}:{creds.password}".encode()
headers["Authorization"] = f"Basic {base64.standard_b64encode(basic_auth_str).decode()}"
elif token := MLFLOW_TRACKING_TOKEN.get():
headers["Authorization"] = f"Bearer {token}"
return headers
def should_use_otlp_exporter() -> bool:
"""
Determine if OTLP traces should be exported based on environment configuration.
"""
return _get_otlp_traces_endpoint() is not None and MLFLOW_ENABLE_OTLP_EXPORTER.get()
def should_export_otlp_metrics() -> bool:
"""
Determine if OTLP metrics should be exported based on environment configuration.
Returns True if metrics endpoint is configured.
"""
return _get_otlp_metrics_endpoint() is not None
def get_otlp_exporter() -> SpanExporter:
"""
Get the OTLP exporter based on the configured protocol.
"""
endpoint = _get_otlp_traces_endpoint()
protocol = _get_otlp_protocol()
if protocol == "grpc":
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
except ImportError:
raise MlflowException(
"gRPC OTLP exporter is not available. Please install the required dependency by "
"running `pip install opentelemetry-exporter-otlp-proto-grpc`.",
error_code=RESOURCE_DOES_NOT_EXIST,
)
return OTLPSpanExporter(endpoint=endpoint)
elif protocol == "http/protobuf":
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
except ImportError as e:
raise MlflowException(
"HTTP OTLP exporter is not available. Please install the required dependency by "
"running `pip install opentelemetry-exporter-otlp-proto-http`.",
error_code=RESOURCE_DOES_NOT_EXIST,
) from e
return OTLPSpanExporter(endpoint=endpoint)
else:
raise MlflowException.invalid_parameter_value(
f"Unsupported OTLP protocol '{protocol}' is configured. Please set "
"the protocol to either 'grpc' or 'http/protobuf'."
)
def _get_otlp_traces_endpoint() -> str | None:
"""
Get the OTLP endpoint from the environment variables.
Ref: https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#endpoint-configuration
Per the OTel spec:
- OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: Full URL used as-is
- OTEL_EXPORTER_OTLP_ENDPOINT: Base URL, requires appending signal path
"""
if traces_endpoint := os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"):
return traces_endpoint
if base_endpoint := os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
return base_endpoint.rstrip("/") + OTLP_TRACES_PATH
return None
def _get_otlp_metrics_endpoint() -> str | None:
"""
Get the OTLP metrics endpoint from the environment variables.
Per the OTel spec:
- OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: Full URL used as-is
- OTEL_EXPORTER_OTLP_ENDPOINT: Base URL, requires appending signal path
"""
if metrics_endpoint := os.environ.get("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"):
return metrics_endpoint
if base_endpoint := os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
return base_endpoint.rstrip("/") + OTLP_METRICS_PATH
return None
def _get_otlp_protocol(default_value: str = "grpc") -> str:
"""
Get the OTLP traces protocol from environment variables.
Returns the value of OTEL_EXPORTER_OTLP_TRACES_PROTOCOL if set,
otherwise falls back to OTEL_EXPORTER_OTLP_PROTOCOL, then to default_value.
Args:
default_value: The default protocol to use if no environment variables are set.
"""
return os.environ.get("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") or os.environ.get(
"OTEL_EXPORTER_OTLP_PROTOCOL", default_value
)
def _get_otlp_metrics_protocol(default_value: str = "grpc") -> str:
"""
Get the OTLP metrics protocol from environment variables.
Returns the value of OTEL_EXPORTER_OTLP_METRICS_PROTOCOL if set,
otherwise falls back to OTEL_EXPORTER_OTLP_PROTOCOL, then to default_value.
Args:
default_value: The default protocol to use if no environment variables are set.
"""
return os.environ.get("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL") or os.environ.get(
"OTEL_EXPORTER_OTLP_PROTOCOL", default_value
)
def _otel_proto_bytes_to_id(id_bytes: bytes) -> int:
"""Convert OTel protobuf bytes to integer ID."""
return int.from_bytes(id_bytes, byteorder="big", signed=False)
def _sanitize_otel_string(value: str) -> str:
"""Replace characters that cannot be encoded as UTF-8 for protobuf string assignment."""
return value.encode("utf-8", errors="replace").decode("utf-8")
def _set_otel_proto_anyvalue(pb_any_value: AnyValue, value: Any) -> None:
"""Set a value on an OTel protobuf AnyValue message.
Args:
pb_any_value: The OTel protobuf AnyValue message to populate.
value: The value to set.
"""
if value is None:
# Leave the value unset for None
pass
elif isinstance(value, bool):
pb_any_value.bool_value = value
elif isinstance(value, str):
pb_any_value.string_value = _sanitize_otel_string(value)
elif isinstance(value, int):
pb_any_value.int_value = value
elif isinstance(value, float):
pb_any_value.double_value = value
elif isinstance(value, bytes):
pb_any_value.bytes_value = value
elif isinstance(value, (list, tuple)):
# Explicitly set array_value using CopyFrom to ensure the field is set even for empty lists
array_value = ArrayValue()
for item in value:
_set_otel_proto_anyvalue(array_value.values.add(), item)
pb_any_value.array_value.CopyFrom(array_value)
elif isinstance(value, dict):
# Explicitly set kvlist_value using CopyFrom to ensure the field is set even for empty dicts
kvlist_value = KeyValueList()
for k, v in value.items():
kv = kvlist_value.values.add()
kv.key = _sanitize_otel_string(str(k))
_set_otel_proto_anyvalue(kv.value, v)
pb_any_value.kvlist_value.CopyFrom(kvlist_value)
else:
# For unknown types, convert to string
pb_any_value.string_value = _sanitize_otel_string(str(value))
def _decode_otel_proto_anyvalue(pb_any_value: AnyValue) -> Any:
"""Decode an OTel protobuf AnyValue.
Args:
pb_any_value: The OTel protobuf AnyValue message to decode.
Returns:
The decoded value.
"""
value_type = pb_any_value.WhichOneof("value")
if not value_type:
return None
# Handle complex types that need recursion
if value_type == "array_value":
return [_decode_otel_proto_anyvalue(v) for v in pb_any_value.array_value.values]
elif value_type == "kvlist_value":
return {
kv.key: _decode_otel_proto_anyvalue(kv.value) for kv in pb_any_value.kvlist_value.values
}
else:
# For simple types, just get the attribute directly
return getattr(pb_any_value, value_type)
def decompress_otlp_body(raw_body: bytes, content_encoding: str) -> bytes:
"""
Decompress OTLP request body according to Content-Encoding.
Supported encodings:
- gzip
- deflate (RFC-compliant and raw deflate)
Raises HTTPException if the payload cannot be decompressed.
"""
from fastapi import HTTPException, status
match content_encoding:
case "gzip":
try:
return gzip.decompress(raw_body)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to decompress gzip payload",
)
case "deflate":
try:
return zlib.decompress(raw_body)
except Exception:
# Try raw DEFLATE stream (some clients send this)
try:
return zlib.decompress(raw_body, -zlib.MAX_WBITS)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to decompress deflate payload",
)
case _:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported Content-Encoding: {content_encoding}",
)
def resource_to_otel_proto(resource: OTelResource | None) -> OTelProtoResource:
"""
Convert an OpenTelemetry SDK Resource to protobuf Resource format.
Args:
resource: The OpenTelemetry SDK Resource object, or None.
Returns:
An OpenTelemetry protobuf Resource message.
"""
otel_resource = OTelProtoResource()
if resource is not None:
for key, value in resource.attributes.items():
attr = otel_resource.attributes.add()
attr.key = key
_set_otel_proto_anyvalue(attr.value, value)
return otel_resource