Skip to content

Commit 756e403

Browse files
authored
feat(ai-monitoring): Sort conversation lists by supported aliases (#123735)
AI conversation lists can sort by one supported alias behind `organizations:gen-ai-conversations-querying-enhancements`. Ordering applies before pagination and defaults to `-max(timestamp)`, with conversation ID breaking ties. Custom expressions and multiple sort entries are rejected while enabled. Start and end timestamp sort aliases are no longer accepted. For now, sorting uses only spans matching the search, while returned summaries include all conversation spans within the selected scope. Whole-conversation filtering and frontend ordering will follow separately.
1 parent 2e7000b commit 756e403

5 files changed

Lines changed: 343 additions & 21 deletions

File tree

src/sentry/ai_monitoring/constants.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
_AI_CONVERSATIONS_ALIAS_DEFINITIONS = {
44
"conversationId": ("gen_ai.conversation.id", "gen_ai.conversation.id"),
5-
"timestamp": ("max(timestamp)", "max(timestamp)"),
5+
"age": ("max(timestamp)", "max(timestamp)"),
66
"duration": (
77
"sum_if(`has:gen_ai.operation.type`,span.duration)",
88
"duration",
@@ -37,14 +37,10 @@
3737
"failure_count_if(gen_ai.operation.type,equals,tool)",
3838
"tool_errors",
3939
),
40-
"startTimestamp": ("min(precise.start_ts)", "start_timestamp"),
41-
"endTimestamp": ("max(precise.finish_ts)", "end_timestamp"),
4240
}
4341

4442
# Accept response field names, selected aliases, and aliases generated by Explore.
45-
AI_CONVERSATIONS_ALIASES = {
46-
field: alias for field, (_, alias) in _AI_CONVERSATIONS_ALIAS_DEFINITIONS.items()
47-
}
48-
for _expression, _alias in _AI_CONVERSATIONS_ALIAS_DEFINITIONS.values():
49-
AI_CONVERSATIONS_ALIASES[_alias] = _alias
50-
AI_CONVERSATIONS_ALIASES[get_function_alias(_expression)] = _alias
43+
AI_CONVERSATIONS_FIELDS: dict[str, tuple[str, str]] = {}
44+
for _field, (_expression, _alias) in _AI_CONVERSATIONS_ALIAS_DEFINITIONS.items():
45+
for _accepted_name in (_field, _alias, get_function_alias(_expression)):
46+
AI_CONVERSATIONS_FIELDS[_accepted_name] = (_expression, _alias)

src/sentry/ai_monitoring/endpoints/organization_ai_conversations.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import logging
22
import re
33
from collections import defaultdict
4-
from collections.abc import Mapping
4+
from collections.abc import Mapping, Sequence
55
from typing import Any, TypedDict
66

77
import sentry_sdk
88
from drf_spectacular.utils import OpenApiParameter, extend_schema
99
from rest_framework.request import Request
1010
from rest_framework.response import Response
1111

12+
from sentry import features
13+
from sentry.ai_monitoring.constants import AI_CONVERSATIONS_FIELDS
1214
from sentry.ai_monitoring.conversation_titles import fetch_conversation_titles
1315
from sentry.ai_monitoring.serializers import OrganizationAIConversationsSerializer
1416
from sentry.ai_monitoring.utils import get_first_input_message, get_last_output, timestamp_to_float
@@ -236,7 +238,12 @@ def get(
236238
except NoProjects:
237239
return Response(status=404)
238240

239-
serializer = OrganizationAIConversationsSerializer(data=request.GET)
241+
sorting_enabled = features.has(
242+
"organizations:gen-ai-conversations-querying-enhancements", organization
243+
)
244+
serializer = OrganizationAIConversationsSerializer(
245+
data=request.GET, context={"sorting_enabled": sorting_enabled}
246+
)
240247
if not serializer.is_valid():
241248
return Response(as_validation_errors(serializer), status=400)
242249

@@ -249,6 +256,7 @@ def data_fn(offset: int, limit: int) -> list[AIConversationResponse]:
249256
limit=limit,
250257
user_query=validated_data.get("query", ""),
251258
sampling_mode=validated_data.get("samplingMode", "NORMAL"),
259+
sorts=validated_data["sort"] if sorting_enabled else None,
252260
)
253261

254262
with handle_query_errors():
@@ -278,12 +286,13 @@ def _get_conversations(
278286
limit: int,
279287
user_query: str,
280288
sampling_mode: SAMPLING_MODES = "NORMAL",
289+
sorts: Sequence[str] | None = None,
281290
) -> list[AIConversationResponse]:
282291
base_filter = "has:gen_ai.conversation.id has:gen_ai.operation.type"
283292
query_string = _build_conversation_query(base_filter, user_query)
284293

285294
conversation_ids_results = self._fetch_conversation_ids(
286-
snuba_params, query_string, offset, limit, sampling_mode
295+
snuba_params, query_string, offset, limit, sampling_mode, sorts
287296
)
288297
conversation_ids = _extract_conversation_ids(conversation_ids_results)
289298

@@ -303,24 +312,45 @@ def _fetch_conversation_ids(
303312
offset: int,
304313
limit: int,
305314
sampling_mode: SAMPLING_MODES,
315+
sorts: Sequence[str] | None = None,
306316
) -> EAPResponse:
317+
selected_columns = ["gen_ai.conversation.id", "max(precise.finish_ts)"]
318+
orderby = ["-max(precise.finish_ts)"]
319+
if sorts is not None:
320+
# Keep groups with missing sort attributes: EAP filters for the presence
321+
# of at least one selected aggregate attribute. Timestamp is always present.
322+
selected_columns = ["gen_ai.conversation.id", "max(timestamp)"]
323+
selected_aliases = set(selected_columns)
324+
orderby = []
325+
for sort in sorts:
326+
expression, alias = AI_CONVERSATIONS_FIELDS[sort.removeprefix("-")]
327+
orderby.append(("-" if sort.startswith("-") else "") + alias)
328+
if alias not in selected_aliases:
329+
selected_columns.append(
330+
expression if expression == alias else f"{expression} as {alias}"
331+
)
332+
selected_aliases.add(alias)
333+
if not any(column.removeprefix("-") == "gen_ai.conversation.id" for column in orderby):
334+
orderby.append("gen_ai.conversation.id")
335+
336+
# TODO (vgrozdanic): Sort on whole conversations instead of only matching spans.
307337
return Spans.run_table_query(
308338
params=snuba_params,
309339
query_string=query_string,
310-
selected_columns=["gen_ai.conversation.id", "max(precise.finish_ts)"],
311-
orderby=["-max(precise.finish_ts)"],
340+
selected_columns=selected_columns,
341+
orderby=orderby,
312342
offset=offset,
313343
limit=limit,
314344
referrer=Referrer.API_AI_CONVERSATIONS.value,
315-
config=SearchResolverConfig(auto_fields=True),
345+
config=SearchResolverConfig(auto_fields=True, disable_aggregate_extrapolation=True),
316346
sampling_mode=sampling_mode,
317347
)
318348

319349
@trace
320350
def _get_conversations_data(
321351
self, snuba_params: SnubaParams, conversation_ids: list[str]
322352
) -> list[AIConversationResponse]:
323-
config = SearchResolverConfig(auto_fields=True)
353+
config = SearchResolverConfig(auto_fields=True, disable_aggregate_extrapolation=True)
324354
resolver = Spans.get_resolver(snuba_params, config)
325355

326356
# Build queries

src/sentry/ai_monitoring/serializers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from rest_framework import serializers
44

5-
from sentry.ai_monitoring.constants import AI_CONVERSATIONS_ALIASES
5+
from sentry.ai_monitoring.constants import AI_CONVERSATIONS_FIELDS
66
from sentry.search.events.types import SAMPLING_MODES
77

88

@@ -13,9 +13,7 @@ class AIConversationsQuery(TypedDict):
1313

1414

1515
class OrganizationAIConversationsSerializer(serializers.Serializer[AIConversationsQuery]):
16-
sort = serializers.ListField(
17-
child=serializers.CharField(), required=False, default=["-timestamp"]
18-
)
16+
sort = serializers.ListField(child=serializers.CharField(), required=False, default=["-age"])
1917
query = serializers.CharField(required=False, allow_blank=True)
2018
samplingMode = serializers.ChoiceField(
2119
choices=[
@@ -28,7 +26,9 @@ class OrganizationAIConversationsSerializer(serializers.Serializer[AIConversatio
2826
)
2927

3028
def validate_sort(self, value: list[str]) -> list[str]:
29+
if self.context.get("sorting_enabled") and len(value) != 1:
30+
raise serializers.ValidationError("Provide exactly one sort option.")
3131
for sort in value:
32-
if sort.removeprefix("-") not in AI_CONVERSATIONS_ALIASES:
32+
if sort.removeprefix("-") not in AI_CONVERSATIONS_FIELDS:
3333
raise serializers.ValidationError(f"Invalid sort option: {sort}")
3434
return value

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
471471
manager.add("organizations:trace-waterfall-version-message", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
472472
# Enable Conversation focused views in AI Insights
473473
manager.add("organizations:gen-ai-conversations", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
474+
# Enable query enhancements for AI conversation lists
475+
manager.add("organizations:gen-ai-conversations-querying-enhancements", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
474476
# Enable AI conversation title generation from gen_ai spans
475477
manager.add("organizations:gen-ai-conversation-title-generation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
476478
# Enables Conduit demo endpoint and UI

0 commit comments

Comments
 (0)