906 lines
37 KiB
Python
906 lines
37 KiB
Python
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import Column, Float, and_, case, distinct, exists, func, literal_column, true
|
|
from sqlalchemy.orm import aliased
|
|
from sqlalchemy.orm.query import Query
|
|
|
|
from mlflow.entities.entity_type import EntityAssociationType
|
|
from mlflow.entities.trace_metrics import (
|
|
AggregationType,
|
|
MetricAggregation,
|
|
MetricDataPoint,
|
|
MetricViewType,
|
|
)
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.store.db import db_types
|
|
from mlflow.store.tracking.dbmodels.models import (
|
|
SqlAssessments,
|
|
SqlEntityAssociation,
|
|
SqlSpan,
|
|
SqlSpanMetrics,
|
|
SqlTraceInfo,
|
|
SqlTraceMetadata,
|
|
SqlTraceMetrics,
|
|
SqlTraceTag,
|
|
)
|
|
from mlflow.tracing.constant import (
|
|
AssessmentMetricDimensionKey,
|
|
AssessmentMetricKey,
|
|
AssessmentMetricSearchKey,
|
|
SpanAttributeKey,
|
|
SpanMetricDimensionKey,
|
|
SpanMetricKey,
|
|
SpanMetricSearchKey,
|
|
TraceMetadataKey,
|
|
TraceMetricDimensionKey,
|
|
TraceMetricKey,
|
|
TraceMetricSearchKey,
|
|
TraceTagKey,
|
|
)
|
|
from mlflow.utils.search_utils import SearchTraceMetricsUtils
|
|
|
|
|
|
@dataclass
|
|
class TraceMetricsConfig:
|
|
"""
|
|
Configuration for traces metrics.
|
|
|
|
Args:
|
|
aggregation_types: Supported aggregation types to apply to the metrics.
|
|
dimensions: Supported dimensions to group metrics by.
|
|
"""
|
|
|
|
aggregation_types: set[AggregationType]
|
|
dimensions: set[str]
|
|
|
|
|
|
# TraceMetricKey -> TraceMetricsConfig mapping for traces
|
|
TRACES_METRICS_CONFIGS: dict[TraceMetricKey, TraceMetricsConfig] = {
|
|
TraceMetricKey.TRACE_COUNT: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.COUNT},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME, TraceMetricDimensionKey.TRACE_STATUS},
|
|
),
|
|
TraceMetricKey.SESSION_COUNT: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.COUNT},
|
|
dimensions=set(),
|
|
),
|
|
TraceMetricKey.LATENCY: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
TraceMetricKey.INPUT_TOKENS: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
TraceMetricKey.OUTPUT_TOKENS: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
TraceMetricKey.TOTAL_TOKENS: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
TraceMetricKey.CACHE_READ_INPUT_TOKENS: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
TraceMetricKey.CACHE_CREATION_INPUT_TOKENS: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={TraceMetricDimensionKey.TRACE_NAME},
|
|
),
|
|
}
|
|
|
|
# SpanMetricKey -> TraceMetricsConfig mapping for spans
|
|
SPANS_METRICS_CONFIGS: dict[SpanMetricKey, TraceMetricsConfig] = {
|
|
SpanMetricKey.SPAN_COUNT: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.COUNT},
|
|
dimensions={
|
|
SpanMetricDimensionKey.SPAN_NAME,
|
|
SpanMetricDimensionKey.SPAN_TYPE,
|
|
SpanMetricDimensionKey.SPAN_STATUS,
|
|
SpanMetricDimensionKey.SPAN_MODEL_NAME,
|
|
SpanMetricDimensionKey.SPAN_MODEL_PROVIDER,
|
|
},
|
|
),
|
|
SpanMetricKey.LATENCY: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={SpanMetricDimensionKey.SPAN_NAME, SpanMetricDimensionKey.SPAN_STATUS},
|
|
),
|
|
SpanMetricKey.INPUT_COST: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={
|
|
SpanMetricDimensionKey.SPAN_MODEL_NAME,
|
|
SpanMetricDimensionKey.SPAN_MODEL_PROVIDER,
|
|
},
|
|
),
|
|
SpanMetricKey.OUTPUT_COST: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={
|
|
SpanMetricDimensionKey.SPAN_MODEL_NAME,
|
|
SpanMetricDimensionKey.SPAN_MODEL_PROVIDER,
|
|
},
|
|
),
|
|
SpanMetricKey.TOTAL_COST: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.SUM, AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={
|
|
SpanMetricDimensionKey.SPAN_MODEL_NAME,
|
|
SpanMetricDimensionKey.SPAN_MODEL_PROVIDER,
|
|
},
|
|
),
|
|
}
|
|
|
|
ASSESSMENTS_METRICS_CONFIGS: dict[str, TraceMetricsConfig] = {
|
|
AssessmentMetricKey.ASSESSMENT_COUNT: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.COUNT},
|
|
dimensions={
|
|
AssessmentMetricDimensionKey.ASSESSMENT_NAME,
|
|
AssessmentMetricDimensionKey.ASSESSMENT_VALUE,
|
|
},
|
|
),
|
|
AssessmentMetricKey.ASSESSMENT_VALUE: TraceMetricsConfig(
|
|
aggregation_types={AggregationType.AVG, AggregationType.PERCENTILE},
|
|
dimensions={AssessmentMetricDimensionKey.ASSESSMENT_NAME},
|
|
),
|
|
}
|
|
|
|
VIEW_TYPE_CONFIGS: dict[MetricViewType, dict[str, TraceMetricsConfig]] = {
|
|
MetricViewType.TRACES: TRACES_METRICS_CONFIGS,
|
|
MetricViewType.SPANS: SPANS_METRICS_CONFIGS,
|
|
MetricViewType.ASSESSMENTS: ASSESSMENTS_METRICS_CONFIGS,
|
|
}
|
|
|
|
TIME_BUCKET_LABEL = "time_bucket"
|
|
_SESSION_TRACE_METADATA = aliased(SqlTraceMetadata)
|
|
|
|
|
|
def get_percentile_aggregation(
|
|
db_type: str, percentile_value: float, column, partition_by_columns: list[Column] | None = None
|
|
):
|
|
"""
|
|
Get percentile aggregation function based on database type.
|
|
|
|
PostgreSQL, MSSQL, and SQLite use linear interpolation via PERCENTILE_CONT (or custom
|
|
aggregate for SQLite), equivalent to numpy.quantile's default method='linear' (H&F
|
|
method 7). The formula is: (1-g)*y[j] + g*y[j+1], where j and g are integral and
|
|
fractional parts of q*(n-1).
|
|
See: https://numpy.org/doc/stable/reference/generated/numpy.quantile.html
|
|
|
|
MySQL uses PERCENT_RANK() which calculates relative rank rather than interpolated values.
|
|
|
|
Args:
|
|
db_type: Database type (e.g., "postgresql", "mssql", "mysql", "sqlite")
|
|
percentile_value: Percentile value between 0 and 100 (e.g., 50 for median)
|
|
column: SQLAlchemy column to compute percentile on
|
|
partition_by_columns: For MSSQL and MySQL, columns to partition by in the OVER clause.
|
|
MSSQL and MySQL require PERCENTILE_CONT to have an OVER clause since it's a window
|
|
function, not a true aggregate. Pass the GROUP BY columns here.
|
|
|
|
Returns:
|
|
SQLAlchemy aggregation function for percentile
|
|
"""
|
|
percentile_fraction = percentile_value / 100 # Convert to 0-1 range
|
|
|
|
match db_type:
|
|
case db_types.POSTGRES:
|
|
# PostgreSQL PERCENTILE_CONT: ordered-set aggregate for exact percentile
|
|
return func.percentile_cont(percentile_fraction).within_group(column)
|
|
case db_types.MSSQL:
|
|
# MSSQL PERCENTILE_CONT: window function that REQUIRES an OVER clause.
|
|
# Unlike PostgreSQL, MSSQL's PERCENTILE_CONT is not a true aggregate function.
|
|
# We use OVER (PARTITION BY group_columns) to compute percentile per group.
|
|
# The result is a value for each row; the caller must handle deduplication
|
|
# (typically by wrapping in MAX/MIN in a subquery approach).
|
|
partition_by = partition_by_columns or []
|
|
return (
|
|
func
|
|
.percentile_cont(percentile_fraction)
|
|
.within_group(column)
|
|
.over(partition_by=partition_by)
|
|
)
|
|
case db_types.SQLITE:
|
|
# Custom percentile aggregate function registered in mlflow/store/db/utils.py
|
|
# Expects percentile as 0-100
|
|
return func.percentile(column, percentile_value)
|
|
case db_types.MYSQL:
|
|
# MySQL 8.0+ supports PERCENT_RANK() function.
|
|
# We use PERCENT_RANK() OVER (PARTITION BY ... ORDER BY column) to get
|
|
# each row's percentile rank, then find values at the target percentile.
|
|
partition_by = partition_by_columns or []
|
|
return func.percent_rank().over(partition_by=partition_by, order_by=column)
|
|
|
|
|
|
def get_time_bucket_expression(
|
|
view_type: MetricViewType, time_interval_seconds: int, db_type: str
|
|
) -> Column:
|
|
"""Get time bucket expression for grouping timestamps.
|
|
|
|
Args:
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS)
|
|
time_interval_seconds: Time interval in seconds for bucketing
|
|
db_type: Database type (e.g., "postgresql", "mssql", "mysql", "sqlite")
|
|
|
|
Returns:
|
|
SQLAlchemy column expression for time bucket
|
|
"""
|
|
# Convert time_interval_seconds to milliseconds
|
|
bucket_size_ms = time_interval_seconds * 1000
|
|
|
|
if db_type == db_types.MSSQL:
|
|
# MSSQL requires the exact same SQL text in SELECT, GROUP BY, and ORDER BY clauses.
|
|
# We use literal_column to generate identical SQL text across all clauses.
|
|
match view_type:
|
|
case MetricViewType.TRACES:
|
|
column_name = "timestamp_ms"
|
|
case MetricViewType.SPANS:
|
|
# For spans, timestamp is an expression (start_time_unix_nano / 1000000)
|
|
# rather than a simple column. Build the complete expression inline.
|
|
column_name = "start_time_unix_nano / 1000000"
|
|
case MetricViewType.ASSESSMENTS:
|
|
column_name = "created_timestamp"
|
|
expr_str = f"floor({column_name} / {bucket_size_ms}) * {bucket_size_ms}"
|
|
return literal_column(expr_str)
|
|
else:
|
|
# For non-MSSQL databases, use SQLAlchemy expressions
|
|
match view_type:
|
|
case MetricViewType.TRACES:
|
|
timestamp_column = SqlTraceInfo.timestamp_ms
|
|
case MetricViewType.SPANS:
|
|
# Convert nanoseconds to milliseconds
|
|
timestamp_column = SqlSpan.start_time_unix_nano / 1000000
|
|
case MetricViewType.ASSESSMENTS:
|
|
timestamp_column = SqlAssessments.created_timestamp
|
|
# This floors the timestamp to the nearest bucket boundary
|
|
return func.floor(timestamp_column / bucket_size_ms) * bucket_size_ms
|
|
|
|
|
|
def _get_aggregation_expression(
|
|
aggregation: MetricAggregation,
|
|
db_type: str,
|
|
column,
|
|
partition_by_columns: list[Column] | None = None,
|
|
) -> Column:
|
|
"""
|
|
Get the SQL aggregation expression for the given aggregation type and column.
|
|
|
|
Args:
|
|
aggregation: The aggregation of the metric
|
|
db_type: Database type (for percentile calculations)
|
|
column: The column to aggregate
|
|
partition_by_columns: For MSSQL and MySQL percentile, columns to partition by in OVER clause
|
|
|
|
Returns:
|
|
SQLAlchemy column expression for the aggregation
|
|
"""
|
|
match aggregation.aggregation_type:
|
|
case AggregationType.COUNT:
|
|
return func.count(column)
|
|
case AggregationType.SUM:
|
|
return func.sum(column)
|
|
case AggregationType.AVG:
|
|
return func.avg(column)
|
|
case AggregationType.PERCENTILE:
|
|
return get_percentile_aggregation(
|
|
db_type, aggregation.percentile_value, column, partition_by_columns
|
|
)
|
|
case _:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Unsupported aggregation type: {aggregation.aggregation_type}",
|
|
)
|
|
|
|
|
|
def _get_assessment_numeric_value_column(json_column: Column) -> Column:
|
|
"""
|
|
Extract numeric value from JSON-encoded assessment value.
|
|
|
|
Handles conversion of JSON primitives to numeric values:
|
|
- JSON true/false -> 1/0
|
|
- JSON numbers -> numeric value
|
|
- other JSON-encoded values -> NULL
|
|
|
|
Args:
|
|
json_column: Column containing JSON-encoded value
|
|
|
|
Returns:
|
|
Column expression that extracts numeric value or NULL for non-numeric values
|
|
"""
|
|
return case(
|
|
# yes / no -> 1.0 / 0.0 to support mlflow.genai.judges.CategoricalRating
|
|
# that is used by builtin judges
|
|
(json_column.in_([json.dumps(True), json.dumps("yes")]), 1.0),
|
|
(json_column.in_([json.dumps(False), json.dumps("no")]), 0.0),
|
|
# Skip null, strings, lists, and dicts (JSON null/objects/arrays)
|
|
(json_column == "null", None),
|
|
(func.substring(json_column, 1, 1).in_(['"', "[", "{"]), None),
|
|
# For numbers, cast to float
|
|
else_=func.cast(json_column, Float),
|
|
)
|
|
|
|
|
|
def _get_column_to_aggregate(view_type: MetricViewType, metric_name: str) -> Column:
|
|
"""
|
|
Get the SQL column for the given metric name and view type.
|
|
|
|
Args:
|
|
metric_name: Name of the metric to query
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS, ASSESSMENTS)
|
|
|
|
Returns:
|
|
SQLAlchemy column to aggregate
|
|
"""
|
|
match view_type:
|
|
case MetricViewType.TRACES:
|
|
match metric_name:
|
|
case TraceMetricKey.TRACE_COUNT:
|
|
return SqlTraceInfo.request_id
|
|
case TraceMetricKey.SESSION_COUNT:
|
|
return distinct(_SESSION_TRACE_METADATA.value)
|
|
case TraceMetricKey.LATENCY:
|
|
return SqlTraceInfo.execution_time_ms
|
|
case metric_name if metric_name in TraceMetricKey.token_usage_keys():
|
|
return SqlTraceMetrics.value
|
|
case MetricViewType.SPANS:
|
|
match metric_name:
|
|
case SpanMetricKey.SPAN_COUNT:
|
|
return SqlSpan.span_id
|
|
case SpanMetricKey.LATENCY:
|
|
# Span latency in milliseconds (nanoseconds converted to ms)
|
|
return (SqlSpan.end_time_unix_nano - SqlSpan.start_time_unix_nano) // 1000000
|
|
case metric_name if metric_name in SpanMetricKey.cost_keys():
|
|
return SqlSpanMetrics.value
|
|
case MetricViewType.ASSESSMENTS:
|
|
match metric_name:
|
|
case AssessmentMetricKey.ASSESSMENT_COUNT:
|
|
return SqlAssessments.assessment_id
|
|
case "assessment_value":
|
|
return _get_assessment_numeric_value_column(SqlAssessments.value)
|
|
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Unsupported metric name: {metric_name} for view type {view_type}",
|
|
)
|
|
|
|
|
|
def _get_json_dimension_column(db_type: str, json_key: str, label: str) -> Column:
|
|
"""
|
|
Extract JSON dimension column with database-specific handling.
|
|
|
|
Args:
|
|
db_type: Database type
|
|
json_key: JSON key to extract from dimension_attributes
|
|
label: Label for the dimension column
|
|
|
|
Returns:
|
|
Column expression for the JSON dimension
|
|
"""
|
|
match db_type:
|
|
case db_types.MSSQL:
|
|
# Use CASE with ISJSON to handle JSON null values stored as 'null' string
|
|
# SQLAlchemy stores Python None as JSON 'null', which JSON_VALUE can't handle
|
|
# ISJSON returns 1 for valid JSON objects, 0 for 'null' string
|
|
return literal_column(
|
|
f"CASE WHEN ISJSON(spans.dimension_attributes) = 1 "
|
|
f"AND spans.dimension_attributes != 'null' "
|
|
f"THEN JSON_VALUE(spans.dimension_attributes, '$.\"{json_key}\"') "
|
|
f"ELSE NULL END"
|
|
).label(label)
|
|
case db_types.POSTGRES:
|
|
# Use ->> operator to extract as text without JSON quotes
|
|
# Use literal_column to ensure identical SQL for consistent GROUP BY
|
|
return literal_column(f"spans.dimension_attributes ->> '{json_key}'").label(label)
|
|
case _:
|
|
return SqlSpan.dimension_attributes[json_key].label(label)
|
|
|
|
|
|
def _apply_dimension_to_query(
|
|
query: Query, dimension: str, view_type: MetricViewType, db_type: str
|
|
) -> tuple[Query, Column]:
|
|
"""
|
|
Apply dimension-specific logic to query and return the dimension column.
|
|
|
|
Args:
|
|
query: SQLAlchemy query to modify
|
|
dimension: Dimension name to apply
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS, ASSESSMENTS)
|
|
db_type: Database type (for MSSQL-specific JSON extraction handling)
|
|
|
|
Returns:
|
|
Tuple of (modified query, labeled dimension column)
|
|
"""
|
|
match view_type:
|
|
case MetricViewType.TRACES:
|
|
match dimension:
|
|
case TraceMetricDimensionKey.TRACE_NAME:
|
|
# Join with SqlTraceTag to get trace name
|
|
query = query.join(
|
|
SqlTraceTag,
|
|
and_(
|
|
SqlTraceInfo.request_id == SqlTraceTag.request_id,
|
|
SqlTraceTag.key == TraceTagKey.TRACE_NAME,
|
|
),
|
|
)
|
|
return query, SqlTraceTag.value.label(TraceMetricDimensionKey.TRACE_NAME)
|
|
case TraceMetricDimensionKey.TRACE_STATUS:
|
|
return query, SqlTraceInfo.status.label(TraceMetricDimensionKey.TRACE_STATUS)
|
|
case MetricViewType.SPANS:
|
|
match dimension:
|
|
case SpanMetricDimensionKey.SPAN_NAME:
|
|
return query, SqlSpan.name.label(SpanMetricDimensionKey.SPAN_NAME)
|
|
case SpanMetricDimensionKey.SPAN_TYPE:
|
|
return query, SqlSpan.type.label(SpanMetricDimensionKey.SPAN_TYPE)
|
|
case SpanMetricDimensionKey.SPAN_STATUS:
|
|
return query, SqlSpan.status.label(SpanMetricDimensionKey.SPAN_STATUS)
|
|
case SpanMetricDimensionKey.SPAN_MODEL_NAME:
|
|
return query, _get_json_dimension_column(
|
|
db_type, SpanAttributeKey.MODEL, SpanMetricDimensionKey.SPAN_MODEL_NAME
|
|
)
|
|
case SpanMetricDimensionKey.SPAN_MODEL_PROVIDER:
|
|
return query, _get_json_dimension_column(
|
|
db_type,
|
|
SpanAttributeKey.MODEL_PROVIDER,
|
|
SpanMetricDimensionKey.SPAN_MODEL_PROVIDER,
|
|
)
|
|
case MetricViewType.ASSESSMENTS:
|
|
match dimension:
|
|
case AssessmentMetricDimensionKey.ASSESSMENT_NAME:
|
|
return query, SqlAssessments.name.label(
|
|
AssessmentMetricDimensionKey.ASSESSMENT_NAME
|
|
)
|
|
case AssessmentMetricDimensionKey.ASSESSMENT_VALUE:
|
|
return query, SqlAssessments.value.label(
|
|
AssessmentMetricDimensionKey.ASSESSMENT_VALUE
|
|
)
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Unsupported dimension `{dimension}` with view type {view_type}"
|
|
)
|
|
|
|
|
|
def _apply_view_initial_join(query: Query, view_type: MetricViewType) -> Query:
|
|
"""
|
|
Apply initial join required for the view type.
|
|
|
|
Args:
|
|
query: SQLAlchemy query (starting from SqlTraceInfo)
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS, ASSESSMENTS)
|
|
|
|
Returns:
|
|
Modified query with view-specific joins
|
|
"""
|
|
match view_type:
|
|
case MetricViewType.SPANS:
|
|
query = query.join(SqlSpan, SqlSpan.trace_id == SqlTraceInfo.request_id)
|
|
case MetricViewType.ASSESSMENTS:
|
|
# Only aggregate valid assessments. When an assessment is overridden via
|
|
# mlflow.override_feedback(), the superseded assessment is marked valid=False,
|
|
# and should be excluded from counts/values so override chains aren't double-counted.
|
|
query = query.join(
|
|
SqlAssessments,
|
|
and_(
|
|
SqlAssessments.trace_id == SqlTraceInfo.request_id,
|
|
SqlAssessments.valid == true(),
|
|
),
|
|
)
|
|
return query
|
|
|
|
|
|
def _apply_metric_specific_joins(
|
|
query: Query, metric_name: str, view_type: MetricViewType
|
|
) -> Query:
|
|
"""
|
|
Apply metric-specific joins to the query.
|
|
|
|
Args:
|
|
query: SQLAlchemy query to modify
|
|
metric_name: Name of the metric being queried
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS)
|
|
|
|
Returns:
|
|
Modified query with necessary joins
|
|
"""
|
|
match view_type:
|
|
case MetricViewType.TRACES:
|
|
# Join with SqlTraceMetrics for token usage metrics
|
|
if metric_name in TraceMetricKey.token_usage_keys():
|
|
query = query.join(
|
|
SqlTraceMetrics,
|
|
and_(
|
|
SqlTraceInfo.request_id == SqlTraceMetrics.request_id,
|
|
SqlTraceMetrics.key == metric_name,
|
|
),
|
|
)
|
|
elif metric_name == TraceMetricKey.SESSION_COUNT:
|
|
# Join with SqlTraceMetadata to access session IDs for unique session counting.
|
|
query = query.join(
|
|
_SESSION_TRACE_METADATA,
|
|
and_(
|
|
SqlTraceInfo.request_id == _SESSION_TRACE_METADATA.request_id,
|
|
_SESSION_TRACE_METADATA.key == TraceMetadataKey.TRACE_SESSION,
|
|
),
|
|
)
|
|
case MetricViewType.SPANS:
|
|
# Join with SqlSpanMetrics for cost metrics
|
|
if metric_name in SpanMetricKey.cost_keys():
|
|
query = query.join(
|
|
SqlSpanMetrics,
|
|
and_(
|
|
SqlSpan.trace_id == SqlSpanMetrics.trace_id,
|
|
SqlSpan.span_id == SqlSpanMetrics.span_id,
|
|
SqlSpanMetrics.key == metric_name,
|
|
),
|
|
)
|
|
return query
|
|
|
|
|
|
def _apply_filters(query: Query, filters: list[str], view_type: MetricViewType) -> Query:
|
|
"""
|
|
Apply filters to the query.
|
|
|
|
Args:
|
|
query: SQLAlchemy query to filter
|
|
filters: List of filter strings
|
|
view_type: Type of metrics view
|
|
|
|
Returns:
|
|
Filtered query
|
|
"""
|
|
if not filters:
|
|
return query
|
|
|
|
for filter_string in filters:
|
|
parsed_filter = SearchTraceMetricsUtils.parse_search_filter(filter_string)
|
|
match parsed_filter.view_type:
|
|
case TraceMetricSearchKey.VIEW_TYPE:
|
|
match parsed_filter.entity:
|
|
case TraceMetricSearchKey.STATUS:
|
|
query = query.filter(SqlTraceInfo.status == parsed_filter.value)
|
|
case TraceMetricSearchKey.METADATA:
|
|
metadata_filter = exists().where(
|
|
and_(
|
|
SqlTraceMetadata.request_id == SqlTraceInfo.request_id,
|
|
SqlTraceMetadata.key == parsed_filter.key,
|
|
SqlTraceMetadata.value == parsed_filter.value,
|
|
)
|
|
)
|
|
if parsed_filter.key == TraceMetadataKey.SOURCE_RUN:
|
|
# OTLP traces linked post-hoc via link_traces_to_run() store the
|
|
# run association in SqlEntityAssociation, not in trace metadata.
|
|
# Include both paths so the filter works regardless of how the
|
|
# trace was created.
|
|
src_type = EntityAssociationType.TRACE
|
|
dst_type = EntityAssociationType.RUN
|
|
association_filter = exists().where(
|
|
and_(
|
|
SqlEntityAssociation.source_id == SqlTraceInfo.request_id,
|
|
SqlEntityAssociation.source_type == src_type,
|
|
SqlEntityAssociation.destination_type == dst_type,
|
|
SqlEntityAssociation.destination_id == parsed_filter.value,
|
|
)
|
|
)
|
|
query = query.filter(metadata_filter | association_filter)
|
|
else:
|
|
query = query.filter(metadata_filter)
|
|
case TraceMetricSearchKey.TAG:
|
|
tag_filter = exists().where(
|
|
and_(
|
|
SqlTraceTag.request_id == SqlTraceInfo.request_id,
|
|
SqlTraceTag.key == parsed_filter.key,
|
|
SqlTraceTag.value == parsed_filter.value,
|
|
)
|
|
)
|
|
query = query.filter(tag_filter)
|
|
case SpanMetricSearchKey.VIEW_TYPE:
|
|
if view_type != MetricViewType.SPANS:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Filtering by span is only supported for {MetricViewType.SPANS} view "
|
|
f"type, got {view_type}",
|
|
)
|
|
match parsed_filter.entity:
|
|
case SpanMetricSearchKey.NAME:
|
|
query = query.filter(SqlSpan.name == parsed_filter.value)
|
|
case SpanMetricSearchKey.STATUS:
|
|
query = query.filter(SqlSpan.status == parsed_filter.value)
|
|
case SpanMetricSearchKey.TYPE:
|
|
query = query.filter(SqlSpan.type == parsed_filter.value)
|
|
case AssessmentMetricSearchKey.VIEW_TYPE:
|
|
if view_type != MetricViewType.ASSESSMENTS:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Filtering by assessment is only supported for "
|
|
f"{MetricViewType.ASSESSMENTS} view type, got {view_type}",
|
|
)
|
|
match parsed_filter.entity:
|
|
case AssessmentMetricSearchKey.NAME:
|
|
query = query.filter(SqlAssessments.name == parsed_filter.value)
|
|
case AssessmentMetricSearchKey.TYPE:
|
|
query = query.filter(SqlAssessments.assessment_type == parsed_filter.value)
|
|
|
|
return query
|
|
|
|
|
|
def _has_percentile_aggregation(aggregations: list[MetricAggregation]) -> bool:
|
|
return any(agg.aggregation_type == AggregationType.PERCENTILE for agg in aggregations)
|
|
|
|
|
|
def _build_query_with_percentile_subquery(
|
|
db_type: str,
|
|
query: Query,
|
|
aggregations: list[MetricAggregation],
|
|
dimension_columns: list[Column],
|
|
agg_column: Column,
|
|
) -> tuple[Query, list[Column]]:
|
|
"""
|
|
Build query with percentile window functions using a subquery approach.
|
|
|
|
Both MSSQL and MySQL require window functions for percentile calculations, which don't
|
|
work directly with GROUP BY. This function uses a two-level query pattern:
|
|
- Inner: compute window function values (percentile or percent_rank)
|
|
- Outer: GROUP BY dimensions and aggregate the window function results
|
|
|
|
MSSQL uses PERCENTILE_CONT(...) OVER (PARTITION BY ...) directly.
|
|
MySQL uses PERCENT_RANK() with linear interpolation to emulate PERCENTILE_CONT.
|
|
|
|
Args:
|
|
db_type: Database type ("mssql" or "mysql")
|
|
query: Base SQLAlchemy query with joins and filters applied
|
|
aggregations: List of aggregations to compute
|
|
dimension_columns: Labeled dimension columns for grouping
|
|
agg_column: Column to aggregate on
|
|
|
|
Returns:
|
|
Tuple of (outer_query, select_columns)
|
|
"""
|
|
partition_by_columns = [col.element for col in dimension_columns] if dimension_columns else []
|
|
|
|
# Build inner subquery columns: dimensions + value + window function columns
|
|
inner_columns = list(dimension_columns)
|
|
inner_columns.append(agg_column.label("_agg_value"))
|
|
|
|
# Add db-specific window function columns
|
|
percentile_labels = {}
|
|
match db_type:
|
|
case db_types.MSSQL:
|
|
# add PERCENTILE_CONT window function for each percentile aggregation
|
|
for agg in aggregations:
|
|
if agg.aggregation_type == AggregationType.PERCENTILE:
|
|
label = f"_p{int(agg.percentile_value)}"
|
|
expr = get_percentile_aggregation(
|
|
db_type, agg.percentile_value, agg_column, partition_by_columns
|
|
)
|
|
inner_columns.append(expr.label(label))
|
|
percentile_labels[str(agg)] = label
|
|
case db_types.MYSQL:
|
|
# add single PERCENT_RANK column for interpolation
|
|
inner_columns.append(
|
|
func
|
|
.percent_rank()
|
|
.over(partition_by=partition_by_columns, order_by=agg_column)
|
|
.label("_pct_rank")
|
|
)
|
|
case _:
|
|
raise ValueError(
|
|
f"Unsupported database type: {db_type}",
|
|
)
|
|
|
|
subquery = query.with_entities(*inner_columns).subquery()
|
|
|
|
# Build outer query percentile expression based on db type
|
|
def _build_outer_percentile_expr(agg):
|
|
match db_type:
|
|
case db_types.MSSQL:
|
|
# MAX picks the pre-computed percentile (same value for all rows in partition)
|
|
return func.max(subquery.c[percentile_labels[str(agg)]])
|
|
case db_types.MYSQL:
|
|
# linear interpolation
|
|
pct_fraction = agg.percentile_value / 100
|
|
val_col = subquery.c["_agg_value"]
|
|
rank_col = subquery.c["_pct_rank"]
|
|
|
|
# Boundary values and ranks for interpolation
|
|
low_val = func.max(case((rank_col <= pct_fraction, val_col)))
|
|
hi_val = func.min(case((rank_col >= pct_fraction, val_col)))
|
|
low_rank = func.max(case((rank_col <= pct_fraction, rank_col)))
|
|
hi_rank = func.min(case((rank_col >= pct_fraction, rank_col)))
|
|
|
|
# Interpolate: low + (hi - low) * (target - low_rank) / (hi_rank - low_rank)
|
|
rank_diff = func.nullif(hi_rank - low_rank, 0)
|
|
interpolation = low_val + (hi_val - low_val) * (pct_fraction - low_rank) / rank_diff
|
|
return func.coalesce(interpolation, low_val)
|
|
|
|
def _outer_agg_column(agg: MetricAggregation) -> Column:
|
|
agg_label = str(agg)
|
|
match agg.aggregation_type:
|
|
case AggregationType.PERCENTILE:
|
|
return _build_outer_percentile_expr(agg).label(agg_label)
|
|
case _:
|
|
return _get_aggregation_expression(agg, db_type, subquery.c["_agg_value"]).label(
|
|
agg_label
|
|
)
|
|
|
|
select_columns = [subquery.c[col.name].label(col.name) for col in dimension_columns]
|
|
select_columns.extend(_outer_agg_column(agg) for agg in aggregations)
|
|
|
|
outer_query = query.session.query(*select_columns).select_from(subquery)
|
|
|
|
if dimension_columns:
|
|
group_by_cols = [subquery.c[col.name] for col in dimension_columns]
|
|
outer_query = outer_query.group_by(*group_by_cols).order_by(*group_by_cols)
|
|
|
|
return outer_query, select_columns
|
|
|
|
|
|
def query_metrics(
|
|
view_type: MetricViewType,
|
|
db_type: str,
|
|
query: Query,
|
|
metric_name: str,
|
|
aggregations: list[MetricAggregation],
|
|
dimensions: list[str] | None,
|
|
filters: list[str] | None,
|
|
time_interval_seconds: int | None,
|
|
max_results: int,
|
|
) -> list[MetricDataPoint]:
|
|
"""Unified query metrics function for all view types.
|
|
|
|
Args:
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS)
|
|
db_type: Database type (e.g., "postgresql", "mssql", "mysql")
|
|
query: Base SQLAlchemy query
|
|
metric_name: Name of the metric to query
|
|
aggregations: List of aggregations to compute
|
|
dimensions: List of dimensions to group by
|
|
filters: List of filter strings (each parsed by SearchTraceUtils), combined with AND
|
|
time_interval_seconds: Time interval in seconds for time bucketing
|
|
max_results: Maximum number of results to return
|
|
|
|
Returns:
|
|
List of MetricDataPoint objects
|
|
"""
|
|
# Apply view-specific initial join
|
|
query = _apply_view_initial_join(query, view_type)
|
|
|
|
query = _apply_filters(query, filters, view_type)
|
|
|
|
# Apply metric-specific joins first, before dimensions
|
|
# This ensures tables like SqlSpanMetrics are available for dimension extraction
|
|
query = _apply_metric_specific_joins(query, metric_name, view_type)
|
|
agg_column = _get_column_to_aggregate(view_type, metric_name)
|
|
|
|
# Group by dimension columns, labeled for SELECT
|
|
dimension_columns = []
|
|
|
|
if time_interval_seconds:
|
|
time_bucket_expr = get_time_bucket_expression(view_type, time_interval_seconds, db_type)
|
|
dimension_columns.append(time_bucket_expr.label(TIME_BUCKET_LABEL))
|
|
|
|
for dimension in dimensions or []:
|
|
query, dimension_column = _apply_dimension_to_query(query, dimension, view_type, db_type)
|
|
dimension_columns.append(dimension_column)
|
|
|
|
# MSSQL and MySQL with percentile need special handling (window function requires subquery)
|
|
if db_type in (db_types.MSSQL, db_types.MYSQL) and _has_percentile_aggregation(aggregations):
|
|
query, select_columns = _build_query_with_percentile_subquery(
|
|
db_type, query, aggregations, dimension_columns, agg_column
|
|
)
|
|
else:
|
|
# Standard path for PostgreSQL, SQLite
|
|
select_columns = list(dimension_columns)
|
|
for agg in aggregations:
|
|
expr = _get_aggregation_expression(agg, db_type, agg_column)
|
|
select_columns.append(expr.label(str(agg)))
|
|
|
|
query = query.with_entities(*select_columns)
|
|
|
|
# Extract underlying column expressions from labeled columns for GROUP BY/ORDER BY
|
|
if dimension_columns:
|
|
group_by_columns = [col.element for col in dimension_columns]
|
|
query = query.group_by(*group_by_columns)
|
|
# order by time bucket first, then by other dimensions
|
|
query = query.order_by(*group_by_columns)
|
|
|
|
results = query.limit(max_results).all()
|
|
|
|
return convert_results_to_metric_data_points(
|
|
results, select_columns, len(dimension_columns), metric_name
|
|
)
|
|
|
|
|
|
def validate_query_trace_metrics_params(
|
|
view_type: MetricViewType,
|
|
metric_name: str,
|
|
aggregations: list[MetricAggregation],
|
|
dimensions: list[str] | None,
|
|
):
|
|
"""Validate parameters for query_trace_metrics.
|
|
|
|
Args:
|
|
view_type: Type of metrics view (e.g., TRACES, SPANS, ASSESSMENTS)
|
|
metric_name: Name of the metric to query
|
|
aggregations: List of aggregations to compute
|
|
dimensions: List of dimensions to group by
|
|
|
|
Raises:
|
|
MlflowException: If any parameter is invalid
|
|
"""
|
|
if view_type not in VIEW_TYPE_CONFIGS:
|
|
supported_view_types = [vt.value for vt in VIEW_TYPE_CONFIGS.keys()]
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"view_type must be one of {supported_view_types}, got '{view_type.value}'",
|
|
)
|
|
|
|
view_type_config = VIEW_TYPE_CONFIGS[view_type]
|
|
if metric_name not in view_type_config:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"metric_name must be one of {list(view_type_config.keys())}, got '{metric_name}'",
|
|
)
|
|
|
|
metrics_config = view_type_config[metric_name]
|
|
aggregation_types = [agg.aggregation_type for agg in aggregations]
|
|
if invalid_agg_types := (set(aggregation_types) - metrics_config.aggregation_types):
|
|
supported_aggs = sorted([a.value for a in metrics_config.aggregation_types])
|
|
invalid_aggs = sorted([a.value for a in invalid_agg_types])
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Found invalid aggregation_type(s): {invalid_aggs}. "
|
|
f"Supported aggregation types: {supported_aggs}",
|
|
)
|
|
|
|
dimensions_list = dimensions or []
|
|
if invalid_dimensions := (set(dimensions_list) - metrics_config.dimensions):
|
|
supported_dims = sorted([d for d in metrics_config.dimensions if d is not None])
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Found invalid dimension(s): {sorted(invalid_dimensions)}. "
|
|
f"Supported dimensions: {supported_dims}",
|
|
)
|
|
|
|
|
|
def convert_results_to_metric_data_points(
|
|
results: list[tuple[...]],
|
|
select_columns: list[Column],
|
|
num_dimensions: int,
|
|
metric_name: str,
|
|
) -> list[MetricDataPoint]:
|
|
"""
|
|
Convert query results to MetricDataPoint objects.
|
|
|
|
Args:
|
|
results: List of tuples containing query results
|
|
select_columns: List of labeled column objects (dimensions + aggregations)
|
|
num_dimensions: Number of dimension columns
|
|
metric_name: Name of the metric being queried
|
|
|
|
Returns:
|
|
List of MetricDataPoint objects
|
|
"""
|
|
data_points = []
|
|
for row in results:
|
|
# Split row values into dimensions and aggregations based on select_columns
|
|
dims = {col.name: row[i] for i, col in enumerate(select_columns[:num_dimensions])}
|
|
|
|
# Skip data points with None dimension values
|
|
if any(value is None for value in dims.values()):
|
|
continue
|
|
|
|
# Convert time_bucket from milliseconds to ISO 8601 datetime string
|
|
if TIME_BUCKET_LABEL in dims:
|
|
timestamp_ms = float(dims[TIME_BUCKET_LABEL])
|
|
timestamp_sec = timestamp_ms / 1000.0
|
|
dt = datetime.fromtimestamp(timestamp_sec, tz=timezone.utc)
|
|
dims[TIME_BUCKET_LABEL] = dt.isoformat()
|
|
|
|
values = {
|
|
col.name: row[i + num_dimensions]
|
|
for i, col in enumerate(select_columns[num_dimensions:])
|
|
if row[i + num_dimensions] is not None
|
|
}
|
|
|
|
# Skip data points with no values (all aggregations returned None)
|
|
if not values:
|
|
continue
|
|
|
|
data_points.append(
|
|
MetricDataPoint(
|
|
dimensions=dims,
|
|
metric_name=metric_name,
|
|
values=values,
|
|
)
|
|
)
|
|
return data_points
|