Skip to content

Commit ec760c4

Browse files
authored
feat(ai-insights): conversations endpoint (#101916)
1 parent 75f7e6b commit ec760c4

File tree

5 files changed

+761
-6
lines changed

5 files changed

+761
-6
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import dataclasses
2+
from collections import defaultdict
3+
from datetime import datetime
4+
5+
from rest_framework import serializers
6+
from rest_framework.request import Request
7+
from rest_framework.response import Response
8+
9+
from sentry import features
10+
from sentry.api.api_owners import ApiOwner
11+
from sentry.api.api_publish_status import ApiPublishStatus
12+
from sentry.api.base import region_silo_endpoint
13+
from sentry.api.bases import NoProjects, OrganizationEventsV2EndpointBase
14+
from sentry.api.paginator import GenericOffsetPaginator
15+
from sentry.api.utils import handle_query_errors
16+
from sentry.models.organization import Organization
17+
from sentry.search.eap.types import SearchResolverConfig
18+
from sentry.snuba.referrer import Referrer
19+
from sentry.snuba.spans_rpc import Spans
20+
21+
22+
class OrganizationAIConversationsSerializer(serializers.Serializer):
23+
"""Serializer for validating query parameters."""
24+
25+
sort = serializers.CharField(required=False, default="-timestamp")
26+
query = serializers.CharField(required=False, allow_blank=True)
27+
28+
def validate_sort(self, value):
29+
allowed_sorts = {
30+
"timestamp",
31+
"-timestamp",
32+
"duration",
33+
"-duration",
34+
"errors",
35+
"-errors",
36+
"llmCalls",
37+
"-llmCalls",
38+
"toolCalls",
39+
"-toolCalls",
40+
"totalTokens",
41+
"-totalTokens",
42+
"totalCost",
43+
"-totalCost",
44+
}
45+
if value not in allowed_sorts:
46+
raise serializers.ValidationError(f"Invalid sort option: {value}")
47+
return value
48+
49+
50+
@region_silo_endpoint
51+
class OrganizationAIConversationsEndpoint(OrganizationEventsV2EndpointBase):
52+
"""Endpoint for fetching AI agent conversation traces."""
53+
54+
publish_status = {
55+
"GET": ApiPublishStatus.PRIVATE,
56+
}
57+
owner = ApiOwner.VISIBILITY
58+
59+
def get(self, request: Request, organization: Organization) -> Response:
60+
"""
61+
Retrieve AI conversation traces for an organization.
62+
"""
63+
if not features.has("organizations:gen-ai-conversations", organization, actor=request.user):
64+
return Response(status=404)
65+
66+
try:
67+
snuba_params = self.get_snuba_params(request, organization)
68+
except NoProjects:
69+
return Response(status=404)
70+
71+
serializer = OrganizationAIConversationsSerializer(data=request.GET)
72+
if not serializer.is_valid():
73+
return Response(serializer.errors, status=400)
74+
75+
validated_data = serializer.validated_data
76+
77+
# Create paginator with data function
78+
def data_fn(offset: int, limit: int):
79+
return self._get_conversations(
80+
snuba_params=snuba_params,
81+
offset=offset,
82+
limit=limit,
83+
_sort=validated_data.get("sort", "-timestamp"),
84+
_query=validated_data.get("query", ""),
85+
)
86+
87+
with handle_query_errors():
88+
return self.paginate(
89+
request=request,
90+
paginator=GenericOffsetPaginator(data_fn=data_fn),
91+
on_results=lambda results: results,
92+
)
93+
94+
def _get_conversations(
95+
self, snuba_params, offset: int, limit: int, _sort: str, _query: str
96+
) -> list[dict]:
97+
"""
98+
Fetch conversation data by querying spans grouped by gen_ai.conversation.id.
99+
100+
This is a two-step process:
101+
1. Find conversation IDs that have spans in the time range (with pagination/sorting)
102+
2. Get complete aggregations for those conversations (all spans, ignoring time filter)
103+
104+
Args:
105+
snuba_params: Snuba parameters including projects, time range, etc.
106+
offset: Starting index for pagination
107+
limit: Number of results to return
108+
_sort: Sort field and direction (currently only supports timestamp sorting, unused for now)
109+
_query: Search query (not yet implemented)
110+
"""
111+
# Step 1: Find conversation IDs with spans in the time range
112+
conversation_ids_results = Spans.run_table_query(
113+
params=snuba_params,
114+
query_string="has:gen_ai.conversation.id",
115+
selected_columns=[
116+
"gen_ai.conversation.id",
117+
"max(precise.finish_ts)",
118+
],
119+
orderby=["-max(precise.finish_ts)"],
120+
offset=offset,
121+
limit=limit,
122+
referrer=Referrer.API_AI_CONVERSATIONS.value,
123+
config=SearchResolverConfig(auto_fields=True),
124+
sampling_mode=None,
125+
)
126+
127+
conversation_ids: list[str] = [
128+
conv_id
129+
for row in conversation_ids_results.get("data", [])
130+
if (conv_id := row.get("gen_ai.conversation.id"))
131+
]
132+
133+
if not conversation_ids:
134+
return []
135+
136+
# Step 2: Get complete aggregations for these conversations (all time)
137+
all_time_params = dataclasses.replace(
138+
snuba_params,
139+
start=datetime(2020, 1, 1),
140+
end=datetime(2100, 1, 1),
141+
)
142+
143+
results = Spans.run_table_query(
144+
params=all_time_params,
145+
query_string=f"gen_ai.conversation.id:[{','.join(conversation_ids)}]",
146+
selected_columns=[
147+
"gen_ai.conversation.id",
148+
"failure_count()",
149+
"count_if(gen_ai.operation.type,equals,ai_client)",
150+
"count_if(span.op,equals,gen_ai.execute_tool)",
151+
"sum(gen_ai.usage.total_tokens)",
152+
"sum(gen_ai.usage.total_cost)",
153+
"min(precise.start_ts)",
154+
"max(precise.finish_ts)",
155+
"count_unique(trace)",
156+
],
157+
orderby=None,
158+
offset=0,
159+
limit=len(conversation_ids),
160+
referrer=Referrer.API_AI_CONVERSATIONS_COMPLETE.value,
161+
config=SearchResolverConfig(auto_fields=True),
162+
sampling_mode=None,
163+
)
164+
165+
# Create a map of conversation data by ID
166+
conversations_map = {}
167+
for row in results.get("data", []):
168+
start_ts = row.get("min(precise.start_ts)", 0)
169+
finish_ts = row.get("max(precise.finish_ts)", 0)
170+
duration_ms = int((finish_ts - start_ts) * 1000) if finish_ts and start_ts else 0
171+
timestamp_ms = int(finish_ts * 1000) if finish_ts else 0
172+
173+
conv_id = row.get("gen_ai.conversation.id", "")
174+
conversations_map[conv_id] = {
175+
"conversationId": conv_id,
176+
"flow": [],
177+
"duration": duration_ms,
178+
"errors": int(row.get("failure_count()") or 0),
179+
"llmCalls": int(row.get("count_if(gen_ai.operation.type,equals,ai_client)") or 0),
180+
"toolCalls": int(row.get("count_if(span.op,equals,gen_ai.execute_tool)") or 0),
181+
"totalTokens": int(row.get("sum(gen_ai.usage.total_tokens)") or 0),
182+
"totalCost": float(row.get("sum(gen_ai.usage.total_cost)") or 0),
183+
"timestamp": timestamp_ms,
184+
"traceCount": int(row.get("count_unique(trace)") or 0),
185+
"traceIds": [],
186+
}
187+
188+
# Preserve the order from step 1
189+
conversations = [
190+
conversations_map[conv_id]
191+
for conv_id in conversation_ids
192+
if conv_id in conversations_map
193+
]
194+
195+
if conversations:
196+
self._enrich_conversations(all_time_params, conversations)
197+
198+
return conversations
199+
200+
def _enrich_conversations(self, snuba_params, conversations: list[dict]) -> None:
201+
"""
202+
Enrich conversations with flow and trace IDs by querying all spans.
203+
"""
204+
conversation_ids = [conv["conversationId"] for conv in conversations]
205+
206+
# Query all spans for these conversations to get both agent flows and trace IDs
207+
all_spans_results = Spans.run_table_query(
208+
params=snuba_params,
209+
query_string=f"gen_ai.conversation.id:[{','.join(conversation_ids)}]",
210+
selected_columns=[
211+
"gen_ai.conversation.id",
212+
"span.op",
213+
"span.description",
214+
"trace",
215+
"precise.start_ts",
216+
],
217+
orderby=["gen_ai.conversation.id", "precise.start_ts"],
218+
offset=0,
219+
limit=10000,
220+
referrer=Referrer.API_AI_CONVERSATIONS_ENRICHMENT.value,
221+
config=SearchResolverConfig(auto_fields=True),
222+
sampling_mode=None,
223+
)
224+
225+
flows_by_conversation = defaultdict(list)
226+
traces_by_conversation = defaultdict(set)
227+
228+
for row in all_spans_results.get("data", []):
229+
conv_id = row.get("gen_ai.conversation.id", "")
230+
if not conv_id:
231+
continue
232+
233+
# Collect trace IDs
234+
trace_id = row.get("trace", "")
235+
if trace_id:
236+
traces_by_conversation[conv_id].add(trace_id)
237+
238+
# Collect agent flow (only from invoke_agent spans)
239+
if row.get("span.op") == "gen_ai.invoke_agent":
240+
agent_name = row.get("span.description", "")
241+
if agent_name:
242+
flows_by_conversation[conv_id].append(agent_name)
243+
244+
for conversation in conversations:
245+
conv_id = conversation["conversationId"]
246+
conversation["flow"] = flows_by_conversation.get(conv_id, [])
247+
conversation["traceIds"] = list(traces_by_conversation.get(conv_id, set()))

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.conf.urls import include
44
from django.urls import URLPattern, URLResolver, re_path
55

6+
from sentry.api.endpoints.organization_ai_conversations import OrganizationAIConversationsEndpoint
67
from sentry.api.endpoints.organization_auth_token_details import (
78
OrganizationAuthTokenDetailsEndpoint,
89
)
@@ -1678,6 +1679,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
16781679
OrganizationTracesEndpoint.as_view(),
16791680
name="sentry-api-0-organization-traces",
16801681
),
1682+
re_path(
1683+
r"^(?P<organization_id_or_slug>[^/]+)/ai-conversations/$",
1684+
OrganizationAIConversationsEndpoint.as_view(),
1685+
name="sentry-api-0-organization-ai-conversations",
1686+
),
16811687
re_path(
16821688
r"^(?P<organization_id_or_slug>[^/]+)/trace-items/attributes/$",
16831689
OrganizationTraceItemAttributesEndpoint.as_view(),

src/sentry/snuba/referrer.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,9 @@ class Referrer(StrEnum):
469469
API_ORGANIZATION_SPANS_HISTOGRAM_MIN_MAX = "api.organization-spans-histogram-min-max"
470470
API_ORGANIZATION_VITALS_PER_PROJECT = "api.organization-vitals-per-project"
471471
API_ORGANIZATION_VITALS = "api.organization-vitals"
472+
API_AI_CONVERSATIONS = "api.ai-conversations"
473+
API_AI_CONVERSATIONS_COMPLETE = "api.ai-conversations.complete"
474+
API_AI_CONVERSATIONS_ENRICHMENT = "api.ai-conversations.enrichment"
472475
API_AI_PIPELINES_VIEW = "api.ai-pipelines.view"
473476
API_AI_PIPELINES_DETAILS_VIEW = "api.ai-pipelines.details.view"
474477
API_PROFILING_ONBOARDING = "profiling-onboarding"

static/app/views/insights/agents/components/conversationsTable.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import useStateBasedColumnResize from 'sentry/components/tables/gridEditable/use
1111
import TimeSince from 'sentry/components/timeSince';
1212
import {IconArrow} from 'sentry/icons';
1313
import {t} from 'sentry/locale';
14+
import {useApiQuery} from 'sentry/utils/queryClient';
15+
import {useLocation} from 'sentry/utils/useLocation';
1416
import {useNavigate} from 'sentry/utils/useNavigate';
1517
import useOrganization from 'sentry/utils/useOrganization';
1618
import usePageFilters from 'sentry/utils/usePageFilters';
@@ -27,12 +29,14 @@ interface TableData {
2729
conversationId: string;
2830
duration: number;
2931
errors: number;
30-
flow: string;
32+
flow: string[];
3133
llmCalls: number;
3234
timestamp: number;
3335
toolCalls: number;
3436
totalCost: number | null;
3537
totalTokens: number;
38+
traceCount: number;
39+
traceIds: string[];
3640
}
3741

3842
export function ConversationsTable() {
@@ -69,10 +73,35 @@ const rightAlignColumns = new Set([
6973

7074
function ConversationsTableInner() {
7175
const navigate = useNavigate();
76+
const location = useLocation();
77+
const organization = useOrganization();
7278
const {columns: columnOrder, handleResizeColumn} = useStateBasedColumnResize({
7379
columns: defaultColumnOrder,
7480
});
7581

82+
// Fetch data from the API
83+
const queryCursor =
84+
typeof location.query.tableCursor === 'string'
85+
? location.query.tableCursor
86+
: undefined;
87+
88+
const {
89+
data = [],
90+
isLoading,
91+
error,
92+
getResponseHeader,
93+
} = useApiQuery<TableData[]>(
94+
[
95+
`/organizations/${organization.slug}/ai-conversations/`,
96+
{query: {cursor: queryCursor}},
97+
],
98+
{
99+
staleTime: 0,
100+
}
101+
);
102+
103+
const pageLinks = getResponseHeader?.('Link');
104+
76105
const handleCursor: CursorHandler = (cursor, pathname, previousQuery) => {
77106
navigate(
78107
{
@@ -107,9 +136,9 @@ function ConversationsTableInner() {
107136
<Fragment>
108137
<GridEditableContainer>
109138
<GridEditable
110-
isLoading={false}
111-
error={null}
112-
data={[]}
139+
isLoading={isLoading}
140+
error={error}
141+
data={data}
113142
columnOrder={columnOrder}
114143
columnSortBy={EMPTY_ARRAY}
115144
stickyHeader
@@ -120,7 +149,7 @@ function ConversationsTableInner() {
120149
}}
121150
/>
122151
</GridEditableContainer>
123-
<Pagination pageLinks={undefined} onCursor={handleCursor} />
152+
<Pagination pageLinks={pageLinks} onCursor={handleCursor} />
124153
</Fragment>
125154
);
126155
}
@@ -139,14 +168,16 @@ const BodyCell = memo(function BodyCell({
139168
switch (column.key) {
140169
case 'conversationId':
141170
return <span>{dataRow.conversationId}</span>;
171+
case 'flow':
172+
return <span>{dataRow.flow.join(' → ')}</span>;
142173
case 'duration':
143174
return <DurationCell milliseconds={dataRow.duration} />;
144175
case 'errors':
145176
return (
146177
<ErrorCell
147178
value={dataRow.errors}
148179
target={getExploreUrl({
149-
// query: `${query} span.status:internal_error trace:[${dataRow.traceId}]`,
180+
query: `span.status:internal_error trace:[${dataRow.traceIds.join(',')}]`,
150181
organization,
151182
selection,
152183
referrer: Referrer.TRACES_TABLE,

0 commit comments

Comments
 (0)