Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
87 changes: 56 additions & 31 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 @@ -14,12 +16,15 @@
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
_WIDENING_STEPS = [timedelta(days=7), timedelta(days=14), timedelta(days=MAX_RETENTION_DAYS)]

AI_CONVERSATION_ATTRIBUTES = [
"span_id",
"trace",
Expand Down Expand Up @@ -68,26 +73,16 @@ 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")

try:
snuba_params = self.get_snuba_params(request, organization)
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

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
max_retention_cutoff = now - timedelta(days=MAX_RETENTION_DAYS)
has_explicit_range = request.GET.get("start") or request.GET.get("end")

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 @@ -99,37 +94,67 @@ def get(self, request: Request, organization: Organization, conversation_id: str
status=400,
)

selected_columns = AI_CONVERSATION_ATTRIBUTES
with handle_query_errors():
if has_explicit_range:
resolved_params = snuba_params
else:
resolved_params = self._resolve_time_window(
snuba_params, request.GET.get("statsPeriod"), now, conversation_id
)

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,
)
def data_fn(offset: int, limit: int) -> list:
return self._fetch_spans(resolved_params, conversation_id, offset, limit)

with handle_query_errors():
return self.paginate(
request=request,
paginator=GenericOffsetPaginator(data_fn=data_fn),
default_per_page=100,
max_per_page=1000,
)

def _fetch_conversation_spans(
def _resolve_time_window(
self,
base_params: SnubaParams,
stats_period: str | None,
now: datetime,
conversation_id: str,
) -> SnubaParams:
"""Probe progressively wider windows to find which contains the conversation."""
candidates = self._build_widening_params(base_params, stats_period, now)
for params in candidates:
if self._fetch_spans(params, conversation_id, offset=0, limit=1):
return params
return candidates[-1]

def _build_widening_params(
self, base_params: SnubaParams, stats_period: str | None, now: datetime
) -> list[SnubaParams]:
max_retention = timedelta(days=MAX_RETENTION_DAYS)
requested_delta: timedelta | None = (
parse_stats_period(stats_period) if stats_period else None
)

steps: list[timedelta] = []
if requested_delta and requested_delta < max_retention:
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]

@sentry_sdk.trace
def _fetch_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
Loading
Loading