1078 lines
43 KiB
Python
1078 lines
43 KiB
Python
import json
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import nullcontext
|
|
from typing import TYPE_CHECKING, Sequence
|
|
|
|
import mlflow
|
|
|
|
if TYPE_CHECKING:
|
|
from mlflow.genai.label_schemas.label_schemas import (
|
|
InputCategorical,
|
|
InputNumeric,
|
|
InputPassFail,
|
|
InputText,
|
|
LabelSchema,
|
|
LabelSchemaType,
|
|
)
|
|
from mlflow.genai.review_queues import (
|
|
ReviewItemType,
|
|
ReviewQueue,
|
|
ReviewQueueItem,
|
|
ReviewQueueType,
|
|
ReviewStatus,
|
|
)
|
|
from mlflow.entities.assessment import Assessment
|
|
from mlflow.entities.issue import Issue, IssueSeverity, IssueStatus
|
|
from mlflow.entities.model_registry import PromptVersion
|
|
from mlflow.entities.span import NO_OP_SPAN_TRACE_ID, Span
|
|
from mlflow.entities.trace import Trace
|
|
from mlflow.entities.trace_data import TraceData
|
|
from mlflow.entities.trace_info import TraceInfo
|
|
from mlflow.entities.trace_location import UCSchemaLocation, UnityCatalog
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_SEARCH_TRACES_MAX_BATCH_SIZE,
|
|
MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS,
|
|
MLFLOW_GET_TRACE_OTEL_MAX_RETRY_INTERVAL_SECONDS,
|
|
MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS,
|
|
MLFLOW_SEARCH_TRACES_MAX_THREADS,
|
|
MLFLOW_TRACING_SQL_WAREHOUSE_ID,
|
|
)
|
|
from mlflow.exceptions import (
|
|
MlflowException,
|
|
MlflowNotImplementedException,
|
|
MlflowTraceDataCorrupted,
|
|
MlflowTraceDataException,
|
|
MlflowTraceDataNotFound,
|
|
)
|
|
from mlflow.protos.databricks_pb2 import (
|
|
BAD_REQUEST,
|
|
INVALID_PARAMETER_VALUE,
|
|
NOT_FOUND,
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
from mlflow.store.artifact.artifact_repository_registry import get_artifact_repository
|
|
from mlflow.store.entities.paged_list import PagedList
|
|
from mlflow.store.tracking import SEARCH_TRACES_DEFAULT_MAX_RESULTS
|
|
from mlflow.telemetry.events import LogAssessmentEvent, StartTraceEvent, TraceAttachmentsEvent
|
|
from mlflow.telemetry.track import record_usage_event
|
|
from mlflow.tracing.attachments import Attachment
|
|
from mlflow.tracing.constant import (
|
|
SpansLocation,
|
|
TraceMetadataKey,
|
|
TraceTagKey,
|
|
)
|
|
from mlflow.tracing.trace_manager import InMemoryTraceManager
|
|
from mlflow.tracing.utils import TraceJSONEncoder, exclude_immutable_tags, parse_trace_id_v4
|
|
from mlflow.tracing.utils.artifact_utils import get_artifact_uri_for_trace
|
|
from mlflow.tracking._tracking_service.utils import _get_store, _resolve_tracking_uri
|
|
from mlflow.utils import is_uuid
|
|
from mlflow.utils.mlflow_tags import IMMUTABLE_TAGS
|
|
from mlflow.utils.uri import add_databricks_profile_info_to_artifact_uri, is_databricks_uri
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TracingClient:
|
|
"""
|
|
Client of an MLflow Tracking Server that creates and manages experiments and runs.
|
|
"""
|
|
|
|
def __init__(self, tracking_uri: str | None = None):
|
|
"""
|
|
Args:
|
|
tracking_uri: Address of local or remote tracking server.
|
|
"""
|
|
self.tracking_uri = _resolve_tracking_uri(tracking_uri)
|
|
# NB: Fetch the tracking store (`self.store`) upon client initialization to ensure that
|
|
# the tracking URI is valid and the store can be properly resolved. We define `store` as a
|
|
# property method to ensure that the client is serializable, even if the store is not
|
|
# self.store
|
|
self.store
|
|
|
|
@property
|
|
def store(self):
|
|
return _get_store(self.tracking_uri)
|
|
|
|
@record_usage_event(StartTraceEvent)
|
|
def start_trace(self, trace_info: TraceInfo) -> TraceInfo:
|
|
"""
|
|
Create a new trace in the backend.
|
|
|
|
Args:
|
|
trace_info: The TraceInfo object to record in the backend.
|
|
|
|
Returns:
|
|
The returned TraceInfoV3 object from the backend.
|
|
"""
|
|
return self.store.start_trace(trace_info=trace_info)
|
|
|
|
def log_spans(self, location: str, spans: list[Span]) -> list[Span]:
|
|
"""
|
|
Log spans to the backend.
|
|
|
|
Args:
|
|
location: The location to log spans to. It should either be an experiment ID or a
|
|
Unity Catalog table name.
|
|
spans: List of Span objects to log.
|
|
|
|
Returns:
|
|
List of logged Span objects from the backend.
|
|
"""
|
|
return self.store.log_spans(
|
|
location=location,
|
|
spans=spans,
|
|
tracking_uri=self.tracking_uri if is_databricks_uri(self.tracking_uri) else None,
|
|
)
|
|
|
|
def delete_traces(
|
|
self,
|
|
experiment_id: str,
|
|
max_timestamp_millis: int | None = None,
|
|
max_traces: int | None = None,
|
|
trace_ids: list[str] | None = None,
|
|
) -> int:
|
|
return self.store.delete_traces(
|
|
experiment_id=experiment_id,
|
|
max_timestamp_millis=max_timestamp_millis,
|
|
max_traces=max_traces,
|
|
trace_ids=trace_ids,
|
|
)
|
|
|
|
def get_trace_info(self, trace_id: str) -> TraceInfo:
|
|
"""
|
|
Get the trace info matching the ``trace_id``.
|
|
|
|
Args:
|
|
trace_id: String id of the trace to fetch.
|
|
|
|
Returns:
|
|
TraceInfo object, of type ``mlflow.entities.trace_info.TraceInfo``.
|
|
"""
|
|
with InMemoryTraceManager.get_instance().get_trace(trace_id) as trace:
|
|
if trace is not None:
|
|
return trace.info
|
|
|
|
return self.store.get_trace_info(trace_id)
|
|
|
|
def get_trace(self, trace_id: str) -> Trace:
|
|
"""
|
|
Get the trace matching the ``trace_id``.
|
|
|
|
Args:
|
|
trace_id: String id of the trace to fetch.
|
|
|
|
Returns:
|
|
The fetched Trace object, of type ``mlflow.entities.Trace``.
|
|
"""
|
|
location, _ = parse_trace_id_v4(trace_id)
|
|
if location is not None:
|
|
# For a V4 trace, load spans from the v4 BatchGetTraces endpoint.
|
|
# BatchGetTraces returns an empty list if the trace is not found, which is
|
|
# retried with exponential backoff (capped per-interval) up to
|
|
# MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS in total.
|
|
deadline = time.monotonic() + MLFLOW_GET_TRACE_OTEL_RETRY_TIMEOUT_SECONDS.get()
|
|
initial_interval = max(0.0, MLFLOW_GET_TRACE_OTEL_INITIAL_RETRY_INTERVAL_SECONDS.get())
|
|
max_interval = max(0.0, MLFLOW_GET_TRACE_OTEL_MAX_RETRY_INTERVAL_SECONDS.get())
|
|
attempt = 0
|
|
while True:
|
|
if traces := self.store.batch_get_traces([trace_id], location):
|
|
return traces[0]
|
|
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
|
|
interval = min(initial_interval * 2**attempt, max_interval, remaining)
|
|
attempt += 1
|
|
_logger.debug(
|
|
f"Trace not found, retrying in {interval:.2f} seconds (attempt {attempt})"
|
|
)
|
|
time.sleep(interval)
|
|
|
|
raise MlflowException(
|
|
message=f"Trace with ID {trace_id} is not found.",
|
|
error_code=NOT_FOUND,
|
|
)
|
|
else:
|
|
try:
|
|
trace_info = self.get_trace_info(trace_id)
|
|
# if the trace is stored in the tracking store or archive repo, load spans via the
|
|
# store/server path; otherwise, load spans from the artifact repository
|
|
if trace_info.tags.get(TraceTagKey.SPANS_LOCATION) in (
|
|
SpansLocation.TRACKING_STORE,
|
|
SpansLocation.ARCHIVE_REPO,
|
|
):
|
|
try:
|
|
return self.store.get_trace(trace_id)
|
|
except MlflowNotImplementedException:
|
|
pass
|
|
if traces := self.store.batch_get_traces([trace_info.trace_id]):
|
|
return traces[0]
|
|
else:
|
|
raise MlflowException(
|
|
f"Trace with ID {trace_id} is not found.",
|
|
error_code=NOT_FOUND,
|
|
)
|
|
else:
|
|
trace_data = self._download_trace_data(trace_info)
|
|
except MlflowTraceDataNotFound:
|
|
raise MlflowException(
|
|
message=(
|
|
f"Trace with ID {trace_id} cannot be loaded because it is missing span "
|
|
"data. Please try creating or loading another trace."
|
|
),
|
|
error_code=BAD_REQUEST,
|
|
) from None # Ensure the original spammy exception is not included in the traceback
|
|
except MlflowTraceDataCorrupted:
|
|
raise MlflowException(
|
|
message=(
|
|
f"Trace with ID {trace_id} cannot be loaded because its span data"
|
|
" is corrupted. Please try creating or loading another trace."
|
|
),
|
|
error_code=BAD_REQUEST,
|
|
) from None # Ensure the original spammy exception is not included in the traceback
|
|
return Trace(trace_info, trace_data)
|
|
|
|
def get_online_trace_details(
|
|
self,
|
|
trace_id: str,
|
|
source_inference_table: str,
|
|
source_databricks_request_id: str,
|
|
) -> str:
|
|
return self.store.get_online_trace_details(
|
|
trace_id=trace_id,
|
|
source_inference_table=source_inference_table,
|
|
source_databricks_request_id=source_databricks_request_id,
|
|
)
|
|
|
|
def _search_traces(
|
|
self,
|
|
experiment_ids: list[str] | None = None,
|
|
filter_string: str | None = None,
|
|
max_results: int = SEARCH_TRACES_DEFAULT_MAX_RESULTS,
|
|
order_by: list[str] | None = None,
|
|
page_token: str | None = None,
|
|
model_id: str | None = None,
|
|
locations: list[str] | None = None,
|
|
):
|
|
return self.store.search_traces(
|
|
experiment_ids=experiment_ids,
|
|
filter_string=filter_string,
|
|
max_results=max_results,
|
|
order_by=order_by,
|
|
page_token=page_token,
|
|
model_id=model_id,
|
|
locations=locations,
|
|
)
|
|
|
|
def search_traces(
|
|
self,
|
|
experiment_ids: list[str] | None = None,
|
|
filter_string: str | None = None,
|
|
max_results: int = SEARCH_TRACES_DEFAULT_MAX_RESULTS,
|
|
order_by: list[str] | None = None,
|
|
page_token: str | None = None,
|
|
run_id: str | None = None,
|
|
include_spans: bool = True,
|
|
model_id: str | None = None,
|
|
locations: list[str] | None = None,
|
|
) -> PagedList[Trace]:
|
|
"""
|
|
Return traces that match the given list of search expressions within the experiments.
|
|
|
|
Args:
|
|
experiment_ids: List of experiment ids to scope the search. Deprecated,
|
|
use `locations` instead.
|
|
filter_string: A search filter string.
|
|
max_results: Maximum number of traces desired.
|
|
order_by: List of order_by clauses.
|
|
page_token: Token specifying the next page of results. It should be obtained from
|
|
a ``search_traces`` call.
|
|
run_id: A run id to scope the search. When a trace is created under an active run,
|
|
it will be associated with the run and you can filter on the run id to retrieve
|
|
the trace.
|
|
include_spans: If ``True``, include spans in the returned traces. Otherwise, only
|
|
the trace metadata is returned, e.g., trace ID, start time, end time, etc,
|
|
without any spans.
|
|
model_id: If specified, return traces associated with the model ID.
|
|
locations: A list of locations to search over. To search over experiments, provide
|
|
a list of experiment IDs. To search over UC tables on databricks, provide
|
|
a list of locations in the format
|
|
`<catalog_name>.<schema_name>[.<table_prefix>]`.
|
|
|
|
Returns:
|
|
A :py:class:`PagedList <mlflow.store.entities.PagedList>` of
|
|
:py:class:`Trace <mlflow.entities.Trace>` objects that satisfy the search
|
|
expressions. If the underlying tracking store supports pagination, the token for the
|
|
next page may be obtained via the ``token`` attribute of the returned object; however,
|
|
some store implementations may not support pagination and thus the returned token would
|
|
not be meaningful in such cases.
|
|
"""
|
|
if model_id is not None:
|
|
if filter_string:
|
|
raise MlflowException(
|
|
message=(
|
|
"Cannot specify both `model_id` or `filter_string` in the search_traces "
|
|
"call."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# if sql_warehouse_id is not set then we convert model_id to filter_string,
|
|
# because `_search_unified_traces` requires sql warehouse id existing.
|
|
if MLFLOW_TRACING_SQL_WAREHOUSE_ID.get() is None:
|
|
filter_string = f"request_metadata.`mlflow.modelId` = '{model_id}'"
|
|
model_id = None
|
|
|
|
if run_id:
|
|
run = self.store.get_run(run_id)
|
|
if run.info.experiment_id not in locations:
|
|
raise MlflowException(
|
|
f"Run {run_id} belongs to experiment {run.info.experiment_id}, which is not "
|
|
f"in the list of locations provided: {locations}. Please include "
|
|
f"experiment {run.info.experiment_id} in the `locations` parameter to "
|
|
"search for traces from this run.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
additional_filter = f"attribute.run_id = '{run_id}'"
|
|
if filter_string:
|
|
if TraceMetadataKey.SOURCE_RUN in filter_string:
|
|
raise MlflowException(
|
|
"You cannot filter by run_id when it is already part of the filter string."
|
|
f"Please remove the {TraceMetadataKey.SOURCE_RUN} filter from the filter "
|
|
"string and try again.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
filter_string += f" AND {additional_filter}"
|
|
else:
|
|
filter_string = additional_filter
|
|
|
|
traces = []
|
|
next_max_results = max_results
|
|
next_token = page_token
|
|
|
|
max_workers = MLFLOW_SEARCH_TRACES_MAX_THREADS.get()
|
|
executor = (
|
|
ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="MlflowTracingSearch")
|
|
if include_spans
|
|
else nullcontext()
|
|
)
|
|
with executor:
|
|
while len(traces) < max_results:
|
|
trace_infos, next_token = self._search_traces(
|
|
experiment_ids=experiment_ids,
|
|
filter_string=filter_string,
|
|
max_results=next_max_results,
|
|
order_by=order_by,
|
|
page_token=next_token,
|
|
model_id=model_id,
|
|
locations=locations,
|
|
)
|
|
|
|
if include_spans:
|
|
trace_infos_by_location = self._group_trace_infos_by_location(trace_infos)
|
|
traces.extend(self._load_traces_by_location(trace_infos_by_location, executor))
|
|
|
|
else:
|
|
traces.extend(Trace(t, TraceData(spans=[])) for t in trace_infos)
|
|
|
|
if not next_token:
|
|
break
|
|
|
|
next_max_results = max_results - len(traces)
|
|
|
|
return PagedList(traces, next_token)
|
|
|
|
def batch_get_traces(self, trace_ids: list[str], location: str | None = None) -> list[Trace]:
|
|
"""
|
|
Retrieve multiple traces by their IDs.
|
|
|
|
Args:
|
|
trace_ids: List of trace IDs to retrieve.
|
|
location: Optional location (e.g., "catalog.schema" for UC schema) to search for traces.
|
|
|
|
Returns:
|
|
List of Trace objects.
|
|
"""
|
|
if not trace_ids:
|
|
return []
|
|
|
|
# If location is provided, this is a UC schema/v4 call - delegate directly
|
|
if location is not None:
|
|
return self.store.batch_get_traces(trace_ids, location)
|
|
|
|
# Get trace infos (metadata only) to determine where spans are stored.
|
|
# Fall back to store.batch_get_traces directly if the store doesn't
|
|
# implement batch_get_trace_infos (e.g. DatabricksRestStore).
|
|
try:
|
|
trace_infos = self.store.batch_get_trace_infos(trace_ids)
|
|
except MlflowNotImplementedException:
|
|
return self.store.batch_get_traces(trace_ids, location)
|
|
|
|
trace_infos_by_location = self._group_trace_infos_by_location(trace_infos)
|
|
|
|
max_workers = min(len(trace_ids), MLFLOW_SEARCH_TRACES_MAX_THREADS.get())
|
|
with ThreadPoolExecutor(
|
|
max_workers=max_workers, thread_name_prefix="MlflowTracingBatchGet"
|
|
) as executor:
|
|
return self._load_traces_by_location(trace_infos_by_location, executor)
|
|
|
|
def _download_spans_from_batch_get_traces(
|
|
self, trace_ids: list[str], location: str | None, executor: ThreadPoolExecutor
|
|
) -> list[Trace]:
|
|
"""
|
|
Fetch full traces including spans from the store batch-get path.
|
|
BatchGetTrace endpoint only support up to 10 traces in a single call.
|
|
"""
|
|
traces = []
|
|
|
|
def _fetch_minibatch(ids: list[str]) -> list[Trace]:
|
|
return self.store.batch_get_traces(ids, location) or []
|
|
|
|
batch_size = _MLFLOW_SEARCH_TRACES_MAX_BATCH_SIZE.get()
|
|
batches = [trace_ids[i : i + batch_size] for i in range(0, len(trace_ids), batch_size)]
|
|
for minibatch_traces in executor.map(_fetch_minibatch, batches):
|
|
traces.extend(minibatch_traces)
|
|
return traces
|
|
|
|
def _load_traces_by_location(
|
|
self,
|
|
trace_infos_by_location: dict[str, list[TraceInfo]],
|
|
executor: ThreadPoolExecutor,
|
|
) -> list[Trace]:
|
|
traces = []
|
|
for location, location_trace_infos in trace_infos_by_location.items():
|
|
if location == SpansLocation.ARTIFACT_REPO:
|
|
traces.extend(
|
|
tr
|
|
for tr in executor.map(
|
|
self._download_spans_from_artifact_repo,
|
|
location_trace_infos,
|
|
)
|
|
if tr
|
|
)
|
|
else:
|
|
batch_get_location = None if location == SpansLocation.ARCHIVE_REPO else location
|
|
traces.extend(
|
|
self._download_spans_from_batch_get_traces(
|
|
[t.trace_id for t in location_trace_infos], batch_get_location, executor
|
|
)
|
|
)
|
|
return traces
|
|
|
|
def _download_spans_from_artifact_repo(self, trace_info: TraceInfo) -> Trace | None:
|
|
"""
|
|
Download trace data for the given trace_info and returns a Trace object.
|
|
If the download fails (e.g., the trace data is missing or corrupted), returns None.
|
|
|
|
This is used for traces whose spans are fetched directly from artifact storage, including
|
|
the existing artifact-backed path.
|
|
"""
|
|
is_online_trace = is_uuid(trace_info.trace_id)
|
|
is_databricks = is_databricks_uri(self.tracking_uri)
|
|
|
|
# For online traces in Databricks, we need to get trace data from a different endpoint
|
|
try:
|
|
if is_databricks and is_online_trace:
|
|
# For online traces, get data from the online API
|
|
trace_data = self.get_online_trace_details(
|
|
trace_id=trace_info.trace_id,
|
|
source_inference_table=trace_info.request_metadata.get("mlflow.sourceTable"),
|
|
source_databricks_request_id=trace_info.request_metadata.get(
|
|
"mlflow.databricksRequestId"
|
|
),
|
|
)
|
|
trace_data = TraceData.from_dict(json.loads(trace_data))
|
|
else:
|
|
# For offline traces, download data from artifact storage
|
|
trace_data = self._download_trace_data(trace_info)
|
|
except MlflowTraceDataException as e:
|
|
_logger.warning(
|
|
(
|
|
f"Failed to download trace data for trace {trace_info.trace_id!r} "
|
|
f"with {e.ctx}. For full traceback, set logging level to DEBUG."
|
|
),
|
|
exc_info=_logger.isEnabledFor(logging.DEBUG),
|
|
)
|
|
return None
|
|
else:
|
|
return Trace(trace_info, trace_data)
|
|
|
|
def _group_trace_infos_by_location(
|
|
self, trace_infos: list[TraceInfo]
|
|
) -> dict[str, list[TraceInfo]]:
|
|
"""
|
|
Group the trace infos based on where the trace data is stored.
|
|
|
|
Returns:
|
|
A dictionary mapping location to a list of trace infos.
|
|
"""
|
|
trace_infos_by_location = defaultdict(list)
|
|
for trace_info in trace_infos:
|
|
if uc_schema := trace_info.trace_location.uc_schema:
|
|
location = f"{uc_schema.catalog_name}.{uc_schema.schema_name}"
|
|
trace_infos_by_location[location].append(trace_info)
|
|
elif uc_tp := trace_info.trace_location.uc_table_prefix:
|
|
location = f"{uc_tp.catalog_name}.{uc_tp.schema_name}.{uc_tp.table_prefix}"
|
|
trace_infos_by_location[location].append(trace_info)
|
|
elif trace_info.trace_location.mlflow_experiment:
|
|
# DB-backed experiment traces use the tracking store. Legacy artifact-backed traces
|
|
# and archived traces are grouped by their spans-location tag so they can be
|
|
# fetched through the correct non-DB retrieval path.
|
|
spans_location = trace_info.tags.get(TraceTagKey.SPANS_LOCATION)
|
|
if spans_location == SpansLocation.TRACKING_STORE:
|
|
# location is not used for traces with mlflow experiment location in tracking
|
|
# store, so we use None as the location
|
|
trace_infos_by_location[None].append(trace_info)
|
|
elif spans_location in (SpansLocation.ARTIFACT_REPO, SpansLocation.ARCHIVE_REPO):
|
|
trace_infos_by_location[spans_location].append(trace_info)
|
|
else:
|
|
# Older traces may not set spansLocation and should continue to use the
|
|
# artifact-backed trace-data path.
|
|
trace_infos_by_location[SpansLocation.ARTIFACT_REPO].append(trace_info)
|
|
else:
|
|
_logger.warning(f"Unsupported location: {trace_info.trace_location}. Skipping.")
|
|
return trace_infos_by_location
|
|
|
|
def calculate_trace_filter_correlation(
|
|
self,
|
|
experiment_ids: list[str],
|
|
filter_string1: str,
|
|
filter_string2: str,
|
|
base_filter: str | None = None,
|
|
):
|
|
"""
|
|
Calculate the correlation (NPMI) between two trace filter conditions.
|
|
|
|
This method computes the Normalized Pointwise Mutual Information (NPMI)
|
|
between traces matching two different filter conditions, which measures
|
|
how much more (or less) likely traces are to satisfy both conditions
|
|
compared to if the conditions were independent.
|
|
|
|
Args:
|
|
experiment_ids: List of experiment IDs to search within.
|
|
filter_string1: First filter condition (e.g., "span.type = 'LLM'").
|
|
filter_string2: Second filter condition (e.g., "feedback.quality > 0.8").
|
|
base_filter: Optional base filter that both filter1 and filter2 are tested on top of
|
|
(e.g., 'request_time > ... and request_time < ...' for time windows).
|
|
|
|
Returns:
|
|
TraceFilterCorrelationResult containing:
|
|
- npmi: NPMI score from -1 (never co-occur) to 1 (always co-occur)
|
|
- npmi_smoothed: Smoothed NPMI value with Jeffreys prior for robustness
|
|
- filter1_count: Number of traces matching filter_string1
|
|
- filter2_count: Number of traces matching filter_string2
|
|
- joint_count: Number of traces matching both filters
|
|
- total_count: Total number of traces in the experiments
|
|
|
|
.. code-block:: python
|
|
|
|
from mlflow.tracing.client import TracingClient
|
|
|
|
client = TracingClient()
|
|
result = client.calculate_trace_filter_correlation(
|
|
experiment_ids=["123"],
|
|
filter_string1="span.type = 'LLM'",
|
|
filter_string2="feedback.quality > 0.8",
|
|
)
|
|
print(f"NPMI: {result.npmi:.3f}")
|
|
# Output: NPMI: 0.456
|
|
"""
|
|
return self.store.calculate_trace_filter_correlation(
|
|
experiment_ids=experiment_ids,
|
|
filter_string1=filter_string1,
|
|
filter_string2=filter_string2,
|
|
base_filter=base_filter,
|
|
)
|
|
|
|
def set_trace_tags(self, trace_id: str, tags: dict[str, str]):
|
|
"""
|
|
Set tags on the trace with the given trace_id.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace.
|
|
tags: A dictionary of key-value pairs.
|
|
"""
|
|
tags = exclude_immutable_tags(tags)
|
|
for k, v in tags.items():
|
|
self.set_trace_tag(trace_id, k, v)
|
|
|
|
def set_trace_tag(self, trace_id: str, key: str, value: str):
|
|
"""
|
|
Set a tag on the trace with the given trace ID.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace to set the tag on.
|
|
key: The string key of the tag. Must be at most 250 characters long, otherwise
|
|
it will be truncated when stored.
|
|
value: The string value of the tag. Must be at most 250 characters long, otherwise
|
|
it will be truncated when stored.
|
|
"""
|
|
if not isinstance(value, str):
|
|
_logger.warning(
|
|
"Received non-string value for trace tag. Please note that non-string tag values"
|
|
"will automatically be stringified when the trace is logged."
|
|
)
|
|
|
|
if key in IMMUTABLE_TAGS:
|
|
_logger.warning(f"Tag '{key}' is immutable and cannot be set on a trace.")
|
|
return
|
|
|
|
# Trying to set the tag on the active trace first
|
|
with InMemoryTraceManager.get_instance().get_trace(trace_id) as trace:
|
|
if trace:
|
|
trace.info.tags[key] = str(value)
|
|
return
|
|
|
|
self.store.set_trace_tag(trace_id, key, str(value))
|
|
|
|
def delete_trace_tag(self, trace_id: str, key: str):
|
|
"""
|
|
Delete a tag on the trace with the given trace ID.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace to delete the tag from.
|
|
key: The string key of the tag. Must be at most 250 characters long, otherwise
|
|
it will be truncated when stored.
|
|
"""
|
|
# Allow users to clear archival-failure markers so the scheduler can retry after
|
|
# manual intervention, while keeping other internal storage tags immutable.
|
|
if key in IMMUTABLE_TAGS and key != TraceTagKey.ARCHIVAL_FAILURE:
|
|
_logger.warning(f"Tag '{key}' is immutable and cannot be deleted on a trace.")
|
|
return
|
|
|
|
# Trying to delete the tag on the active trace first
|
|
with InMemoryTraceManager.get_instance().get_trace(trace_id) as trace:
|
|
if trace:
|
|
if key in trace.info.tags:
|
|
trace.info.tags.pop(key)
|
|
return
|
|
else:
|
|
raise MlflowException(
|
|
f"Tag with key {key} not found in trace with ID {trace_id}.",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
|
|
self.store.delete_trace_tag(trace_id, key)
|
|
|
|
def get_assessment(self, trace_id: str, assessment_id: str) -> Assessment:
|
|
"""
|
|
Get an assessment entity from the backend store.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace.
|
|
assessment_id: The ID of the assessment to get.
|
|
|
|
Returns:
|
|
The Assessment object.
|
|
"""
|
|
|
|
return self.store.get_assessment(trace_id, assessment_id)
|
|
|
|
@record_usage_event(LogAssessmentEvent)
|
|
def log_assessment(self, trace_id: str, assessment: Assessment) -> Assessment:
|
|
"""
|
|
Log an assessment to a trace.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace.
|
|
assessment: The assessment object to log.
|
|
|
|
Returns:
|
|
The logged Assessment object.
|
|
"""
|
|
assessment.trace_id = trace_id
|
|
|
|
if trace_id is None or trace_id == NO_OP_SPAN_TRACE_ID:
|
|
_logger.debug(
|
|
"Skipping assessment logging for NO_OP_SPAN_TRACE_ID. This is expected when "
|
|
"tracing is disabled."
|
|
)
|
|
return assessment
|
|
|
|
# If the trace is the active trace, add the assessment to it in-memory.
|
|
# Exception: remote (distributed) traces use a dummy in-memory entry that is discarded
|
|
# when the context exits, so assessments must be persisted directly to the backend.
|
|
if trace_id == mlflow.get_active_trace_id():
|
|
with InMemoryTraceManager.get_instance().get_trace(trace_id) as trace:
|
|
if trace is None:
|
|
_logger.debug(
|
|
f"Trace {trace_id} is active but not found in the in-memory buffer. "
|
|
"Something is wrong with trace handling. Skipping assessment logging."
|
|
)
|
|
return assessment
|
|
if trace.is_remote_trace:
|
|
return self.store.create_assessment(assessment)
|
|
trace.info.assessments.append(assessment)
|
|
return assessment
|
|
return self.store.create_assessment(assessment)
|
|
|
|
def update_assessment(
|
|
self,
|
|
trace_id: str,
|
|
assessment_id: str,
|
|
assessment: Assessment,
|
|
):
|
|
"""
|
|
Update an existing assessment entity in the backend store.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace.
|
|
assessment_id: The ID of the feedback assessment to update.
|
|
assessment: The updated assessment.
|
|
"""
|
|
|
|
return self.store.update_assessment(
|
|
trace_id=trace_id,
|
|
assessment_id=assessment_id,
|
|
name=assessment.name,
|
|
expectation=assessment.expectation,
|
|
feedback=assessment.feedback,
|
|
rationale=assessment.rationale,
|
|
metadata=assessment.metadata,
|
|
)
|
|
|
|
def delete_assessment(self, trace_id: str, assessment_id: str):
|
|
"""
|
|
Delete an assessment associated with a trace.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace.
|
|
assessment_id: The ID of the assessment to delete.
|
|
"""
|
|
|
|
self.store.delete_assessment(trace_id=trace_id, assessment_id=assessment_id)
|
|
|
|
def _get_artifact_repo_for_trace(self, trace_info: TraceInfo):
|
|
artifact_uri = get_artifact_uri_for_trace(trace_info)
|
|
artifact_uri = add_databricks_profile_info_to_artifact_uri(artifact_uri, self.tracking_uri)
|
|
return get_artifact_repository(artifact_uri)
|
|
|
|
def _download_trace_data(self, trace_info: TraceInfo) -> TraceData:
|
|
"""
|
|
Download trace data from artifact repository.
|
|
|
|
Args:
|
|
trace_info: Either a TraceInfo or TraceInfoV3 object containing trace metadata.
|
|
|
|
Returns:
|
|
TraceData object representing the downloaded trace data.
|
|
"""
|
|
artifact_repo = self._get_artifact_repo_for_trace(trace_info)
|
|
return TraceData.from_dict(artifact_repo.download_trace_data())
|
|
|
|
def _upload_trace_data(self, trace_info: TraceInfo, trace_data: TraceData) -> None:
|
|
artifact_repo = self._get_artifact_repo_for_trace(trace_info)
|
|
trace_data_json = json.dumps(trace_data.to_dict(), cls=TraceJSONEncoder, ensure_ascii=False)
|
|
return artifact_repo.upload_trace_data(trace_data_json)
|
|
|
|
@record_usage_event(TraceAttachmentsEvent)
|
|
def _upload_attachments(
|
|
self,
|
|
trace_info: TraceInfo,
|
|
attachments: dict[str, Attachment],
|
|
) -> None:
|
|
artifact_repo = self._get_artifact_repo_for_trace(trace_info)
|
|
for attachment_id, attachment in attachments.items():
|
|
try:
|
|
artifact_repo.upload_attachment(attachment_id, attachment.content_bytes)
|
|
except Exception as e:
|
|
_logger.warning(f"Failed to upload attachment {attachment_id}: {e}")
|
|
|
|
def link_prompt_versions_to_trace(
|
|
self, trace_id: str, prompts: Sequence[PromptVersion]
|
|
) -> None:
|
|
"""
|
|
Link multiple prompt versions to a trace.
|
|
|
|
Args:
|
|
trace_id: The ID of the trace to link prompts to.
|
|
prompts: List of PromptVersion objects to link to the trace.
|
|
"""
|
|
from mlflow.tracking._model_registry.utils import _get_store as _get_model_registry_store
|
|
|
|
registry_store = _get_model_registry_store()
|
|
registry_store.link_prompts_to_trace(prompt_versions=prompts, trace_id=trace_id)
|
|
|
|
def _set_experiment_trace_location(
|
|
self,
|
|
location: UCSchemaLocation,
|
|
experiment_id: str,
|
|
sql_warehouse_id: str | None = None,
|
|
) -> UCSchemaLocation:
|
|
if is_databricks_uri(self.tracking_uri):
|
|
return self.store.set_experiment_trace_location(
|
|
experiment_id=str(experiment_id),
|
|
location=location,
|
|
sql_warehouse_id=sql_warehouse_id,
|
|
)
|
|
raise MlflowException(
|
|
"Setting storage location is not supported on non-Databricks backends."
|
|
)
|
|
|
|
def _get_trace_location(self, telemetry_profile_id: str) -> UnityCatalog:
|
|
if is_databricks_uri(self.tracking_uri) and hasattr(self.store, "get_trace_location"):
|
|
return self.store.get_trace_location(telemetry_profile_id)
|
|
raise MlflowException("Getting trace location by ID is not supported on this backend.")
|
|
|
|
def _create_or_get_trace_location(
|
|
self, location: UnityCatalog, sql_warehouse_id: str | None = None
|
|
) -> UnityCatalog:
|
|
if is_databricks_uri(self.tracking_uri) and hasattr(
|
|
self.store, "create_or_get_trace_location"
|
|
):
|
|
return self.store.create_or_get_trace_location(location, sql_warehouse_id)
|
|
raise MlflowException("Creating trace location is not supported on this backend.")
|
|
|
|
def _link_trace_location(self, experiment_id: str, location: UnityCatalog) -> None:
|
|
if is_databricks_uri(self.tracking_uri) and hasattr(self.store, "link_trace_location"):
|
|
self.store.link_trace_location(experiment_id, location)
|
|
return
|
|
raise MlflowException("Linking trace location is not supported on this backend.")
|
|
|
|
def _unset_experiment_trace_location(
|
|
self, experiment_id: str, location: UCSchemaLocation | UnityCatalog
|
|
) -> None:
|
|
if is_databricks_uri(self.tracking_uri):
|
|
self.store.unset_experiment_trace_location(str(experiment_id), location)
|
|
else:
|
|
raise MlflowException(
|
|
"Clearing storage location is not supported on non-Databricks backends."
|
|
)
|
|
|
|
def _create_issue(
|
|
self,
|
|
experiment_id: str,
|
|
name: str,
|
|
description: str,
|
|
status: IssueStatus = IssueStatus.PENDING,
|
|
severity: IssueSeverity | None = None,
|
|
root_causes: list[str] | None = None,
|
|
source_run_id: str | None = None,
|
|
categories: list[str] | None = None,
|
|
created_by: str | None = None,
|
|
) -> Issue:
|
|
"""
|
|
Create a new issue in the tracking store.
|
|
|
|
Args:
|
|
experiment_id: The experiment ID.
|
|
name: Short descriptive name for the issue.
|
|
description: Detailed description of the issue.
|
|
status: Issue status. Defaults to IssueStatus.PENDING if not provided.
|
|
severity: Optional severity level indicator.
|
|
root_causes: Optional list of root cause analyses.
|
|
source_run_id: Optional MLflow run ID that discovered this issue.
|
|
categories: Optional list of categories for the issue.
|
|
created_by: Optional identifier for who created this issue.
|
|
|
|
Returns:
|
|
The created Issue entity.
|
|
"""
|
|
return self.store.create_issue(
|
|
experiment_id=experiment_id,
|
|
name=name,
|
|
description=description,
|
|
status=status,
|
|
severity=severity,
|
|
root_causes=root_causes,
|
|
source_run_id=source_run_id,
|
|
categories=categories,
|
|
created_by=created_by,
|
|
)
|
|
|
|
def _get_issue(self, issue_id: str) -> Issue:
|
|
"""
|
|
Get an issue by ID.
|
|
|
|
Args:
|
|
issue_id: The ID of the issue to retrieve.
|
|
|
|
Returns:
|
|
The Issue entity.
|
|
"""
|
|
return self.store.get_issue(issue_id)
|
|
|
|
# ----- Label schemas (tracking-store CRUD) -----
|
|
|
|
def _create_label_schema(
|
|
self,
|
|
experiment_id: str,
|
|
*,
|
|
name: str,
|
|
type: "LabelSchemaType | str",
|
|
input: "InputPassFail | InputCategorical | InputNumeric | InputText",
|
|
instruction: str | None = None,
|
|
enable_comment: bool = False,
|
|
) -> "LabelSchema":
|
|
"""Create a new label schema.
|
|
|
|
Args:
|
|
experiment_id: Parent experiment ID.
|
|
name: Schema name. Free text shown to reviewers as the label
|
|
prompt and used as the assessment key; unique within the
|
|
experiment.
|
|
type: ``"feedback"`` or ``"expectation"``.
|
|
input: One of :py:class:`InputPassFail` / :py:class:`InputCategorical`
|
|
/ :py:class:`InputNumeric` / :py:class:`InputText`.
|
|
instruction: Optional supplementary guidance (<=1000 chars).
|
|
enable_comment: UI hint; persisted but not consumed server-side.
|
|
|
|
Returns:
|
|
The created :py:class:`LabelSchema` with backend-generated
|
|
``schema_id`` and audit fields populated.
|
|
"""
|
|
return self.store.create_label_schema(
|
|
experiment_id=experiment_id,
|
|
name=name,
|
|
type=type,
|
|
input=input,
|
|
instruction=instruction,
|
|
enable_comment=enable_comment,
|
|
)
|
|
|
|
def _get_label_schema(self, schema_id: str) -> "LabelSchema":
|
|
return self.store.get_label_schema(schema_id)
|
|
|
|
def _get_label_schema_by_name(self, experiment_id: str, name: str) -> "LabelSchema":
|
|
return self.store.get_label_schema_by_name(experiment_id, name)
|
|
|
|
def _list_label_schemas(
|
|
self,
|
|
experiment_id: str,
|
|
max_results: int = 100,
|
|
page_token: str | None = None,
|
|
) -> "PagedList[LabelSchema]":
|
|
return self.store.list_label_schemas(
|
|
experiment_id, max_results=max_results, page_token=page_token
|
|
)
|
|
|
|
def _update_label_schema(
|
|
self,
|
|
schema_id: str,
|
|
*,
|
|
name: str | None = None,
|
|
instruction: str | None = None,
|
|
enable_comment: bool | None = None,
|
|
input: "InputPassFail | InputCategorical | InputNumeric | InputText | None" = None,
|
|
) -> "LabelSchema":
|
|
"""Sparse-update a label schema.
|
|
|
|
``type`` is immutable post-create and is not accepted. Fields left as
|
|
``None`` are unchanged on the server. ``enable_comment=None`` is
|
|
treated as "unchanged"; pass ``True`` or ``False`` to set explicitly.
|
|
|
|
Returns:
|
|
The updated :py:class:`LabelSchema`.
|
|
"""
|
|
return self.store.update_label_schema(
|
|
schema_id,
|
|
name=name,
|
|
instruction=instruction,
|
|
enable_comment=enable_comment,
|
|
input=input,
|
|
)
|
|
|
|
def _delete_label_schema(self, schema_id: str) -> None:
|
|
"""Delete a label schema. No-op when the schema doesn't exist."""
|
|
return self.store.delete_label_schema(schema_id)
|
|
|
|
# ----- Review queues (tracking-store CRUD) -----
|
|
|
|
def _create_review_queue(
|
|
self,
|
|
experiment_id: str,
|
|
*,
|
|
name: str,
|
|
queue_type: "ReviewQueueType | str",
|
|
users: list[str] | None = None,
|
|
schema_ids: list[str] | None = None,
|
|
) -> "ReviewQueue":
|
|
# `created_by` (the owner) is stamped server-side, never by the client.
|
|
return self.store.create_review_queue(
|
|
experiment_id,
|
|
name=name,
|
|
queue_type=queue_type,
|
|
users=users,
|
|
schema_ids=schema_ids,
|
|
)
|
|
|
|
def _get_or_create_user_queue(self, experiment_id: str, *, user: str) -> "ReviewQueue":
|
|
return self.store.get_or_create_user_queue(experiment_id, user=user)
|
|
|
|
def _get_review_queue(self, queue_id: str) -> "ReviewQueue":
|
|
return self.store.get_review_queue(queue_id)
|
|
|
|
def _get_review_queue_by_name(self, experiment_id: str, name: str) -> "ReviewQueue":
|
|
return self.store.get_review_queue_by_name(experiment_id, name=name)
|
|
|
|
def _list_review_queues(
|
|
self,
|
|
experiment_id: str,
|
|
*,
|
|
user: str | None = None,
|
|
max_results: int | None = None,
|
|
page_token: str | None = None,
|
|
) -> "PagedList[ReviewQueue]":
|
|
return self.store.list_review_queues(
|
|
experiment_id, user=user, max_results=max_results, page_token=page_token
|
|
)
|
|
|
|
def _update_review_queue(
|
|
self,
|
|
queue_id: str,
|
|
*,
|
|
name: str | None = None,
|
|
new_owner: str | None = None,
|
|
users: list[str] | None = None,
|
|
schema_ids: list[str] | None = None,
|
|
) -> "ReviewQueue":
|
|
return self.store.update_review_queue(
|
|
queue_id, name=name, new_owner=new_owner, users=users, schema_ids=schema_ids
|
|
)
|
|
|
|
def _delete_review_queue(self, queue_id: str) -> None:
|
|
return self.store.delete_review_queue(queue_id)
|
|
|
|
def _add_items_to_review_queue(
|
|
self,
|
|
queue_id: str,
|
|
*,
|
|
item_ids: list[str],
|
|
item_type: "ReviewItemType | str" = "trace",
|
|
) -> "list[ReviewQueueItem]":
|
|
return self.store.add_items_to_review_queue(
|
|
queue_id, item_ids=item_ids, item_type=item_type
|
|
)
|
|
|
|
def _remove_items_from_review_queue(self, queue_id: str, *, item_ids: list[str]) -> None:
|
|
return self.store.remove_items_from_review_queue(queue_id, item_ids=item_ids)
|
|
|
|
def _list_review_queue_items(
|
|
self,
|
|
queue_id: str,
|
|
*,
|
|
status: "ReviewStatus | str | None" = None,
|
|
max_results: int | None = None,
|
|
page_token: str | None = None,
|
|
) -> "PagedList[ReviewQueueItem]":
|
|
return self.store.list_review_queue_items(
|
|
queue_id, status=status, max_results=max_results, page_token=page_token
|
|
)
|
|
|
|
def _set_review_queue_item_status(
|
|
self,
|
|
queue_id: str,
|
|
*,
|
|
item_id: str,
|
|
status: "ReviewStatus | str",
|
|
completed_by: str | None = None,
|
|
) -> "ReviewQueueItem":
|
|
return self.store.set_review_queue_item_status(
|
|
queue_id, item_id=item_id, status=status, completed_by=completed_by
|
|
)
|