foundry-mcp 0.8.22__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 foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Error data collection infrastructure for foundry-mcp.
|
|
2
|
+
|
|
3
|
+
Provides structured error capture, fingerprinting, and collection for
|
|
4
|
+
future introspection and analysis. This module focuses on data collection
|
|
5
|
+
only - analysis/insights are handled separately.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from foundry_mcp.core.error_collection import get_error_collector
|
|
9
|
+
|
|
10
|
+
collector = get_error_collector()
|
|
11
|
+
|
|
12
|
+
# Collect tool errors
|
|
13
|
+
try:
|
|
14
|
+
do_something()
|
|
15
|
+
except Exception as e:
|
|
16
|
+
collector.collect_tool_error(
|
|
17
|
+
tool_name="my-tool",
|
|
18
|
+
error=e,
|
|
19
|
+
input_params={"key": "value"},
|
|
20
|
+
duration_ms=42.5,
|
|
21
|
+
)
|
|
22
|
+
raise
|
|
23
|
+
|
|
24
|
+
# Collect AI provider errors
|
|
25
|
+
collector.collect_provider_error(
|
|
26
|
+
provider_id="gemini",
|
|
27
|
+
error=exc,
|
|
28
|
+
request_context={"workflow": "plan_review"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Query errors
|
|
32
|
+
records, cursor = collector.query(tool_name="my-tool", limit=10)
|
|
33
|
+
stats = collector.get_stats(group_by="fingerprint")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import hashlib
|
|
39
|
+
import logging
|
|
40
|
+
import re
|
|
41
|
+
import threading
|
|
42
|
+
import traceback
|
|
43
|
+
import uuid
|
|
44
|
+
from dataclasses import dataclass, asdict
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
47
|
+
|
|
48
|
+
from foundry_mcp.core.context import get_correlation_id
|
|
49
|
+
from foundry_mcp.core.observability import redact_sensitive_data
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from foundry_mcp.core.error_store import ErrorStore
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# ErrorRecord Dataclass
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ErrorRecord:
|
|
64
|
+
"""Rich error record for storage and analysis.
|
|
65
|
+
|
|
66
|
+
Captures comprehensive error context including classification,
|
|
67
|
+
timing, provider info (for AI errors), and aggregation metadata.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
id: Unique error identifier (err_<uuid>)
|
|
71
|
+
fingerprint: Computed signature for deduplication
|
|
72
|
+
error_code: Machine-readable error code (from ErrorCode enum)
|
|
73
|
+
error_type: Error category (from ErrorType enum)
|
|
74
|
+
tool_name: Name of the tool that generated the error
|
|
75
|
+
correlation_id: Request correlation ID for tracing
|
|
76
|
+
message: User-facing error message (sanitized)
|
|
77
|
+
exception_type: Python exception class name
|
|
78
|
+
stack_trace: Sanitized stack trace (server-side only)
|
|
79
|
+
provider_id: AI provider ID (for provider errors)
|
|
80
|
+
provider_model: AI model used (for provider errors)
|
|
81
|
+
provider_status: Provider status code (for provider errors)
|
|
82
|
+
input_summary: Redacted summary of input parameters
|
|
83
|
+
timestamp: ISO 8601 timestamp of error occurrence
|
|
84
|
+
duration_ms: Operation duration before failure
|
|
85
|
+
count: Occurrence count (for aggregated records)
|
|
86
|
+
first_seen: First occurrence timestamp
|
|
87
|
+
last_seen: Last occurrence timestamp
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Identity
|
|
91
|
+
id: str
|
|
92
|
+
fingerprint: str
|
|
93
|
+
|
|
94
|
+
# Classification
|
|
95
|
+
error_code: str
|
|
96
|
+
error_type: str
|
|
97
|
+
|
|
98
|
+
# Context
|
|
99
|
+
tool_name: str
|
|
100
|
+
correlation_id: str
|
|
101
|
+
|
|
102
|
+
# Error details
|
|
103
|
+
message: str
|
|
104
|
+
exception_type: Optional[str] = None
|
|
105
|
+
stack_trace: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
# Provider context (for AI errors)
|
|
108
|
+
provider_id: Optional[str] = None
|
|
109
|
+
provider_model: Optional[str] = None
|
|
110
|
+
provider_status: Optional[str] = None
|
|
111
|
+
|
|
112
|
+
# Input context (redacted)
|
|
113
|
+
input_summary: Optional[Dict[str, Any]] = None
|
|
114
|
+
|
|
115
|
+
# Timing
|
|
116
|
+
timestamp: str = ""
|
|
117
|
+
duration_ms: Optional[float] = None
|
|
118
|
+
|
|
119
|
+
# Aggregation support
|
|
120
|
+
count: int = 1
|
|
121
|
+
first_seen: Optional[str] = None
|
|
122
|
+
last_seen: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
"""Set defaults for timestamp fields."""
|
|
126
|
+
if not self.timestamp:
|
|
127
|
+
self.timestamp = datetime.utcnow().isoformat() + "Z"
|
|
128
|
+
if not self.first_seen:
|
|
129
|
+
self.first_seen = self.timestamp
|
|
130
|
+
if not self.last_seen:
|
|
131
|
+
self.last_seen = self.timestamp
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
134
|
+
"""Convert to dictionary for JSON serialization."""
|
|
135
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ErrorRecord":
|
|
139
|
+
"""Create ErrorRecord from dictionary."""
|
|
140
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# =============================================================================
|
|
144
|
+
# ErrorFingerprinter
|
|
145
|
+
# =============================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ErrorFingerprinter:
|
|
149
|
+
"""Generate consistent fingerprints for error deduplication.
|
|
150
|
+
|
|
151
|
+
Fingerprints capture the "signature" of an error for grouping:
|
|
152
|
+
- Same tool + same error_code + same exception_type = same fingerprint
|
|
153
|
+
- Provider errors include provider_id in fingerprint
|
|
154
|
+
- Message patterns are normalized (remove IDs, timestamps, etc.)
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
# Patterns to normalize in error messages
|
|
158
|
+
_NORMALIZE_PATTERNS = [
|
|
159
|
+
# UUIDs
|
|
160
|
+
(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "<UUID>"),
|
|
161
|
+
# Timestamps (ISO 8601)
|
|
162
|
+
(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?", "<TIMESTAMP>"),
|
|
163
|
+
# Unix timestamps
|
|
164
|
+
(r"\b\d{10,13}\b", "<UNIX_TS>"),
|
|
165
|
+
# File paths
|
|
166
|
+
(r"(/[\w\-.]+)+(\.\w+)?", "<PATH>"),
|
|
167
|
+
# Line numbers in stack traces
|
|
168
|
+
(r"line \d+", "line <N>"),
|
|
169
|
+
# Numeric IDs
|
|
170
|
+
(r"\b\d{5,}\b", "<ID>"),
|
|
171
|
+
# Correlation IDs
|
|
172
|
+
(r"(req|tool|task|err)_[a-f0-9]{12}", "<CORR_ID>"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
def __init__(self) -> None:
|
|
176
|
+
"""Initialize fingerprinter with compiled patterns."""
|
|
177
|
+
self._compiled_patterns = [
|
|
178
|
+
(re.compile(pattern, re.IGNORECASE), replacement)
|
|
179
|
+
for pattern, replacement in self._NORMALIZE_PATTERNS
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
def _normalize_message(self, message: str) -> str:
|
|
183
|
+
"""Normalize a message by removing dynamic content."""
|
|
184
|
+
normalized = message
|
|
185
|
+
for pattern, replacement in self._compiled_patterns:
|
|
186
|
+
normalized = pattern.sub(replacement, normalized)
|
|
187
|
+
return normalized.strip().lower()
|
|
188
|
+
|
|
189
|
+
def fingerprint(
|
|
190
|
+
self,
|
|
191
|
+
error_code: str,
|
|
192
|
+
error_type: str,
|
|
193
|
+
tool_name: str,
|
|
194
|
+
exception_type: Optional[str] = None,
|
|
195
|
+
message: Optional[str] = None,
|
|
196
|
+
provider_id: Optional[str] = None,
|
|
197
|
+
) -> str:
|
|
198
|
+
"""Generate a fingerprint based on error characteristics.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
error_code: Machine-readable error code
|
|
202
|
+
error_type: Error category
|
|
203
|
+
tool_name: Name of the tool
|
|
204
|
+
exception_type: Python exception class name
|
|
205
|
+
message: Error message (will be normalized)
|
|
206
|
+
provider_id: AI provider ID (for provider errors)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
SHA256 hash of normalized components (first 16 chars)
|
|
210
|
+
"""
|
|
211
|
+
components = [
|
|
212
|
+
tool_name,
|
|
213
|
+
error_code,
|
|
214
|
+
error_type,
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
if exception_type:
|
|
218
|
+
components.append(exception_type)
|
|
219
|
+
|
|
220
|
+
if provider_id:
|
|
221
|
+
components.append(f"provider:{provider_id}")
|
|
222
|
+
|
|
223
|
+
if message:
|
|
224
|
+
normalized_msg = self._normalize_message(message)
|
|
225
|
+
# Only include first 100 chars of normalized message
|
|
226
|
+
components.append(normalized_msg[:100])
|
|
227
|
+
|
|
228
|
+
fingerprint_input = "|".join(components)
|
|
229
|
+
hash_obj = hashlib.sha256(fingerprint_input.encode("utf-8"))
|
|
230
|
+
return hash_obj.hexdigest()[:16]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# =============================================================================
|
|
234
|
+
# ErrorCollector
|
|
235
|
+
# =============================================================================
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ErrorCollector:
|
|
239
|
+
"""Central error collection point with enrichment and storage.
|
|
240
|
+
|
|
241
|
+
Provides methods to collect tool errors and AI provider errors,
|
|
242
|
+
automatically enriching them with context (correlation ID, timing)
|
|
243
|
+
and storing them for later analysis.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
store: Optional["ErrorStore"] = None,
|
|
249
|
+
fingerprinter: Optional[ErrorFingerprinter] = None,
|
|
250
|
+
enabled: bool = True,
|
|
251
|
+
include_stack_traces: bool = True,
|
|
252
|
+
redact_inputs: bool = True,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Initialize the error collector.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
store: Error storage backend (lazy-loaded if None)
|
|
258
|
+
fingerprinter: Fingerprint generator (created if None)
|
|
259
|
+
enabled: Whether error collection is enabled
|
|
260
|
+
include_stack_traces: Whether to capture stack traces
|
|
261
|
+
redact_inputs: Whether to redact input parameters
|
|
262
|
+
"""
|
|
263
|
+
self._store = store
|
|
264
|
+
self._fingerprinter = fingerprinter or ErrorFingerprinter()
|
|
265
|
+
self._enabled = enabled
|
|
266
|
+
self._include_stack_traces = include_stack_traces
|
|
267
|
+
self._redact_inputs = redact_inputs
|
|
268
|
+
self._lock = threading.Lock()
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def store(self) -> "ErrorStore":
|
|
272
|
+
"""Get the error store, lazy-loading if necessary."""
|
|
273
|
+
if self._store is None:
|
|
274
|
+
from foundry_mcp.core.error_store import get_error_store
|
|
275
|
+
|
|
276
|
+
self._store = get_error_store()
|
|
277
|
+
return self._store
|
|
278
|
+
|
|
279
|
+
def is_enabled(self) -> bool:
|
|
280
|
+
"""Check if error collection is enabled."""
|
|
281
|
+
return self._enabled
|
|
282
|
+
|
|
283
|
+
def initialize(
|
|
284
|
+
self,
|
|
285
|
+
store: "ErrorStore",
|
|
286
|
+
config: Any,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Initialize the collector with a store and configuration.
|
|
289
|
+
|
|
290
|
+
Called by server initialization to set up the error collection
|
|
291
|
+
infrastructure with the configured storage backend.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
store: Error storage backend
|
|
295
|
+
config: ErrorCollectionConfig instance
|
|
296
|
+
"""
|
|
297
|
+
with self._lock:
|
|
298
|
+
self._store = store
|
|
299
|
+
self._enabled = config.enabled
|
|
300
|
+
self._include_stack_traces = config.include_stack_traces
|
|
301
|
+
self._redact_inputs = config.redact_inputs
|
|
302
|
+
logger.debug(
|
|
303
|
+
f"ErrorCollector initialized: enabled={self._enabled}, "
|
|
304
|
+
f"include_stack_traces={self._include_stack_traces}, "
|
|
305
|
+
f"redact_inputs={self._redact_inputs}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _generate_id(self) -> str:
|
|
309
|
+
"""Generate a unique error ID."""
|
|
310
|
+
return f"err_{uuid.uuid4().hex[:12]}"
|
|
311
|
+
|
|
312
|
+
def _extract_exception_info(
|
|
313
|
+
self, error: Exception
|
|
314
|
+
) -> Tuple[str, Optional[str]]:
|
|
315
|
+
"""Extract exception type and sanitized stack trace.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
error: The exception to extract info from
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Tuple of (exception_type, sanitized_stack_trace)
|
|
322
|
+
"""
|
|
323
|
+
exception_type = type(error).__name__
|
|
324
|
+
|
|
325
|
+
stack_trace = None
|
|
326
|
+
if self._include_stack_traces:
|
|
327
|
+
try:
|
|
328
|
+
# Get the full traceback
|
|
329
|
+
tb_lines = traceback.format_exception(
|
|
330
|
+
type(error), error, error.__traceback__
|
|
331
|
+
)
|
|
332
|
+
raw_trace = "".join(tb_lines)
|
|
333
|
+
# Redact sensitive data from stack trace
|
|
334
|
+
stack_trace = redact_sensitive_data(raw_trace)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
return exception_type, stack_trace
|
|
339
|
+
|
|
340
|
+
# Sensitive key name patterns that should always be redacted
|
|
341
|
+
_SENSITIVE_KEY_PATTERNS = [
|
|
342
|
+
"api_key", "apikey", "api-key",
|
|
343
|
+
"password", "passwd", "pwd",
|
|
344
|
+
"secret", "token", "auth",
|
|
345
|
+
"credential", "private", "key",
|
|
346
|
+
"bearer", "access_token", "refresh_token",
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
def _is_sensitive_key(self, key: str) -> bool:
|
|
350
|
+
"""Check if a parameter key name indicates sensitive data."""
|
|
351
|
+
key_lower = key.lower()
|
|
352
|
+
return any(pattern in key_lower for pattern in self._SENSITIVE_KEY_PATTERNS)
|
|
353
|
+
|
|
354
|
+
def _redact_input_params(
|
|
355
|
+
self, params: Optional[Dict[str, Any]]
|
|
356
|
+
) -> Optional[Dict[str, Any]]:
|
|
357
|
+
"""Redact sensitive data from input parameters.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
params: Input parameters dictionary
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Redacted parameters or None
|
|
364
|
+
"""
|
|
365
|
+
if not params or not self._redact_inputs:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
# Create a summary with redacted values
|
|
370
|
+
summary: Dict[str, Any] = {}
|
|
371
|
+
for key, value in params.items():
|
|
372
|
+
if value is None:
|
|
373
|
+
summary[key] = None
|
|
374
|
+
elif self._is_sensitive_key(key):
|
|
375
|
+
# Always redact values for sensitive key names
|
|
376
|
+
summary[key] = "<REDACTED>"
|
|
377
|
+
elif isinstance(value, str):
|
|
378
|
+
# Redact string values using pattern matching
|
|
379
|
+
redacted = redact_sensitive_data(value)
|
|
380
|
+
# Truncate long strings
|
|
381
|
+
if len(redacted) > 100:
|
|
382
|
+
redacted = redacted[:100] + "..."
|
|
383
|
+
summary[key] = redacted
|
|
384
|
+
elif isinstance(value, (int, float, bool)):
|
|
385
|
+
summary[key] = value
|
|
386
|
+
elif isinstance(value, (list, tuple)):
|
|
387
|
+
summary[key] = f"<{type(value).__name__}[{len(value)}]>"
|
|
388
|
+
elif isinstance(value, dict):
|
|
389
|
+
summary[key] = f"<dict[{len(value)}]>"
|
|
390
|
+
else:
|
|
391
|
+
summary[key] = f"<{type(value).__name__}>"
|
|
392
|
+
return summary
|
|
393
|
+
except Exception:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
def _map_exception_to_codes(
|
|
397
|
+
self, error: Exception
|
|
398
|
+
) -> Tuple[str, str]:
|
|
399
|
+
"""Map an exception to error_code and error_type.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
error: The exception to map
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Tuple of (error_code, error_type)
|
|
406
|
+
"""
|
|
407
|
+
# Import here to avoid circular imports
|
|
408
|
+
from foundry_mcp.core.responses import ErrorCode, ErrorType
|
|
409
|
+
|
|
410
|
+
exception_name = type(error).__name__
|
|
411
|
+
|
|
412
|
+
# Map common exceptions to error codes
|
|
413
|
+
exception_mapping: Dict[str, Tuple[str, str]] = {
|
|
414
|
+
"FileNotFoundError": (ErrorCode.NOT_FOUND.value, ErrorType.NOT_FOUND.value),
|
|
415
|
+
"PermissionError": (ErrorCode.FORBIDDEN.value, ErrorType.AUTHORIZATION.value),
|
|
416
|
+
"ValueError": (ErrorCode.VALIDATION_ERROR.value, ErrorType.VALIDATION.value),
|
|
417
|
+
"TypeError": (ErrorCode.VALIDATION_ERROR.value, ErrorType.VALIDATION.value),
|
|
418
|
+
"KeyError": (ErrorCode.NOT_FOUND.value, ErrorType.NOT_FOUND.value),
|
|
419
|
+
"TimeoutError": (ErrorCode.UNAVAILABLE.value, ErrorType.UNAVAILABLE.value),
|
|
420
|
+
"ConnectionError": (ErrorCode.UNAVAILABLE.value, ErrorType.UNAVAILABLE.value),
|
|
421
|
+
"JSONDecodeError": (ErrorCode.INVALID_FORMAT.value, ErrorType.VALIDATION.value),
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if exception_name in exception_mapping:
|
|
425
|
+
return exception_mapping[exception_name]
|
|
426
|
+
|
|
427
|
+
# Check for provider-specific exceptions
|
|
428
|
+
if "Provider" in exception_name or "AI" in exception_name:
|
|
429
|
+
if "Timeout" in exception_name:
|
|
430
|
+
return (ErrorCode.AI_PROVIDER_TIMEOUT.value, ErrorType.AI_PROVIDER.value)
|
|
431
|
+
elif "Unavailable" in exception_name:
|
|
432
|
+
return (ErrorCode.AI_NO_PROVIDER.value, ErrorType.AI_PROVIDER.value)
|
|
433
|
+
else:
|
|
434
|
+
return (ErrorCode.AI_PROVIDER_ERROR.value, ErrorType.AI_PROVIDER.value)
|
|
435
|
+
|
|
436
|
+
# Default to internal error
|
|
437
|
+
return (ErrorCode.INTERNAL_ERROR.value, ErrorType.INTERNAL.value)
|
|
438
|
+
|
|
439
|
+
def collect_tool_error(
|
|
440
|
+
self,
|
|
441
|
+
tool_name: str,
|
|
442
|
+
error: Exception,
|
|
443
|
+
error_code: Optional[str] = None,
|
|
444
|
+
error_type: Optional[str] = None,
|
|
445
|
+
input_params: Optional[Dict[str, Any]] = None,
|
|
446
|
+
duration_ms: Optional[float] = None,
|
|
447
|
+
) -> Optional[ErrorRecord]:
|
|
448
|
+
"""Collect and store a tool error with full context.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
tool_name: Name of the tool that generated the error
|
|
452
|
+
error: The exception that occurred
|
|
453
|
+
error_code: Override error code (auto-detected if None)
|
|
454
|
+
error_type: Override error type (auto-detected if None)
|
|
455
|
+
input_params: Tool input parameters (will be redacted)
|
|
456
|
+
duration_ms: Operation duration before failure
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
The stored ErrorRecord, or None if collection failed/disabled
|
|
460
|
+
"""
|
|
461
|
+
if not self._enabled:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
# Auto-detect error codes if not provided
|
|
466
|
+
if error_code is None or error_type is None:
|
|
467
|
+
detected_code, detected_type = self._map_exception_to_codes(error)
|
|
468
|
+
error_code = error_code or detected_code
|
|
469
|
+
error_type = error_type or detected_type
|
|
470
|
+
|
|
471
|
+
# Extract exception info
|
|
472
|
+
exception_type, stack_trace = self._extract_exception_info(error)
|
|
473
|
+
|
|
474
|
+
# Get correlation ID from context
|
|
475
|
+
correlation_id = get_correlation_id() or "unknown"
|
|
476
|
+
|
|
477
|
+
# Generate fingerprint
|
|
478
|
+
fingerprint = self._fingerprinter.fingerprint(
|
|
479
|
+
error_code=error_code,
|
|
480
|
+
error_type=error_type,
|
|
481
|
+
tool_name=tool_name,
|
|
482
|
+
exception_type=exception_type,
|
|
483
|
+
message=str(error),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Create error record
|
|
487
|
+
record = ErrorRecord(
|
|
488
|
+
id=self._generate_id(),
|
|
489
|
+
fingerprint=fingerprint,
|
|
490
|
+
error_code=error_code,
|
|
491
|
+
error_type=error_type,
|
|
492
|
+
tool_name=tool_name,
|
|
493
|
+
correlation_id=correlation_id,
|
|
494
|
+
message=str(error),
|
|
495
|
+
exception_type=exception_type,
|
|
496
|
+
stack_trace=stack_trace,
|
|
497
|
+
input_summary=self._redact_input_params(input_params),
|
|
498
|
+
duration_ms=duration_ms,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Store the record
|
|
502
|
+
self.store.append(record)
|
|
503
|
+
|
|
504
|
+
logger.debug(
|
|
505
|
+
"Collected tool error",
|
|
506
|
+
extra={
|
|
507
|
+
"error_id": record.id,
|
|
508
|
+
"fingerprint": record.fingerprint,
|
|
509
|
+
"tool_name": tool_name,
|
|
510
|
+
"error_code": error_code,
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
return record
|
|
515
|
+
|
|
516
|
+
except Exception as e:
|
|
517
|
+
logger.warning(f"Failed to collect tool error: {e}")
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def collect_provider_error(
|
|
521
|
+
self,
|
|
522
|
+
provider_id: str,
|
|
523
|
+
error: Optional[Exception] = None,
|
|
524
|
+
provider_result: Optional[Any] = None,
|
|
525
|
+
request_context: Optional[Dict[str, Any]] = None,
|
|
526
|
+
) -> Optional[ErrorRecord]:
|
|
527
|
+
"""Collect and store an AI provider error.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
provider_id: AI provider identifier
|
|
531
|
+
error: The exception that occurred (if any)
|
|
532
|
+
provider_result: ProviderResult object (if available)
|
|
533
|
+
request_context: Request context (workflow, prompt_id, etc.)
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
The stored ErrorRecord, or None if collection failed/disabled
|
|
537
|
+
"""
|
|
538
|
+
if not self._enabled:
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
# Import here to avoid circular imports
|
|
543
|
+
from foundry_mcp.core.responses import ErrorCode, ErrorType
|
|
544
|
+
|
|
545
|
+
# Determine error details
|
|
546
|
+
if error:
|
|
547
|
+
exception_type, stack_trace = self._extract_exception_info(error)
|
|
548
|
+
message = str(error)
|
|
549
|
+
error_code, error_type = self._map_exception_to_codes(error)
|
|
550
|
+
else:
|
|
551
|
+
exception_type = None
|
|
552
|
+
stack_trace = None
|
|
553
|
+
message = "Provider error"
|
|
554
|
+
error_code = ErrorCode.AI_PROVIDER_ERROR.value
|
|
555
|
+
error_type = ErrorType.AI_PROVIDER.value
|
|
556
|
+
|
|
557
|
+
# Extract provider result info if available
|
|
558
|
+
provider_model = None
|
|
559
|
+
provider_status = None
|
|
560
|
+
if provider_result:
|
|
561
|
+
provider_model = getattr(provider_result, "model", None)
|
|
562
|
+
if hasattr(provider_result, "status"):
|
|
563
|
+
provider_status = str(provider_result.status)
|
|
564
|
+
# Include stderr in message if present
|
|
565
|
+
stderr = getattr(provider_result, "stderr", None)
|
|
566
|
+
if stderr:
|
|
567
|
+
message = f"{message} - {stderr[:200]}"
|
|
568
|
+
|
|
569
|
+
# Get correlation ID
|
|
570
|
+
correlation_id = get_correlation_id() or "unknown"
|
|
571
|
+
|
|
572
|
+
# Determine tool name from context
|
|
573
|
+
tool_name = "ai-consultation"
|
|
574
|
+
if request_context and "workflow" in request_context:
|
|
575
|
+
tool_name = f"ai-consultation:{request_context['workflow']}"
|
|
576
|
+
|
|
577
|
+
# Generate fingerprint (includes provider_id)
|
|
578
|
+
fingerprint = self._fingerprinter.fingerprint(
|
|
579
|
+
error_code=error_code,
|
|
580
|
+
error_type=error_type,
|
|
581
|
+
tool_name=tool_name,
|
|
582
|
+
exception_type=exception_type,
|
|
583
|
+
message=message,
|
|
584
|
+
provider_id=provider_id,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Create error record
|
|
588
|
+
record = ErrorRecord(
|
|
589
|
+
id=self._generate_id(),
|
|
590
|
+
fingerprint=fingerprint,
|
|
591
|
+
error_code=error_code,
|
|
592
|
+
error_type=error_type,
|
|
593
|
+
tool_name=tool_name,
|
|
594
|
+
correlation_id=correlation_id,
|
|
595
|
+
message=message,
|
|
596
|
+
exception_type=exception_type,
|
|
597
|
+
stack_trace=stack_trace,
|
|
598
|
+
provider_id=provider_id,
|
|
599
|
+
provider_model=provider_model,
|
|
600
|
+
provider_status=provider_status,
|
|
601
|
+
input_summary=self._redact_input_params(request_context),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Store the record
|
|
605
|
+
self.store.append(record)
|
|
606
|
+
|
|
607
|
+
logger.debug(
|
|
608
|
+
"Collected provider error",
|
|
609
|
+
extra={
|
|
610
|
+
"error_id": record.id,
|
|
611
|
+
"fingerprint": record.fingerprint,
|
|
612
|
+
"provider_id": provider_id,
|
|
613
|
+
"error_code": error_code,
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return record
|
|
618
|
+
|
|
619
|
+
except Exception as e:
|
|
620
|
+
logger.warning(f"Failed to collect provider error: {e}")
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
def query(
|
|
624
|
+
self,
|
|
625
|
+
tool_name: Optional[str] = None,
|
|
626
|
+
error_code: Optional[str] = None,
|
|
627
|
+
error_type: Optional[str] = None,
|
|
628
|
+
fingerprint: Optional[str] = None,
|
|
629
|
+
provider_id: Optional[str] = None,
|
|
630
|
+
since: Optional[str] = None,
|
|
631
|
+
until: Optional[str] = None,
|
|
632
|
+
limit: int = 100,
|
|
633
|
+
offset: int = 0,
|
|
634
|
+
) -> List[ErrorRecord]:
|
|
635
|
+
"""Query collected errors with filtering.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
tool_name: Filter by tool name
|
|
639
|
+
error_code: Filter by error code
|
|
640
|
+
error_type: Filter by error type
|
|
641
|
+
fingerprint: Filter by fingerprint
|
|
642
|
+
provider_id: Filter by provider ID
|
|
643
|
+
since: ISO 8601 timestamp - filter errors after this time
|
|
644
|
+
until: ISO 8601 timestamp - filter errors before this time
|
|
645
|
+
limit: Maximum records to return
|
|
646
|
+
offset: Number of records to skip
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
List of matching ErrorRecord objects
|
|
650
|
+
"""
|
|
651
|
+
return self.store.query(
|
|
652
|
+
tool_name=tool_name,
|
|
653
|
+
error_code=error_code,
|
|
654
|
+
error_type=error_type,
|
|
655
|
+
fingerprint=fingerprint,
|
|
656
|
+
provider_id=provider_id,
|
|
657
|
+
since=since,
|
|
658
|
+
until=until,
|
|
659
|
+
limit=limit,
|
|
660
|
+
offset=offset,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def get(self, error_id: str) -> Optional[ErrorRecord]:
|
|
664
|
+
"""Get a specific error record by ID.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
error_id: Error record ID
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
ErrorRecord or None if not found
|
|
671
|
+
"""
|
|
672
|
+
return self.store.get(error_id)
|
|
673
|
+
|
|
674
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
675
|
+
"""Get aggregated error statistics.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
Statistics dictionary with total_errors, unique_patterns,
|
|
679
|
+
by_tool, by_error_code, and top_patterns
|
|
680
|
+
"""
|
|
681
|
+
return self.store.get_stats()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# =============================================================================
|
|
685
|
+
# Singleton Instance
|
|
686
|
+
# =============================================================================
|
|
687
|
+
|
|
688
|
+
_collector: Optional[ErrorCollector] = None
|
|
689
|
+
_collector_lock = threading.Lock()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def get_error_collector() -> ErrorCollector:
|
|
693
|
+
"""Get the singleton ErrorCollector instance.
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
ErrorCollector singleton instance
|
|
697
|
+
"""
|
|
698
|
+
global _collector
|
|
699
|
+
|
|
700
|
+
if _collector is None:
|
|
701
|
+
with _collector_lock:
|
|
702
|
+
if _collector is None:
|
|
703
|
+
_collector = ErrorCollector()
|
|
704
|
+
|
|
705
|
+
return _collector
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def reset_error_collector() -> None:
|
|
709
|
+
"""Reset the singleton error collector (mainly for testing)."""
|
|
710
|
+
global _collector
|
|
711
|
+
with _collector_lock:
|
|
712
|
+
_collector = None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# =============================================================================
|
|
716
|
+
# Exports
|
|
717
|
+
# =============================================================================
|
|
718
|
+
|
|
719
|
+
__all__ = [
|
|
720
|
+
# Data classes
|
|
721
|
+
"ErrorRecord",
|
|
722
|
+
# Fingerprinting
|
|
723
|
+
"ErrorFingerprinter",
|
|
724
|
+
# Collection
|
|
725
|
+
"ErrorCollector",
|
|
726
|
+
"get_error_collector",
|
|
727
|
+
"reset_error_collector",
|
|
728
|
+
]
|