Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
12 changes: 12 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,8 @@
from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheckEndpoint
from sentry.seer.endpoints.organization_trace_summary import OrganizationTraceSummaryEndpoint
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
from sentry.seer.endpoints.search_agent_start import SearchAgentStartEndpoint
from sentry.seer.endpoints.search_agent_state import SearchAgentStateEndpoint
from sentry.seer.endpoints.seer_rpc import SeerRpcServiceEndpoint
from sentry.seer.endpoints.trace_explorer_ai_query import TraceExplorerAIQuery
from sentry.seer.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup
Expand Down Expand Up @@ -2368,6 +2370,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
SearchAgentTranslateEndpoint.as_view(),
name="sentry-api-0-search-agent-translate",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/search-agent/start/$",
SearchAgentStartEndpoint.as_view(),
name="sentry-api-0-search-agent-start",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/search-agent/state/(?P<run_id>[^/]+)/$",
SearchAgentStateEndpoint.as_view(),
name="sentry-api-0-search-agent-state",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/seer/explorer-chat/(?:(?P<run_id>[^/]+)/)?$",
OrganizationSeerExplorerChatEndpoint.as_view(),
Expand Down
181 changes: 181 additions & 0 deletions src/sentry/seer/endpoints/search_agent_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from __future__ import annotations

import logging
from typing import Any

import orjson
import requests
from django.conf import settings
from rest_framework import serializers, status
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases import OrganizationEndpoint
from sentry.models.organization import Organization
from sentry.seer.endpoints.trace_explorer_ai_setup import OrganizationTraceExplorerAIPermission
from sentry.seer.explorer.client_utils import collect_user_org_context
from sentry.seer.seer_setup import has_seer_access_with_detail
from sentry.seer.signed_seer_api import sign_with_seer_secret

logger = logging.getLogger(__name__)


class SearchAgentStartSerializer(serializers.Serializer):
project_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
allow_empty=False,
help_text="List of project IDs to search in.",
)
natural_language_query = serializers.CharField(
required=True,
allow_blank=False,
help_text="Natural language query to translate.",
)
strategy = serializers.CharField(
required=False,
default="Traces",
help_text="Search strategy to use (Traces, Issues, Logs, Errors).",
)
options = serializers.DictField(
required=False,
allow_null=True,
help_text="Optional configuration options.",
)

def validate_options(self, value: dict[str, Any] | None) -> dict[str, Any] | None:
if value is None:
return None
if "model_name" in value and not isinstance(value["model_name"], str):
raise serializers.ValidationError("model_name must be a string")
return value


def send_search_agent_start_request(
org_id: int,
org_slug: str,
project_ids: list[int],
natural_language_query: str,
strategy: str = "Traces",
user_email: str | None = None,
timezone: str | None = None,
model_name: str | None = None,
) -> dict[str, Any]:
"""
Sends a request to Seer to start an async search agent and returns a run_id for polling.
"""
body_dict: dict[str, Any] = {
"org_id": org_id,
"org_slug": org_slug,
"project_ids": project_ids,
"natural_language_query": natural_language_query,
"strategy": strategy,
}

if user_email:
body_dict["user_email"] = user_email

if timezone:
body_dict["timezone"] = timezone

options: dict[str, Any] = {}
if model_name is not None:
options["model_name"] = model_name

if options:
body_dict["options"] = options

body = orjson.dumps(body_dict)

response = requests.post(
f"{settings.SEER_AUTOFIX_URL}/v1/assisted-query/start",
data=body,
headers={
"content-type": "application/json;charset=utf-8",
**sign_with_seer_secret(body),
},
)
response.raise_for_status()
return response.json()


@region_silo_endpoint
class SearchAgentStartEndpoint(OrganizationEndpoint):
"""
Endpoint to start an async search agent and return a run_id for polling.

This starts the agent processing in the background and immediately returns
a run_id that can be used with the /search-agent/state/ endpoint to poll
for progress and results.
"""

publish_status = {
"POST": ApiPublishStatus.EXPERIMENTAL,
}
owner = ApiOwner.ML_AI

permission_classes = (OrganizationTraceExplorerAIPermission,)

def post(self, request: Request, organization: Organization) -> Response:
"""
Start an async search agent and return a run_id for polling.

Returns:
{"run_id": int}
"""
serializer = SearchAgentStartSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

validated_data = serializer.validated_data
natural_language_query = validated_data["natural_language_query"]
strategy = validated_data.get("strategy", "Traces")
options = validated_data.get("options") or {}
model_name = options.get("model_name")

projects = self.get_projects(
request, organization, project_ids=set(validated_data["project_ids"])
)
project_ids = [project.id for project in projects]

if not features.has("organizations:seer-explorer", organization, actor=request.user):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this feature exactly? we need seer explorer for ai assisted queries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops wrong feature flag

