shotgun-sh 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (130) hide show
  1. shotgun/__init__.py +5 -0
  2. shotgun/agents/__init__.py +1 -0
  3. shotgun/agents/agent_manager.py +651 -0
  4. shotgun/agents/common.py +549 -0
  5. shotgun/agents/config/__init__.py +13 -0
  6. shotgun/agents/config/constants.py +17 -0
  7. shotgun/agents/config/manager.py +294 -0
  8. shotgun/agents/config/models.py +185 -0
  9. shotgun/agents/config/provider.py +206 -0
  10. shotgun/agents/conversation_history.py +106 -0
  11. shotgun/agents/conversation_manager.py +105 -0
  12. shotgun/agents/export.py +96 -0
  13. shotgun/agents/history/__init__.py +5 -0
  14. shotgun/agents/history/compaction.py +85 -0
  15. shotgun/agents/history/constants.py +19 -0
  16. shotgun/agents/history/context_extraction.py +108 -0
  17. shotgun/agents/history/history_building.py +104 -0
  18. shotgun/agents/history/history_processors.py +426 -0
  19. shotgun/agents/history/message_utils.py +84 -0
  20. shotgun/agents/history/token_counting.py +429 -0
  21. shotgun/agents/history/token_estimation.py +138 -0
  22. shotgun/agents/messages.py +35 -0
  23. shotgun/agents/models.py +275 -0
  24. shotgun/agents/plan.py +98 -0
  25. shotgun/agents/research.py +108 -0
  26. shotgun/agents/specify.py +98 -0
  27. shotgun/agents/tasks.py +96 -0
  28. shotgun/agents/tools/__init__.py +34 -0
  29. shotgun/agents/tools/codebase/__init__.py +28 -0
  30. shotgun/agents/tools/codebase/codebase_shell.py +256 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +141 -0
  32. shotgun/agents/tools/codebase/file_read.py +144 -0
  33. shotgun/agents/tools/codebase/models.py +252 -0
  34. shotgun/agents/tools/codebase/query_graph.py +67 -0
  35. shotgun/agents/tools/codebase/retrieve_code.py +81 -0
  36. shotgun/agents/tools/file_management.py +218 -0
  37. shotgun/agents/tools/user_interaction.py +37 -0
  38. shotgun/agents/tools/web_search/__init__.py +60 -0
  39. shotgun/agents/tools/web_search/anthropic.py +144 -0
  40. shotgun/agents/tools/web_search/gemini.py +85 -0
  41. shotgun/agents/tools/web_search/openai.py +98 -0
  42. shotgun/agents/tools/web_search/utils.py +20 -0
  43. shotgun/build_constants.py +20 -0
  44. shotgun/cli/__init__.py +1 -0
  45. shotgun/cli/codebase/__init__.py +5 -0
  46. shotgun/cli/codebase/commands.py +202 -0
  47. shotgun/cli/codebase/models.py +21 -0
  48. shotgun/cli/config.py +275 -0
  49. shotgun/cli/export.py +81 -0
  50. shotgun/cli/models.py +10 -0
  51. shotgun/cli/plan.py +73 -0
  52. shotgun/cli/research.py +85 -0
  53. shotgun/cli/specify.py +69 -0
  54. shotgun/cli/tasks.py +78 -0
  55. shotgun/cli/update.py +152 -0
  56. shotgun/cli/utils.py +25 -0
  57. shotgun/codebase/__init__.py +12 -0
  58. shotgun/codebase/core/__init__.py +46 -0
  59. shotgun/codebase/core/change_detector.py +358 -0
  60. shotgun/codebase/core/code_retrieval.py +243 -0
  61. shotgun/codebase/core/ingestor.py +1497 -0
  62. shotgun/codebase/core/language_config.py +297 -0
  63. shotgun/codebase/core/manager.py +1662 -0
  64. shotgun/codebase/core/nl_query.py +331 -0
  65. shotgun/codebase/core/parser_loader.py +128 -0
  66. shotgun/codebase/models.py +111 -0
  67. shotgun/codebase/service.py +206 -0
  68. shotgun/logging_config.py +227 -0
  69. shotgun/main.py +167 -0
  70. shotgun/posthog_telemetry.py +158 -0
  71. shotgun/prompts/__init__.py +5 -0
  72. shotgun/prompts/agents/__init__.py +1 -0
  73. shotgun/prompts/agents/export.j2 +350 -0
  74. shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
  76. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  77. shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
  78. shotgun/prompts/agents/plan.j2 +144 -0
  79. shotgun/prompts/agents/research.j2 +69 -0
  80. shotgun/prompts/agents/specify.j2 +51 -0
  81. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
  82. shotgun/prompts/agents/state/system_state.j2 +31 -0
  83. shotgun/prompts/agents/tasks.j2 +143 -0
  84. shotgun/prompts/codebase/__init__.py +1 -0
  85. shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
  86. shotgun/prompts/codebase/cypher_system.j2 +28 -0
  87. shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
  88. shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
  89. shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
  90. shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
  91. shotgun/prompts/history/__init__.py +1 -0
  92. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  93. shotgun/prompts/history/summarization.j2 +46 -0
  94. shotgun/prompts/loader.py +140 -0
  95. shotgun/py.typed +0 -0
  96. shotgun/sdk/__init__.py +13 -0
  97. shotgun/sdk/codebase.py +219 -0
  98. shotgun/sdk/exceptions.py +17 -0
  99. shotgun/sdk/models.py +189 -0
  100. shotgun/sdk/services.py +23 -0
  101. shotgun/sentry_telemetry.py +87 -0
  102. shotgun/telemetry.py +93 -0
  103. shotgun/tui/__init__.py +0 -0
  104. shotgun/tui/app.py +116 -0
  105. shotgun/tui/commands/__init__.py +76 -0
  106. shotgun/tui/components/prompt_input.py +69 -0
  107. shotgun/tui/components/spinner.py +86 -0
  108. shotgun/tui/components/splash.py +25 -0
  109. shotgun/tui/components/vertical_tail.py +13 -0
  110. shotgun/tui/screens/chat.py +782 -0
  111. shotgun/tui/screens/chat.tcss +43 -0
  112. shotgun/tui/screens/chat_screen/__init__.py +0 -0
  113. shotgun/tui/screens/chat_screen/command_providers.py +219 -0
  114. shotgun/tui/screens/chat_screen/hint_message.py +40 -0
  115. shotgun/tui/screens/chat_screen/history.py +221 -0
  116. shotgun/tui/screens/directory_setup.py +113 -0
  117. shotgun/tui/screens/provider_config.py +221 -0
  118. shotgun/tui/screens/splash.py +31 -0
  119. shotgun/tui/styles.tcss +10 -0
  120. shotgun/tui/utils/__init__.py +5 -0
  121. shotgun/tui/utils/mode_progress.py +257 -0
  122. shotgun/utils/__init__.py +5 -0
  123. shotgun/utils/env_utils.py +35 -0
  124. shotgun/utils/file_system_utils.py +36 -0
  125. shotgun/utils/update_checker.py +375 -0
  126. shotgun_sh-0.1.0.dist-info/METADATA +466 -0
  127. shotgun_sh-0.1.0.dist-info/RECORD +130 -0
  128. shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
  129. shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
  130. shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,219 @@
