mcp-hangar 0.2.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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Langfuse adapter for MCP Hangar observability.
|
|
2
|
+
|
|
3
|
+
This module provides thread-safe integration with Langfuse for tracing
|
|
4
|
+
MCP tool invocations, recording health scores, and correlating traces
|
|
5
|
+
with external LLM applications.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
config = LangfuseConfig(
|
|
9
|
+
public_key="pk-...",
|
|
10
|
+
secret_key="sk-...",
|
|
11
|
+
)
|
|
12
|
+
adapter = LangfuseObservabilityAdapter(config)
|
|
13
|
+
|
|
14
|
+
span = adapter.start_tool_span("math", "add", {"a": 1, "b": 2})
|
|
15
|
+
try:
|
|
16
|
+
result = invoke_tool(...)
|
|
17
|
+
span.end_success(result)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
span.end_error(e)
|
|
20
|
+
|
|
21
|
+
Note:
|
|
22
|
+
Requires `langfuse` package. Install with:
|
|
23
|
+
pip install mcp-hangar[observability]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any
|
|
31
|
+
import uuid
|
|
32
|
+
|
|
33
|
+
from ...application.ports.observability import ObservabilityPort, SpanHandle, TraceContext
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Lazy import to handle optional dependency
|
|
38
|
+
_langfuse_available = False
|
|
39
|
+
_Langfuse: type | None = None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from langfuse import Langfuse as _LangfuseClient
|
|
43
|
+
|
|
44
|
+
_langfuse_available = True
|
|
45
|
+
_Langfuse = _LangfuseClient
|
|
46
|
+
except ImportError:
|
|
47
|
+
_LangfuseClient = None # type: ignore[misc, assignment]
|
|
48
|
+
logger.debug("Langfuse not installed. Install with: pip install mcp-hangar[observability]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class LangfuseConfig:
|
|
53
|
+
"""Configuration for Langfuse integration.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
enabled: Whether Langfuse tracing is active.
|
|
57
|
+
public_key: Langfuse public API key.
|
|
58
|
+
secret_key: Langfuse secret API key.
|
|
59
|
+
host: Langfuse host URL.
|
|
60
|
+
flush_interval_s: Interval for background flushes.
|
|
61
|
+
sample_rate: Fraction of traces to sample (0.0 to 1.0).
|
|
62
|
+
scrub_inputs: Whether to redact sensitive input data.
|
|
63
|
+
scrub_outputs: Whether to redact sensitive output data.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
enabled: bool = True
|
|
67
|
+
public_key: str = ""
|
|
68
|
+
secret_key: str = ""
|
|
69
|
+
host: str = "https://cloud.langfuse.com"
|
|
70
|
+
flush_interval_s: float = 1.0
|
|
71
|
+
sample_rate: float = 1.0
|
|
72
|
+
scrub_inputs: bool = False
|
|
73
|
+
scrub_outputs: bool = False
|
|
74
|
+
|
|
75
|
+
def validate(self) -> list[str]:
|
|
76
|
+
"""Validate configuration, return list of errors."""
|
|
77
|
+
errors = []
|
|
78
|
+
if self.enabled:
|
|
79
|
+
if not self.public_key:
|
|
80
|
+
errors.append("langfuse.public_key is required when enabled")
|
|
81
|
+
if not self.secret_key:
|
|
82
|
+
errors.append("langfuse.secret_key is required when enabled")
|
|
83
|
+
if not 0.0 <= self.sample_rate <= 1.0:
|
|
84
|
+
errors.append("langfuse.sample_rate must be between 0.0 and 1.0")
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LangfuseAdapter:
|
|
89
|
+
"""Low-level thread-safe wrapper around Langfuse SDK.
|
|
90
|
+
|
|
91
|
+
This adapter handles SDK initialization, connection management,
|
|
92
|
+
and provides thread-safe access to Langfuse operations.
|
|
93
|
+
|
|
94
|
+
Compatible with Langfuse SDK v3.x API.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, config: LangfuseConfig) -> None:
|
|
98
|
+
"""Initialize Langfuse adapter.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config: Langfuse configuration.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ImportError: If langfuse package is not installed.
|
|
105
|
+
ValueError: If configuration is invalid.
|
|
106
|
+
"""
|
|
107
|
+
self._config = config
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
self._client: Any = None
|
|
110
|
+
|
|
111
|
+
if not config.enabled:
|
|
112
|
+
logger.info("Langfuse integration disabled by configuration")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if not _langfuse_available:
|
|
116
|
+
raise ImportError("Langfuse package not installed. Install with: pip install mcp-hangar[observability]")
|
|
117
|
+
|
|
118
|
+
errors = config.validate()
|
|
119
|
+
if errors:
|
|
120
|
+
raise ValueError(f"Invalid Langfuse config: {'; '.join(errors)}")
|
|
121
|
+
|
|
122
|
+
self._client = _Langfuse(
|
|
123
|
+
public_key=config.public_key,
|
|
124
|
+
secret_key=config.secret_key,
|
|
125
|
+
host=config.host,
|
|
126
|
+
)
|
|
127
|
+
logger.info("Langfuse adapter initialized", extra={"host": config.host})
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_enabled(self) -> bool:
|
|
131
|
+
"""Check if Langfuse is enabled and initialized."""
|
|
132
|
+
return self._config.enabled and self._client is not None
|
|
133
|
+
|
|
134
|
+
def start_span(
|
|
135
|
+
self,
|
|
136
|
+
name: str,
|
|
137
|
+
trace_id: str | None = None,
|
|
138
|
+
input_data: dict[str, Any] | None = None,
|
|
139
|
+
metadata: dict[str, Any] | None = None,
|
|
140
|
+
user_id: str | None = None,
|
|
141
|
+
session_id: str | None = None,
|
|
142
|
+
) -> tuple[Any, str]:
|
|
143
|
+
"""Start a new span (creates trace automatically in v3 API).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name: Span name.
|
|
147
|
+
trace_id: Optional trace ID for correlation.
|
|
148
|
+
input_data: Input data for the span.
|
|
149
|
+
metadata: Optional metadata.
|
|
150
|
+
user_id: Optional user ID for attribution.
|
|
151
|
+
session_id: Optional session ID for grouping.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Tuple of (Langfuse span object, trace_id) or (None, "") if disabled.
|
|
155
|
+
"""
|
|
156
|
+
if not self.is_enabled:
|
|
157
|
+
return None, ""
|
|
158
|
+
|
|
159
|
+
input_to_send = input_data
|
|
160
|
+
if self._config.scrub_inputs and input_data:
|
|
161
|
+
input_to_send = {"scrubbed": True, "keys": list(input_data.keys())}
|
|
162
|
+
|
|
163
|
+
# Generate or use provided trace_id
|
|
164
|
+
# Langfuse v3 requires 32 lowercase hex char trace id
|
|
165
|
+
if trace_id:
|
|
166
|
+
# Convert UUID format to hex if needed
|
|
167
|
+
effective_trace_id = trace_id.replace("-", "").lower()[:32]
|
|
168
|
+
if len(effective_trace_id) < 32:
|
|
169
|
+
effective_trace_id = effective_trace_id.ljust(32, "0")
|
|
170
|
+
else:
|
|
171
|
+
effective_trace_id = uuid.uuid4().hex
|
|
172
|
+
|
|
173
|
+
# Build trace_context for Langfuse v3 API
|
|
174
|
+
try:
|
|
175
|
+
from langfuse.types import TraceContext as LangfuseTraceContext
|
|
176
|
+
|
|
177
|
+
trace_context = LangfuseTraceContext(
|
|
178
|
+
trace_id=effective_trace_id,
|
|
179
|
+
user_id=user_id,
|
|
180
|
+
session_id=session_id,
|
|
181
|
+
)
|
|
182
|
+
except ImportError:
|
|
183
|
+
trace_context = None
|
|
184
|
+
|
|
185
|
+
with self._lock:
|
|
186
|
+
span = self._client.start_span(
|
|
187
|
+
name=name,
|
|
188
|
+
trace_context=trace_context,
|
|
189
|
+
input=input_to_send,
|
|
190
|
+
metadata=metadata or {},
|
|
191
|
+
)
|
|
192
|
+
return span, effective_trace_id
|
|
193
|
+
|
|
194
|
+
def end_span(
|
|
195
|
+
self,
|
|
196
|
+
span: Any,
|
|
197
|
+
output: Any = None,
|
|
198
|
+
level: str = "DEFAULT",
|
|
199
|
+
status_message: str | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""End a span with output.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
span: Span object to end.
|
|
205
|
+
output: Output data.
|
|
206
|
+
level: Log level (DEFAULT, DEBUG, WARNING, ERROR).
|
|
207
|
+
status_message: Optional status message.
|
|
208
|
+
"""
|
|
209
|
+
if not self.is_enabled or span is None:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
output_to_send = output
|
|
213
|
+
if self._config.scrub_outputs and output is not None:
|
|
214
|
+
if isinstance(output, dict):
|
|
215
|
+
output_to_send = {"scrubbed": True, "keys": list(output.keys())}
|
|
216
|
+
else:
|
|
217
|
+
output_to_send = {"scrubbed": True, "type": type(output).__name__}
|
|
218
|
+
|
|
219
|
+
with self._lock:
|
|
220
|
+
# Update span with output before ending
|
|
221
|
+
span.update(
|
|
222
|
+
output=output_to_send,
|
|
223
|
+
level=level,
|
|
224
|
+
status_message=status_message,
|
|
225
|
+
)
|
|
226
|
+
span.end()
|
|
227
|
+
|
|
228
|
+
def create_score(
|
|
229
|
+
self,
|
|
230
|
+
trace_id: str,
|
|
231
|
+
name: str,
|
|
232
|
+
value: float,
|
|
233
|
+
comment: str | None = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Record a score on a trace.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
trace_id: Trace ID.
|
|
239
|
+
name: Score name.
|
|
240
|
+
value: Score value.
|
|
241
|
+
comment: Optional comment.
|
|
242
|
+
"""
|
|
243
|
+
if not self.is_enabled:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._client.create_score(
|
|
248
|
+
trace_id=trace_id,
|
|
249
|
+
name=name,
|
|
250
|
+
value=value,
|
|
251
|
+
comment=comment,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def flush(self) -> None:
|
|
255
|
+
"""Flush pending events to Langfuse."""
|
|
256
|
+
if not self.is_enabled:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
with self._lock:
|
|
260
|
+
self._client.flush()
|
|
261
|
+
|
|
262
|
+
def shutdown(self) -> None:
|
|
263
|
+
"""Shutdown with final flush."""
|
|
264
|
+
if not self.is_enabled:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
logger.info("Shutting down Langfuse adapter")
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._client.shutdown()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class LangfuseSpanHandle(SpanHandle):
|
|
273
|
+
"""Span handle implementation for Langfuse.
|
|
274
|
+
|
|
275
|
+
Tracks timing and manages span lifecycle with proper
|
|
276
|
+
success/error handling.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
adapter: LangfuseAdapter,
|
|
282
|
+
span: Any,
|
|
283
|
+
trace_id: str,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Initialize span handle.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
adapter: Langfuse adapter instance.
|
|
289
|
+
span: Span object.
|
|
290
|
+
trace_id: Trace ID for scoring.
|
|
291
|
+
"""
|
|
292
|
+
self._adapter = adapter
|
|
293
|
+
self._span = span
|
|
294
|
+
self._trace_id = trace_id
|
|
295
|
+
self._start_time = time.perf_counter()
|
|
296
|
+
self._ended = False
|
|
297
|
+
|
|
298
|
+
def end_success(self, output: Any) -> None:
|
|
299
|
+
"""End span with successful outcome.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
output: Result data.
|
|
303
|
+
"""
|
|
304
|
+
if self._ended:
|
|
305
|
+
logger.warning("Span already ended, ignoring duplicate end_success call")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
self._ended = True
|
|
309
|
+
duration_ms = (time.perf_counter() - self._start_time) * 1000
|
|
310
|
+
|
|
311
|
+
self._adapter.end_span(
|
|
312
|
+
self._span,
|
|
313
|
+
output=output,
|
|
314
|
+
level="DEFAULT",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Record latency as score
|
|
318
|
+
self._adapter.create_score(
|
|
319
|
+
trace_id=self._trace_id,
|
|
320
|
+
name="tool_latency_ms",
|
|
321
|
+
value=duration_ms,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def end_error(self, error: Exception) -> None:
|
|
325
|
+
"""End span with error.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
error: The exception that occurred.
|
|
329
|
+
"""
|
|
330
|
+
if self._ended:
|
|
331
|
+
logger.warning("Span already ended, ignoring duplicate end_error call")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
self._ended = True
|
|
335
|
+
duration_ms = (time.perf_counter() - self._start_time) * 1000
|
|
336
|
+
|
|
337
|
+
self._adapter.end_span(
|
|
338
|
+
self._span,
|
|
339
|
+
output={"error": str(error), "type": type(error).__name__},
|
|
340
|
+
level="ERROR",
|
|
341
|
+
status_message=f"Tool invocation failed: {error}",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Record failure score
|
|
345
|
+
self._adapter.create_score(
|
|
346
|
+
trace_id=self._trace_id,
|
|
347
|
+
name="tool_success",
|
|
348
|
+
value=0.0,
|
|
349
|
+
comment=str(error),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
self._adapter.create_score(
|
|
353
|
+
trace_id=self._trace_id,
|
|
354
|
+
name="tool_latency_ms",
|
|
355
|
+
value=duration_ms,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def set_metadata(self, key: str, value: Any) -> None:
|
|
359
|
+
"""Add metadata to span.
|
|
360
|
+
|
|
361
|
+
Note: Langfuse spans don't support updating metadata after creation.
|
|
362
|
+
This logs a warning and stores for debugging.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
key: Metadata key.
|
|
366
|
+
value: Metadata value.
|
|
367
|
+
"""
|
|
368
|
+
logger.debug(
|
|
369
|
+
"Span metadata update requested (not supported by Langfuse)",
|
|
370
|
+
extra={"key": key, "value": value},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class LangfuseObservabilityAdapter(ObservabilityPort):
|
|
375
|
+
"""Observability port implementation using Langfuse.
|
|
376
|
+
|
|
377
|
+
Provides full tracing and scoring capabilities for MCP tool
|
|
378
|
+
invocations, integrating with the Langfuse observability platform.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(self, config: LangfuseConfig) -> None:
|
|
382
|
+
"""Initialize adapter.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
config: Langfuse configuration.
|
|
386
|
+
"""
|
|
387
|
+
self._config = config
|
|
388
|
+
self._adapter = LangfuseAdapter(config)
|
|
389
|
+
self._sample_lock = threading.Lock()
|
|
390
|
+
|
|
391
|
+
def _should_sample(self) -> bool:
|
|
392
|
+
"""Determine if this trace should be sampled."""
|
|
393
|
+
if self._config.sample_rate >= 1.0:
|
|
394
|
+
return True
|
|
395
|
+
if self._config.sample_rate <= 0.0:
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
import random
|
|
399
|
+
|
|
400
|
+
with self._sample_lock:
|
|
401
|
+
return random.random() < self._config.sample_rate
|
|
402
|
+
|
|
403
|
+
def start_tool_span(
|
|
404
|
+
self,
|
|
405
|
+
provider_name: str,
|
|
406
|
+
tool_name: str,
|
|
407
|
+
input_params: dict[str, Any],
|
|
408
|
+
trace_context: TraceContext | None = None,
|
|
409
|
+
) -> SpanHandle:
|
|
410
|
+
"""Start a traced span for tool invocation.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
provider_name: Provider name.
|
|
414
|
+
tool_name: Tool name.
|
|
415
|
+
input_params: Tool input parameters.
|
|
416
|
+
trace_context: Optional trace context for correlation.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Span handle for managing the span lifecycle.
|
|
420
|
+
"""
|
|
421
|
+
if not self._adapter.is_enabled or not self._should_sample():
|
|
422
|
+
from ...application.ports.observability import NullSpanHandle
|
|
423
|
+
|
|
424
|
+
return NullSpanHandle()
|
|
425
|
+
|
|
426
|
+
span, trace_id = self._adapter.start_span(
|
|
427
|
+
name=f"mcp/{provider_name}/{tool_name}",
|
|
428
|
+
trace_id=trace_context.trace_id if trace_context else None,
|
|
429
|
+
input_data=input_params,
|
|
430
|
+
metadata={
|
|
431
|
+
"provider": provider_name,
|
|
432
|
+
"tool": tool_name,
|
|
433
|
+
"mcp_hangar": True,
|
|
434
|
+
},
|
|
435
|
+
user_id=trace_context.user_id if trace_context else None,
|
|
436
|
+
session_id=trace_context.session_id if trace_context else None,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Record success score at start (will be updated on error)
|
|
440
|
+
self._adapter.create_score(
|
|
441
|
+
trace_id=trace_id,
|
|
442
|
+
name="tool_success",
|
|
443
|
+
value=1.0,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return LangfuseSpanHandle(
|
|
447
|
+
adapter=self._adapter,
|
|
448
|
+
span=span,
|
|
449
|
+
trace_id=trace_id,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def record_score(
|
|
453
|
+
self,
|
|
454
|
+
trace_id: str,
|
|
455
|
+
name: str,
|
|
456
|
+
value: float,
|
|
457
|
+
comment: str | None = None,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Record a score on a trace.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
trace_id: Trace ID.
|
|
463
|
+
name: Score name.
|
|
464
|
+
value: Score value.
|
|
465
|
+
comment: Optional comment.
|
|
466
|
+
"""
|
|
467
|
+
self._adapter.create_score(
|
|
468
|
+
trace_id=trace_id,
|
|
469
|
+
name=name,
|
|
470
|
+
value=value,
|
|
471
|
+
comment=comment,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def record_health_check(
|
|
475
|
+
self,
|
|
476
|
+
provider_name: str,
|
|
477
|
+
healthy: bool,
|
|
478
|
+
latency_ms: float,
|
|
479
|
+
trace_id: str | None = None,
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Record provider health check result.
|
|
482
|
+
|
|
483
|
+
If no trace_id is provided, creates a standalone span for the
|
|
484
|
+
health check event.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
provider_name: Provider name.
|
|
488
|
+
healthy: Whether the check passed.
|
|
489
|
+
latency_ms: Check latency in milliseconds.
|
|
490
|
+
trace_id: Optional trace to attach to.
|
|
491
|
+
"""
|
|
492
|
+
if not self._adapter.is_enabled:
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
effective_trace_id = trace_id
|
|
496
|
+
|
|
497
|
+
if trace_id is None:
|
|
498
|
+
# Create standalone health check span
|
|
499
|
+
span, effective_trace_id = self._adapter.start_span(
|
|
500
|
+
name=f"health/{provider_name}",
|
|
501
|
+
metadata={
|
|
502
|
+
"provider": provider_name,
|
|
503
|
+
"type": "health_check",
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
if span:
|
|
507
|
+
self._adapter.end_span(
|
|
508
|
+
span,
|
|
509
|
+
output={"healthy": healthy, "latency_ms": latency_ms},
|
|
510
|
+
level="DEFAULT" if healthy else "WARNING",
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
effective_trace_id = trace_id
|
|
514
|
+
|
|
515
|
+
self._adapter.create_score(
|
|
516
|
+
trace_id=effective_trace_id,
|
|
517
|
+
name="provider_healthy",
|
|
518
|
+
value=1.0 if healthy else 0.0,
|
|
519
|
+
comment=f"Provider: {provider_name}",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
self._adapter.create_score(
|
|
523
|
+
trace_id=effective_trace_id,
|
|
524
|
+
name="health_check_latency_ms",
|
|
525
|
+
value=latency_ms,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def flush(self) -> None:
|
|
529
|
+
"""Flush pending events."""
|
|
530
|
+
self._adapter.flush()
|
|
531
|
+
|
|
532
|
+
def shutdown(self) -> None:
|
|
533
|
+
"""Shutdown with final flush."""
|
|
534
|
+
self._adapter.shutdown()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Infrastructure persistence layer.
|
|
2
|
+
|
|
3
|
+
Provides implementations of domain persistence contracts
|
|
4
|
+
using SQLite, in-memory storage, and other backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .audit_repository import InMemoryAuditRepository, SQLiteAuditRepository
|
|
8
|
+
from .config_repository import InMemoryProviderConfigRepository, SQLiteProviderConfigRepository
|
|
9
|
+
from .database import Database, DatabaseConfig
|
|
10
|
+
from .event_serializer import EventSerializationError, EventSerializer, register_event_type
|
|
11
|
+
from .event_upcaster import IEventUpcaster, UpcasterChain
|
|
12
|
+
from .in_memory_event_store import InMemoryEventStore
|
|
13
|
+
from .recovery_service import RecoveryService
|
|
14
|
+
from .sqlite_event_store import SQLiteEventStore
|
|
15
|
+
from .unit_of_work import SQLiteUnitOfWork
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Database",
|
|
19
|
+
"DatabaseConfig",
|
|
20
|
+
"EventSerializationError",
|
|
21
|
+
"EventSerializer",
|
|
22
|
+
"IEventUpcaster",
|
|
23
|
+
"InMemoryAuditRepository",
|
|
24
|
+
"InMemoryEventStore",
|
|
25
|
+
"InMemoryProviderConfigRepository",
|
|
26
|
+
"RecoveryService",
|
|
27
|
+
"UpcasterChain",
|
|
28
|
+
"register_event_type",
|
|
29
|
+
"SQLiteAuditRepository",
|
|
30
|
+
"SQLiteEventStore",
|
|
31
|
+
"SQLiteProviderConfigRepository",
|
|
32
|
+
"SQLiteUnitOfWork",
|
|
33
|
+
]
|