Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 76 additions & 33 deletions src/sentry/api/endpoints/organization_ai_conversation_details.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from datetime import timedelta
from dataclasses import replace
from datetime import datetime, timedelta

import sentry_sdk
from django.utils import timezone
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -10,16 +12,20 @@
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase
from sentry.api.paginator import GenericOffsetPaginator
from sentry.api.utils import handle_query_errors
from sentry.api.utils import TimeoutException, handle_query_errors
from sentry.models.organization import Organization
from sentry.search.eap.occurrences.query_utils import build_escaped_term_filter
from sentry.search.eap.types import SearchResolverConfig
from sentry.search.events.types import SnubaParams
from sentry.snuba.referrer import Referrer
from sentry.snuba.spans_rpc import Spans
from sentry.utils.dates import parse_stats_period

MAX_RETENTION_DAYS = 30

# Base span fields always returned
# Try progressively wider windows before giving up.
_WIDENING_STEPS = [timedelta(days=7), timedelta(days=MAX_RETENTION_DAYS)]

AI_CONVERSATION_ATTRIBUTES = [
"span_id",
"trace",
Expand Down Expand Up @@ -58,6 +64,8 @@
"user.ip",
]

_TIMEOUT_DETAIL = "Query timed out. Try searching with a narrower time range."


