hmdl 0.0.1__py3-none-any.whl → 0.0.2__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.
- hmdl/__init__.py +3 -1
- hmdl/client.py +62 -9
- hmdl/config.py +11 -1
- hmdl/decorators.py +186 -21
- {hmdl-0.0.1.dist-info → hmdl-0.0.2.dist-info}/METADATA +42 -1
- hmdl-0.0.2.dist-info/RECORD +10 -0
- hmdl-0.0.1.dist-info/RECORD +0 -10
- {hmdl-0.0.1.dist-info → hmdl-0.0.2.dist-info}/WHEEL +0 -0
- {hmdl-0.0.1.dist-info → hmdl-0.0.2.dist-info}/licenses/LICENSE +0 -0
hmdl/__init__.py
CHANGED
|
@@ -6,7 +6,7 @@ OpenTelemetry-based observability tracking.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from hmdl.client import HeimdallClient
|
|
9
|
-
from hmdl.decorators import trace_mcp_tool
|
|
9
|
+
from hmdl.decorators import trace_mcp_tool, UserExtractor, SessionExtractor
|
|
10
10
|
from hmdl.config import HeimdallConfig
|
|
11
11
|
from hmdl.types import SpanKind, SpanStatus
|
|
12
12
|
|
|
@@ -17,6 +17,8 @@ __all__ = [
|
|
|
17
17
|
"HeimdallClient",
|
|
18
18
|
# Decorators
|
|
19
19
|
"trace_mcp_tool",
|
|
20
|
+
"UserExtractor",
|
|
21
|
+
"SessionExtractor",
|
|
20
22
|
# Configuration
|
|
21
23
|
"HeimdallConfig",
|
|
22
24
|
# Types
|
hmdl/client.py
CHANGED
|
@@ -20,27 +20,31 @@ logger = logging.getLogger(__name__)
|
|
|
20
20
|
|
|
21
21
|
class HeimdallClient:
|
|
22
22
|
"""Client for sending observability data to Heimdall platform.
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
This client sets up OpenTelemetry tracing and provides methods for
|
|
25
25
|
creating spans and recording MCP operations.
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
Example:
|
|
28
28
|
>>> from hmdl import HeimdallClient
|
|
29
29
|
>>> client = HeimdallClient(api_key="your-api-key")
|
|
30
30
|
>>> with client.start_span("my-operation") as span:
|
|
31
31
|
... # Your code here
|
|
32
32
|
... span.set_attribute("custom.attribute", "value")
|
|
33
|
+
|
|
34
|
+
# Track user sessions
|
|
35
|
+
>>> client.set_session_id("session-123")
|
|
36
|
+
>>> client.set_user_id("user-456")
|
|
33
37
|
"""
|
|
34
|
-
|
|
38
|
+
|
|
35
39
|
_instance: Optional["HeimdallClient"] = None
|
|
36
40
|
_initialized: bool = False
|
|
37
|
-
|
|
41
|
+
|
|
38
42
|
def __new__(cls, *args: Any, **kwargs: Any) -> "HeimdallClient":
|
|
39
43
|
"""Singleton pattern to ensure only one client instance."""
|
|
40
44
|
if cls._instance is None:
|
|
41
45
|
cls._instance = super().__new__(cls)
|
|
42
46
|
return cls._instance
|
|
43
|
-
|
|
47
|
+
|
|
44
48
|
def __init__(
|
|
45
49
|
self,
|
|
46
50
|
config: Optional[HeimdallConfig] = None,
|
|
@@ -50,6 +54,8 @@ class HeimdallClient:
|
|
|
50
54
|
environment: Optional[str] = None,
|
|
51
55
|
org_id: Optional[str] = None,
|
|
52
56
|
project_id: Optional[str] = None,
|
|
57
|
+
session_id: Optional[str] = None,
|
|
58
|
+
user_id: Optional[str] = None,
|
|
53
59
|
) -> None:
|
|
54
60
|
"""Initialize the Heimdall client.
|
|
55
61
|
|
|
@@ -61,6 +67,8 @@ class HeimdallClient:
|
|
|
61
67
|
environment: Deployment environment.
|
|
62
68
|
org_id: Organization ID from Heimdall dashboard.
|
|
63
69
|
project_id: Project ID from Heimdall dashboard.
|
|
70
|
+
session_id: Session ID for tracking MCP client sessions.
|
|
71
|
+
user_id: User ID for tracking users.
|
|
64
72
|
"""
|
|
65
73
|
if self._initialized:
|
|
66
74
|
return
|
|
@@ -76,16 +84,22 @@ class HeimdallClient:
|
|
|
76
84
|
environment=environment or HeimdallConfig().environment,
|
|
77
85
|
org_id=org_id or HeimdallConfig().org_id,
|
|
78
86
|
project_id=project_id or HeimdallConfig().project_id,
|
|
87
|
+
session_id=session_id or HeimdallConfig().session_id,
|
|
88
|
+
user_id=user_id or HeimdallConfig().user_id,
|
|
79
89
|
)
|
|
80
|
-
|
|
90
|
+
|
|
81
91
|
self._tracer: Optional[trace.Tracer] = None
|
|
82
92
|
self._provider: Optional[TracerProvider] = None
|
|
83
|
-
|
|
93
|
+
|
|
94
|
+
# Runtime session and user tracking (can be updated dynamically)
|
|
95
|
+
self._session_id: Optional[str] = self.config.session_id
|
|
96
|
+
self._user_id: Optional[str] = self.config.user_id
|
|
97
|
+
|
|
84
98
|
if self.config.enabled:
|
|
85
99
|
self._setup_tracing()
|
|
86
|
-
|
|
100
|
+
|
|
87
101
|
self._initialized = True
|
|
88
|
-
|
|
102
|
+
|
|
89
103
|
# Register cleanup on exit
|
|
90
104
|
atexit.register(self.shutdown)
|
|
91
105
|
|
|
@@ -181,6 +195,45 @@ class HeimdallClient:
|
|
|
181
195
|
self._provider.shutdown()
|
|
182
196
|
logger.debug("Heimdall client shutdown complete")
|
|
183
197
|
|
|
198
|
+
def get_session_id(self) -> Optional[str]:
|
|
199
|
+
"""Get the current session ID."""
|
|
200
|
+
return self._session_id
|
|
201
|
+
|
|
202
|
+
def set_session_id(self, session_id: Optional[str]) -> None:
|
|
203
|
+
"""Set the session ID for all subsequent spans.
|
|
204
|
+
|
|
205
|
+
Call this when an MCP client connects to associate all operations
|
|
206
|
+
with that session.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
session_id: The session identifier (e.g., from MCP client connection)
|
|
210
|
+
|
|
211
|
+
Example:
|
|
212
|
+
>>> # When MCP client connects
|
|
213
|
+
>>> client.set_session_id(ctx.session_id or ctx.client_info.name)
|
|
214
|
+
"""
|
|
215
|
+
self._session_id = session_id
|
|
216
|
+
logger.debug(f"Session ID set to: {session_id}")
|
|
217
|
+
|
|
218
|
+
def get_user_id(self) -> Optional[str]:
|
|
219
|
+
"""Get the current user ID."""
|
|
220
|
+
return self._user_id
|
|
221
|
+
|
|
222
|
+
def set_user_id(self, user_id: Optional[str]) -> None:
|
|
223
|
+
"""Set the user ID for all subsequent spans.
|
|
224
|
+
|
|
225
|
+
Can be overridden per-span using user_extractor option in decorators.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
user_id: The user identifier
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> # When user is identified
|
|
232
|
+
>>> client.set_user_id("user-123")
|
|
233
|
+
"""
|
|
234
|
+
self._user_id = user_id
|
|
235
|
+
logger.debug(f"User ID set to: {user_id}")
|
|
236
|
+
|
|
184
237
|
@classmethod
|
|
185
238
|
def get_instance(cls) -> Optional["HeimdallClient"]:
|
|
186
239
|
"""Get the singleton client instance."""
|
hmdl/config.py
CHANGED
|
@@ -18,6 +18,10 @@ class HeimdallConfig:
|
|
|
18
18
|
environment: Deployment environment (e.g., 'production', 'staging').
|
|
19
19
|
org_id: Organization ID from Heimdall dashboard.
|
|
20
20
|
project_id: Project ID to associate traces with in Heimdall.
|
|
21
|
+
session_id: Session ID to associate with all spans. Useful for tracking
|
|
22
|
+
requests from the same MCP client session.
|
|
23
|
+
user_id: User ID to associate with all spans. Can be overridden per-span
|
|
24
|
+
using user_extractor option in decorators.
|
|
21
25
|
enabled: Whether tracing is enabled.
|
|
22
26
|
debug: Enable debug logging.
|
|
23
27
|
batch_size: Number of spans to batch before sending.
|
|
@@ -31,7 +35,7 @@ class HeimdallConfig:
|
|
|
31
35
|
)
|
|
32
36
|
endpoint: str = field(
|
|
33
37
|
default_factory=lambda: os.environ.get(
|
|
34
|
-
"HEIMDALL_ENDPOINT", "
|
|
38
|
+
"HEIMDALL_ENDPOINT", "http://localhost:4318"
|
|
35
39
|
)
|
|
36
40
|
)
|
|
37
41
|
service_name: str = field(
|
|
@@ -46,6 +50,12 @@ class HeimdallConfig:
|
|
|
46
50
|
project_id: str = field(
|
|
47
51
|
default_factory=lambda: os.environ.get("HEIMDALL_PROJECT_ID", "default")
|
|
48
52
|
)
|
|
53
|
+
session_id: Optional[str] = field(
|
|
54
|
+
default_factory=lambda: os.environ.get("HEIMDALL_SESSION_ID")
|
|
55
|
+
)
|
|
56
|
+
user_id: Optional[str] = field(
|
|
57
|
+
default_factory=lambda: os.environ.get("HEIMDALL_USER_ID")
|
|
58
|
+
)
|
|
49
59
|
enabled: bool = field(
|
|
50
60
|
default_factory=lambda: os.environ.get("HEIMDALL_ENABLED", "true").lower() == "true"
|
|
51
61
|
)
|
hmdl/decorators.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import functools
|
|
6
7
|
import inspect
|
|
7
8
|
import json
|
|
8
9
|
import time
|
|
9
|
-
from typing import Any, Callable, Optional, TypeVar, Union, overload
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, TypeVar, Union, overload
|
|
10
11
|
|
|
11
12
|
from opentelemetry import trace
|
|
12
13
|
from opentelemetry.trace import Status, StatusCode
|
|
@@ -15,6 +16,64 @@ from hmdl.types import HeimdallAttributes, SpanKind, SpanStatus
|
|
|
15
16
|
|
|
16
17
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
18
|
|
|
19
|
+
# Type alias for user extractor function
|
|
20
|
+
# Takes (args, kwargs) and returns user ID string or None
|
|
21
|
+
UserExtractor = Callable[[tuple, dict], Optional[str]]
|
|
22
|
+
|
|
23
|
+
# Type alias for session extractor function
|
|
24
|
+
# Takes (args, kwargs) and returns session ID string or None
|
|
25
|
+
SessionExtractor = Callable[[tuple, dict], Optional[str]]
|
|
26
|
+
|
|
27
|
+
# MCP header names
|
|
28
|
+
MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
|
29
|
+
AUTHORIZATION_HEADER = "Authorization"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_jwt_claims(token: str) -> Dict[str, Any]:
|
|
33
|
+
"""Parse JWT claims from a token string (without verification)."""
|
|
34
|
+
try:
|
|
35
|
+
token_str = token
|
|
36
|
+
if token_str.lower().startswith("bearer "):
|
|
37
|
+
token_str = token_str[7:]
|
|
38
|
+
parts = token_str.split(".")
|
|
39
|
+
if len(parts) != 3:
|
|
40
|
+
return {}
|
|
41
|
+
payload = parts[1]
|
|
42
|
+
# Add padding if needed
|
|
43
|
+
padding = 4 - len(payload) % 4
|
|
44
|
+
if padding != 4:
|
|
45
|
+
payload += "=" * padding
|
|
46
|
+
decoded = base64.urlsafe_b64decode(payload).decode("utf-8")
|
|
47
|
+
return json.loads(decoded)
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_user_id_from_token(token: str) -> Optional[str]:
|
|
53
|
+
"""Extract user ID from a JWT token."""
|
|
54
|
+
claims = _parse_jwt_claims(token)
|
|
55
|
+
# Check common user ID claims in order of preference
|
|
56
|
+
for claim in ["sub", "user_id", "userId", "uid"]:
|
|
57
|
+
if claim in claims and isinstance(claims[claim], str):
|
|
58
|
+
return claims[claim]
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_from_headers(headers: Dict[str, str]) -> tuple[Optional[str], Optional[str]]:
|
|
63
|
+
"""Extract session ID and user ID from HTTP headers.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (session_id, user_id)
|
|
67
|
+
"""
|
|
68
|
+
# Normalize headers to lowercase for case-insensitive lookup
|
|
69
|
+
normalized = {k.lower(): v for k, v in headers.items()}
|
|
70
|
+
|
|
71
|
+
session_id = normalized.get(MCP_SESSION_ID_HEADER.lower())
|
|
72
|
+
auth_header = normalized.get(AUTHORIZATION_HEADER.lower())
|
|
73
|
+
user_id = _extract_user_id_from_token(auth_header) if auth_header else None
|
|
74
|
+
|
|
75
|
+
return session_id, user_id
|
|
76
|
+
|
|
18
77
|
|
|
19
78
|
def _serialize_value(value: Any) -> str:
|
|
20
79
|
"""Safely serialize a value to string for span attributes."""
|
|
@@ -30,52 +89,128 @@ def _get_client() -> Any:
|
|
|
30
89
|
return HeimdallClient.get_instance()
|
|
31
90
|
|
|
32
91
|
|
|
92
|
+
def _extract_session_id(
|
|
93
|
+
args: tuple,
|
|
94
|
+
kwargs: dict,
|
|
95
|
+
session_extractor: Optional[SessionExtractor],
|
|
96
|
+
header_session_id: Optional[str],
|
|
97
|
+
) -> Optional[str]:
|
|
98
|
+
"""Extract session ID using the extractor callback or headers.
|
|
99
|
+
|
|
100
|
+
Priority: session_extractor callback > headers > None
|
|
101
|
+
"""
|
|
102
|
+
# Try extractor callback first
|
|
103
|
+
if session_extractor:
|
|
104
|
+
try:
|
|
105
|
+
result = session_extractor(args, kwargs)
|
|
106
|
+
if result:
|
|
107
|
+
return result
|
|
108
|
+
except Exception:
|
|
109
|
+
# Ignore extraction errors
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# Fall back to headers
|
|
113
|
+
if header_session_id:
|
|
114
|
+
return header_session_id
|
|
115
|
+
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_user_id(
|
|
120
|
+
args: tuple,
|
|
121
|
+
kwargs: dict,
|
|
122
|
+
user_extractor: Optional[UserExtractor],
|
|
123
|
+
header_user_id: Optional[str],
|
|
124
|
+
) -> Optional[str]:
|
|
125
|
+
"""Extract user ID using the extractor callback or headers.
|
|
126
|
+
|
|
127
|
+
Priority: user_extractor callback > headers > None
|
|
128
|
+
"""
|
|
129
|
+
# Try extractor callback first
|
|
130
|
+
if user_extractor:
|
|
131
|
+
try:
|
|
132
|
+
result = user_extractor(args, kwargs)
|
|
133
|
+
if result:
|
|
134
|
+
return result
|
|
135
|
+
except Exception:
|
|
136
|
+
# Ignore extraction errors
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# Fall back to headers
|
|
140
|
+
if header_user_id:
|
|
141
|
+
return header_user_id
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
33
146
|
def _create_span_decorator(
|
|
34
147
|
span_kind: SpanKind,
|
|
35
148
|
name_attr: str,
|
|
36
149
|
args_attr: str,
|
|
37
150
|
result_attr: str,
|
|
38
|
-
) -> Callable[
|
|
151
|
+
) -> Callable[..., Callable[[F], F]]:
|
|
39
152
|
"""Factory for creating MCP-specific decorators."""
|
|
40
|
-
|
|
41
|
-
def decorator(
|
|
153
|
+
|
|
154
|
+
def decorator(
|
|
155
|
+
name: Optional[str] = None,
|
|
156
|
+
*,
|
|
157
|
+
headers: Optional[Dict[str, str]] = None,
|
|
158
|
+
user_extractor: Optional[UserExtractor] = None,
|
|
159
|
+
session_extractor: Optional[SessionExtractor] = None,
|
|
160
|
+
) -> Callable[[F], F]:
|
|
161
|
+
# Pre-extract from headers if provided
|
|
162
|
+
header_session_id, header_user_id = _extract_from_headers(headers) if headers else (None, None)
|
|
163
|
+
|
|
42
164
|
def wrapper(func: F) -> F:
|
|
43
165
|
span_name = name or func.__name__
|
|
44
166
|
is_async = inspect.iscoroutinefunction(func)
|
|
45
|
-
|
|
167
|
+
|
|
46
168
|
if is_async:
|
|
47
169
|
@functools.wraps(func)
|
|
48
170
|
async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
49
171
|
client = _get_client()
|
|
50
172
|
if client is None:
|
|
51
173
|
return await func(*args, **kwargs)
|
|
52
|
-
|
|
174
|
+
|
|
53
175
|
tracer = client.tracer
|
|
54
176
|
with tracer.start_as_current_span(
|
|
55
177
|
name=span_name,
|
|
56
178
|
kind=trace.SpanKind.SERVER,
|
|
57
179
|
) as span:
|
|
58
180
|
start_time = time.perf_counter()
|
|
59
|
-
|
|
181
|
+
|
|
60
182
|
# Set input attributes
|
|
61
183
|
span.set_attribute(name_attr, span_name)
|
|
62
184
|
span.set_attribute("heimdall.span_kind", span_kind.value)
|
|
63
|
-
|
|
185
|
+
|
|
186
|
+
# Extract session ID - priority: extractor > headers > client
|
|
187
|
+
session_id = _extract_session_id(args, kwargs, session_extractor, header_session_id)
|
|
188
|
+
if not session_id:
|
|
189
|
+
session_id = client.get_session_id()
|
|
190
|
+
if session_id:
|
|
191
|
+
span.set_attribute(HeimdallAttributes.HEIMDALL_SESSION_ID, session_id)
|
|
192
|
+
|
|
193
|
+
# Extract user ID - priority: extractor > headers > client > "anonymous"
|
|
194
|
+
user_id = _extract_user_id(args, kwargs, user_extractor, header_user_id)
|
|
195
|
+
if not user_id:
|
|
196
|
+
user_id = client.get_user_id()
|
|
197
|
+
span.set_attribute(HeimdallAttributes.HEIMDALL_USER_ID, user_id or "anonymous")
|
|
198
|
+
|
|
64
199
|
# Capture arguments
|
|
65
200
|
try:
|
|
66
201
|
all_args = _capture_arguments(func, args, kwargs)
|
|
67
202
|
span.set_attribute(args_attr, _serialize_value(all_args))
|
|
68
203
|
except Exception:
|
|
69
204
|
pass
|
|
70
|
-
|
|
205
|
+
|
|
71
206
|
try:
|
|
72
207
|
result = await func(*args, **kwargs)
|
|
73
|
-
|
|
208
|
+
|
|
74
209
|
# Set output attributes
|
|
75
210
|
span.set_attribute(result_attr, _serialize_value(result))
|
|
76
211
|
span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
|
|
77
212
|
span.set_status(Status(StatusCode.OK))
|
|
78
|
-
|
|
213
|
+
|
|
79
214
|
return result
|
|
80
215
|
except Exception as e:
|
|
81
216
|
_record_error(span, e)
|
|
@@ -83,7 +218,7 @@ def _create_span_decorator(
|
|
|
83
218
|
finally:
|
|
84
219
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
85
220
|
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
86
|
-
|
|
221
|
+
|
|
87
222
|
return async_wrapped # type: ignore
|
|
88
223
|
else:
|
|
89
224
|
@functools.wraps(func)
|
|
@@ -91,33 +226,46 @@ def _create_span_decorator(
|
|
|
91
226
|
client = _get_client()
|
|
92
227
|
if client is None:
|
|
93
228
|
return func(*args, **kwargs)
|
|
94
|
-
|
|
229
|
+
|
|
95
230
|
tracer = client.tracer
|
|
96
231
|
with tracer.start_as_current_span(
|
|
97
232
|
name=span_name,
|
|
98
233
|
kind=trace.SpanKind.SERVER,
|
|
99
234
|
) as span:
|
|
100
235
|
start_time = time.perf_counter()
|
|
101
|
-
|
|
236
|
+
|
|
102
237
|
# Set input attributes
|
|
103
238
|
span.set_attribute(name_attr, span_name)
|
|
104
239
|
span.set_attribute("heimdall.span_kind", span_kind.value)
|
|
105
|
-
|
|
240
|
+
|
|
241
|
+
# Extract session ID - priority: extractor > headers > client
|
|
242
|
+
session_id = _extract_session_id(args, kwargs, session_extractor, header_session_id)
|
|
243
|
+
if not session_id:
|
|
244
|
+
session_id = client.get_session_id()
|
|
245
|
+
if session_id:
|
|
246
|
+
span.set_attribute(HeimdallAttributes.HEIMDALL_SESSION_ID, session_id)
|
|
247
|
+
|
|
248
|
+
# Extract user ID - priority: extractor > headers > client > "anonymous"
|
|
249
|
+
user_id = _extract_user_id(args, kwargs, user_extractor, header_user_id)
|
|
250
|
+
if not user_id:
|
|
251
|
+
user_id = client.get_user_id()
|
|
252
|
+
span.set_attribute(HeimdallAttributes.HEIMDALL_USER_ID, user_id or "anonymous")
|
|
253
|
+
|
|
106
254
|
# Capture arguments
|
|
107
255
|
try:
|
|
108
256
|
all_args = _capture_arguments(func, args, kwargs)
|
|
109
257
|
span.set_attribute(args_attr, _serialize_value(all_args))
|
|
110
258
|
except Exception:
|
|
111
259
|
pass
|
|
112
|
-
|
|
260
|
+
|
|
113
261
|
try:
|
|
114
262
|
result = func(*args, **kwargs)
|
|
115
|
-
|
|
263
|
+
|
|
116
264
|
# Set output attributes
|
|
117
265
|
span.set_attribute(result_attr, _serialize_value(result))
|
|
118
266
|
span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
|
|
119
267
|
span.set_status(Status(StatusCode.OK))
|
|
120
|
-
|
|
268
|
+
|
|
121
269
|
return result
|
|
122
270
|
except Exception as e:
|
|
123
271
|
_record_error(span, e)
|
|
@@ -125,11 +273,11 @@ def _create_span_decorator(
|
|
|
125
273
|
finally:
|
|
126
274
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
127
275
|
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
128
|
-
|
|
276
|
+
|
|
129
277
|
return sync_wrapped # type: ignore
|
|
130
|
-
|
|
278
|
+
|
|
131
279
|
return wrapper
|
|
132
|
-
|
|
280
|
+
|
|
133
281
|
return decorator
|
|
134
282
|
|
|
135
283
|
|
|
@@ -160,6 +308,15 @@ trace_mcp_tool = _create_span_decorator(
|
|
|
160
308
|
trace_mcp_tool.__doc__ = """
|
|
161
309
|
Decorator to trace MCP tool calls.
|
|
162
310
|
|
|
311
|
+
Args:
|
|
312
|
+
name: Custom name for the span (defaults to function name)
|
|
313
|
+
user_extractor: Function to extract user ID from (args, kwargs).
|
|
314
|
+
Useful for extracting user info from MCP Context.
|
|
315
|
+
Returns user ID string or None to use default from client.
|
|
316
|
+
session_extractor: Function to extract session ID from (args, kwargs).
|
|
317
|
+
Useful for extracting session info from MCP Context.
|
|
318
|
+
Returns session ID string or None to use default from client.
|
|
319
|
+
|
|
163
320
|
Example:
|
|
164
321
|
>>> @trace_mcp_tool()
|
|
165
322
|
... def my_tool(arg1: str, arg2: int) -> str:
|
|
@@ -168,6 +325,14 @@ Example:
|
|
|
168
325
|
>>> @trace_mcp_tool("custom-tool-name")
|
|
169
326
|
... async def async_tool(data: dict) -> dict:
|
|
170
327
|
... return {"processed": data}
|
|
328
|
+
|
|
329
|
+
# Extract user and session from MCP Context (first argument)
|
|
330
|
+
>>> @trace_mcp_tool(
|
|
331
|
+
... user_extractor=lambda args, kwargs: getattr(args[0], 'user_id', None) if args else None,
|
|
332
|
+
... session_extractor=lambda args, kwargs: getattr(args[0], 'session_id', None) if args else None,
|
|
333
|
+
... )
|
|
334
|
+
... def my_tool_with_ctx(ctx, query: str) -> str:
|
|
335
|
+
... return f"Query: {query}"
|
|
171
336
|
"""
|
|
172
337
|
|
|
173
338
|
trace_mcp_resource = _create_span_decorator(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hmdl
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: Observability SDK for MCP (Model Context Protocol) servers - Heimdall Platform
|
|
5
5
|
Project-URL: Homepage, https://tryheimdall.com
|
|
6
6
|
Project-URL: Documentation, https://docs.tryheimdall.com
|
|
@@ -141,6 +141,8 @@ async def async_search(query: str) -> list:
|
|
|
141
141
|
| `HEIMDALL_DEBUG` | Enable debug logging | `false` |
|
|
142
142
|
| `HEIMDALL_BATCH_SIZE` | Spans per batch | `100` |
|
|
143
143
|
| `HEIMDALL_FLUSH_INTERVAL_MS` | Flush interval (ms) | `5000` |
|
|
144
|
+
| `HEIMDALL_SESSION_ID` | Default session ID | - |
|
|
145
|
+
| `HEIMDALL_USER_ID` | Default user ID | - |
|
|
144
146
|
|
|
145
147
|
### Local Development
|
|
146
148
|
|
|
@@ -155,6 +157,45 @@ export HEIMDALL_ENABLED="true"
|
|
|
155
157
|
|
|
156
158
|
## Advanced Usage
|
|
157
159
|
|
|
160
|
+
### Session and User Tracking
|
|
161
|
+
|
|
162
|
+
`trace_mcp_tool` automatically includes session and user IDs in spans. You just need to provide them via one of these methods:
|
|
163
|
+
|
|
164
|
+
#### Option 1: HTTP Headers (Recommended for MCP servers)
|
|
165
|
+
|
|
166
|
+
Pass HTTP headers directly to `trace_mcp_tool`. Session ID is extracted from the `Mcp-Session-Id` header, and user ID from the JWT token in the `Authorization` header:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from hmdl import trace_mcp_tool
|
|
170
|
+
|
|
171
|
+
@app.post("/mcp")
|
|
172
|
+
def handle_request():
|
|
173
|
+
@trace_mcp_tool(headers=dict(request.headers))
|
|
174
|
+
def search_tool(query: str):
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
return search_tool("test") # Session/user included in span
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
#### Option 2: Extractors (Per-tool extraction)
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from typing import Optional
|
|
184
|
+
|
|
185
|
+
@trace_mcp_tool(
|
|
186
|
+
session_extractor=lambda args, kwargs: kwargs.get('session_id'),
|
|
187
|
+
user_extractor=lambda args, kwargs: kwargs.get('user_id'),
|
|
188
|
+
)
|
|
189
|
+
def my_tool(query: str, session_id: Optional[str] = None, user_id: Optional[str] = None):
|
|
190
|
+
return f"Query: {query}"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### Resolution Priority
|
|
194
|
+
|
|
195
|
+
1. Extractor callback → 2. HTTP headers → 3. Client value (initialized from environment variables)
|
|
196
|
+
|
|
197
|
+
> **Note**: If no user ID is found through any of these methods, `"anonymous"` is used as the default.
|
|
198
|
+
|
|
158
199
|
### Custom span names
|
|
159
200
|
|
|
160
201
|
```python
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
hmdl/__init__.py,sha256=2OCWYFkHkNNRhYawHGVd9tUVfZ8WMMUuxhvJvgHsvFQ,648
|
|
2
|
+
hmdl/client.py,sha256=XItMSgojsDwz-NAjFzWF2dXHDo7o6rXKBHppbCgsSz0,8633
|
|
3
|
+
hmdl/config.py,sha256=crHQu_b2KO6WJ6vJ8GTtdCbuizAQopI5-WQtgaCHfj0,3520
|
|
4
|
+
hmdl/decorators.py,sha256=HupF7DUZRIqPPR8mvw2jCTv4US7fAQgAOuqPMlCGBNY,17561
|
|
5
|
+
hmdl/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
hmdl/types.py,sha256=C_QDl4YTJjnrhvMUdnDnTG35jKmovLFqMrHXL4LHOG0,3130
|
|
7
|
+
hmdl-0.0.2.dist-info/METADATA,sha256=pxcj7lxXZd1Bnu7aLnvOo6zhgWDhjM9Ldx9sQwIzTR4,8083
|
|
8
|
+
hmdl-0.0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
hmdl-0.0.2.dist-info/licenses/LICENSE,sha256=b8jAb5oXJiKCT9GmhRp2uDLqZXIA63QnLT4_3JvzxhE,1064
|
|
10
|
+
hmdl-0.0.2.dist-info/RECORD,,
|
hmdl-0.0.1.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
hmdl/__init__.py,sha256=EA49ssIg0WJTzi1-Oi0J8GWV6k55R-oav45xbufd4WU,570
|
|
2
|
-
hmdl/client.py,sha256=_VHVIlfq8JV_FucLN9rjgj_0fE_C0h5DZWQCUPXSSCw,6782
|
|
3
|
-
hmdl/config.py,sha256=jXo0XC962JM3-N2uUl6o8Dt0hUZjIWvDJcthm04Ux0U,3028
|
|
4
|
-
hmdl/decorators.py,sha256=yUywG_AMCA-tqT-w4fO1bSpOrVn3TDk7Y981R2UmJjQ,11697
|
|
5
|
-
hmdl/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
-
hmdl/types.py,sha256=C_QDl4YTJjnrhvMUdnDnTG35jKmovLFqMrHXL4LHOG0,3130
|
|
7
|
-
hmdl-0.0.1.dist-info/METADATA,sha256=gbak-CcmYZkcegGv0GqSvMaC41EtCo99IgWC4or3UE8,6743
|
|
8
|
-
hmdl-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
hmdl-0.0.1.dist-info/licenses/LICENSE,sha256=b8jAb5oXJiKCT9GmhRp2uDLqZXIA63QnLT4_3JvzxhE,1064
|
|
10
|
-
hmdl-0.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|