From 4edf1fbc4755b4c84eac1d427c92df80c843d4c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 2 Apr 2026 14:28:57 -0700 Subject: [PATCH 01/12] feat(seer): Thread short-lived API token to Explorer MCP tools Create a 1-hour scoped API token (org:read, project:read, event:read) when starting an Explorer session and pass it to Seer via the user_auth_token field on ExplorerChatRequest. This enables Seer's new sentry_api_execute tool to call the Sentry API on behalf of the user. Includes instrumentation logging at each stage to trace token flow. Co-Authored-By: Claude --- .../organization_seer_explorer_chat.py | 1 + src/sentry/seer/explorer/client.py | 16 ++++++++-- src/sentry/seer/explorer/client_utils.py | 31 ++++++++++++++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index a0ba5be568ce1d..463cea1e1b3b09 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -194,6 +194,7 @@ def post( on_page_context=on_page_context, page_name=page_name, override_ce_enable=override_ce_enable, + request=request, ) return Response({"run_id": result_run_id}) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 1c3d0dd803ad50..447b81117d1d97 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -266,6 +266,8 @@ def start_run( if bool(artifact_schema) != bool(artifact_key): raise ValueError("artifact_key and artifact_schema must be provided together") + user_org_context = collect_user_org_context(self.user, self.organization, request=request) + chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, query=prompt, @@ -273,12 +275,20 @@ def start_run( insert_index=None, on_page_context=on_page_context, page_name=page_name, - user_org_context=collect_user_org_context( - self.user, self.organization, request=request - ), + user_org_context=user_org_context, intelligence_level=self.intelligence_level, is_interactive=self.is_interactive, enable_coding=self.enable_coding, + user_auth_token=user_org_context.get("user_auth_token"), + ) + + _token = user_org_context.get("user_auth_token") + logger.info( + "seer_explorer.start_run.auth_token", + extra={ + "has_token": _token is not None, + "token_prefix": _token[:12] + "..." if _token else None, + }, ) if self.max_iterations is not None: diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index fa0be21574d64c..21d01b077368bd 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -9,12 +9,13 @@ import logging import time -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, NotRequired, TypedDict import orjson from django.conf import settings from django.contrib.auth.models import AnonymousUser +from django.utils import timezone from rest_framework.request import Request from urllib3 import BaseHTTPResponse, HTTPConnectionPool @@ -68,6 +69,7 @@ class ExplorerChatRequest(TypedDict): metadata: NotRequired[dict[str, Any]] is_context_engine_enabled: NotRequired[bool] max_iterations: NotRequired[int] + user_auth_token: NotRequired[str | None] class ExplorerRunsRequest(TypedDict): @@ -284,6 +286,32 @@ def collect_user_org_context( # Get IP address from http request, if provided user_ip: str | None = request.META.get("REMOTE_ADDR") if request else None + # Create a short-lived API token for Seer to call back into Sentry on behalf of the user + user_auth_token: str | None = None + try: + from sentry.models.apitoken import ApiToken + from sentry.types.token import AuthTokenType + + token = ApiToken.objects.create( + user=user, + token_type=AuthTokenType.USER, + scoping_organization_id=organization.id, + scope_list=["org:read", "project:read", "event:read"], + expires_at=timezone.now() + timedelta(hours=1), + ) + user_auth_token = token.plaintext_token + logger.info( + "seer_explorer.auth_token_created", + extra={ + "user_id": user.id, + "org_id": organization.id, + "token_prefix": user_auth_token[:12] + "..." if user_auth_token else None, + "expires_at": str(token.expires_at), + }, + ) + except Exception: + logger.exception("Failed to create short-lived API token for Seer Explorer") + return { "org_slug": organization.slug, "user_id": user.id, @@ -294,6 +322,7 @@ def collect_user_org_context( "user_teams": user_teams, "user_projects": user_projects, "all_org_projects": all_org_projects, + "user_auth_token": user_auth_token, } From a2e24e20c1cf0d4061031616b7f33102cd48b5f5 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 2 Apr 2026 15:29:51 -0700 Subject: [PATCH 02/12] feat(seer): Broaden Explorer API token scopes for write operations Add org:write, project:write, event:write, alerts:read, alerts:write, member:read, and team:read scopes to the short-lived token. The agent needs write scopes to create dashboards, alerts, and other resources on behalf of the user. Token remains org-scoped and 1-hour expiry. Co-Authored-By: Claude --- src/sentry/seer/explorer/client_utils.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 21d01b077368bd..6141157d8f47b4 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -291,12 +291,27 @@ def collect_user_org_context( try: from sentry.models.apitoken import ApiToken from sentry.types.token import AuthTokenType + from sentry.users.models.user import User + + # request.user may be an RpcUser proxy — ApiToken needs the real User model + real_user = User.objects.get(id=user.id) token = ApiToken.objects.create( - user=user, + user=real_user, token_type=AuthTokenType.USER, scoping_organization_id=organization.id, - scope_list=["org:read", "project:read", "event:read"], + scope_list=[ + "org:read", + "org:write", + "project:read", + "project:write", + "event:read", + "event:write", + "alerts:read", + "alerts:write", + "member:read", + "team:read", + ], expires_at=timezone.now() + timedelta(hours=1), ) user_auth_token = token.plaintext_token From 65404750f33e14473d9f4831803bb8de576ae575 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Mon, 6 Apr 2026 15:02:50 -0700 Subject: [PATCH 03/12] fix(seer): Update explorer chat test for request= kwarg The endpoint now passes request= to start_run() for auth token creation. Update the mock assertion to expect it. Co-Authored-By: Claude --- .../seer/endpoints/test_organization_seer_explorer_chat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index a72dc1bfddde8c..a7bbe03ae3e2ff 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -76,6 +76,7 @@ def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): on_page_context=None, page_name=None, override_ce_enable=True, + request=ANY, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") From c7f8626efce2090d8725f7f87f8608a28f2df5be Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Wed, 8 Apr 2026 15:43:21 -0700 Subject: [PATCH 04/12] feat(seer): Add MCP tools feature flag and refresh auth token on continue 1. Register organizations:seer-explorer-mcp-tools feature flag to gate general-purpose MCP tools (sentry_api_search/execute) in Explorer. 2. Thread enable_mcp_tools from the chat endpoint through SeerExplorerClient to the Seer request body. 3. Fix auth token expiry on multi-turn sessions: continue_run now calls collect_user_org_context to create a fresh token on every request, rather than relying on the token from start_run which has a 1-hour TTL. Co-Authored-By: Claude --- src/sentry/features/temporary.py | 2 ++ .../seer/endpoints/organization_seer_explorer_chat.py | 5 +++++ src/sentry/seer/explorer/client.py | 8 ++++++++ src/sentry/seer/explorer/client_utils.py | 1 + 4 files changed, 16 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index e44308d8bacf46..64395faaaf3dc5 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -317,6 +317,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-context-engine-fe-override-ui-flag", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code editing tools in Seer Explorer chat manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable general-purpose MCP tools (sentry_api_search/execute) in Seer Explorer + manager.add("organizations:seer-explorer-mcp-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable structured LLM context (JSON snapshot) instead of ASCII DOM snapshot manager.add("organizations:context-engine-structured-page-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer Overview sections for Seat-Based users diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index 463cea1e1b3b09..c41868314e885e 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -172,11 +172,15 @@ def post( ) and features.has( "organizations:seer-explorer-chat-coding", organization, actor=request.user ) + enable_mcp_tools = features.has( + "organizations:seer-explorer-mcp-tools", organization, actor=request.user + ) client = SeerExplorerClient( organization, request.user, is_interactive=True, enable_coding=enable_coding, + enable_mcp_tools=enable_mcp_tools, ) if run_id: # Continue existing conversation @@ -186,6 +190,7 @@ def post( insert_index=insert_index, on_page_context=on_page_context, page_name=page_name, + request=request, ) else: # Start new conversation diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 447b81117d1d97..fbea891ba9ee62 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -196,6 +196,7 @@ def __init__( intelligence_level: Literal["low", "medium", "high"] = "medium", is_interactive: bool = False, enable_coding: bool = False, + enable_mcp_tools: bool = False, max_iterations: int | None = None, ): self.organization = organization @@ -207,6 +208,7 @@ def __init__( self.category_key = category_key self.category_value = category_value self.is_interactive = is_interactive + self.enable_mcp_tools = enable_mcp_tools self.max_iterations = max_iterations if enable_coding and not organization.get_option("sentry:enable_seer_coding", True): @@ -279,6 +281,7 @@ def start_run( intelligence_level=self.intelligence_level, is_interactive=self.is_interactive, enable_coding=self.enable_coding, + enable_mcp_tools=self.enable_mcp_tools, user_auth_token=user_org_context.get("user_auth_token"), ) @@ -354,6 +357,7 @@ def continue_run( page_name: str | None = None, artifact_key: str | None = None, artifact_schema: type[BaseModel] | None = None, + request: Request | None = None, ) -> int: """ Continue an existing Seer Explorer session. This allows you to add follow-up queries to an ongoing conversation. @@ -376,6 +380,8 @@ def continue_run( if bool(artifact_schema) != bool(artifact_key): raise ValueError("artifact_key and artifact_schema must be provided together") + user_org_context = collect_user_org_context(self.user, self.organization, request=request) + chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, query=prompt, @@ -385,6 +391,8 @@ def continue_run( page_name=page_name, is_interactive=self.is_interactive, enable_coding=self.enable_coding, + enable_mcp_tools=self.enable_mcp_tools, + user_auth_token=user_org_context.get("user_auth_token"), ) if prompt_metadata: diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 6141157d8f47b4..396bf8a5cc9a32 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -58,6 +58,7 @@ class ExplorerChatRequest(TypedDict): intelligence_level: NotRequired[str] is_interactive: NotRequired[bool] enable_coding: NotRequired[bool] + enable_mcp_tools: NotRequired[bool] project_id: NotRequired[int] query_metadata: NotRequired[dict[str, str]] artifact_key: NotRequired[str] From 4f76aceab0bbf9df89cee5f033715cf76d95297d Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Wed, 8 Apr 2026 16:10:08 -0700 Subject: [PATCH 05/12] fix(seer): Update explorer chat test for enable_mcp_tools kwarg Co-Authored-By: Claude --- .../seer/endpoints/test_organization_seer_explorer_chat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index a7bbe03ae3e2ff..1ff77940af8263 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -69,7 +69,7 @@ def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): assert response.status_code == 200 assert response.data == {"run_id": 456} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False + self.organization, ANY, is_interactive=True, enable_coding=False, enable_mcp_tools=False ) mock_client.start_run.assert_called_once_with( prompt="What is this error about?", @@ -122,7 +122,7 @@ def test_post_continue_conversation_calls_client(self, mock_client_class: MagicM assert response.status_code == 200 assert response.data == {"run_id": 789} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False + self.organization, ANY, is_interactive=True, enable_coding=False, enable_mcp_tools=False ) mock_client.continue_run.assert_called_once_with( run_id=789, From 7ca1d34792bebbfe097164abf38f5c3b9b5c5474 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Wed, 8 Apr 2026 16:27:15 -0700 Subject: [PATCH 06/12] fix(seer): Update all explorer chat tests for new kwargs Add enable_mcp_tools=False to remaining assert_called_with calls and request=ANY to continue_run assertion. Co-Authored-By: Claude --- .../seer/endpoints/test_organization_seer_explorer_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index 1ff77940af8263..d9cc416fe17b3a 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -105,6 +105,7 @@ def test_post_new_conversation_enable_coding(self, mock_client_class: MagicMock) ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, + enable_mcp_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -130,6 +131,7 @@ def test_post_continue_conversation_calls_client(self, mock_client_class: MagicM insert_index=2, on_page_context=None, page_name=None, + request=ANY, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -153,6 +155,7 @@ def test_post_continue_conversation_enable_coding(self, mock_client_class: Magic ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, + enable_mcp_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") From e073969d48ec6ee4cde2aa4045ad503f2a263db8 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 11:25:07 -0700 Subject: [PATCH 07/12] ref(seer): Rename enable_mcp_tools to enable_code_mode_tools Rename feature flag and field to reflect the code mode project naming rather than the generic MCP implementation detail. Co-Authored-By: Claude --- src/sentry/features/temporary.py | 4 ++-- .../endpoints/organization_seer_explorer_chat.py | 6 +++--- src/sentry/seer/explorer/client.py | 8 ++++---- src/sentry/seer/explorer/client_utils.py | 2 +- .../test_organization_seer_explorer_chat.py | 16 ++++++++++++---- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 64395faaaf3dc5..1a1650d6599890 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -317,8 +317,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-context-engine-fe-override-ui-flag", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code editing tools in Seer Explorer chat manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable general-purpose MCP tools (sentry_api_search/execute) in Seer Explorer - manager.add("organizations:seer-explorer-mcp-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable code mode tools (sentry_api_search/execute) in Seer Explorer + manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable structured LLM context (JSON snapshot) instead of ASCII DOM snapshot manager.add("organizations:context-engine-structured-page-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer Overview sections for Seat-Based users diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index c41868314e885e..7281ed0f19047b 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -172,15 +172,15 @@ def post( ) and features.has( "organizations:seer-explorer-chat-coding", organization, actor=request.user ) - enable_mcp_tools = features.has( - "organizations:seer-explorer-mcp-tools", organization, actor=request.user + enable_code_mode_tools = features.has( + "organizations:seer-explorer-code-mode-tools", organization, actor=request.user ) client = SeerExplorerClient( organization, request.user, is_interactive=True, enable_coding=enable_coding, - enable_mcp_tools=enable_mcp_tools, + enable_code_mode_tools=enable_code_mode_tools, ) if run_id: # Continue existing conversation diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index fbea891ba9ee62..8e03f0fcadbc26 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -196,7 +196,7 @@ def __init__( intelligence_level: Literal["low", "medium", "high"] = "medium", is_interactive: bool = False, enable_coding: bool = False, - enable_mcp_tools: bool = False, + enable_code_mode_tools: bool = False, max_iterations: int | None = None, ): self.organization = organization @@ -208,7 +208,7 @@ def __init__( self.category_key = category_key self.category_value = category_value self.is_interactive = is_interactive - self.enable_mcp_tools = enable_mcp_tools + self.enable_code_mode_tools = enable_code_mode_tools self.max_iterations = max_iterations if enable_coding and not organization.get_option("sentry:enable_seer_coding", True): @@ -281,7 +281,7 @@ def start_run( intelligence_level=self.intelligence_level, is_interactive=self.is_interactive, enable_coding=self.enable_coding, - enable_mcp_tools=self.enable_mcp_tools, + enable_code_mode_tools=self.enable_code_mode_tools, user_auth_token=user_org_context.get("user_auth_token"), ) @@ -391,7 +391,7 @@ def continue_run( page_name=page_name, is_interactive=self.is_interactive, enable_coding=self.enable_coding, - enable_mcp_tools=self.enable_mcp_tools, + enable_code_mode_tools=self.enable_code_mode_tools, user_auth_token=user_org_context.get("user_auth_token"), ) diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 396bf8a5cc9a32..d0dcd544dcfe3b 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -58,7 +58,7 @@ class ExplorerChatRequest(TypedDict): intelligence_level: NotRequired[str] is_interactive: NotRequired[bool] enable_coding: NotRequired[bool] - enable_mcp_tools: NotRequired[bool] + enable_code_mode_tools: NotRequired[bool] project_id: NotRequired[int] query_metadata: NotRequired[dict[str, str]] artifact_key: NotRequired[str] diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index d9cc416fe17b3a..7496559f3ba582 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -69,7 +69,11 @@ def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): assert response.status_code == 200 assert response.data == {"run_id": 456} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False, enable_mcp_tools=False + self.organization, + ANY, + is_interactive=True, + enable_coding=False, + enable_code_mode_tools=False, ) mock_client.start_run.assert_called_once_with( prompt="What is this error about?", @@ -105,7 +109,7 @@ def test_post_new_conversation_enable_coding(self, mock_client_class: MagicMock) ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, - enable_mcp_tools=False, + enable_code_mode_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -123,7 +127,11 @@ def test_post_continue_conversation_calls_client(self, mock_client_class: MagicM assert response.status_code == 200 assert response.data == {"run_id": 789} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False, enable_mcp_tools=False + self.organization, + ANY, + is_interactive=True, + enable_coding=False, + enable_code_mode_tools=False, ) mock_client.continue_run.assert_called_once_with( run_id=789, @@ -155,7 +163,7 @@ def test_post_continue_conversation_enable_coding(self, mock_client_class: Magic ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, - enable_mcp_tools=False, + enable_code_mode_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") From 841a3aaa60125b6a501a6ddf063c06386ce5ed63 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 13:03:20 -0700 Subject: [PATCH 08/12] ref(seer): Remove auth token prefix logging Remove token_prefix from log lines to avoid persisting credential material in logs. Keep the token creation log for debugging but without sensitive fields. Co-Authored-By: Claude --- src/sentry/seer/explorer/client.py | 9 --------- src/sentry/seer/explorer/client_utils.py | 1 - 2 files changed, 10 deletions(-) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 8e03f0fcadbc26..20ea9dac61b5b5 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -285,15 +285,6 @@ def start_run( user_auth_token=user_org_context.get("user_auth_token"), ) - _token = user_org_context.get("user_auth_token") - logger.info( - "seer_explorer.start_run.auth_token", - extra={ - "has_token": _token is not None, - "token_prefix": _token[:12] + "..." if _token else None, - }, - ) - if self.max_iterations is not None: chat_body["max_iterations"] = self.max_iterations diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index d0dcd544dcfe3b..3949c283ded84f 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -321,7 +321,6 @@ def collect_user_org_context( extra={ "user_id": user.id, "org_id": organization.id, - "token_prefix": user_auth_token[:12] + "..." if user_auth_token else None, "expires_at": str(token.expires_at), }, ) From 9128ff2312fbe75d28260ef125cb6b2bddabf385 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 13:11:27 -0700 Subject: [PATCH 09/12] ref(seer): Restrict Explorer API token to read-only scopes Remove write scopes (org:write, project:write, event:write, alerts:write) from the short-lived token created for Seer Explorer. Read-only is sufficient for code mode tools for now. Co-Authored-By: Claude --- src/sentry/seer/explorer/client_utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 3949c283ded84f..76e3fb9eab9016 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -303,13 +303,9 @@ def collect_user_org_context( scoping_organization_id=organization.id, scope_list=[ "org:read", - "org:write", "project:read", - "project:write", "event:read", - "event:write", "alerts:read", - "alerts:write", "member:read", "team:read", ], From ad3e2949edad94c19760f8eeb9762540bcb6c18b Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 14:57:31 -0700 Subject: [PATCH 10/12] fix(seer): Address PR feedback from review - Set api_expose=False on code mode feature flag (not needed by frontend) - Strip user_auth_token from user_org_context before sending to Seer to avoid sending it twice (once in dict, once as top-level field) Co-Authored-By: Claude --- src/sentry/features/temporary.py | 2 +- src/sentry/seer/explorer/client.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 1a1650d6599890..74f9484dd2642f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -318,7 +318,7 @@ def register_temporary_features(manager: FeatureManager) -> None: # Enable code editing tools in Seer Explorer chat manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code mode tools (sentry_api_search/execute) in Seer Explorer - manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable structured LLM context (JSON snapshot) instead of ASCII DOM snapshot manager.add("organizations:context-engine-structured-page-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer Overview sections for Seat-Based users diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 20ea9dac61b5b5..255aa6f6cc0591 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -269,6 +269,7 @@ def start_run( raise ValueError("artifact_key and artifact_schema must be provided together") user_org_context = collect_user_org_context(self.user, self.organization, request=request) + user_auth_token = user_org_context.pop("user_auth_token", None) chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, @@ -282,7 +283,7 @@ def start_run( is_interactive=self.is_interactive, enable_coding=self.enable_coding, enable_code_mode_tools=self.enable_code_mode_tools, - user_auth_token=user_org_context.get("user_auth_token"), + user_auth_token=user_auth_token, ) if self.max_iterations is not None: @@ -372,6 +373,7 @@ def continue_run( raise ValueError("artifact_key and artifact_schema must be provided together") user_org_context = collect_user_org_context(self.user, self.organization, request=request) + user_auth_token = user_org_context.pop("user_auth_token", None) chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, @@ -383,7 +385,7 @@ def continue_run( is_interactive=self.is_interactive, enable_coding=self.enable_coding, enable_code_mode_tools=self.enable_code_mode_tools, - user_auth_token=user_org_context.get("user_auth_token"), + user_auth_token=user_auth_token, ) if prompt_metadata: From 593d1b15b515cc2a853f7cb5d35b1bb30b6d0bbc Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 15:42:21 -0700 Subject: [PATCH 11/12] fix(seer): Only create API token when code mode is enabled Extract token creation into standalone create_explorer_api_token() function and only call it when enable_code_mode_tools is True. This avoids creating unnecessary tokens when the feature is off, and eliminates expensive collect_user_org_context queries in continue_run that were only needed for the token. Co-Authored-By: Claude --- src/sentry/seer/explorer/client.py | 14 +++++-- src/sentry/seer/explorer/client_utils.py | 47 ++++++++++-------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 255aa6f6cc0591..4bc97092d2180a 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -20,6 +20,7 @@ ExplorerRunsRequest, ExplorerUpdateRequest, collect_user_org_context, + create_explorer_api_token, fetch_run_status, make_explorer_chat_request, make_explorer_runs_request, @@ -269,7 +270,11 @@ def start_run( raise ValueError("artifact_key and artifact_schema must be provided together") user_org_context = collect_user_org_context(self.user, self.organization, request=request) - user_auth_token = user_org_context.pop("user_auth_token", None) + user_auth_token = ( + create_explorer_api_token(self.user, self.organization) + if self.enable_code_mode_tools + else None + ) chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, @@ -372,8 +377,11 @@ def continue_run( if bool(artifact_schema) != bool(artifact_key): raise ValueError("artifact_key and artifact_schema must be provided together") - user_org_context = collect_user_org_context(self.user, self.organization, request=request) - user_auth_token = user_org_context.pop("user_auth_token", None) + user_auth_token = ( + create_explorer_api_token(self.user, self.organization) + if self.enable_code_mode_tools + else None + ) chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index 76e3fb9eab9016..0149a2cafb18d9 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -287,16 +287,27 @@ def collect_user_org_context( # Get IP address from http request, if provided user_ip: str | None = request.META.get("REMOTE_ADDR") if request else None - # Create a short-lived API token for Seer to call back into Sentry on behalf of the user - user_auth_token: str | None = None + return { + "org_slug": organization.slug, + "user_id": user.id, + "user_ip": user_ip, + "user_name": user_name, + "user_email": user.email, + "user_timezone": user_timezone, + "user_teams": user_teams, + "user_projects": user_projects, + "all_org_projects": all_org_projects, + } + + +def create_explorer_api_token(user: SentryUser | RpcUser, organization: Organization) -> str | None: + """Create a short-lived read-only API token for Seer to call back into Sentry.""" try: from sentry.models.apitoken import ApiToken from sentry.types.token import AuthTokenType - from sentry.users.models.user import User - - # request.user may be an RpcUser proxy — ApiToken needs the real User model - real_user = User.objects.get(id=user.id) + from sentry.users.models.user import User as UserModel + real_user = UserModel.objects.get(id=user.id) token = ApiToken.objects.create( user=real_user, token_type=AuthTokenType.USER, @@ -311,30 +322,10 @@ def collect_user_org_context( ], expires_at=timezone.now() + timedelta(hours=1), ) - user_auth_token = token.plaintext_token - logger.info( - "seer_explorer.auth_token_created", - extra={ - "user_id": user.id, - "org_id": organization.id, - "expires_at": str(token.expires_at), - }, - ) + return token.plaintext_token except Exception: logger.exception("Failed to create short-lived API token for Seer Explorer") - - return { - "org_slug": organization.slug, - "user_id": user.id, - "user_ip": user_ip, - "user_name": user_name, - "user_email": user.email, - "user_timezone": user_timezone, - "user_teams": user_teams, - "user_projects": user_projects, - "all_org_projects": all_org_projects, - "user_auth_token": user_auth_token, - } + return None def fetch_run_status(run_id: int, organization: Organization) -> SeerRunState: From 11de8afa8a0c9fd6c4fc39aee533fc5353772036 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 9 Apr 2026 16:26:15 -0700 Subject: [PATCH 12/12] fix(seer): Guard token creation against None/AnonymousUser create_explorer_api_token expects a real user, but self.user can be None or AnonymousUser. Skip token creation in those cases. Co-Authored-By: Claude --- src/sentry/seer/explorer/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 4bc97092d2180a..a51f4bdde0a265 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -273,6 +273,8 @@ def start_run( user_auth_token = ( create_explorer_api_token(self.user, self.organization) if self.enable_code_mode_tools + and self.user + and not isinstance(self.user, AnonymousUser) else None ) @@ -380,6 +382,8 @@ def continue_run( user_auth_token = ( create_explorer_api_token(self.user, self.organization) if self.enable_code_mode_tools + and self.user + and not isinstance(self.user, AnonymousUser) else None )