diff --git a/src/sentry/api/endpoints/organization_events_trends_v2.py b/src/sentry/api/endpoints/organization_events_trends_v2.py index fd065746ec69..58f62d80e2f5 100644 --- a/src/sentry/api/endpoints/organization_events_trends_v2.py +++ b/src/sentry/api/endpoints/organization_events_trends_v2.py @@ -1,5 +1,4 @@ import logging -from functools import partial import sentry_sdk from rest_framework.exceptions import ParseError @@ -17,7 +16,6 @@ from sentry.ratelimits.config import RateLimitConfig from sentry.search.events.constants import METRICS_GRANULARITIES from sentry.seer.breakpoints import detect_breakpoints -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba import metrics_performance from sentry.snuba.discover import create_result_key, zerofill from sentry.snuba.metrics_performance import query as metrics_query @@ -69,8 +67,6 @@ class OrganizationEventsNewTrendsStatsEndpoint(OrganizationEventsEndpointBase): ) def get(self, request: Request, organization: Organization) -> Response: - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) - try: snuba_params = self.get_snuba_params(request, organization) except NoProjects: @@ -284,7 +280,7 @@ def get_trends_data(stats_data, request): ) as query_thread_pool: results = list( query_thread_pool.map( - partial(detect_breakpoints, viewer_context=viewer_context), + detect_breakpoints, trends_requests, ) ) diff --git a/src/sentry/api/endpoints/organization_profiling_functions.py b/src/sentry/api/endpoints/organization_profiling_functions.py index 8402c1feb931..bf0ac85d1518 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -20,7 +20,6 @@ from sentry.search.events.builder.profile_functions import ProfileTopFunctionsTimeseriesQueryBuilder from sentry.search.events.types import QueryBuilderConfig from sentry.seer.breakpoints import BreakpointData, BreakpointRequest, detect_breakpoints -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba import functions from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer @@ -82,8 +81,6 @@ def get(self, request: Request, organization: Organization) -> Response: if not self.has_feature(organization, request): return Response(status=404) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) - try: snuba_params = self.get_snuba_params(request, organization) except NoProjects: @@ -189,7 +186,7 @@ def get_trends_data(stats_data) -> list[BreakpointData]: "sort": data["trend"].as_sort(), } - return detect_breakpoints(trends_request, viewer_context=viewer_context)["data"] + return detect_breakpoints(trends_request)["data"] stats_data = self.get_event_stats_data( request, diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py index d3560f833986..67e1ca1448bd 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -29,7 +29,6 @@ from sentry.search.eap.utils import can_expose_attribute, translate_internal_to_public_alias from sentry.search.events import fields from sentry.seer.endpoints.compare import compare_distributions -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans from sentry.utils import snuba_rpc @@ -105,7 +104,6 @@ def get(self, request: Request, organization: Organization) -> Response: {"referrer": Referrer.API_TRACE_EXPLORER_STATS.value}, ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) scored_attrs_rrr = compare_distributions( baseline=cohort_2_distribution, outliers=cohort_1_distribution, @@ -118,7 +116,6 @@ def get(self, request: Request, organization: Organization) -> Response: meta={ "referrer": Referrer.API_TRACE_EXPLORER_STATS.value, }, - viewer_context=viewer_context, ) logger.info("scored_attrs_rrr: %s", scored_attrs_rrr) diff --git a/src/sentry/feedback/endpoints/organization_feedback_categories.py b/src/sentry/feedback/endpoints/organization_feedback_categories.py index 1bb71c8270ea..f12d6e3171a6 100644 --- a/src/sentry/feedback/endpoints/organization_feedback_categories.py +++ b/src/sentry/feedback/endpoints/organization_feedback_categories.py @@ -26,7 +26,6 @@ from sentry.grouping.utils import hash_from_values from sentry.models.organization import Organization from sentry.seer.seer_setup import has_seer_access -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.utils.cache import cache logger = logging.getLogger(__name__) @@ -108,8 +107,6 @@ def get(self, request: Request, organization: Organization) -> Response: {"detail": "AI categorization is not available for this organization."}, status=403 ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) - try: start, end = get_date_range_from_stats_period( request.GET, @@ -195,7 +192,6 @@ def get(self, request: Request, organization: Organization) -> Response: seer_request, timeout=SEER_TIMEOUT_S, retries=SEER_RETRIES, - viewer_context=viewer_context, ) except Exception: logger.exception("Seer failed to generate user feedback label groups") diff --git a/src/sentry/feedback/endpoints/organization_feedback_summary.py b/src/sentry/feedback/endpoints/organization_feedback_summary.py index 4f10d724582c..c52fc5826f3d 100644 --- a/src/sentry/feedback/endpoints/organization_feedback_summary.py +++ b/src/sentry/feedback/endpoints/organization_feedback_summary.py @@ -18,7 +18,6 @@ from sentry.models.group import Group, GroupStatus from sentry.models.organization import Organization from sentry.seer.seer_setup import has_seer_access -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.utils import metrics from sentry.utils.cache import cache @@ -38,7 +37,6 @@ def get_summary_from_seer( feedback_msgs: list[str], - viewer_context: SeerViewerContext | None = None, ) -> str | None: request_body = SummarizeFeedbacksRequest(feedbacks=feedback_msgs) try: @@ -46,7 +44,6 @@ def get_summary_from_seer( request_body, timeout=SEER_TIMEOUT_S, retries=SEER_RETRIES, - viewer_context=viewer_context, ) except Exception: logger.exception( @@ -95,8 +92,6 @@ def get(self, request: Request, organization: Organization) -> Response: {"detail": "AI summaries are not available for this organization."}, status=403 ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) - try: start, end = get_date_range_from_stats_period( request.GET, @@ -162,7 +157,7 @@ def get(self, request: Request, organization: Organization) -> Response: if len(feedback_msgs) < MIN_FEEDBACKS_TO_SUMMARIZE: logger.error("Too few feedbacks to summarize after enforcing the character limit") - summary = get_summary_from_seer(feedback_msgs, viewer_context=viewer_context) + summary = get_summary_from_seer(feedback_msgs) if summary is None: return Response( {"detail": "Failed to generate a summary for a list of feedbacks"}, status=500 diff --git a/src/sentry/integrations/utils/external_issues.py b/src/sentry/integrations/utils/external_issues.py index 122a546f4c27..be31f32d9b21 100644 --- a/src/sentry/integrations/utils/external_issues.py +++ b/src/sentry/integrations/utils/external_issues.py @@ -7,7 +7,6 @@ from sentry.models.group import Group from sentry.seer.signed_seer_api import ( LlmGenerateRequest, - SeerViewerContext, make_llm_generate_request, ) from sentry.services.eventstore.models import GroupEvent @@ -63,9 +62,9 @@ class GeneratedExternalIssueDetails(TypedDict): def _make_generate_external_issue_details_request( - group: Group, event: Any | None = None, viewer_context: SeerViewerContext | None = None + group: Group, event: Any | None = None ) -> GeneratedExternalIssueDetails | None: - logging_ctx: dict[str, Any] = {"group_id": group.id, "viewer_context": viewer_context} + logging_ctx: dict[str, Any] = {"group_id": group.id} context = _build_event_context(group, event=event) body = LlmGenerateRequest( @@ -85,7 +84,7 @@ def _make_generate_external_issue_details_request( "required": ["title", "description"], }, ) - response = make_llm_generate_request(body, timeout=10, viewer_context=viewer_context) + response = make_llm_generate_request(body, timeout=10) logging_ctx["status_code"] = response.status if response.status >= 400: logger.warning("external_issues.seer_request_failed", extra=logging_ctx) @@ -131,10 +130,7 @@ def maybe_generate_external_issue_details( return empty_result try: - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=user.id) - result = _make_generate_external_issue_details_request( - group, event=event, viewer_context=viewer_context - ) + result = _make_generate_external_issue_details_request(group, event=event) except Exception: logger.error("external_issues.generate_issue_details_failed", exc_info=True) return empty_result diff --git a/src/sentry/issues/endpoints/group_similar_issues_embeddings.py b/src/sentry/issues/endpoints/group_similar_issues_embeddings.py index ef23ebec4494..3c5dba3c60d7 100644 --- a/src/sentry/issues/endpoints/group_similar_issues_embeddings.py +++ b/src/sentry/issues/endpoints/group_similar_issues_embeddings.py @@ -18,7 +18,6 @@ from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.group import Group from sentry.models.grouphash import GroupHash -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.seer.similarity.config import get_grouping_model_version, should_skip_seer_fallback from sentry.seer.similarity.similar_issues import get_similarity_data_from_seer from sentry.seer.similarity.types import SeerSimilarIssueData, SimilarIssuesEmbeddingsRequest @@ -136,12 +135,7 @@ def get(self, request: Request, group: Group) -> Response: logger.info("Similar issues embeddings parameters", extra=similar_issues_params) - viewer_context = SeerViewerContext( - organization_id=group.project.organization.id, user_id=request.user.id - ) - results, _model_used = get_similarity_data_from_seer( - similar_issues_params, viewer_context=viewer_context - ) + results, _model_used = get_similarity_data_from_seer(similar_issues_params) analytics.record( GroupSimilarIssuesEmbeddingsCountEvent( diff --git a/src/sentry/issues/endpoints/organization_issues_with_supergroups.py b/src/sentry/issues/endpoints/organization_issues_with_supergroups.py index b598bcc36fee..c48f55b935cd 100644 --- a/src/sentry/issues/endpoints/organization_issues_with_supergroups.py +++ b/src/sentry/issues/endpoints/organization_issues_with_supergroups.py @@ -94,7 +94,7 @@ def get(self, request: Request, organization: Organization) -> Response: # breaking the stream. try: supergroup_data = get_supergroups_by_group_ids( - organization, [int(g["id"]) for g in groups], user_id=request.user.id + organization, [int(g["id"]) for g in groups] ) except SeerApiError: logger.exception("issues_with_supergroups.seer_fetch_failed") diff --git a/src/sentry/replays/endpoints/project_replay_summary.py b/src/sentry/replays/endpoints/project_replay_summary.py index 8faae596c5c8..e5285b80c40e 100644 --- a/src/sentry/replays/endpoints/project_replay_summary.py +++ b/src/sentry/replays/endpoints/project_replay_summary.py @@ -23,7 +23,6 @@ from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_instance from sentry.seer.seer_setup import has_seer_access -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.utils.tracing import start_span logger = logging.getLogger(__name__) @@ -68,7 +67,6 @@ def __init__(self, **kw) -> None: def _make_seer_start_request( self, body: ReplaySummaryStartRequest, - viewer_context: SeerViewerContext | None = None, ) -> Response: """Make a start-summary request to Seer with error handling.""" serialized = orjson.dumps(body) @@ -89,7 +87,6 @@ def _make_seer_start_request( body, timeout=5, retries=0, - viewer_context=viewer_context, ) except Exception: logger.exception( @@ -114,7 +111,6 @@ def _make_seer_start_request( def _make_seer_state_request( self, body: ReplaySummaryStateRequest, - viewer_context: SeerViewerContext | None = None, ) -> Response: """Make a poll-state request to Seer with error handling.""" try: @@ -122,7 +118,6 @@ def _make_seer_state_request( body, timeout=5, retries=0, - viewer_context=viewer_context, ) except Exception: logger.exception( @@ -177,10 +172,6 @@ def get(self, request: Request, project: Project, replay_id: str) -> Response: # Since this endpoint is polled, we skip checking Seer permissions here for performance. # Both the frontend and summary generation are gated by the same permissions. - viewer_context = SeerViewerContext( - organization_id=project.organization_id, user_id=request.user.id - ) - # Request Seer for the state of the summary task. return self._make_seer_state_request( ReplaySummaryStateRequest( @@ -188,7 +179,6 @@ def get(self, request: Request, project: Project, replay_id: str) -> Response: organization_id=project.organization.id, project_id=project.id, ), - viewer_context=viewer_context, ) def post(self, request: Request, project: Project, replay_id: str) -> Response: @@ -282,8 +272,4 @@ def validate_iso_timestamp(timestamp: str | None) -> str | None: if temperature is not None: start_request["temperature"] = temperature - viewer_context = SeerViewerContext( - organization_id=project.organization_id, user_id=request.user.id - ) - - return self._make_seer_start_request(start_request, viewer_context=viewer_context) + return self._make_seer_start_request(start_request) diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 3aa0f8ce46d5..9f79fc279b38 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -57,7 +57,6 @@ ) from sentry.seer.models.run import SeerAgentRun, SeerRun, SeerRunType from sentry.seer.seer_setup import has_seer_access_with_detail -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.tasks.seer.context_engine_index import build_service_map, index_org_project_knowledge from sentry.tasks.seer.explorer_index import dispatch_explorer_index_projects from sentry.users.models.user import User @@ -360,8 +359,6 @@ def __init__( self.enable_pr_context_tools = enable_pr_context_tools - self.viewer_context = self._build_viewer_context() - # Validate that category_key and category_value are provided together if category_key == "" or category_value == "": raise ValueError("category_key and category_value cannot be empty strings") @@ -373,12 +370,6 @@ def __init__( if not has_access: raise SeerPermissionError(error or "Access denied") - def _build_viewer_context(self) -> SeerViewerContext: - context = SeerViewerContext(organization_id=self.organization.id) - if self.user and hasattr(self.user, "id") and self.user.id is not None: - context["user_id"] = self.user.id - return context - def start_run( self, prompt: str, @@ -518,7 +509,6 @@ def _create_agent_run(run: SeerRun) -> None: run_type=SeerRunType.EXPLORER, body=chat_body, on_run_created=_create_agent_run, - viewer_context=self.viewer_context, user_id=user_id, referrer=metadata.get("referrer") if metadata else None, flush=True, @@ -576,7 +566,6 @@ def _create_agent_run(run: SeerRun) -> None: payload=payload, agent_run_options=self._build_agent_run_options(), ), - viewer_context=self.viewer_context, user_id=user_id, referrer=feature_id, flush=flush, @@ -775,7 +764,7 @@ def continue_run( ): agent_run_options["enable_streaming"] = True - response = make_agent_chat_request(chat_body, viewer_context=self.viewer_context) + response = make_agent_chat_request(chat_body) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) @@ -813,10 +802,9 @@ def get_run( self.organization, poll_interval, poll_timeout, - viewer_context=self.viewer_context, ) else: - state = fetch_run_status(run_id, self.organization, viewer_context=self.viewer_context) + state = fetch_run_status(run_id, self.organization) return state @@ -912,7 +900,7 @@ def get_runs( if query is not None: runs_body["query"] = query - response = make_agent_runs_request(runs_body, viewer_context=self.viewer_context) + response = make_agent_runs_request(runs_body) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) @@ -927,7 +915,7 @@ def get_repos(self, run_id: int) -> BaseHTTPResponse: run_id=run_id, organization_id=self.organization.id, ) - return make_agent_repos_request(body, viewer_context=self.viewer_context) + return make_agent_repos_request(body) def push_changes( self, @@ -984,7 +972,7 @@ def push_changes( organization_id=self.organization.id, payload=payload, ) - response = make_agent_update_request(update_body, viewer_context=self.viewer_context) + response = make_agent_update_request(update_body) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) @@ -995,7 +983,7 @@ def push_changes( start_time = time.time() while True: - state = fetch_run_status(run_id, self.organization, viewer_context=self.viewer_context) + state = fetch_run_status(run_id, self.organization) # Check if any PRs are still being created any_creating = any( diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index 8b613505e446..86f3d4a7c874 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -48,6 +48,7 @@ from sentry.users.services.user_option.service import get_option_from_list from sentry.utils import metrics from sentry.utils.strings import strip_lone_surrogates +from sentry.viewer_context import get_viewer_context logger = logging.getLogger(__name__) @@ -57,6 +58,21 @@ ) +def _serialize_viewer_context_for_outbox( + explicit: SeerViewerContext | None, +) -> dict[str, Any] | None: + """Serialize viewer context for outbox payload, falling back to contextvar.""" + if explicit is not None: + return dict(explicit) + vc = get_viewer_context() + if vc is None: + return None + result: dict[str, Any] = {"organization_id": vc.organization_id} + if vc.user_id is not None: + result["user_id"] = vc.user_id + return result + + class AgentStateRequest(TypedDict): run_id: int organization_id: int @@ -250,7 +266,7 @@ def enqueue_seer_run( organization: Organization, run_type: SeerRunType, body: Mapping[str, Any], - viewer_context: SeerViewerContext | None, + viewer_context: SeerViewerContext | None = None, user_id: int | None = None, referrer: str | None = None, flush: bool = True, @@ -287,7 +303,7 @@ def enqueue_seer_run( payload=_sanitize_json_strings( { "body": dict(body), - "viewer_context": dict(viewer_context) if viewer_context else None, + "viewer_context": _serialize_viewer_context_for_outbox(viewer_context), } ), ).save() diff --git a/src/sentry/seer/endpoints/compare.py b/src/sentry/seer/endpoints/compare.py index 170b9ebb30ec..9ab4b4677f2a 100644 --- a/src/sentry/seer/endpoints/compare.py +++ b/src/sentry/seer/endpoints/compare.py @@ -6,7 +6,6 @@ from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( CompareDistributionsRequest, - SeerViewerContext, make_compare_distributions_request, ) from sentry.utils.json import JSONDecodeError @@ -21,7 +20,6 @@ def compare_distributions( total_outliers: int, config: dict[str, Any], meta: dict[str, Any], - viewer_context: SeerViewerContext | None = None, ) -> Any: """ Sends a request to seer to compare two distributions and rank their attributes by suspisiouness @@ -35,7 +33,7 @@ def compare_distributions( config=config, meta=meta, ) - response = make_compare_distributions_request(body, viewer_context=viewer_context) + response = make_compare_distributions_request(body) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) try: diff --git a/src/sentry/seer/endpoints/issue_view_title_generate.py b/src/sentry/seer/endpoints/issue_view_title_generate.py index cfe714c733ea..606c947880f9 100644 --- a/src/sentry/seer/endpoints/issue_view_title_generate.py +++ b/src/sentry/seer/endpoints/issue_view_title_generate.py @@ -14,7 +14,6 @@ from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( LlmGenerateRequest, - SeerViewerContext, make_llm_generate_request, ) @@ -53,7 +52,7 @@ class IssueViewTitleGeneratePermission(OrganizationPermission): def generate_title_from_query( - query: str, viewer_context: SeerViewerContext | None = None + query: str, ) -> str | None: truncated_query = query[:MAX_QUERY_LENGTH] if len(query) > MAX_QUERY_LENGTH else query @@ -68,7 +67,7 @@ def generate_title_from_query( temperature=0.2, max_tokens=100, ) - response = make_llm_generate_request(body, timeout=10, viewer_context=viewer_context) + response = make_llm_generate_request(body, timeout=10) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) data = response.json() @@ -96,10 +95,7 @@ def post(self, request: Request, organization: Organization) -> Response: ) try: - viewer_context = SeerViewerContext( - organization_id=organization.id, user_id=request.user.id - ) - title = generate_title_from_query(query, viewer_context=viewer_context) + title = generate_title_from_query(query) if not title or not title.strip(): logger.error( "No title returned from Seer", diff --git a/src/sentry/seer/endpoints/organization_seer_runs.py b/src/sentry/seer/endpoints/organization_seer_runs.py index 37bf97061c30..8cde709b2079 100644 --- a/src/sentry/seer/endpoints/organization_seer_runs.py +++ b/src/sentry/seer/endpoints/organization_seer_runs.py @@ -51,7 +51,6 @@ def _fetch_run_outputs( organization: Organization, *, questions: Sequence[Question], - user_id: int | None, ) -> dict[int, list[RunQuestionOutput]]: qualifying = [ run @@ -65,7 +64,6 @@ def _fetch_run_outputs( [run.seer_run_state_id for run in qualifying], organization, questions=questions, - user_id=user_id, ) def to_output(q: RunQuestion) -> RunQuestionOutput: @@ -224,7 +222,6 @@ def on_results(runs: Sequence[SeerRun]) -> list[SeerRunResponse]: runs, organization, questions=run_questions, - user_id=request.user.id, ) for run, data in zip(runs, serialized): data["outputs"] = outputs_by_run_id.get(run.id, []) diff --git a/src/sentry/seer/endpoints/search_agent_start.py b/src/sentry/seer/endpoints/search_agent_start.py index be507dca16e8..6f75b326fe35 100644 --- a/src/sentry/seer/endpoints/search_agent_start.py +++ b/src/sentry/seer/endpoints/search_agent_start.py @@ -19,7 +19,7 @@ from sentry.seer.models import SeerApiError from sentry.seer.models.run import SeerRun, SeerRunType from sentry.seer.seer_setup import has_seer_access_with_detail -from sentry.seer.signed_seer_api import SearchAgentStartRequest, SeerViewerContext +from sentry.seer.signed_seer_api import SearchAgentStartRequest logger = logging.getLogger(__name__) @@ -65,7 +65,6 @@ def send_search_agent_start_request( timezone: str | None = None, model_name: str | None = None, metric_context: dict[str, Any] | None = None, - viewer_context: SeerViewerContext | None = None, ) -> SeerRun: """Create the SeerRun mirror and enqueue the outbox that starts the agent in Seer.""" body = SearchAgentStartRequest( @@ -91,7 +90,6 @@ def send_search_agent_start_request( organization=organization, run_type=SeerRunType.ASSISTED_QUERY, body=body, - viewer_context=viewer_context, user_id=user_id, ) @@ -175,9 +173,6 @@ def post(self, request: Request, organization: Organization) -> Response: user_email = user_org_context.get("user_email") timezone = user_org_context.get("user_timezone") try: - viewer_context = SeerViewerContext( - organization_id=organization.id, user_id=request.user.id - ) result = send_search_agent_start_request( organization=organization, user_id=request.user.id, @@ -188,7 +183,6 @@ def post(self, request: Request, organization: Organization) -> Response: timezone=timezone, model_name=model_name, metric_context=metric_context, - viewer_context=viewer_context, ) return Response( { diff --git a/src/sentry/seer/endpoints/search_agent_state.py b/src/sentry/seer/endpoints/search_agent_state.py index 6fd186069eea..2ee99555c37c 100644 --- a/src/sentry/seer/endpoints/search_agent_state.py +++ b/src/sentry/seer/endpoints/search_agent_state.py @@ -20,23 +20,20 @@ from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.seer.signed_seer_api import ( SearchAgentStateRequest, - SeerViewerContext, make_search_agent_state_request, ) logger = logging.getLogger(__name__) -def fetch_search_agent_state( - run_id: int, organization_id: int, viewer_context: SeerViewerContext | None = None -) -> dict[str, Any]: +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 = SearchAgentStateRequest(run_id=run_id, organization_id=organization_id) - response = make_search_agent_state_request(body, timeout=10, viewer_context=viewer_context) + response = make_search_agent_state_request(body, timeout=10) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) return response.json() @@ -110,12 +107,7 @@ def get(self, request: Request, organization: Organization, run_id: str) -> Resp seer_run_id = resolved.seer_run_state_id try: - viewer_context = SeerViewerContext( - organization_id=organization.id, user_id=request.user.id - ) - data = fetch_search_agent_state( - seer_run_id, organization.id, viewer_context=viewer_context - ) + data = fetch_search_agent_state(seer_run_id, organization.id) data["sentry_run_id"] = resolved.uuid return Response(data) diff --git a/src/sentry/seer/endpoints/trace_explorer_ai_query.py b/src/sentry/seer/endpoints/trace_explorer_ai_query.py index 12146d67bf06..937dadd27cbe 100644 --- a/src/sentry/seer/endpoints/trace_explorer_ai_query.py +++ b/src/sentry/seer/endpoints/trace_explorer_ai_query.py @@ -17,7 +17,6 @@ from sentry.seer.endpoints.trace_explorer_ai_setup import OrganizationTraceExplorerAIPermission from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( - SeerViewerContext, TranslateQueryRequest, make_translate_query_request, ) @@ -30,7 +29,6 @@ def send_translate_request( org_slug: str, project_ids: list[int], natural_language_query: str, - viewer_context: SeerViewerContext | None = None, ) -> Any: """ Sends a request to seer to create the initial cached prompt / setup the AI models @@ -41,7 +39,7 @@ def send_translate_request( project_ids=project_ids, natural_language_query=natural_language_query, ) - response = make_translate_query_request(body, timeout=30, viewer_context=viewer_context) + response = make_translate_query_request(body, timeout=30) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) return response.json() @@ -110,13 +108,11 @@ def post(self, request: Request, organization: Organization) -> Response: {"detail": "Seer is not properly configured."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) data = send_translate_request( organization.id, organization.slug, project_ids, natural_language_query, - viewer_context=viewer_context, ) responses = data.get("responses", [])[:limit] diff --git a/src/sentry/seer/endpoints/trace_explorer_ai_setup.py b/src/sentry/seer/endpoints/trace_explorer_ai_setup.py index 6c34880a50f8..4f7213bdab20 100644 --- a/src/sentry/seer/endpoints/trace_explorer_ai_setup.py +++ b/src/sentry/seer/endpoints/trace_explorer_ai_setup.py @@ -17,7 +17,6 @@ from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( CreateCacheRequest, - SeerViewerContext, make_create_cache_request, ) @@ -33,14 +32,12 @@ class OrganizationTraceExplorerAIPermission(OrganizationPermission): } -def fire_setup_request( - org_id: int, project_ids: list[int], viewer_context: SeerViewerContext | None = None -) -> None: +def fire_setup_request(org_id: int, project_ids: list[int]) -> None: """ Sends a request to seer to create the initial cached prompt / setup the AI models """ body = CreateCacheRequest(org_id=org_id, project_ids=project_ids) - response = make_create_cache_request(body, viewer_context=viewer_context) + response = make_create_cache_request(body) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) @@ -97,7 +94,6 @@ def post(self, request: Request, organization: Organization) -> Response: {"detail": "Seer is not properly configured."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) - fire_setup_request(organization.id, validated_project_ids, viewer_context=viewer_context) + fire_setup_request(organization.id, validated_project_ids) return Response({"status": "ok"}) diff --git a/src/sentry/seer/endpoints/trace_explorer_ai_translate_agentic.py b/src/sentry/seer/endpoints/trace_explorer_ai_translate_agentic.py index f9102b40100a..3aa3addabb5a 100644 --- a/src/sentry/seer/endpoints/trace_explorer_ai_translate_agentic.py +++ b/src/sentry/seer/endpoints/trace_explorer_ai_translate_agentic.py @@ -18,7 +18,6 @@ from sentry.seer.models import SeerApiError from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.seer.signed_seer_api import ( - SeerViewerContext, TranslateAgenticRequest, make_translate_agentic_request, ) @@ -65,7 +64,6 @@ def send_translate_agentic_request( strategy: str = "Traces", model_name: str | None = None, metric_context: dict[str, Any] | None = None, - viewer_context: SeerViewerContext | None = None, ) -> Any: """ Sends a request to seer to translate a natural language query using the agentic search API. @@ -84,7 +82,7 @@ def send_translate_agentic_request( options["metric_context"] = metric_context body["options"] = options - response = make_translate_agentic_request(body, timeout=10, viewer_context=viewer_context) + response = make_translate_agentic_request(body, timeout=10) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) return response.json() @@ -142,7 +140,6 @@ def post(self, request: Request, organization: Organization) -> Response: status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) data = send_translate_agentic_request( organization.id, organization.slug, @@ -151,6 +148,5 @@ def post(self, request: Request, organization: Organization) -> Response: strategy=strategy, model_name=model_name, metric_context=metric_context, - viewer_context=viewer_context, ) return Response(data) diff --git a/src/sentry/seer/oneshot.py b/src/sentry/seer/oneshot.py index 3d86fdfbe44b..565c19a01b12 100644 --- a/src/sentry/seer/oneshot.py +++ b/src/sentry/seer/oneshot.py @@ -21,7 +21,6 @@ from sentry.seer.models.seer_api_models import SeerApiError from sentry.seer.signed_seer_api import ( OneShotRunRequest, - SeerViewerContext, make_oneshot_request, ) from sentry.utils import metrics @@ -41,29 +40,21 @@ def call_seer_oneshot( *, error_metric: str, error_metric_tags: dict[str, Any] | None = None, - user_id: int | None = None, timeout: int | float | None = None, ) -> dict[str, Any]: """Dispatch a single synchronous Seer task and return its parsed JSON body. - This is the shared boilerplate behind the one-shot style Seer calls: it - builds viewer context from ``organization`` (plus an optional ``user_id``), - invokes ``make_request`` with the default timeout, and on a non-2xx response + Invokes ``make_request`` with the default timeout. On a non-2xx response increments ``error_metric`` (merging ``error_metric_tags`` with the response ``status``) before raising :class:`SeerApiError`. On success it returns the decoded JSON object; callers shape it into their own result contract. - Seer task endpoints require viewer context with an organization, so - ``organization`` is mandatory. + Viewer context is expected to be set via the contextvar by the calling + endpoint's middleware. """ - viewer_context = SeerViewerContext(organization_id=organization.id) - if user_id is not None: - viewer_context["user_id"] = user_id - response = make_request( body, timeout=timeout if timeout is not None else settings.SEER_DEFAULT_TIMEOUT, - viewer_context=viewer_context, ) if response.status >= 400: @@ -89,15 +80,13 @@ def run_oneshot( payload: dict[str, Any], organization: Organization, *, - user_id: int | None = None, timeout: int | float | None = None, ) -> dict[str, Any]: """Dispatch a one-shot to Seer and return its structured ``result``. - The one-shot endpoint requires viewer context with an organization, so - ``organization`` is mandatory. Raises :class:`SeerApiError` on a non-2xx - response; callers validate the returned dict against the one-shot's result - contract. + Viewer context is expected to be set via the contextvar by the calling + endpoint's middleware. Raises :class:`SeerApiError` on a non-2xx response; + callers validate the returned dict against the one-shot's result contract. """ body = OneShotRunRequest(oneshot_id=oneshot_id, payload=payload) data = call_seer_oneshot( @@ -106,7 +95,6 @@ def run_oneshot( organization, error_metric="seer.oneshot.error", error_metric_tags={}, - user_id=user_id, timeout=timeout, ) return data.get("result") or {} diff --git a/src/sentry/seer/run_questions.py b/src/sentry/seer/run_questions.py index d1f79abd5680..6d117699540d 100644 --- a/src/sentry/seer/run_questions.py +++ b/src/sentry/seer/run_questions.py @@ -104,7 +104,6 @@ def _get_answer( run_id: int, question: str, *, - user_id: int | None, timeout: int | float | None, ) -> str: """Return the cached answer for one question, or ask Seer and cache it. @@ -123,7 +122,6 @@ def _get_answer( _ONESHOT_ID, {"run_id": run_id, "question": question}, organization, - user_id=user_id, timeout=timeout, ) except Exception: @@ -145,7 +143,6 @@ def get_run_questions( organization: Organization, *, questions: Sequence[Question] = QUESTIONS, - user_id: int | None = None, timeout: int | float | None = None, ) -> dict[int, list[RunQuestion]]: """Answer ``questions`` about each run in ``run_ids``. @@ -163,9 +160,7 @@ def get_run_questions( with ContextPropagatingThreadPoolExecutor(max_workers=_MAX_WORKERS) as executor: answers = list( executor.map( - lambda task: _get_answer( - organization, task[0], task[1].question, user_id=user_id, timeout=timeout - ), + lambda task: _get_answer(organization, task[0], task[1].question, timeout=timeout), tasks, ) ) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 1a24dc620642..4d50701bc6ec 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -142,7 +142,9 @@ def make_signed_seer_api_request( } resolved = _resolve_viewer_context(viewer_context) - observe_viewer_context_propagation("seer_rpc_out", ctx=resolved) + observe_viewer_context_propagation( + "seer_rpc_out", ctx=resolved, extra_attributes={"seer_path": path} + ) if resolved: try: headers["X-Viewer-Context"] = encode_viewer_context(resolved) @@ -580,7 +582,7 @@ def make_lightweight_rca_cluster_request( def make_supergroups_get_request( body: SupergroupsGetRequest, - viewer_context: SeerViewerContext, + viewer_context: SeerViewerContext | None = None, timeout: int | float | None = None, ) -> BaseHTTPResponse: return make_signed_seer_api_request( @@ -594,7 +596,7 @@ def make_supergroups_get_request( def make_supergroups_get_by_group_ids_request( body: SupergroupsGetByGroupIdsRequest, - viewer_context: SeerViewerContext, + viewer_context: SeerViewerContext | None = None, timeout: int | float | None = None, ) -> BaseHTTPResponse: return make_signed_seer_api_request( diff --git a/src/sentry/seer/supergroups/by_group.py b/src/sentry/seer/supergroups/by_group.py index 2a41de9293f8..fbfedeb000a7 100644 --- a/src/sentry/seer/supergroups/by_group.py +++ b/src/sentry/seer/supergroups/by_group.py @@ -7,7 +7,6 @@ from sentry.models.organization import Organization from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( - SeerViewerContext, SupergroupsByGroupIdsResponse, make_supergroups_get_by_group_ids_request, ) @@ -16,15 +15,12 @@ def get_supergroups_by_group_ids( organization: Organization, group_ids: Sequence[int], - *, - user_id: int | None = None, ) -> SupergroupsByGroupIdsResponse: response = make_supergroups_get_by_group_ids_request( { "organization_id": organization.id, "group_ids": list(group_ids), }, - SeerViewerContext(organization_id=organization.id, user_id=user_id), timeout=10, ) if response.status >= 400: diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py b/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py index 8c255036d676..c739474ac355 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroup_details.py @@ -12,7 +12,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.models.organization import Organization -from sentry.seer.signed_seer_api import SeerViewerContext, make_supergroups_get_request +from sentry.seer.signed_seer_api import make_supergroups_get_request logger = logging.getLogger(__name__) @@ -40,7 +40,6 @@ def get(self, request: Request, organization: Organization, supergroup_id: int) "organization_id": organization.id, "supergroup_id": supergroup_id, }, - SeerViewerContext(organization_id=organization.id, user_id=request.user.id), timeout=10, ) diff --git a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py index 566bd5a5f4bd..8908c718062f 100644 --- a/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py +++ b/src/sentry/seer/supergroups/endpoints/organization_supergroups_by_group.py @@ -84,7 +84,7 @@ def get(self, request: Request, organization: Organization) -> Response: ) try: - data = get_supergroups_by_group_ids(organization, group_ids, user_id=request.user.id) + data = get_supergroups_by_group_ids(organization, group_ids) except SeerApiError as exc: return Response({"detail": "Failed to fetch supergroups"}, status=exc.status) diff --git a/src/sentry/viewer_context.py b/src/sentry/viewer_context.py index f0071c5c9eb9..b46d6d68dc45 100644 --- a/src/sentry/viewer_context.py +++ b/src/sentry/viewer_context.py @@ -120,7 +120,7 @@ def observe_viewer_context_propagation( ) -> None: """Emit a ``viewer_context.observation`` counter for the current ViewerContext at *point*. - Tags: ``point``, ``actor_type``, ``has_user_id``, ``has_org_id``, ``expected``, + Tags: ``point``, ``actor_type``, ``has_user_id``, ``has_org_id``, ``has_project_id``, ``expected``, plus any *extra_attributes* the caller provides (e.g. ``method`` for RPC dispatch points where we want per-method breakdown). Callers are responsible for keeping extra-attribute cardinality bounded — values must come from a closed set. @@ -143,10 +143,12 @@ def observe_viewer_context_propagation( actor_type = "none" has_user = False has_org = False + has_project = False else: actor_type = ctx.actor_type.value has_user = ctx.user_id is not None has_org = ctx.organization_id is not None + has_project = ctx.project_id is not None # Fixed tags layered on top of extras so callers can't accidentally swap # `point` / `actor_type` / etc. and corrupt the metric's cardinality story. @@ -157,6 +159,7 @@ def observe_viewer_context_propagation( "actor_type": actor_type, "has_user_id": str(has_user).lower(), "has_org_id": str(has_org).lower(), + "has_project_id": str(has_project).lower(), "expected": str(expected).lower(), } ) diff --git a/tests/sentry/seer/agent/test_agent_client.py b/tests/sentry/seer/agent/test_agent_client.py index 3af02b1f031f..aeb266451c44 100644 --- a/tests/sentry/seer/agent/test_agent_client.py +++ b/tests/sentry/seer/agent/test_agent_client.py @@ -555,9 +555,7 @@ def test_get_run_immediate(self, mock_fetch, mock_access): assert result.run_id == 123 assert result.status == "processing" - mock_fetch.assert_called_once_with( - 123, self.organization, viewer_context=client.viewer_context - ) + mock_fetch.assert_called_once_with(123, self.organization) @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.poll_until_done") @@ -577,9 +575,7 @@ def test_get_run_with_blocking(self, mock_poll, mock_access): assert result.run_id == 123 assert result.status == "completed" - mock_poll.assert_called_once_with( - 123, self.organization, 1.0, 30.0, viewer_context=client.viewer_context - ) + mock_poll.assert_called_once_with(123, self.organization, 1.0, 30.0) @patch("sentry.seer.agent.client.has_seer_access_with_detail") @patch("sentry.seer.agent.client.fetch_run_status") @@ -1355,13 +1351,16 @@ def _outbox_for(self, run: SeerRun) -> CellOutbox | None: @patch("sentry.seer.agent.client.has_seer_access_with_detail", return_value=(True, None)) @patch("sentry.receivers.outbox.cell.make_feature_run_request") def test_flush_false_enqueues_without_dispatch(self, mock_request, _mock_access) -> None: - client = SeerAgentClient(self.organization, self.user) - run = client.start_feature_run( - feature_id="night_shift", - payload={"candidates": [1, 2]}, - title="Agentic triage (2 candidates)", - flush=False, - ) + from sentry.viewer_context import ViewerContext, viewer_context_scope + + with viewer_context_scope(ViewerContext(organization_id=self.organization.id)): + client = SeerAgentClient(self.organization, self.user) + run = client.start_feature_run( + feature_id="night_shift", + payload={"candidates": [1, 2]}, + title="Agentic triage (2 candidates)", + flush=False, + ) mock_request.assert_not_called() assert run.type == SeerRunType.FEATURE_RUN diff --git a/tests/sentry/seer/endpoints/test_search_agent_start.py b/tests/sentry/seer/endpoints/test_search_agent_start.py index ed2b13a6664a..b3057607c44d 100644 --- a/tests/sentry/seer/endpoints/test_search_agent_start.py +++ b/tests/sentry/seer/endpoints/test_search_agent_start.py @@ -5,7 +5,6 @@ from sentry.seer.endpoints.search_agent_start import send_search_agent_start_request from sentry.seer.models import SeerApiError from sentry.seer.models.run import SeerRun, SeerRunMirrorStatus, SeerRunType -from sentry.seer.signed_seer_api import SeerViewerContext from sentry.testutils.cases import TestCase @@ -13,9 +12,6 @@ class SendSearchAgentStartRequestTest(TestCase): @patch("sentry.receivers.outbox.cell.make_search_agent_start_request") def test_outbox_path_creates_run_and_flushes(self, mock_request: Mock) -> None: mock_request.return_value = Mock(status=200, json=Mock(return_value={"run_id": 42})) - viewer_context = SeerViewerContext( - organization_id=self.organization.id, user_id=self.user.id - ) result = send_search_agent_start_request( organization=self.organization, @@ -23,7 +19,6 @@ def test_outbox_path_creates_run_and_flushes(self, mock_request: Mock) -> None: project_ids=[self.project.id], natural_language_query="errors today", strategy="Issues", - viewer_context=viewer_context, ) assert isinstance(result, SeerRun) @@ -36,25 +31,17 @@ def test_outbox_path_creates_run_and_flushes(self, mock_request: Mock) -> None: assert sent_body["natural_language_query"] == "errors today" def test_outbox_flush_error_raises(self) -> None: - viewer_context = SeerViewerContext( - organization_id=self.organization.id, user_id=self.user.id - ) - with pytest.raises(SeerApiError): send_search_agent_start_request( organization=self.organization, user_id=self.user.id, project_ids=[self.project.id], natural_language_query="errors today", - viewer_context=viewer_context, ) @patch("sentry.receivers.outbox.cell.make_search_agent_start_request") def test_terminal_seer_failure_raises(self, mock_request: Mock) -> None: mock_request.return_value = Mock(status=400, json=Mock(return_value={})) - viewer_context = SeerViewerContext( - organization_id=self.organization.id, user_id=self.user.id - ) with pytest.raises(SeerApiError): send_search_agent_start_request( @@ -62,5 +49,4 @@ def test_terminal_seer_failure_raises(self, mock_request: Mock) -> None: user_id=self.user.id, project_ids=[self.project.id], natural_language_query="errors today", - viewer_context=viewer_context, )