@cell_silo_endpoint
class OrganizationAIConversationDetailsEndpoint(OrganizationEventsEndpointBase):
Expand All @@ -68,7 +76,6 @@ def get(self, request: Request, organization: Organization, conversation_id: str
if not features.has("organizations:gen-ai-conversations", organization, actor=request.user):
return Response(status=404)

# Check what date params were passed before calling get_snuba_params
stats_period = request.GET.get("statsPeriod")
has_explicit_range = request.GET.get("start") or request.GET.get("end")

Expand All @@ -77,17 +84,10 @@ def get(self, request: Request, organization: Organization, conversation_id: str
except NoProjects:
return Response(status=404)

# Enforce 30-day retention limit
max_retention = timedelta(days=MAX_RETENTION_DAYS)
now = timezone.now()
max_retention_cutoff = now - max_retention
max_retention_cutoff = now - timedelta(days=MAX_RETENTION_DAYS)

if stats_period or not has_explicit_range:
# Always use full 30d range when statsPeriod is passed or no date params
snuba_params.start = max_retention_cutoff
snuba_params.end = now
else:
# Validate explicit start/end aren't older than retention limit
if has_explicit_range:
if snuba_params.start and snuba_params.start < max_retention_cutoff:
return Response(
{"detail": f"start time cannot be older than {MAX_RETENTION_DAYS} days"},
Expand All @@ -98,38 +98,81 @@ def get(self, request: Request, organization: Organization, conversation_id: str
{"detail": f"end time cannot be older than {MAX_RETENTION_DAYS} days"},
status=400,
)
data_fn = self._make_direct_data_fn(snuba_params, conversation_id)
else:
params_sequence = self._build_widening_sequence(snuba_params, stats_period, now)
data_fn = self._make_widening_data_fn(params_sequence, conversation_id)

selected_columns = AI_CONVERSATION_ATTRIBUTES

def data_fn(offset: int, limit: int):
return self._fetch_conversation_spans(
snuba_params=snuba_params,
conversation_id=conversation_id,
selected_columns=selected_columns,
offset=offset,
limit=limit,
try:
with handle_query_errors():
return self.paginate(
request=request,
paginator=GenericOffsetPaginator(data_fn=data_fn),
default_per_page=100,
max_per_page=1000,
)
except TimeoutException:
sentry_sdk.set_tag("ai_conversations.detail_timeout", True)
return Response(
{"detail": _TIMEOUT_DETAIL, "code": "query_timeout"},
status=504,
)

with handle_query_errors():
return self.paginate(
request=request,
paginator=GenericOffsetPaginator(data_fn=data_fn),
default_per_page=100,
max_per_page=1000,
)
def _build_widening_sequence(
self, base_params: SnubaParams, stats_period: str | None, now: datetime
) -> list[SnubaParams]:
# parse_stats_period returns None for invalid input; skip to default steps
requested_delta = parse_stats_period(stats_period) if stats_period else None

steps: list[timedelta] = []
if requested_delta and requested_delta < timedelta(days=MAX_RETENTION_DAYS):
steps.append(requested_delta)

for step in _WIDENING_STEPS:
if not steps or step > steps[-1]:
steps.append(step)

return [replace(base_params, start=now - delta, end=now) for delta in steps]

def _make_direct_data_fn(self, snuba_params: SnubaParams, conversation_id: str):
def data_fn(offset: int, limit: int) -> list:
return self._fetch_conversation_spans(snuba_params, conversation_id, offset, limit)

return data_fn

def _make_widening_data_fn(self, params_sequence: list[SnubaParams], conversation_id: str):
winning_params: SnubaParams | None = None

def data_fn(offset: int, limit: int) -> list:
nonlocal winning_params

Comment thread
obostjancic marked this conversation as resolved.
Outdated
if winning_params is not None:
return self._fetch_conversation_spans(
winning_params, conversation_id, offset, limit
)

for params in params_sequence:
result = self._fetch_conversation_spans(params, conversation_id, offset, limit)
if result:
winning_params = params
return result
Comment thread
obostjancic marked this conversation as resolved.
Outdated

return []

return data_fn

@sentry_sdk.trace
def _fetch_conversation_spans(
self,
snuba_params,
snuba_params: SnubaParams,
conversation_id: str,
selected_columns: list[str],
offset: int,
limit: int,
):
) -> list:
result = Spans.run_table_query(
params=snuba_params,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
query_string=build_escaped_term_filter("gen_ai.conversation.id", [conversation_id]),
selected_columns=selected_columns,
selected_columns=AI_CONVERSATION_ATTRIBUTES,
orderby=["precise.start_ts"],
offset=offset,
limit=limit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ describe('useConversation', () => {
expect(queryArg).not.toHaveProperty('statsPeriod');
});

it('falls back to ALL_ACCESS_PROJECTS and 30d when no filters are set', async () => {
it('falls back to ALL_ACCESS_PROJECTS with no time params when no filters are set', async () => {
const mockRequest = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/ai-conversations/conv-123/`,
body: [BASE_SPAN],
Expand All @@ -421,10 +421,14 @@ describe('useConversation', () => {
expect.objectContaining({
query: expect.objectContaining({
project: [-1],
statsPeriod: '30d',
}),
})
);
// No statsPeriod sent — backend uses its 30d retention fallback
const queryArg = mockRequest.mock.calls[0]![1]!.query;
expect(queryArg).not.toHaveProperty('statsPeriod');
expect(queryArg).not.toHaveProperty('start');
expect(queryArg).not.toHaveProperty('end');
});

it('uses relative period from page filters when explicitly set', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function useConversation(
}
: hasExplicitDatetime
? normalizeDateTimeParams(selection.datetime)
: {statsPeriod: '30d'};
: {};

const project =
selection.projects.length > 0 ? selection.projects : [ALL_ACCESS_PROJECTS];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ export function ExploreSecondaryNavigation() {
<Feature features="gen-ai-conversations">
<SecondaryNavigation.ListItem>
<SecondaryNavigation.Link
to={`${baseUrl}/${CONVERSATIONS_LANDING_SUB_PATH}/`}
// TODO: Remove once query performance is improved - defaults to 24h to avoid slow loads
to={{
pathname: `${baseUrl}/${CONVERSATIONS_LANDING_SUB_PATH}/`,
search: '?statsPeriod=24h',
}}
analyticsItemName="explore_conversations"
trailingItems={<FeatureBadge type="beta" />}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock, patch
from uuid import uuid4

from django.urls import reverse
from urllib3.exceptions import ReadTimeoutError

from sentry.testutils.helpers import parse_link_header
from sentry.testutils.helpers.datetime import before_now
from sentry.utils.snuba_rpc import SnubaRPCTimeout

from .test_organization_ai_conversations_base import BaseAIConversationsTestCase

Expand Down Expand Up @@ -389,21 +392,45 @@ def test_returns_tool_attributes(self) -> None:
assert span["gen_ai.operation.type"] == "tool"
assert span["gen_ai.tool.name"] == "search_database"

def test_stats_period_overrides_to_30d(self) -> None:
"""Test that statsPeriod is overridden to 30d so all recent data is returned"""
timestamp = before_now(days=15).replace(microsecond=0)
def test_stats_period_is_tried_first_then_widened(self) -> None:
"""A passed statsPeriod is used as the first attempt; if the conversation
falls outside it the endpoint widens to 7d then 30d automatically."""
timestamp_15d = before_now(days=15).replace(microsecond=0)
trace_id = uuid4().hex
conversation_id = uuid4().hex

self.store_ai_span(
conversation_id=conversation_id,
timestamp=timestamp,
timestamp=timestamp_15d,
op="gen_ai.chat",
trace_id=trace_id,
)

# statsPeriod=1h is too narrow; widening finds the 15-day-old span via 30d
query = {
"project": [self.project.id],
"statsPeriod": "1h",
}

response = self.do_request(conversation_id, query)
assert response.status_code == 200, response.data
assert len(response.data) == 1
assert response.data[0]["gen_ai.conversation.id"] == conversation_id

def test_stats_period_recent_conversation_returned_without_widening(self) -> None:
"""When the conversation falls within the requested statsPeriod it is
returned immediately without trying wider windows."""
timestamp_1h = before_now(minutes=30).replace(microsecond=0)
trace_id = uuid4().hex
conversation_id = uuid4().hex

self.store_ai_span(
conversation_id=conversation_id,
timestamp=timestamp_1h,
op="gen_ai.chat",
trace_id=trace_id,
)

# statsPeriod=1h would normally restrict to last hour and exclude our 15-day-old span,
# but endpoint overrides to 30d
query = {
"project": [self.project.id],
"statsPeriod": "1h",
Expand All @@ -412,6 +439,25 @@ def test_stats_period_overrides_to_30d(self) -> None:
response = self.do_request(conversation_id, query)
assert response.status_code == 200, response.data
assert len(response.data) == 1

def test_no_time_params_falls_back_to_30d(self) -> None:
"""Omitting all time params searches the full 30d retention window."""
timestamp = before_now(days=15).replace(microsecond=0)
trace_id = uuid4().hex
conversation_id = uuid4().hex

self.store_ai_span(
conversation_id=conversation_id,
timestamp=timestamp,
op="gen_ai.chat",
trace_id=trace_id,
)

query = {"project": [self.project.id]}

response = self.do_request(conversation_id, query)
assert response.status_code == 200, response.data
assert len(response.data) == 1
assert response.data[0]["gen_ai.conversation.id"] == conversation_id

def test_tokens_on_multiple_span_types(self) -> None:
Expand Down Expand Up @@ -473,3 +519,21 @@ def test_tokens_on_multiple_span_types(self) -> None:
assert ai_client_span["gen_ai.operation.type"] == "ai_client"
assert ai_client_span["gen_ai.usage.total_tokens"] == 100
assert ai_client_span["gen_ai.cost.total_tokens"] == 0.01

def test_timeout_returns_504_with_error_code(self) -> None:
"""A Snuba RPC timeout surfaces as 504 with a machine-readable code."""
conversation_id = uuid4().hex

# Wrap in a ReadTimeoutError so handle_query_errors() converts it to
# TimeoutException, which our endpoint then catches to return 504.
rpc_timeout = SnubaRPCTimeout(ReadTimeoutError(MagicMock(), "/", "timed out"))

with patch(
"sentry.snuba.spans_rpc.Spans.run_table_query",
side_effect=rpc_timeout,
):
response = self.do_request(conversation_id, {"project": [self.project.id]})

assert response.status_code == 504
assert response.data["code"] == "query_timeout"
assert "detail" in response.data