return Response(
{"detail": "Feature flag not enabled"},
status=status.HTTP_403_FORBIDDEN,
)

has_seer_access, detail = has_seer_access_with_detail(organization, actor=request.user)
if not has_seer_access:
return Response(
{"detail": detail},
status=status.HTTP_403_FORBIDDEN,
)

if not settings.SEER_AUTOFIX_URL:
return Response(
{"detail": "Seer is not properly configured."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

# Collect user context for the agent
user_org_context = collect_user_org_context(request.user, organization)
user_email = user_org_context.get("user_email")
timezone = user_org_context.get("user_timezone")

data = send_search_agent_start_request(
organization.id,
organization.slug,
project_ids,
natural_language_query,
strategy=strategy,
user_email=user_email,
timezone=timezone,
model_name=model_name,
)

# Return just the run_id for polling
return Response({"run_id": data.get("run_id")})
139 changes: 139 additions & 0 deletions src/sentry/seer/endpoints/search_agent_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import logging
from typing import Any

import orjson
import requests
from django.conf import settings
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases import OrganizationEndpoint
from sentry.models.organization import Organization
from sentry.seer.endpoints.trace_explorer_ai_setup import OrganizationTraceExplorerAIPermission
from sentry.seer.seer_setup import has_seer_access_with_detail
from sentry.seer.signed_seer_api import sign_with_seer_secret

logger = logging.getLogger(__name__)


def fetch_search_agent_state(run_id: int, organization_id: int) -> dict[str, Any]:
"""
Fetch the current state of a search agent run from Seer.

Calls POST /v1/assisted-query/state with the run_id and organization_id.
"""
body = orjson.dumps({"run_id": run_id, "organization_id": organization_id})

response = requests.post(
f"{settings.SEER_AUTOFIX_URL}/v1/assisted-query/state",
data=body,
headers={
"content-type": "application/json;charset=utf-8",
**sign_with_seer_secret(body),
},
)
response.raise_for_status()
return response.json()


@region_silo_endpoint
class SearchAgentStateEndpoint(OrganizationEndpoint):
"""
Endpoint to poll for search agent state by run_id.

This returns the current state of a search agent run, including:
- status: processing, completed, or error
- current_step: the step currently being processed (if any)
- completed_steps: list of completed steps
- final_response: the translated query result (when completed)
- unsupported_reason: error message (when status is error)
"""

publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}
owner = ApiOwner.ML_AI

permission_classes = (OrganizationTraceExplorerAIPermission,)

def get(self, request: Request, organization: Organization, run_id: str) -> Response:
"""
Get the current state of a search agent run.

Args:
run_id: The run ID returned from /search-agent/start/

Returns:
{
"session": {
"run_id": int,
"status": "processing" | "completed" | "error",
"current_step": {"key": str} | null,
"completed_steps": [{"key": str}, ...],
"updated_at": str,
"final_response": {...} | null, // Present when completed
"unsupported_reason": str | null // Present on error
}
}
"""
if not features.has("organizations:seer-explorer", organization, actor=request.user):
return Response(
{"detail": "Feature flag not enabled"},
status=status.HTTP_403_FORBIDDEN,
)

has_seer_access, detail = has_seer_access_with_detail(organization, actor=request.user)
if not has_seer_access:
return Response(
{"detail": detail},
status=status.HTTP_403_FORBIDDEN,
)

try:
run_id_int = int(run_id)
except ValueError:
return Response(
{"detail": "Invalid run_id"},
status=status.HTTP_400_BAD_REQUEST,
)

try:
data = fetch_search_agent_state(run_id_int, organization.id)

# Return the session data directly from Seer
return Response(data)

except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
logger.warning(
"search_agent.state_not_found",
extra={"run_id": run_id_int},
)
return Response(
{"session": None},
status=status.HTTP_404_NOT_FOUND,
)
logger.exception(
"search_agent.state_error",
extra={"run_id": run_id_int},
)
return Response(
{"detail": "Failed to fetch run state"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception:
logger.exception(
"search_agent.state_error",
extra={"run_id": run_id_int},
)
return Response(
{"detail": "Failed to fetch run state"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
2 changes: 2 additions & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,8 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/sdk-deprecations/'
| '/organizations/$organizationIdOrSlug/sdk-updates/'
| '/organizations/$organizationIdOrSlug/sdks/'
| '/organizations/$organizationIdOrSlug/search-agent/start/'
| '/organizations/$organizationIdOrSlug/search-agent/state/$runId/'
| '/organizations/$organizationIdOrSlug/search-agent/translate/'
| '/organizations/$organizationIdOrSlug/searches/'
| '/organizations/$organizationIdOrSlug/searches/$searchId/'
Expand Down
Loading