1
+ """Codebase SDK for framework-agnostic business logic."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from pathlib import Path
6
+
7
+ from shotgun.codebase.models import CodebaseGraph, QueryType
8
+
9
+ from .exceptions import CodebaseNotFoundError, InvalidPathError
10
+ from .models import (
11
+ DeleteResult,
12
+ IndexResult,
13
+ InfoResult,
14
+ ListResult,
15
+ QueryCommandResult,
16
+ ReindexResult,
17
+ )
18
+ from .services import get_codebase_service
19
+
20
+
21
+ class CodebaseSDK:
22
+ """Framework-agnostic SDK for codebase operations.
23
+
24
+ This SDK provides business logic for codebase management that can be
25
+ used by both CLI and TUI implementations without framework dependencies.
26
+ """
27
+
28
+ def __init__(self, storage_dir: Path | None = None):
29
+ """Initialize SDK with optional storage directory.
30
+
31
+ Args:
32
+ storage_dir: Optional custom storage directory.
33
+ Defaults to ~/.shotgun-sh/codebases/
34
+ """
35
+ self.service = get_codebase_service(storage_dir)
36
+
37
+ async def list_codebases(self) -> ListResult:
38
+ """List all indexed codebases.
39
+
40
+ Returns:
41
+ ListResult containing list of codebases
42
+ """
43
+ graphs = await self.service.list_graphs()
44
+ return ListResult(graphs=graphs)
45
+
46
+ async def list_codebases_for_directory(
47
+ self, directory: Path | None = None
48
+ ) -> ListResult:
49
+ """List codebases accessible from a specific directory.
50
+
51
+ Args:
52
+ directory: Directory to filter by. If None, uses current working directory.
53
+
54
+ Returns:
55
+ ListResult containing filtered list of codebases
56
+ """
57
+ graphs = await self.service.list_graphs_for_directory(directory)
58
+ return ListResult(graphs=graphs)
59
+
60
+ async def index_codebase(
61
+ self, path: Path, name: str, indexed_from_cwd: str | None = None
62
+ ) -> IndexResult:
63
+ """Index a new codebase.
64
+
65
+ Args:
66
+ path: Path to the repository to index
67
+ name: Human-readable name for the codebase
68
+ indexed_from_cwd: Working directory from which indexing was initiated.
69
+ If None, uses current working directory.
70
+
71
+ Returns:
72
+ IndexResult with indexing details
73
+
74
+ Raises:
75
+ InvalidPathError: If the path does not exist
76
+ """
77
+ resolved_path = path.resolve()
78
+ if not resolved_path.exists():
79
+ raise InvalidPathError(f"Path does not exist: {resolved_path}")
80
+
81
+ # Default to current working directory if not specified
82
+ if indexed_from_cwd is None:
83
+ indexed_from_cwd = str(Path.cwd().resolve())
84
+
85
+ graph = await self.service.create_graph(
86
+ resolved_path, name, indexed_from_cwd=indexed_from_cwd
87
+ )
88
+ file_count = sum(graph.language_stats.values()) if graph.language_stats else 0
89
+
90
+ return IndexResult(
91
+ graph_id=graph.graph_id,
92
+ name=name,
93
+ repo_path=str(resolved_path),
94
+ file_count=file_count,
95
+ node_count=graph.node_count,
96
+ relationship_count=graph.relationship_count,
97
+ )
98
+
99
+ async def delete_codebase(
100
+ self,
101
+ graph_id: str,
102
+ confirm_callback: Callable[[CodebaseGraph], bool]
103
+ | Callable[[CodebaseGraph], Awaitable[bool]]
104
+ | None = None,
105
+ ) -> DeleteResult:
106
+ """Delete a codebase with optional confirmation.
107
+
108
+ Args:
109
+ graph_id: ID of the graph to delete
110
+ confirm_callback: Optional callback for confirmation.
111
+ Can be sync or async function that receives
112
+ the CodebaseGraph object and returns boolean.
113
+
114
+ Returns:
115
+ DeleteResult indicating success, failure, or cancellation
116
+
117
+ Raises:
118
+ CodebaseNotFoundError: If the graph is not found
119
+ """
120
+ graph = await self.service.get_graph(graph_id)
121
+ if not graph:
122
+ raise CodebaseNotFoundError(f"Graph not found: {graph_id}")
123
+
124
+ # Handle confirmation callback if provided
125
+ if confirm_callback:
126
+ if asyncio.iscoroutinefunction(confirm_callback):
127
+ confirmed = await confirm_callback(graph)
128
+ else:
129
+ confirmed = confirm_callback(graph)
130
+
131
+ if not confirmed:
132
+ return DeleteResult(
133
+ graph_id=graph_id,
134
+ name=graph.name,
135
+ deleted=False,
136
+ cancelled=True,
137
+ )
138
+
139
+ await self.service.delete_graph(graph_id)
140
+ return DeleteResult(
141
+ graph_id=graph_id,
142
+ name=graph.name,
143
+ deleted=True,
144
+ cancelled=False,
145
+ )
146
+
147
+ async def get_info(self, graph_id: str) -> InfoResult:
148
+ """Get detailed information about a codebase.
149
+
150
+ Args:
151
+ graph_id: ID of the graph to get info for
152
+
153
+ Returns:
154
+ InfoResult with detailed graph information
155
+
156
+ Raises:
157
+ CodebaseNotFoundError: If the graph is not found
158
+ """
159
+ graph = await self.service.get_graph(graph_id)
160
+ if not graph:
161
+ raise CodebaseNotFoundError(f"Graph not found: {graph_id}")
162
+
163
+ return InfoResult(graph=graph)
164
+
165
+ async def query_codebase(
166
+ self, graph_id: str, query_text: str, query_type: QueryType
167
+ ) -> QueryCommandResult:
168
+ """Query a codebase using natural language or Cypher.
169
+
170
+ Args:
171
+ graph_id: ID of the graph to query
172
+ query_text: Query text (natural language or Cypher)
173
+ query_type: Type of query (NATURAL_LANGUAGE or CYPHER)
174
+
175
+ Returns:
176
+ QueryCommandResult with query results
177
+
178
+ Raises:
179
+ CodebaseNotFoundError: If the graph is not found
180
+ """
181
+ graph = await self.service.get_graph(graph_id)
182
+ if not graph:
183
+ raise CodebaseNotFoundError(f"Graph not found: {graph_id}")
184
+
185
+ query_result = await self.service.execute_query(
186
+ graph_id, query_text, query_type
187
+ )
188
+
189
+ return QueryCommandResult(
190
+ graph_name=graph.name,
191
+ query_type="Cypher"
192
+ if query_type == QueryType.CYPHER
193
+ else "natural language",
194
+ result=query_result,
195
+ )
196
+
197
+ async def reindex_codebase(self, graph_id: str) -> ReindexResult:
198
+ """Reindex an existing codebase.
199
+
200
+ Args:
201
+ graph_id: ID of the graph to reindex
202
+
203
+ Returns:
204
+ ReindexResult with reindexing details
205
+
206
+ Raises:
207
+ CodebaseNotFoundError: If the graph is not found
208
+ """
209
+ graph = await self.service.get_graph(graph_id)
210
+ if not graph:
211
+ raise CodebaseNotFoundError(f"Graph not found: {graph_id}")
212
+
213
+ stats = await self.service.reindex_graph(graph_id)
214
+
215
+ return ReindexResult(
216
+ graph_id=graph_id,
217
+ name=graph.name,
218
+ stats=stats,
219
+ )
@@ -0,0 +1,17 @@
1
+ """SDK-specific exceptions."""
2
+
3
+
4
+ class ShotgunSDKError(Exception):
5
+ """Base exception for all SDK operations."""
6
+
7
+
8
+ class CodebaseNotFoundError(ShotgunSDKError):
9
+ """Raised when a codebase or graph is not found."""
10
+
11
+
12
+ class CodebaseOperationError(ShotgunSDKError):
13
+ """Raised when a codebase operation fails."""
14
+
15
+
16
+ class InvalidPathError(ShotgunSDKError):
17
+ """Raised when a provided path is invalid."""
shotgun/sdk/models.py ADDED
@@ -0,0 +1,189 @@
1
+ """Result models for SDK operations."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from shotgun.codebase.models import CodebaseGraph, QueryResult
8
+
9
+
10
+ class ListResult(BaseModel):
11
+ """Result for list command."""
12
+
13
+ graphs: list[CodebaseGraph]
14
+
15
+ def __str__(self) -> str:
16
+ """Format list result as plain text table."""
17
+ if not self.graphs:
18
+ return "No indexed codebases found."
19
+
20
+ lines = [
21
+ f"{'ID':<12} {'Name':<30} {'Status':<10} {'Files':<8} {'Path'}",
22
+ "-" * 80,
23
+ ]
24
+
25
+ for graph in self.graphs:
26
+ file_count = (
27
+ sum(graph.language_stats.values()) if graph.language_stats else 0
28
+ )
29
+ lines.append(
30
+ f"{graph.graph_id[:12]:<12} {graph.name[:30]:<30} {graph.status.value:<10} {file_count:<8} {graph.repo_path}"
31
+ )
32
+
33
+ return "\n".join(lines)
34
+
35
+
36
+ class IndexResult(BaseModel):
37
+ """Result for index command."""
38
+
39
+ graph_id: str
40
+ name: str
41
+ repo_path: str
42
+ file_count: int
43
+ node_count: int
44
+ relationship_count: int
45
+
46
+ def __str__(self) -> str:
47
+ """Format index result as success message."""
48
+ return (
49
+ "Successfully indexed codebase!\n"
50
+ f"Graph ID: {self.graph_id}\n"
51
+ f"Files processed: {self.file_count}\n"
52
+ f"Nodes: {self.node_count}\n"
53
+ f"Relationships: {self.relationship_count}"
54
+ )
55
+
56
+
57
+ class DeleteResult(BaseModel):
58
+ """Result for delete command."""
59
+
60
+ graph_id: str
61
+ name: str
62
+ deleted: bool
63
+ cancelled: bool = False
64
+
65
+ def __str__(self) -> str:
66
+ """Format delete result message."""
67
+ if self.cancelled:
68
+ return "Deletion cancelled."
69
+ elif self.deleted:
70
+ return f"Successfully deleted codebase: {self.graph_id}"
71
+ else:
72
+ return f"Failed to delete codebase: {self.graph_id}"
73
+
74
+
75
+ class InfoResult(BaseModel):
76
+ """Result for info command."""
77
+
78
+ graph: CodebaseGraph
79
+
80
+ def __str__(self) -> str:
81
+ """Format detailed graph information."""
82
+ graph = self.graph
83
+ lines = [
84
+ f"Graph ID: {graph.graph_id}",
85
+ f"Name: {graph.name}",
86
+ f"Status: {graph.status.value}",
87
+ f"Repository Path: {graph.repo_path}",
88
+ f"Database Path: {graph.graph_path}",
89
+ f"Created: {graph.created_at}",
90
+ f"Updated: {graph.updated_at}",
91
+ f"Schema Version: {graph.schema_version}",
92
+ f"Total Nodes: {graph.node_count}",
93
+ f"Total Relationships: {graph.relationship_count}",
94
+ ]
95
+
96
+ if graph.language_stats:
97
+ lines.append("\nLanguage Statistics:")
98
+ for lang, count in graph.language_stats.items():
99
+ lines.append(f" {lang}: {count} files")
100
+
101
+ if graph.node_stats:
102
+ lines.append("\nNode Statistics:")
103
+ for node_type, count in graph.node_stats.items():
104
+ lines.append(f" {node_type}: {count}")
105
+
106
+ if graph.relationship_stats:
107
+ lines.append("\nRelationship Statistics:")
108
+ for rel_type, count in graph.relationship_stats.items():
109
+ lines.append(f" {rel_type}: {count}")
110
+
111
+ return "\n".join(lines)
112
+
113
+
114
+ class QueryCommandResult(BaseModel):
115
+ """Result for query command."""
116
+
117
+ graph_name: str
118
+ query_type: str
119
+ result: QueryResult
120
+
121
+ def __str__(self) -> str:
122
+ """Format query results table."""
123
+ query_result = self.result
124
+
125
+ if not query_result.success:
126
+ return f"Query failed: {query_result.error}"
127
+
128
+ if not query_result.results:
129
+ return "No results found."
130
+
131
+ lines = [
132
+ f"Query executed in {query_result.execution_time_ms:.2f}ms",
133
+ f"Results: {query_result.row_count} rows",
134
+ ]
135
+
136
+ if query_result.cypher_query:
137
+ lines.append(f"Generated Cypher: {query_result.cypher_query}")
138
+
139
+ lines.append("") # Empty line
140
+
141
+ # Format results table
142
+ if query_result.column_names:
143
+ header = " | ".join(f"{col:<20}" for col in query_result.column_names)
144
+ lines.append(header)
145
+ lines.append("-" * len(header))
146
+
147
+ for row in query_result.results:
148
+ row_data = " | ".join(
149
+ f"{str(row.get(col, '')):<20}" for col in query_result.column_names
150
+ )
151
+ lines.append(row_data)
152
+ else:
153
+ # Fallback for results without column names
154
+ for i, row in enumerate(query_result.results):
155
+ lines.append(f"Row {i + 1}:")
156
+ for key, value in row.items():
157
+ lines.append(f" {key}: {value}")
158
+ lines.append("")
159
+
160
+ return "\n".join(lines)
161
+
162
+
163
+ class ReindexResult(BaseModel):
164
+ """Result for reindex command."""
165
+
166
+ graph_id: str
167
+ name: str
168
+ stats: dict[str, Any] | None = None
169
+
170
+ def __str__(self) -> str:
171
+ """Format reindex completion message."""
172
+ lines = ["Reindexing completed!"]
173
+ if self.stats:
174
+ lines.append(f"Stats: {self.stats}")
175
+ return "\n".join(lines)
176
+
177
+
178
+ class ErrorResult(BaseModel):
179
+ """Result for error cases."""
180
+
181
+ error_message: str
182
+ details: str | None = None
183
+
184
+ def __str__(self) -> str:
185
+ """Format error message."""
186
+ output = f"Error: {self.error_message}"
187
+ if self.details:
188
+ output += f"\n{self.details}"
189
+ return output
@@ -0,0 +1,23 @@
1
+ """Service factory functions for SDK."""
2
+
3
+ from pathlib import Path
4
+
5
+ from shotgun.codebase.service import CodebaseService
6
+ from shotgun.utils import get_shotgun_home
7
+
8
+
9
+ def get_codebase_service(storage_dir: Path | str | None = None) -> CodebaseService:
10
+ """Get CodebaseService instance with configurable storage.
11
+
12
+ Args:
13
+ storage_dir: Optional custom storage directory.
14
+ Defaults to ~/.shotgun-sh/codebases/
15
+
16
+ Returns:
17
+ Configured CodebaseService instance
18
+ """
19
+ if storage_dir is None:
20
+ storage_dir = get_shotgun_home() / "codebases"
21
+ elif isinstance(storage_dir, str):
22
+ storage_dir = Path(storage_dir)
23
+ return CodebaseService(storage_dir)
@@ -0,0 +1,87 @@
1
+ """Sentry observability setup for Shotgun."""
2
+
3
+ import os
4
+
5
+ from shotgun.logging_config import get_early_logger
6
+
7
+ # Use early logger to prevent automatic StreamHandler creation
8
+ logger = get_early_logger(__name__)
9
+
10
+
11
+ def setup_sentry_observability() -> bool:
12
+ """Set up Sentry observability for error tracking.
13
+
14
+ Returns:
15
+ True if Sentry was successfully set up, False otherwise
16
+ """
17
+ try:
18
+ import sentry_sdk
19
+
20
+ # Check if Sentry is already initialized
21
+ if sentry_sdk.is_initialized():
22
+ logger.debug("Sentry is already initialized, skipping")
23
+ return True
24
+
25
+ # Try to get DSN from build constants first (production builds)
26
+ dsn = None
27
+ try:
28
+ from shotgun import build_constants
29
+
30
+ dsn = build_constants.SENTRY_DSN
31
+ logger.debug("Using Sentry DSN from build constants")
32
+ except ImportError:
33
+ # Fallback to environment variable (development)
34
+ dsn = os.getenv("SENTRY_DSN", "")
35
+ if dsn:
36
+ logger.debug("Using Sentry DSN from environment variable")
37
+
38
+ if not dsn:
39
+ logger.debug("No Sentry DSN configured, skipping Sentry initialization")
40
+ return False
41
+
42
+ logger.debug("Found DSN, proceeding with Sentry setup")
43
+
44
+ # Get version for release tracking
45
+ from shotgun import __version__
46
+
47
+ # Determine environment based on version
48
+ # Dev versions contain "dev", "rc", "alpha", or "beta"
49
+ if any(marker in __version__ for marker in ["dev", "rc", "alpha", "beta"]):
50
+ environment = "development"
51
+ else:
52
+ environment = "production"
53
+
54
+ # Initialize Sentry
55
+ sentry_sdk.init(
56
+ dsn=dsn,
57
+ release=f"shotgun-sh@{__version__}",
58
+ environment=environment,
59
+ send_default_pii=False, # Privacy-first: never send PII
60
+ traces_sample_rate=0.1 if environment == "production" else 1.0,
61
+ profiles_sample_rate=0.1 if environment == "production" else 1.0,
62
+ )
63
+
64
+ # Set user context with anonymous user ID from config
65
+ try:
66
+ from shotgun.agents.config import get_config_manager
67
+
68
+ config_manager = get_config_manager()
69
+ user_id = config_manager.get_user_id()
70
+ sentry_sdk.set_user({"id": user_id})
71
+ logger.debug("Sentry user context set with anonymous ID")
72
+ except Exception as e:
73
+ logger.warning("Failed to set Sentry user context: %s", e)
74
+
75
+ logger.debug(
76
+ "Sentry observability configured successfully (environment: %s, version: %s)",
77
+ environment,
78
+ __version__,
79
+ )
80
+ return True
81
+
82
+ except ImportError as e:
83
+ logger.error("Sentry SDK not available: %s", e)
84
+ return False
85
+ except Exception as e:
86
+ logger.warning("Failed to setup Sentry observability: %s", e)
87
+ return False
shotgun/telemetry.py ADDED
@@ -0,0 +1,93 @@
1
+ """Observability setup for Logfire."""
2
+
3
+ import os
4
+
5
+ from shotgun.logging_config import get_early_logger
6
+ from shotgun.utils.env_utils import is_falsy, is_truthy
7
+
8
+ # Use early logger to prevent automatic StreamHandler creation
9
+ logger = get_early_logger(__name__)
10
+
11
+
12
+ def setup_logfire_observability() -> bool:
13
+ """Set up Logfire observability if enabled.
14
+
15
+ Returns:
16
+ True if Logfire was successfully set up, False otherwise
17
+ """
18
+ # Try to get Logfire configuration from build constants first, fall back to env vars
19
+ logfire_enabled = None
20
+ logfire_token = None
21
+
22
+ try:
23
+ from shotgun.build_constants import LOGFIRE_ENABLED, LOGFIRE_TOKEN
24
+
25
+ # Use build constants if they're not empty
26
+ if LOGFIRE_ENABLED:
27
+ logfire_enabled = LOGFIRE_ENABLED
28
+ if LOGFIRE_TOKEN:
29
+ logfire_token = LOGFIRE_TOKEN
30
+ except ImportError:
31
+ # No build constants available
32
+ pass
33
+
34
+ # Fall back to environment variables if not set from build constants
35
+ if not logfire_enabled:
36
+ logfire_enabled = os.getenv("LOGFIRE_ENABLED", "false")
37
+ if not logfire_token:
38
+ logfire_token = os.getenv("LOGFIRE_TOKEN")
39
+
40
+ # Allow environment variable to override and disable Logfire
41
+ env_override = os.getenv("LOGFIRE_ENABLED")
42
+ if env_override and is_falsy(env_override):
43
+ logfire_enabled = env_override
44
+
45
+ # Check if Logfire observability is enabled
46
+ if not is_truthy(logfire_enabled):
47
+ logger.debug("Logfire observability disabled via LOGFIRE_ENABLED")
48
+ return False
49
+
50
+ try:
51
+ import logfire
52
+
53
+ # Check for Logfire token
54
+ if not logfire_token:
55
+ logger.warning("LOGFIRE_TOKEN not set, Logfire observability disabled")
56
+ return False
57
+
58
+ # Configure Logfire
59
+ # Always disable console output - we only want telemetry sent to the web service
60
+ logfire.configure(
61
+ token=logfire_token,
62
+ console=False, # Never output to console, only send to Logfire service
63
+ )
64
+
65
+ # Instrument Pydantic AI for better observability
66
+ logfire.instrument_pydantic_ai()
67
+
68
+ # Set user context using baggage for all logs and spans
69
+ try:
70
+ from opentelemetry import baggage, context
71
+
72
+ from shotgun.agents.config import get_config_manager
73
+
74
+ config_manager = get_config_manager()
75
+ user_id = config_manager.get_user_id()
76
+
77
+ # Set user_id as baggage in global context - this will be included in all logs/spans
78
+ ctx = baggage.set_baggage("user_id", user_id)
79
+ context.attach(ctx)
80
+ logger.debug("Logfire user context set with user_id: %s", user_id)
81
+ except Exception as e:
82
+ logger.warning("Failed to set Logfire user context: %s", e)
83
+
84
+ logger.debug("Logfire observability configured successfully")
85
+ logger.debug("Token configured: %s", "Yes" if logfire_token else "No")
86
+ return True
87
+
88
+ except ImportError as e:
89
+ logger.warning("Logfire not available: %s", e)
90
+ return False
91
+ except Exception as e:
92
+ logger.warning("Failed to setup Logfire observability: %s", e)
93
+ return False
File without changes